M1: core path escaping, order-preserving index value encoding, errors
Scaffold TypeScript/Vitest project, add DESIGN.md (brief) and CLAUDE.md. Property tests cover escape round-trips, separator-freedom and order preservation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { DEFAULT_SEPARATOR, succ } from './path.js'
|
||||
|
||||
export type IndexValue = string | number | boolean | null
|
||||
|
||||
/**
|
||||
* Order-preserving, separator-safe encoding of index values.
|
||||
*
|
||||
* Every character the encoder emits sorts strictly above the separator. That makes encodings
|
||||
* prefix-safe inside keys: `enc(a) + sep + …` sorts below `enc(b)` whenever `enc(a)` is a proper
|
||||
* prefix of `enc(b)`, so `a < b ⇔ key(a, …) < key(b, …)` and range scans over index entries are exact.
|
||||
*
|
||||
* Layout: one type-tag character, then a body.
|
||||
* - Type tags order `null < false < true < number < string`.
|
||||
* - Numbers: IEEE-754 float64, big-endian; positive values have the sign bit flipped, negative
|
||||
* values have all bits flipped; written as 16 fixed-width "hex" digits. `-0` is normalised to `0`;
|
||||
* `NaN` is rejected.
|
||||
* - Strings: each code unit `c` with `c <= E` (where `E = succ(sep)`) is written as `E` followed by
|
||||
* `chr(E + 1 + c)`; every other code unit is written as-is. This is monotone, so order is kept.
|
||||
*
|
||||
* The 16 digits are `0-9a-f` when the separator sorts below `'0'` (e.g. `/`), otherwise the 16
|
||||
* characters immediately after the separator.
|
||||
*/
|
||||
export interface IndexValueCodec {
|
||||
readonly sep: string
|
||||
encode(value: IndexValue): string
|
||||
decode(encoded: string): IndexValue
|
||||
}
|
||||
|
||||
const codecs = new Map<string, IndexValueCodec>()
|
||||
|
||||
export function indexValueCodec(sep: string = DEFAULT_SEPARATOR): IndexValueCodec {
|
||||
let codec = codecs.get(sep)
|
||||
if (!codec) {
|
||||
codec = makeCodec(sep)
|
||||
codecs.set(sep, codec)
|
||||
}
|
||||
return codec
|
||||
}
|
||||
|
||||
export function encodeIndexValue(value: IndexValue, sep: string = DEFAULT_SEPARATOR): string {
|
||||
return indexValueCodec(sep).encode(value)
|
||||
}
|
||||
|
||||
export function decodeIndexValue(encoded: string, sep: string = DEFAULT_SEPARATOR): IndexValue {
|
||||
return indexValueCodec(sep).decode(encoded)
|
||||
}
|
||||
|
||||
/** Encodes a compound value as `enc(v1) + sep + enc(v2) + …`. */
|
||||
export function encodeIndexValues(values: readonly IndexValue[], sep: string = DEFAULT_SEPARATOR): string {
|
||||
const codec = indexValueCodec(sep)
|
||||
let out = ''
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (i > 0) out += sep
|
||||
out += codec.encode(values[i]!)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function isIndexValue(v: unknown): v is IndexValue {
|
||||
return v === null || typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && !Number.isNaN(v))
|
||||
}
|
||||
|
||||
function makeCodec(sep: string): IndexValueCodec {
|
||||
const sepCode = sep.charCodeAt(0)
|
||||
const digits =
|
||||
sepCode < 0x30 ? '0123456789abcdef' : Array.from({ length: 16 }, (_, i) => String.fromCharCode(sepCode + 1 + i)).join('')
|
||||
const digitValue = new Map<string, number>([...digits].map((d, i) => [d, i]))
|
||||
const TAG_NULL = digits[0]!
|
||||
const TAG_FALSE = digits[1]!
|
||||
const TAG_TRUE = digits[2]!
|
||||
const TAG_NUMBER = digits[3]!
|
||||
const TAG_STRING = digits[4]!
|
||||
const esc = succ(sep)
|
||||
const escCode = esc.charCodeAt(0)
|
||||
|
||||
const view = new DataView(new ArrayBuffer(8))
|
||||
|
||||
function hex32(n: number): string {
|
||||
let out = ''
|
||||
for (let shift = 28; shift >= 0; shift -= 4) out += digits[(n >>> shift) & 0xf]
|
||||
return out
|
||||
}
|
||||
|
||||
function encodeNumber(n: number): string {
|
||||
if (Number.isNaN(n)) throw new TypeError('NaN cannot be indexed')
|
||||
view.setFloat64(0, n === 0 ? 0 : n) // normalise -0
|
||||
let hi = view.getUint32(0)
|
||||
let lo = view.getUint32(4)
|
||||
if (hi & 0x80000000) {
|
||||
hi = ~hi >>> 0
|
||||
lo = ~lo >>> 0
|
||||
} else {
|
||||
hi = (hi ^ 0x80000000) >>> 0
|
||||
}
|
||||
return hex32(hi) + hex32(lo)
|
||||
}
|
||||
|
||||
function decodeNumber(body: string): number {
|
||||
if (body.length !== 16) throw new TypeError(`Bad encoded number ${JSON.stringify(body)}`)
|
||||
let hi = 0
|
||||
let lo = 0
|
||||
for (let i = 0; i < 8; i++) hi = (hi << 4) | digitValue.get(body[i]!)!
|
||||
for (let i = 8; i < 16; i++) lo = (lo << 4) | digitValue.get(body[i]!)!
|
||||
hi >>>= 0
|
||||
lo >>>= 0
|
||||
if (hi & 0x80000000) {
|
||||
hi = (hi ^ 0x80000000) >>> 0
|
||||
} else {
|
||||
hi = ~hi >>> 0
|
||||
lo = ~lo >>> 0
|
||||
}
|
||||
view.setUint32(0, hi)
|
||||
view.setUint32(4, lo)
|
||||
return view.getFloat64(0)
|
||||
}
|
||||
|
||||
function encodeString(s: string): string {
|
||||
let out = ''
|
||||
let start = 0
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i)
|
||||
if (c <= escCode) {
|
||||
out += s.slice(start, i) + esc + String.fromCharCode(escCode + 1 + c)
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return start === 0 ? s : out + s.slice(start)
|
||||
}
|
||||
|
||||
function decodeString(body: string): string {
|
||||
if (body.indexOf(esc) === -1) return body
|
||||
let out = ''
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const c = body[i]!
|
||||
if (c === esc) {
|
||||
const next = body.charCodeAt(++i)
|
||||
out += String.fromCharCode(next - escCode - 1)
|
||||
} else out += c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
return {
|
||||
sep,
|
||||
encode(value) {
|
||||
if (value === null) return TAG_NULL
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
return value ? TAG_TRUE : TAG_FALSE
|
||||
case 'number':
|
||||
return TAG_NUMBER + encodeNumber(value)
|
||||
case 'string':
|
||||
return TAG_STRING + encodeString(value)
|
||||
default:
|
||||
throw new TypeError(`Cannot index value of type ${typeof value}`)
|
||||
}
|
||||
},
|
||||
decode(encoded) {
|
||||
const tag = encoded[0]
|
||||
const body = encoded.slice(1)
|
||||
switch (tag) {
|
||||
case TAG_NULL:
|
||||
return null
|
||||
case TAG_FALSE:
|
||||
return false
|
||||
case TAG_TRUE:
|
||||
return true
|
||||
case TAG_NUMBER:
|
||||
return decodeNumber(body)
|
||||
case TAG_STRING:
|
||||
return decodeString(body)
|
||||
default:
|
||||
throw new TypeError(`Bad encoded index value ${JSON.stringify(encoded)}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { InvalidKeyError } from '../errors.js'
|
||||
|
||||
export const DEFAULT_SEPARATOR = '/'
|
||||
|
||||
/** Reserved segments. User segments can never start with `~` (a leading `~` is escaped). */
|
||||
export const RESERVED = {
|
||||
index: '~i',
|
||||
unique: '~u',
|
||||
events: '~e',
|
||||
projections: '~p',
|
||||
} as const
|
||||
|
||||
export type Key = string | number
|
||||
|
||||
/** The character immediately after `c` (by UTF-16 code unit). */
|
||||
export function succ(c: string): string {
|
||||
return String.fromCharCode(c.charCodeAt(0) + 1)
|
||||
}
|
||||
|
||||
export function validateSeparator(sep: string): void {
|
||||
if (sep.length !== 1) throw new TypeError(`Separator must be a single character, got ${JSON.stringify(sep)}`)
|
||||
const code = sep.charCodeAt(0)
|
||||
if (code <= 0x20 || code >= 0x7f || sep === '%' || sep === '~') {
|
||||
throw new TypeError(`Separator must be printable ASCII other than "%" and "~", got ${JSON.stringify(sep)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function hex2(code: number): string {
|
||||
return '%' + code.toString(16).toUpperCase().padStart(2, '0')
|
||||
}
|
||||
|
||||
/** Converts a user key to a segment string, rejecting `undefined`/`null`/empty. */
|
||||
export function toSegment(key: unknown): string {
|
||||
if (key === undefined || key === null) throw new InvalidKeyError(`Key must not be ${key}`)
|
||||
if (typeof key === 'number') {
|
||||
if (!Number.isFinite(key)) throw new InvalidKeyError(`Numeric key must be finite, got ${key}`)
|
||||
return String(key)
|
||||
}
|
||||
if (typeof key !== 'string') throw new InvalidKeyError(`Key must be a string or number, got ${typeof key}`)
|
||||
if (key === '') throw new InvalidKeyError('Key must not be empty')
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes one path segment: percent-encodes `%`, the separator, and a leading `~`.
|
||||
* The result never contains a raw separator and never starts with `~`.
|
||||
*/
|
||||
export function escapeSegment(segment: string, sep: string = DEFAULT_SEPARATOR): string {
|
||||
// Fast path: nothing to escape (the overwhelmingly common case).
|
||||
if (segment.indexOf('%') === -1 && segment.indexOf(sep) === -1 && segment.charCodeAt(0) !== 0x7e) return segment
|
||||
let out = ''
|
||||
for (let i = 0; i < segment.length; i++) {
|
||||
const c = segment[i]!
|
||||
if (c === '%' || c === sep || (i === 0 && c === '~')) out += hex2(c.charCodeAt(0))
|
||||
else out += c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function unescapeSegment(escaped: string): string {
|
||||
if (escaped.indexOf('%') === -1) return escaped
|
||||
return escaped.replace(/%([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(parseInt(h, 16)))
|
||||
}
|
||||
|
||||
export function isReservedSegment(escaped: string): boolean {
|
||||
return escaped.charCodeAt(0) === 0x7e // '~'
|
||||
}
|
||||
|
||||
/** Joins unescaped segments into an escaped key. */
|
||||
export function joinPath(path: readonly string[], sep: string = DEFAULT_SEPARATOR): string {
|
||||
let out = ''
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
if (i > 0) out += sep
|
||||
out += escapeSegment(path[i]!, sep)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Splits an escaped key into unescaped segments. */
|
||||
export function splitPath(key: string, sep: string = DEFAULT_SEPARATOR): string[] {
|
||||
if (key === '') return []
|
||||
return key.split(sep).map(unescapeSegment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Precomputed key strings for a node path.
|
||||
* - `selfKey`: escaped segments joined by the separator (`''` for the root)
|
||||
* - `prefix`: `selfKey + sep` (`''` for the root) — every descendant key starts with it
|
||||
* - `end`: exclusive upper bound of the subtree range (`selfKey + succ(sep)`; `undefined` for the root)
|
||||
*/
|
||||
export interface PathKeys {
|
||||
readonly path: readonly string[]
|
||||
readonly sep: string
|
||||
readonly selfKey: string
|
||||
readonly prefix: string
|
||||
readonly end: string | undefined
|
||||
}
|
||||
|
||||
export function pathKeys(path: readonly string[], sep: string): PathKeys {
|
||||
const selfKey = joinPath(path, sep)
|
||||
const root = path.length === 0
|
||||
return {
|
||||
path,
|
||||
sep,
|
||||
selfKey,
|
||||
prefix: root ? '' : selfKey + sep,
|
||||
end: root ? undefined : selfKey + succ(sep),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Base class for all PotentialStorage errors. */
|
||||
export class PotentialStorageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
|
||||
/** An optimistic-concurrency check failed (stale `expectedVersion` or a lost commit race). */
|
||||
export class ConcurrencyError extends PotentialStorageError {
|
||||
constructor(
|
||||
readonly stream: string,
|
||||
readonly expectedVersion: number | 'any',
|
||||
readonly actualVersion: number,
|
||||
) {
|
||||
super(`Concurrency conflict on "${stream}": expected version ${expectedVersion}, actual ${actualVersion}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A unique index already holds the given value for another key. */
|
||||
export class UniqueConstraintError extends PotentialStorageError {
|
||||
constructor(
|
||||
readonly index: string,
|
||||
readonly values: readonly unknown[],
|
||||
readonly existingKey?: string,
|
||||
) {
|
||||
super(`Unique index "${index}" already contains ${JSON.stringify(values)}${existingKey === undefined ? '' : ` (key "${existingKey}")`}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The driver lacks an optional capability required by the requested feature. */
|
||||
export class CapabilityError extends PotentialStorageError {
|
||||
constructor(readonly capability: string, detail?: string) {
|
||||
super(`Driver lacks capability "${capability}"${detail ? `: ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key argument is invalid: passed but `undefined`/`null`, empty, or otherwise unusable.
|
||||
* Extends `TypeError` so callers can catch either.
|
||||
*/
|
||||
export class InvalidKeyError extends TypeError {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvalidKeyError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './errors.js'
|
||||
export * from './core/path.js'
|
||||
export * from './core/encoding.js'
|
||||
Reference in New Issue
Block a user