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
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
"stream": [
"dist/stream.d.ts"
],
"staged-query": [
"dist/staged-query/index.d.ts"
],
"valueproxy": [
"dist/valueproxy.d.ts"
]
Expand Down Expand Up @@ -80,6 +83,10 @@
"types": "./dist/stream.d.ts",
"default": "./dist/stream.js"
},
"./staged-query": {
"types": "./dist/staged-query/index.d.ts",
"default": "./dist/staged-query/index.js"
},
"./valueproxy": {
"types": "./dist/valueproxy.d.ts",
"default": "./dist/valueproxy.js"
Expand All @@ -98,6 +105,7 @@
"prepack": "pnpm run build",
"release": "pnpm run lint && pnpm run prepack && release-it",
"test": "pnpm run build && ajs module test .",
"test:staged-query": "pnpm run build && node scripts/test-staged-query.cjs",
"format:check": "oxfmt --check .",
"knip": "knip"
},
Expand Down
125 changes: 125 additions & 0 deletions scripts/test-staged-query.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
const fs = require("node:fs");
const path = require("node:path");
const Module = require("node:module");
const assert = require("node:assert/strict");

const forbiddenImports = ["@antelopejs/interface-core", "node:async_hooks"];
const originalLoad = Module._load;

Module._load = function (request, parent, isMain) {
assert.equal(
forbiddenImports.includes(request),
false,
`staged query imported ${request}`,
);
return originalLoad.call(this, request, parent, isMain);
};

const staged = require("../dist/staged-query");
Module._load = originalLoad;

const schema = new staged.Schema("parity", {
posts: { fields: {}, indexes: {} },
});
const query = schema
.instance("default")
.table("posts")
.filter((post) => post.key("author").eq("Alice"))
.orderBy("views", "desc")
.slice(0, 10);

assert.deepEqual(query.build(), [
{ stage: "schema", options: { id: "parity" }, args: [] },
{ stage: "instance", options: { id: "default" }, args: [] },
{ stage: "table", options: { id: "posts" }, args: [] },
{
stage: "filter",
options: undefined,
args: [
{
stage: "func",
args: [
[0],
new staged.ValueProxy({
stage: "arg",
options: undefined,
args: [0],
})
.key("author")
.eq("Alice"),
],
},
],
},
{
stage: "orderBy",
options: { index: "views", direction: "desc" },
args: [],
},
{ stage: "slice", options: undefined, args: [0, 10] },
]);

for (const method of ["run", "then", "cursor"]) {
assert.equal(method in query, false, `staged query exposes ${method}`);
}
assert.equal(Symbol.asyncIterator in query, false);

const atomicQuery = schema
.instance("default")
.table("posts")
.atomicMutation("post-000", {
type: "deleteIfEqual",
field: "author",
expectedValue: "Nobody",
});
assert.equal(atomicQuery instanceof staged.AtomicMutationQuery, true);
assert.equal("run" in atomicQuery, false);

const distDirectory = path.join(__dirname, "../dist/staged-query");
for (const file of fs.readdirSync(distDirectory)) {
if (!file.endsWith(".js")) continue;
const source = fs.readFileSync(path.join(distDirectory, file), "utf8");
for (const forbidden of forbiddenImports) {
assert.equal(
source.includes(forbidden),
false,
`${file} contains ${forbidden}`,
);
}
}

const executable = require("../dist");
const executableTable = new executable.Schema("runtime", {
records: { fields: {}, indexes: {} },
})
.instance()
.table("records");
const executableInsert = executableTable.insert({});

assert.equal(executableTable instanceof executable.Table, true);
assert.equal(executableTable instanceof executable.Selection, true);
assert.equal(executableTable instanceof executable.Stream, true);
assert.equal(executableTable instanceof executable.Query, true);
assert.equal(typeof executableTable.run, "function");
assert.equal(typeof executableTable.atomicMutation, "function");
assert.equal(executableInsert instanceof executable.Query, true);
assert.equal(typeof executableInsert.run, "function");

const originalRun = executable.Query.prototype.run;

Check warning on line 108 in scripts/test-staged-query.cjs

View workflow job for this annotation

GitHub Actions / checks

typescript(unbound-method)

scripts/test-staged-query.cjs:108:48: Avoid referencing unbound methods which may cause unintentional scoping of `this`.
executable.Query.prototype.run = async () => "not-applied";
const executableAtomic = executableTable.atomicMutation("record-1", {
type: "deleteIfEqual",
field: "status",
expectedValue: "draft",
});
void executableAtomic
.run()
.then((result) => assert.equal(result, "not-applied"))
.finally(() => {
executable.Query.prototype.run = originalRun;
})
.then(() => {
console.log(
"staged-query purity, AQL stages, and root compatibility: PASS",
);
});
204 changes: 28 additions & 176 deletions src/atomic.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,12 @@
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";
import {
AtomicMutationQuery as StagedAtomicMutationQuery,
ValidateAtomicMutation,
ValidateAtomicMutationTable,
type AtomicMutationOutcome,
} from "./staged-query/atomic";

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 {
Expand All @@ -50,149 +16,35 @@ export class AtomicMutationUnsupportedError extends Error {
}
}

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;
async function run(
this: StagedAtomicMutationQuery,
): Promise<AtomicMutationOutcome> {
const stages = this.build();
const terminal = stages.at(-1)!;
ValidateAtomicMutationTable(stages.slice(0, -1));
if (
!["string", "number", "boolean"].includes(typeof value) &&
!(value instanceof Date)
terminal.stage !== "atomicMutation" ||
terminal.options !== undefined ||
terminal.args.length !== 2
) {
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",
"Atomic mutation requires its canonical terminal stage",
);
}
validateConstant(request.patch);
ValidateAtomicMutation(terminal.args[0], terminal.args[1]);
const result = await Query.prototype.run.call(this);
if (!outcomes.includes(result)) throw new AtomicMutationUnsupportedError();
return result;
}

/** 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");
}
function cursor(): AsyncGenerator<AtomicMutationOutcome, void, unknown> {
throw new TypeError("Atomic mutations cannot be executed as cursors");
}

/** 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;
}
Object.defineProperties(StagedAtomicMutationQuery.prototype, {
run: { configurable: true, value: run, writable: true },
cursor: { configurable: true, value: cursor, writable: true },
});

public override cursor(): AsyncGenerator<
AtomicMutationOutcome,
void,
unknown
> {
throw new TypeError("Atomic mutations cannot be executed as cursors");
}
}
export * from "./staged-query/atomic";
export { StagedAtomicMutationQuery as AtomicMutationQuery };
Loading
Loading