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
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
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),
|
|
}
|
|
}
|