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,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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user