Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/2.operations/8.atomic_mutations.md
Original file line number Diff line number Diff line change
@@ -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<AtomicMutationOutcome>`. 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<T>` is the union of `AtomicUpdate<T>`, `AtomicDelete<T>`, and `AtomicDeleteIfEqual<T>`.

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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"types": "dist/index.d.ts",
"typesVersions": {
"*": {
"atomic": [
"dist/atomic.d.ts"
],
"common": [
"dist/common.d.ts"
],
Expand Down Expand Up @@ -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"
Expand Down
198 changes: 198 additions & 0 deletions src/atomic.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
revisionField: keyof T & string;
expectedRevision: string | MissingRevision;
}

/** Replaces supplied top-level fields and atomically installs a fresh revision. */
export interface AtomicUpdate<T> extends AtomicCondition<T> {
type: "update";
nextRevision: string;
patch: Partial<T>;
}

export interface AtomicDelete<T> extends AtomicCondition<T> {
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<T> {
type: "deleteIfEqual";
field: keyof T & string;
expectedValue: AtomicEqualityValue;
}

export type AtomicMutation<T> =
| AtomicUpdate<T>
| AtomicDelete<T>
| AtomicDeleteIfEqual<T>;

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<string, unknown> {
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<unknown>()) {
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<T>(
key: string,
request: AtomicMutation<T>,
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<T>(
request: AtomicDeleteIfEqual<T>,
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<T>(request: AtomicUpdate<T>, 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<AtomicMutationOutcome> {
public override async run(): Promise<AtomicMutationOutcome> {
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");
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./atomic";
export { Datum } from "./datum";
export { Query } from "./query";
export {
Expand Down
23 changes: 23 additions & 0 deletions src/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import type {
ExtractType,
InsertOptions,
} from "./common";
import {
AtomicMutationQuery,
ValidateAtomicMutation,
ValidateAtomicMutationTable,
type AtomicMutation,
type AtomicMutationOutcome,
} from "./atomic";

type SelectionKey = string | number | boolean;

Expand Down Expand Up @@ -116,6 +123,22 @@ export class Selection<T> extends Stream<T> {
* Database table
*/
export class Table<T> extends Selection<T> {
/** Atomically checks one record's revision and patches or deletes it without upsert. */
public atomicMutation(
key: string,
request: AtomicMutation<T>,
): Query<AtomicMutationOutcome> {
ValidateAtomicMutationTable(this.stages);
ValidateAtomicMutation(key, request);
return this.stage(
AtomicMutationQuery,
"atomicMutation",
undefined,
key,
request,
);
}

/**
* Inserts one or more documents into this table
*
Expand Down
Loading
Loading