Simplify: one abstract base class per layer
- 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
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user