From c8a805d4666bed90f0d8bc7c4a4cada8445ad46c Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Mon, 14 Sep 2026 14:11:07 +0000 Subject: [PATCH 1/4] feat(database): add explicit atomic single-record mutations --- package.json | 7 + src/atomic.ts | 198 +++++++++++++++++++++ src/index.ts | 1 + src/selection.ts | 23 +++ src/tests/atomic_mutation.test.ts | 286 ++++++++++++++++++++++++++++++ 5 files changed, 515 insertions(+) create mode 100644 src/atomic.ts create mode 100644 src/tests/atomic_mutation.test.ts diff --git a/package.json b/package.json index 3c6bdcc..6a18cfb 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "types": "dist/index.d.ts", "typesVersions": { "*": { + "atomic": [ + "dist/atomic.d.ts" + ], "common": [ "dist/common.d.ts" ], @@ -49,6 +52,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./atomic": { + "types": "./dist/atomic.d.ts", + "default": "./dist/atomic.js" + }, "./common": { "types": "./dist/common.d.ts", "default": "./dist/common.js" diff --git a/src/atomic.ts b/src/atomic.ts new file mode 100644 index 0000000..6b81796 --- /dev/null +++ b/src/atomic.ts @@ -0,0 +1,198 @@ +import { Query } from "./query"; +import type { QueryStage } from "./common"; + +/** Matches an absent revision field on an existing record, not stored null. */ +export interface MissingRevision { + kind: "missing"; +} + +export interface AtomicCondition { + revisionField: keyof T & string; + expectedRevision: string | MissingRevision; +} + +/** Replaces supplied top-level fields and atomically installs a fresh revision. */ +export interface AtomicUpdate extends AtomicCondition { + type: "update"; + nextRevision: string; + patch: Partial; +} + +export interface AtomicDelete extends AtomicCondition { + type: "delete"; +} + +export type AtomicEqualityValue = string | number | boolean | Date; + +/** Deletes by one observed scalar value; does not provide revision or ABA protection. */ +export interface AtomicDeleteIfEqual { + type: "deleteIfEqual"; + field: keyof T & string; + expectedValue: AtomicEqualityValue; +} + +export type AtomicMutation = + | AtomicUpdate + | AtomicDelete + | AtomicDeleteIfEqual; + +export type AtomicMutationOutcome = "applied" | "not-applied" | "unknown"; + +const outcomes: readonly unknown[] = ["applied", "not-applied", "unknown"]; +const tableStages = ["schema", "instance", "table"]; +const identityFields = ["id", "_id"]; + +/** Signals an adapter that does not implement the atomic mutation contract. */ +export class AtomicMutationUnsupportedError extends Error { + public constructor() { + super("The database adapter does not support atomicMutation"); + this.name = "AtomicMutationUnsupportedError"; + } +} + +function isRecord(value: unknown): value is Record { + return ( + value !== null && + typeof value === "object" && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) + ); +} + +function isToken(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function validateField(field: string) { + if (!isToken(field) || field.includes(".") || field.startsWith("$")) { + throw new TypeError("Atomic mutation fields must be literal field names"); + } +} + +function validateConstant(value: unknown, ancestors = new Set()) { + if (value === null || ["string", "boolean"].includes(typeof value)) return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (value instanceof Date && Number.isFinite(value.getTime())) return; + if ((!Array.isArray(value) && !isRecord(value)) || ancestors.has(value)) { + throw new TypeError("Atomic mutation patches must contain constant data"); + } + if (Array.isArray(value) && Object.keys(value).length !== value.length) { + throw new TypeError("Atomic mutation arrays must not contain holes"); + } + ancestors.add(value); + for (const [field, child] of Object.entries(value)) { + validateField(field); + validateConstant(child, ancestors); + } + ancestors.delete(value); +} + +/** Validates literal input; adapters must additionally supply scope fields. */ +export function ValidateAtomicMutation( + key: string, + request: AtomicMutation, + protectedFields: readonly string[] = [], +): void { + if (!isToken(key) || !isRecord(request)) { + throw new TypeError("Atomic mutation requires one literal record identity"); + } + const forbidden = [...identityFields, ...protectedFields]; + if (request.type === "deleteIfEqual") { + validateEquality(request, forbidden); + return; + } + validateField(request.revisionField); + if (forbidden.includes(request.revisionField)) { + throw new TypeError("Atomic mutation revision cannot be an identity field"); + } + const expected = request.expectedRevision; + const isMissing = + isRecord(expected) && + expected.kind === "missing" && + Object.keys(expected).length === 1; + if (!isToken(expected) && !isMissing) { + throw new TypeError("Expected revision must be a token or missing tag"); + } + if (request.type === "delete") return; + if (request.type !== "update") { + throw new TypeError("Unknown atomic mutation operation"); + } + validateUpdate(request, forbidden); +} + +function validateEquality( + request: AtomicDeleteIfEqual, + forbidden: string[], +) { + validateField(request.field); + if (forbidden.includes(request.field)) { + throw new TypeError("Atomic equality field cannot be an identity field"); + } + const value = request.expectedValue; + if ( + !["string", "number", "boolean"].includes(typeof value) && + !(value instanceof Date) + ) { + throw new TypeError("Atomic equality requires a scalar or Date"); + } + validateConstant(value); +} + +function validateUpdate(request: AtomicUpdate, forbidden: string[]) { + if ( + !isToken(request.nextRevision) || + request.nextRevision === request.expectedRevision + ) { + throw new TypeError("Atomic mutation requires a changed revision token"); + } + if (!isRecord(request.patch)) { + throw new TypeError("Atomic mutation patch must be an object"); + } + forbidden.push(request.revisionField); + if (Object.keys(request.patch).some((field) => forbidden.includes(field))) { + throw new TypeError( + "Atomic mutation patch cannot change identity or revision", + ); + } + validateConstant(request.patch); +} + +/** Rejects selections and cross-instance operations before adapter dispatch. */ +export function ValidateAtomicMutationTable(stages: QueryStage[]): void { + if ( + stages.length !== tableStages.length || + stages.some((stage, index) => stage.stage !== tableStages[index]) || + (stages[1].options?.id !== undefined && + typeof stages[1].options.id !== "string") + ) { + throw new TypeError("Atomic mutation requires one instance-scoped table"); + } +} + +/** Executes only the explicit atomic stage; invalid adapter results fail closed. */ +export class AtomicMutationQuery extends Query { + public override async run(): Promise { + const terminal = this.stages.at(-1)!; + ValidateAtomicMutationTable(this.stages.slice(0, -1)); + if ( + terminal.stage !== "atomicMutation" || + terminal.options !== undefined || + terminal.args.length !== 2 + ) { + throw new TypeError( + "Atomic mutation requires its canonical terminal stage", + ); + } + ValidateAtomicMutation(terminal.args[0], terminal.args[1]); + const result = await super.run(); + if (!outcomes.includes(result)) throw new AtomicMutationUnsupportedError(); + return result; + } + + public override cursor(): AsyncGenerator< + AtomicMutationOutcome, + void, + unknown + > { + throw new TypeError("Atomic mutations cannot be executed as cursors"); + } +} diff --git a/src/index.ts b/src/index.ts index fd45543..bd2eeaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./atomic"; export { Datum } from "./datum"; export { Query } from "./query"; export { diff --git a/src/selection.ts b/src/selection.ts index 18fa3d5..77c6235 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -1,6 +1,13 @@ import { Datum } from "./datum"; import { Query } from "./query"; import { Stream } from "./stream"; +import { + AtomicMutationQuery, + ValidateAtomicMutation, + ValidateAtomicMutationTable, + type AtomicMutation, + type AtomicMutationOutcome, +} from "./atomic"; import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; import type { Changes, @@ -116,6 +123,22 @@ export class Selection extends Stream { * Database table */ export class Table extends Selection { + /** Atomically checks one record's revision and patches or deletes it without upsert. */ + public atomicMutation( + key: string, + request: AtomicMutation, + ): Query { + ValidateAtomicMutationTable(this.stages); + ValidateAtomicMutation(key, request); + return this.stage( + AtomicMutationQuery, + "atomicMutation", + undefined, + key, + request, + ); + } + /** * Inserts one or more documents into this table * diff --git a/src/tests/atomic_mutation.test.ts b/src/tests/atomic_mutation.test.ts new file mode 100644 index 0000000..63e51ad --- /dev/null +++ b/src/tests/atomic_mutation.test.ts @@ -0,0 +1,286 @@ +import { expect } from "chai"; +import { + AtomicMutationUnsupportedError, + CROSS_INSTANCE, + Query, + SchemaInstance, + ValidateAtomicMutation, + ValueProxy, + type AtomicMutation, + type AtomicMutationOutcome, + type AtomicUpdate, +} from "../index"; +import { QueryStage, StagedObject } from "../common"; +import type { InstanceId, Table } from "@antelopejs/interface-database"; + +interface RecordData { + revision?: string; + title: string; + details: Record; +} + +interface TestTables { + records: RecordData; +} + +function Instance(id?: InstanceId) { + return new SchemaInstance( + QueryStage("instance", { id }), + new StagedObject(QueryStage("schema", { id: "atomic-contract" })), + ); +} + +const table = Instance("tenant-a").table("records"); +const request: AtomicUpdate = { + type: "update", + revisionField: "revision", + expectedRevision: "observed", + nextRevision: "fresh", + patch: { details: { count: 3 } }, +}; + +describe("Atomic mutation interface contract", () => { + it("encodes one literal identity and constant operation", EncodeMutation); + it( + "accepts missing-only bootstrap, not null or empty revisions", + ValidateRevision, + ); + it( + "rejects identity, revision and adapter-owned fields", + ValidateProtectedFields, + ); + it("rejects expressions and undefined even in nested patches", ValidatePatch); + it("rejects cross-instance scope and cursor execution", ValidateScope); + it("accepts only scalar equality deletion", ValidateEquality); + it("preserves all outcomes and never retries unknown", PreserveOutcomes); + it( + "fails closed on legacy results and preserves errors", + RejectLegacyResults, + ); + it("exposes type-safe consumer shapes", CheckConsumerTypes); + it("rejects noncanonical terminal stages before dispatch", ValidateTerminal); +}); + +function EncodeMutation() { + const query: Query = table.atomicMutation( + "record-a", + request, + ); + expect(query.build()).to.deep.equal([ + { stage: "schema", options: { id: "atomic-contract" }, args: [] }, + { stage: "instance", options: { id: "tenant-a" }, args: [] }, + { stage: "table", options: { id: "records" }, args: [] }, + { + stage: "atomicMutation", + options: undefined, + args: ["record-a", request], + }, + ]); + expect(table.build()).to.have.length(3); +} + +function ValidateRevision() { + expect(() => + table.atomicMutation("record-a", { + ...request, + expectedRevision: { kind: "missing" }, + }), + ).not.to.throw(); + for (const expectedRevision of [ + null, + undefined, + "", + { kind: "missing", extra: true }, + ]) { + expect(() => + ValidateAtomicMutation("record-a", { + ...request, + expectedRevision, + } as AtomicMutation), + ).to.throw(TypeError); + } + expect(() => + table.atomicMutation("record-a", { + ...request, + nextRevision: "observed", + }), + ).to.throw(TypeError); +} + +function ValidateProtectedFields() { + for (const field of ["id", "_id", "revision", "tenant_id"]) { + expect(() => + ValidateAtomicMutation( + "record-a", + { + ...request, + patch: { [field]: "changed" }, + }, + ["tenant_id"], + ), + ).to.throw(TypeError); + } + expect(() => + ValidateAtomicMutation>( + "record-a", + { + ...request, + revisionField: "tenant_id", + }, + ["tenant_id"], + ), + ).to.throw(TypeError); +} + +function ValidatePatch() { + for (const value of [ + undefined, + () => true, + ValueProxy.constant(1), + Infinity, + new Array(1), + ]) { + expect(() => + table.atomicMutation("record-a", { + ...request, + patch: { details: { nested: [value] } }, + }), + ).to.throw(TypeError); + } + expect(() => + table.atomicMutation("record-a", { + ...request, + patch: { details: { date: new Date("2026-01-01"), nil: null } }, + }), + ).not.to.throw(); +} + +function ValidateScope() { + expect(() => + Instance(CROSS_INSTANCE) + .table("records") + .atomicMutation("record-a", request), + ).to.throw(TypeError); + expect(() => + Instance().table("records").atomicMutation("record-a", request), + ).not.to.throw(); + expect(() => table.atomicMutation("record-a", request).cursor()).to.throw( + TypeError, + ); + expect(() => + table.filter(() => true).atomicMutation("record-a", request), + ).to.throw(TypeError); +} + +function ValidateEquality() { + for (const expectedValue of ["", false, 0, new Date("2026-01-01")]) { + expect(() => + table.atomicMutation("record-a", { + type: "deleteIfEqual", + field: "title", + expectedValue, + }), + ).not.to.throw(); + } + for (const expectedValue of [null, undefined, {}, [], NaN, new Date(NaN)]) { + expect(() => + ValidateAtomicMutation("record-a", { + type: "deleteIfEqual", + field: "title", + expectedValue, + } as AtomicMutation), + ).to.throw(TypeError); + } +} + +async function WithResult(result: unknown, check: () => Promise) { + const original = Query.prototype.run; + let calls = 0; + Query.prototype.run = () => { + calls++; + return Promise.resolve(result as never); + }; + try { + await check(); + expect(calls).to.equal(1); + } finally { + Query.prototype.run = original; + } +} + +async function PreserveOutcomes() { + for (const result of ["applied", "not-applied", "unknown"]) { + await WithResult(result, async () => { + expect(await table.atomicMutation("record-a", request)).to.equal(result); + }); + } +} + +async function RejectLegacyResults() { + for (const result of [undefined, 0, 1, {}, []]) { + await WithResult(result, async () => { + const error = await table + .atomicMutation("record-a", request) + .run() + .then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).to.be.instanceOf(AtomicMutationUnsupportedError); + }); + } + const failure = new TypeError("definitive validation failure"); + await WithResult(Promise.reject(failure), async () => { + const error = await table + .atomicMutation("record-a", request) + .run() + .then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).to.equal(failure); + }); +} + +type IsAssignable = From extends To ? true : false; + +async function ValidateTerminal() { + for (const terminal of [ + { stage: "delete" }, + { options: {} }, + { args: ["record-a"] }, + ]) { + const query = table.atomicMutation("record-a", request); + Object.assign(query.build().at(-1)!, terminal); + const error = await query.run().then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error).to.be.instanceOf(TypeError); + } +} + +function CheckConsumerTypes() { + const rejectsNull: IsAssignable< + null, + AtomicUpdate["expectedRevision"] + > = false; + const rejectsArrayKey: IsAssignable< + string[], + Parameters["atomicMutation"]>[0] + > = false; + const rejectsFunctionPatch: IsAssignable< + () => RecordData, + AtomicUpdate["patch"] + > = false; + const excludesSelection: IsAssignable< + "atomicMutation", + keyof ReturnType + > = false; + expect([ + rejectsNull, + rejectsArrayKey, + rejectsFunctionPatch, + excludesSelection, + ]).to.deep.equal([false, false, false, false]); +} From 3aa17ab6adead03dc6b351665cdf2005cb1bf042 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Mon, 14 Sep 2026 14:11:07 +0000 Subject: [PATCH 2/4] docs(database): define atomic mutation guarantees and recovery limits --- docs/2.operations/8.atomic_mutations.md | 71 +++++++++++++++++++++++++ docs/index.md | 1 + 2 files changed, 72 insertions(+) create mode 100644 docs/2.operations/8.atomic_mutations.md diff --git a/docs/2.operations/8.atomic_mutations.md b/docs/2.operations/8.atomic_mutations.md new file mode 100644 index 0000000..9232b5b --- /dev/null +++ b/docs/2.operations/8.atomic_mutations.md @@ -0,0 +1,71 @@ +# Atomic single-record mutations + +`Table.atomicMutation(id, request)` checks one immutable, instance-scoped record identity and one condition inside the database mutation. It returns `Query`. It does not provide multi-record transactions, a lock service, receipts, or external-effect coordination. + +Generic `filter(...).update(...)` and `filter(...).delete()` do **not** promise this guarantee. Use a direct `schema.instance(instanceId).table(tableId)` with one literal string identity. The default instance is supported; `CROSS_INSTANCE`, filtered selections, expression keys, cursors, and upserts are not. + +## Compare a revision + +Public types are exported from `@antelopejs/interface-database` and `@antelopejs/interface-database/atomic`. An update checks the current revision and installs the patch and next revision together. A delete checks the revision inside the deletion. + +```ts +const outcome = await table.atomicMutation(recordId, { + type: "update", + revisionField: "revision", + expectedRevision: observed.revision, + nextRevision: freshRevision, + patch: { title: "Updated title" }, +}); + +const deletion = await table.atomicMutation(recordId, { + type: "delete", + revisionField: "revision", + expectedRevision: observed.revision, +}); +``` + +Revision tokens are nonempty strings. Each update must install a changed token that the caller guarantees has never been used for that identity. All writers of protected state must advance the revision; otherwise a successful comparison does not prove that the observed state is current. Initialize new records with fresh revisions and use a new identity for each incarnation, including recreation after deletion. + +`expectedRevision: { kind: "missing" }` matches an **absent revision field on an existing record**. It does not match a stored `null`, an empty string, or a missing record. An update can use this expectation to bootstrap legacy data atomically; a delete can use it when the domain permits deleting an unversioned record. A backend that cannot distinguish absence from null must reject this expectation rather than weaken it. In PostgreSQL, declared SQL columns collapse this distinction; an overflow JSON field can preserve it. Review mapper serialization before choosing revision storage. + +## Patch semantics + +The patch replaces each supplied top-level field. Nested objects and arrays replace their entire previous values; they do not merge recursively. Unspecified fields remain unchanged, and `null` stores null rather than removing the field. There is no unset operation. + +Patches contain only constant strings, booleans, finite numbers, null, valid Dates, dense arrays, and plain objects. Undefined, functions, query proxies, cycles, and operator/path field names are rejected. The patch cannot contain `id`, `_id`, the revision field, or adapter-owned scope fields. Adapters must preserve their normal Date storage representation without interpreting objects that resemble query stages as executable expressions. + +## Delete by one observed scalar + +`deleteIfEqual` provides a narrower condition for domains where one field equality is sufficient, such as deletion of an expired record whose observed timestamp remains unchanged. + +```ts +const outcome = await table.atomicMutation(recordId, { + type: "deleteIfEqual", + field: "lastSeenAt", + expectedValue: observed.lastSeenAt, +}); +``` + +`expectedValue` accepts a string, finite number, boolean, or valid Date. Equality is exact and type-sensitive, with Dates compared by their stored instant; scalar equality must not match an array containing that scalar. Missing fields never match. Null, objects, arrays, identity fields, and scope fields are not supported. + +This variant does not check or advance a revision and does not prevent ABA: a value can change and later return to its observed value. The domain must prove that equality alone authorizes deletion. It is not a substitute for revision checks on state transitions. + +## Handle all outcomes + +| Outcome | Meaning | +| ------------- | -------------------------------------------------------------------------------------------------------------------- | +| `applied` | The adapter received definitive acknowledgement that the one mutation applied. | +| `not-applied` | The adapter received definitive acknowledgement that the target was missing or its scope or condition did not match. | +| `unknown` | The adapter cannot determine whether the mutation applied, including acknowledgement loss. | + +Definitive validation and unsupported-operation errors throw. Neither an exception nor `unknown` means mismatch. The interface rejects invalid adapter results, including `undefined`, zero, and generic modified counts, with `AtomicMutationUnsupportedError`; adapters must reject unsupported stages explicitly. There is no fallback to generic filtered writes and no separate capability probe. Use an adapter whose implementation has been verified before relying on this API. + +The interface and adapter must not automatically retry an uncertain mutation. Do not repeat external effects or infer failure from a later mismatch. Domain-specific recovery can reconcile durable evidence and receipts, but a fresh read alone cannot prove whether an earlier operation or external effect occurred. Any deliberate replay requires a domain proof and does not turn `not-applied` into evidence about the original attempt. + +## Adapter contract + +The canonical terminal stage is `{ stage: "atomicMutation", options: undefined, args: [id, request] }`, immediately after `schema`, `instance`, and `table`. `AtomicMutation` is the union of `AtomicUpdate`, `AtomicDelete`, and `AtomicDeleteIfEqual`. + +Adapters can reuse `ValidateAtomicMutation(id, request, protectedFields)` and `ValidateAtomicMutationTable(prefixStages)`. They must additionally validate the terminal envelope and schema-specific restrictions. Scope, immutable identity, and the revision or equality condition must participate in the target-local native mutation, not only an earlier selection or identity subquery. Backend tests must prove contention, scope isolation, missing/null distinction, top-level replacement, and acknowledgement-loss behavior. + +This API remains unpublished during adapter and consumer verification. Package metadata alone does not establish capability; do not infer support from the unchanged development package version. diff --git a/docs/index.md b/docs/index.md index b420ae0..77fdece 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ The Database interface provides AQL (Antelope Query Language), a database-agnost - [Index Management](./2.operations/5.indexes.md) - Define and use secondary indexes - [Filtering and Querying](./2.operations/6.filtering.md) - Filter, compare, and transform data - [Lookup](./2.operations/7.lookup.md) - Foreign key joins and data population + - [Atomic Mutations](./2.operations/8.atomic_mutations.md) - Conditional single-record mutations and outcomes - [**3. Results**](./3.results/1.index.md) - Result types and handling: - [Insert Results](./3.results/2.write_results.md) - Results from insert operations From 30527f995d0a5ba8e3b174ab31cfd5df5569a971 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Mon, 14 Sep 2026 14:15:07 +0000 Subject: [PATCH 3/4] test(database): resolve atomic contract lint diagnostics --- src/selection.ts | 2 +- src/tests/atomic_mutation.test.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/selection.ts b/src/selection.ts index 77c6235..d819088 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -1,6 +1,7 @@ import { Datum } from "./datum"; import { Query } from "./query"; import { Stream } from "./stream"; +import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; import { AtomicMutationQuery, ValidateAtomicMutation, @@ -8,7 +9,6 @@ import { type AtomicMutation, type AtomicMutationOutcome, } from "./atomic"; -import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; import type { Changes, DeepPartial, diff --git a/src/tests/atomic_mutation.test.ts b/src/tests/atomic_mutation.test.ts index 63e51ad..65e8484 100644 --- a/src/tests/atomic_mutation.test.ts +++ b/src/tests/atomic_mutation.test.ts @@ -1,4 +1,7 @@ import { expect } from "chai"; +import type { InstanceId, Table } from "@antelopejs/interface-database"; + +import { QueryStage, StagedObject } from "../common"; import { AtomicMutationUnsupportedError, CROSS_INSTANCE, @@ -10,8 +13,6 @@ import { type AtomicMutationOutcome, type AtomicUpdate, } from "../index"; -import { QueryStage, StagedObject } from "../common"; -import type { InstanceId, Table } from "@antelopejs/interface-database"; interface RecordData { revision?: string; @@ -133,12 +134,14 @@ function ValidateProtectedFields() { } function ValidatePatch() { + const sparse: unknown[] = []; + sparse.length = 1; for (const value of [ undefined, () => true, ValueProxy.constant(1), Infinity, - new Array(1), + sparse, ]) { expect(() => table.atomicMutation("record-a", { @@ -194,7 +197,7 @@ function ValidateEquality() { } async function WithResult(result: unknown, check: () => Promise) { - const original = Query.prototype.run; + const original = Object.getOwnPropertyDescriptor(Query.prototype, "run")!; let calls = 0; Query.prototype.run = () => { calls++; @@ -204,7 +207,7 @@ async function WithResult(result: unknown, check: () => Promise) { await check(); expect(calls).to.equal(1); } finally { - Query.prototype.run = original; + Object.defineProperty(Query.prototype, "run", original); } } From 0d492469fd3bccb3ab712e62d0062452e3c2d4a4 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Mon, 14 Sep 2026 14:16:16 +0000 Subject: [PATCH 4/4] style(database): complete sorted atomic imports --- src/selection.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/selection.ts b/src/selection.ts index d819088..c2ad186 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -2,6 +2,12 @@ import { Datum } from "./datum"; import { Query } from "./query"; import { Stream } from "./stream"; import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; +import type { + Changes, + DeepPartial, + ExtractType, + InsertOptions, +} from "./common"; import { AtomicMutationQuery, ValidateAtomicMutation, @@ -9,12 +15,6 @@ import { type AtomicMutation, type AtomicMutationOutcome, } from "./atomic"; -import type { - Changes, - DeepPartial, - ExtractType, - InsertOptions, -} from "./common"; type SelectionKey = string | number | boolean;