- BaseKVStorage / BaseEventStorage / BaseDocumentStorage define the API once; implementations provide only raw primitives (client and server share an API) - Drop KV indexes, query planner, unique indexes; document stores take index fields - Escape every char <= '/' so child keys stay contiguous; skip-scan is now simple - Event-sourced documents = event storage + any document store for projections - New folder layout (kv-storage, event-storage, document-storage, utils); tests only in test/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
76 lines
3.1 KiB
TypeScript
76 lines
3.1 KiB
TypeScript
import { DocumentPath, type Doc } from './document-path.js'
|
|
import { DocumentQuery } from './document-query.js'
|
|
import { Json } from './json.js'
|
|
|
|
/** Update operators. Paths may use dots. `$push`/`$addToSet` accept `{ $each: [...] }`. */
|
|
export interface UpdateSpec {
|
|
$set?: Record<string, unknown>
|
|
$unset?: Record<string, unknown>
|
|
$inc?: Record<string, number>
|
|
$push?: Record<string, unknown>
|
|
$pull?: Record<string, unknown>
|
|
$addToSet?: Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* A resolved change to one document. Operators are resolved to final values (`$inc` becomes
|
|
* `set: { n: 6 }`), so replaying changes is deterministic.
|
|
*/
|
|
export type DocChange = { op: 'set'; doc: Doc } | { op: 'patch'; set: Record<string, unknown>; unset: string[] } | { op: 'delete' }
|
|
|
|
export class DocumentUpdate {
|
|
/** Resolves update operators against the current state into a patch. */
|
|
static resolve(state: Doc, spec: UpdateSpec): DocChange {
|
|
const set: Record<string, unknown> = {}
|
|
const unset: string[] = []
|
|
const array = (path: string) => {
|
|
const current = DocumentPath.get(state, path) ?? []
|
|
if (!Array.isArray(current)) throw new TypeError(`"${path}" is not an array`)
|
|
return Json.clone(current)
|
|
}
|
|
const items = (value: unknown) => (Json.isObject(value) && Array.isArray(value.$each) ? value.$each : [value])
|
|
|
|
for (const [op, fields] of Object.entries(spec)) {
|
|
for (const [path, value] of Object.entries(fields as Record<string, unknown>)) {
|
|
if (path in set || unset.includes(path)) throw new TypeError(`"${path}" is updated twice`)
|
|
switch (op) {
|
|
case '$set':
|
|
set[path] = Json.clone(value)
|
|
break
|
|
case '$unset':
|
|
unset.push(path)
|
|
break
|
|
case '$inc': {
|
|
const current = DocumentPath.get(state, path) ?? 0
|
|
if (typeof current !== 'number' || typeof value !== 'number') throw new TypeError(`$inc needs numbers at "${path}"`)
|
|
set[path] = current + value
|
|
break
|
|
}
|
|
case '$push':
|
|
set[path] = [...array(path), ...Json.clone(items(value))]
|
|
break
|
|
case '$addToSet':
|
|
set[path] = items(value).reduce<unknown[]>((acc, item) => (acc.some((x) => Json.equal(x, item)) ? acc : [...acc, Json.clone(item)]), array(path))
|
|
break
|
|
case '$pull':
|
|
set[path] = array(path).filter((item) => !DocumentQuery.matchesValue(item, value))
|
|
break
|
|
default:
|
|
throw new TypeError(`Unsupported update operator ${op}`)
|
|
}
|
|
}
|
|
}
|
|
return { op: 'patch', set, unset }
|
|
}
|
|
|
|
/** Applies a change to a state (`undefined` = no document). */
|
|
static apply(state: Doc | undefined, change: DocChange): Doc | undefined {
|
|
if (change.op === 'delete') return undefined
|
|
if (change.op === 'set') return Json.clone(change.doc)
|
|
const next = Json.clone(state ?? {})
|
|
for (const [path, value] of Object.entries(change.set)) DocumentPath.set(next, path, Json.clone(value))
|
|
for (const path of change.unset) DocumentPath.unset(next, path)
|
|
return next
|
|
}
|
|
}
|