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 $unset?: Record $inc?: Record $push?: Record $pull?: Record $addToSet?: Record } /** * 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; 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 = {} 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)) { 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((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 } }