From d4946b07aab4edb5d9dcaf038f294c523f689538 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 00:25:45 +0100 Subject: [PATCH 01/16] refactor: clear the ratchet findings a pure refactor can clear (#49) * refactor(staged-query): check kind symbols with one predicate, annotate key() and distinct() returns * refactor(atomic): narrow the adapter result with a guard and compare the prototype as unknown * test: reject through a typed helper, type the change-feed and other-instance fixtures * test(scripts): give the staged-query script its types and use registerHooks for the import guard * chore: lower the ratchet ceilings --- guardrails.ratchet.json | 9 +++----- scripts/test-staged-query.cjs | 23 +++++++++--------- scripts/tsconfig.json | 10 ++++++++ src/atomic.ts | 8 ++++--- src/errors.ts | 4 ++-- src/staged-query/atomic.ts | 8 +++---- src/staged-query/datum.ts | 11 ++++++--- src/staged-query/kind.ts | 6 +---- src/staged-query/query.ts | 4 ++-- src/staged-query/stream.ts | 18 +++++++++------ src/staged-query/valueproxy.ts | 4 ++-- src/tests/atomic_mutation.test.ts | 30 ++++++++---------------- src/tests/change_feeds.test.ts | 2 +- src/tests/helpers/other-instance.ts | 36 +++++++++++++++++++++++------ src/tests/helpers/rejection.ts | 16 +++++++++++++ 15 files changed, 115 insertions(+), 74 deletions(-) create mode 100644 scripts/tsconfig.json create mode 100644 src/tests/helpers/rejection.ts diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json index 97d0827..cc5de2c 100644 --- a/guardrails.ratchet.json +++ b/guardrails.ratchet.json @@ -1,15 +1,12 @@ { "pack": "0.2.17", "rules": { - "antelopejs/no-presence-only-type-guard": 3, "anti-slop/no-chained-type-assertions": 3, "anti-slop/no-unknown-parameters": 2, "anti-slop/require-safety-comment-for-type-assertion": 2, - "typescript/no-unnecessary-type-parameters": 5, - "typescript/no-unsafe-argument": 5, - "typescript/no-unsafe-assignment": 14, - "typescript/no-unsafe-return": 11, - "typescript/use-unknown-in-catch-callback-variable": 3 + "typescript/no-unnecessary-type-parameters": 2, + "typescript/no-unsafe-argument": 3, + "typescript/no-unsafe-assignment": 2 }, "oversizeFiles": {} } diff --git a/scripts/test-staged-query.cjs b/scripts/test-staged-query.cjs index 610972e..e799290 100644 --- a/scripts/test-staged-query.cjs +++ b/scripts/test-staged-query.cjs @@ -4,19 +4,20 @@ 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 loadGuard = Module.registerHooks({ + resolve(specifier, context, nextResolve) { + assert.equal( + forbiddenImports.includes(specifier), + false, + `staged query imported ${specifier}`, + ); + return nextResolve(specifier, context); + }, +}); const staged = require("../dist/staged-query"); -Module._load = originalLoad; +loadGuard.deregister(); const schema = new staged.Schema("parity", { posts: { fields: {}, indexes: {} }, @@ -88,7 +89,7 @@ for (const file of fs.readdirSync(distDirectory)) { } } -const executable = require("../dist"); +const executable = require("../dist/index"); const executableTable = new executable.Schema("runtime", { records: { fields: {}, indexes: {} }, }) diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..09fdfef --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "noEmit": true, + "rootDir": "..", + "rootDirs": ["../src", "../dist"] + }, + "include": ["./*.cjs"] +} diff --git a/src/atomic.ts b/src/atomic.ts index 96b8aac..7f918d3 100644 --- a/src/atomic.ts +++ b/src/atomic.ts @@ -6,7 +6,9 @@ import { type AtomicMutationOutcome, } from "./staged-query/atomic"; -const outcomes: readonly unknown[] = ["applied", "not-applied", "unknown"]; +function isOutcome(value: unknown): value is AtomicMutationOutcome { + return value === "applied" || value === "not-applied" || value === "unknown"; +} /** Signals an adapter that does not implement the atomic mutation contract. */ export class AtomicMutationUnsupportedError extends Error { @@ -32,8 +34,8 @@ async function run( ); } ValidateAtomicMutation(terminal.args[0], terminal.args[1]); - const result = await Query.prototype.run.call(this); - if (!outcomes.includes(result)) throw new AtomicMutationUnsupportedError(); + const result: unknown = await Query.prototype.run.call(this); + if (!isOutcome(result)) throw new AtomicMutationUnsupportedError(); return result; } diff --git a/src/errors.ts b/src/errors.ts index ee88958..6c1e35c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import { HasKind, MarkKind } from "./staged-query/kind"; +import { isKindHolder, MarkKind } from "./staged-query/kind"; const DUPLICATE_KEY_KIND = Symbol.for( "@antelopejs/interface-database/DuplicateKeyError@1", @@ -41,5 +41,5 @@ MarkKind(DuplicateKeyError.prototype, DUPLICATE_KEY_KIND); export function IsDuplicateKeyError( value: unknown, ): value is DuplicateKeyError { - return HasKind(value, DUPLICATE_KEY_KIND); + return isKindHolder(value) && value[DUPLICATE_KEY_KIND] === true; } diff --git a/src/staged-query/atomic.ts b/src/staged-query/atomic.ts index 9d32935..ea7d1a7 100644 --- a/src/staged-query/atomic.ts +++ b/src/staged-query/atomic.ts @@ -42,11 +42,9 @@ const tableStages = ["schema", "instance", "table"]; const identityFields = ["id", "_id"]; function isRecord(value: Value): value is Value & object { - return ( - value !== null && - typeof value === "object" && - [Object.prototype, null].includes(Object.getPrototypeOf(value)) - ); + if (value === null || typeof value !== "object") return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } function isString(value: Value): value is Value & string { diff --git a/src/staged-query/datum.ts b/src/staged-query/datum.ts index be50293..74771fb 100644 --- a/src/staged-query/datum.ts +++ b/src/staged-query/datum.ts @@ -2,7 +2,7 @@ import { Query } from "./query"; import { ValueProxy } from "./valueproxy"; import type { Selection } from "./selection"; import type { ExtractType, Value } from "./common"; -import { HasKind, MarkKind } from "./kind"; +import { isKindHolder, MarkKind } from "./kind"; const DATUM_KIND = Symbol.for("@antelopejs/interface-database/Datum@1"); @@ -33,7 +33,12 @@ export class Datum extends Query { * @param def Default value * @returns New datum with the value */ - public key, U = undefined>(key: K, def?: U) { + public key, U = undefined>( + key: K, + def?: U, + ): Datum< + U extends undefined ? NonNullable[K] : NonNullable[K]> | U + > { return this.stage( Datum< U extends undefined @@ -122,5 +127,5 @@ MarkKind(Datum.prototype, DATUM_KIND); * @returns True if the value is a datum */ export function IsDatum(value: unknown): value is Datum { - return HasKind(value, DATUM_KIND); + return isKindHolder(value) && value[DATUM_KIND] === true; } diff --git a/src/staged-query/kind.ts b/src/staged-query/kind.ts index ac97c1a..6b7b0ad 100644 --- a/src/staged-query/kind.ts +++ b/src/staged-query/kind.ts @@ -4,14 +4,10 @@ type KindOwner = StagedObject | Error; type KindHolder = Partial>; -function isKindHolder(value: Value): value is Value & KindHolder { +export function isKindHolder(value: unknown): value is KindHolder { return typeof value === "object" && value !== null; } export function MarkKind(target: KindOwner, kind: symbol): void { Object.defineProperty(target, kind, { value: true }); } - -export function HasKind(value: Value, kind: symbol): boolean { - return isKindHolder(value) && value[kind] === true; -} diff --git a/src/staged-query/query.ts b/src/staged-query/query.ts index b77f094..8bdc1d5 100644 --- a/src/staged-query/query.ts +++ b/src/staged-query/query.ts @@ -1,5 +1,5 @@ import { StagedObject } from "./common"; -import { HasKind, MarkKind } from "./kind"; +import { isKindHolder, MarkKind } from "./kind"; const QUERY_KIND = Symbol.for("@antelopejs/interface-database/Query@1"); @@ -18,5 +18,5 @@ MarkKind(Query.prototype, QUERY_KIND); * @returns True if the value is a query */ export function IsQuery(value: unknown): value is Query { - return HasKind(value, QUERY_KIND); + return isKindHolder(value) && value[QUERY_KIND] === true; } diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts index afe3496..d53d0e0 100644 --- a/src/staged-query/stream.ts +++ b/src/staged-query/stream.ts @@ -3,7 +3,7 @@ import { Query } from "./query"; import type { Selection } from "./selection"; import type { Changes, ExtractType, Value } from "./common"; import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; -import { HasKind, MarkKind } from "./kind"; +import { isKindHolder, MarkKind } from "./kind"; const STREAM_KIND = Symbol.for("@antelopejs/interface-database/Stream@1"); @@ -33,7 +33,10 @@ export class Stream extends Query { * @param def Default value * @returns New stream */ - public key(key: K, def?: U) { + public key( + key: K, + def?: U, + ): Stream | U> { return this.stage( Stream | U>, "key", @@ -316,10 +319,11 @@ export class Stream extends Query { * @returns New stream */ public distinct(field: TK): Stream; - public distinct(field?: keyof T) { - return this.stage(field ? Stream : Datum, "distinct", { - field, - }); + public distinct(field?: keyof T): Datum | Stream { + if (field) { + return this.stage(Stream, "distinct", { field }); + } + return this.stage(Datum, "distinct", { field }); } /** @@ -344,5 +348,5 @@ MarkKind(Stream.prototype, STREAM_KIND); * @returns True if the value is a stream */ export function IsStream(value: unknown): value is Stream { - return HasKind(value, STREAM_KIND); + return isKindHolder(value) && value[STREAM_KIND] === true; } diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts index 78594f0..6c57064 100644 --- a/src/staged-query/valueproxy.ts +++ b/src/staged-query/valueproxy.ts @@ -4,7 +4,7 @@ import { type ExtractType, type StagedConstructor, } from "./common"; -import { HasKind, MarkKind } from "./kind"; +import { isKindHolder, MarkKind } from "./kind"; const VALUEPROXY_KIND = Symbol.for( "@antelopejs/interface-database/ValueProxy@1", @@ -811,5 +811,5 @@ MarkKind(ValueProxy.prototype, VALUEPROXY_KIND); * @returns True if the value is a value proxy */ export function IsValueProxy(value: unknown): value is ValueProxy { - return HasKind(value, VALUEPROXY_KIND); + return isKindHolder(value) && value[VALUEPROXY_KIND] === true; } diff --git a/src/tests/atomic_mutation.test.ts b/src/tests/atomic_mutation.test.ts index 9e18ead..82281f1 100644 --- a/src/tests/atomic_mutation.test.ts +++ b/src/tests/atomic_mutation.test.ts @@ -14,6 +14,7 @@ import { type InstanceId, type Table, } from "../index"; +import { rejectionOf } from "./helpers/rejection"; interface RecordDetails { count?: number; @@ -247,25 +248,18 @@ async function PreserveOutcomes() { 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) => reason, - ); - expect(error).to.be.instanceOf(AtomicMutationUnsupportedError); + await rejectionOf( + table.atomicMutation("record-a", request).run(), + 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) => reason, - ); + const error = await rejectionOf( + table.atomicMutation("record-a", request).run(), + TypeError, + ); expect(error).to.equal(failure); }); } @@ -280,11 +274,7 @@ async function ValidateTerminal() { ]) { const query = table.atomicMutation("record-a", request); Object.assign(query.build().at(-1)!, terminal); - const error = await query.run().then( - () => undefined, - (reason) => reason, - ); - expect(error).to.be.instanceOf(TypeError); + await rejectionOf(query.run(), TypeError); } } diff --git a/src/tests/change_feeds.test.ts b/src/tests/change_feeds.test.ts index 70e1240..50a58af 100644 --- a/src/tests/change_feeds.test.ts +++ b/src/tests/change_feeds.test.ts @@ -143,7 +143,7 @@ async function BulkInsertEventTest() { }); const insertedCars = results - .map((r: any) => r.newValue.car) + .flatMap((entry) => (entry.newValue ? [entry.newValue.car] : [])) .sort((a, b) => Number(a > b) - Number(a < b)); expect(insertedCars).to.deep.equal(["BMW", "Tesla"]); } diff --git a/src/tests/helpers/other-instance.ts b/src/tests/helpers/other-instance.ts index 7ac76a0..5e07664 100644 --- a/src/tests/helpers/other-instance.ts +++ b/src/tests/helpers/other-instance.ts @@ -16,11 +16,28 @@ type ModuleRequire = (request: string) => object; function hasInternals( candidate: typeof Module, ): candidate is typeof Module & ModuleInternals { - return "_resolveFilename" in candidate && "_nodeModulePaths" in candidate; + return ( + "_resolveFilename" in candidate && + typeof candidate._resolveFilename === "function" && + "_nodeModulePaths" in candidate && + typeof candidate._nodeModulePaths === "function" + ); } function isLoadable(candidate: Module): candidate is Module & LoadableModule { - return "load" in candidate; + return "load" in candidate && typeof candidate.load === "function"; +} + +function isObjectLike(value: unknown): value is object { + return (typeof value === "object" || typeof value === "function") && !!value; +} + +function exportsOf(loaded: Module): object { + const exported: unknown = loaded.exports; + if (!isObjectLike(exported)) { + throw new TypeError(`${loaded.id} did not export an object`); + } + return exported; } function resolveInternals() { @@ -47,7 +64,7 @@ export function CreateOtherInstance(folders: string[]): ModuleRequire { const load = (filename: string, parent: Module): object => { const cached = modules.get(filename); if (cached) { - return cached.exports; + return exportsOf(cached); } const loaded = new Module(filename, parent); loaded.filename = filename; @@ -58,15 +75,20 @@ export function CreateOtherInstance(folders: string[]): ModuleRequire { throw new Error("node:module instances cannot load a file directly"); } loaded.load(filename); - return loaded.exports; + return exportsOf(loaded); }; const requireFrom = (parent: Module, request: string): object => { const filename = resolveInternals()._resolveFilename(request, parent); const isOwned = owned.some((root) => filename.startsWith(root)); - return isOwned - ? load(filename, parent) - : Module.prototype.require.call(parent, request); + if (isOwned) { + return load(filename, parent); + } + const required: unknown = Module.prototype.require.call(parent, request); + if (!isObjectLike(required)) { + throw new TypeError(`${request} did not export an object`); + } + return required; }; return (request: string) => requireFrom(module, request); diff --git a/src/tests/helpers/rejection.ts b/src/tests/helpers/rejection.ts new file mode 100644 index 0000000..7a18c1c --- /dev/null +++ b/src/tests/helpers/rejection.ts @@ -0,0 +1,16 @@ +import { expect } from "chai"; + +/** Awaits a promise that must reject with an instance of `kind` and returns that rejection. */ +export async function rejectionOf( + promise: PromiseLike, + kind: new (...args: never[]) => E, +): Promise { + const [settled] = await Promise.allSettled([promise]); + if (settled.status === "fulfilled") { + expect.fail(`expected a rejection with ${kind.name}`); + } + if (settled.reason instanceof kind) { + return settled.reason; + } + return expect.fail(`expected a rejection with ${kind.name}`); +} From 3cd380868a132386ec653592191a3bf6e7aa0f37 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 07:12:28 +0100 Subject: [PATCH 02/16] docs: typecheck every example and drop hand-written version prose (#50) * docs: typecheck every example and drop hand-written version prose * docs: import Schema in the complete examples and type the untyped schemas * docs: restate the schema types from the package and check them --- .github/workflows/ci.yml | 2 +- README.md | 2 +- docs/1.query_types/1.index.md | 2 +- docs/1.query_types/2.table.md | 10 +++--- docs/1.query_types/3.selection.md | 8 ++--- docs/1.query_types/4.single_selection.md | 12 ++++---- docs/1.query_types/5.stream.md | 32 +++++++++---------- docs/1.query_types/6.feed.md | 2 +- docs/1.query_types/7.query.md | 10 +++--- docs/1.query_types/8.datum.md | 12 +++++--- docs/1.query_types/9.valueproxy.md | 28 ++++++++--------- docs/2.operations/2.database_management.md | 20 +++++++++--- docs/2.operations/3.table_operations.md | 12 ++++---- docs/2.operations/4.crud.md | 36 +++++++++++----------- docs/2.operations/5.indexes.md | 19 +++++++++--- docs/2.operations/6.filtering.md | 22 ++++++------- docs/2.operations/7.lookup.md | 6 ++-- docs/2.operations/8.atomic_mutations.md | 4 +-- docs/3.results/1.index.md | 9 +++++- docs/3.results/2.write_results.md | 16 +++++++--- docs/3.results/3.index_changes.md | 10 +++--- docs/3.results/4.table_changes.md | 11 ++++++- docs/3.results/5.database_changes.md | 12 +++++--- docs/3.results/6.value_changes.md | 2 +- docs/tsconfig.json | 2 ++ 25 files changed, 177 insertions(+), 124 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90ab75f..9ee1268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: ci: - uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1 + uses: AntelopeJS/.github/.github/workflows/ci-node.yml@v1-campaign with: docs-files: | README.md diff --git a/README.md b/README.md index 9df1ae5..175497a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Callbacks such as the `filter` above receive a `ValueProxy`, not a document: onl ## Compatibility -Requires AntelopeJS core `1.x` and the following peer dependency: +The package declares this `@antelopejs/*` peer dependency: diff --git a/docs/1.query_types/1.index.md b/docs/1.query_types/1.index.md index b7290a4..c19cbad 100644 --- a/docs/1.query_types/1.index.md +++ b/docs/1.query_types/1.index.md @@ -41,7 +41,7 @@ All query types that extend `Query` share these capabilities: - **`cursor()`** - Return an `AsyncGenerator` for streaming results one at a time. - **`[Symbol.asyncIterator]()`** - Enable `for await...of` loops over query results. -```typescript +```typescript fragment import { Schema } from "@antelopejs/interface-database"; // Execute a query explicitly diff --git a/docs/1.query_types/2.table.md b/docs/1.query_types/2.table.md index a1f5360..9563611 100644 --- a/docs/1.query_types/2.table.md +++ b/docs/1.query_types/2.table.md @@ -4,7 +4,7 @@ The `Table` class represents a database table and provides methods for insert ## Class Definition -```typescript +```typescript fragment class Table extends Selection { atomicMutation( id: string, @@ -45,7 +45,7 @@ const users = schema.instance().table("users"); The `insert` method adds one or more documents to the table and returns the generated IDs. -```typescript +```typescript fragment // Insert a single document const ids = await users.insert({ name: "Alice Johnson", @@ -80,7 +80,7 @@ Retrieve a single document by its primary key. Returns a `SingleSelection`. Awaiting this selection is declared as `T`, not `T | null`. The declaration does not prove a row exists; providers can return `null` for missing keys, so check before reading fields. -```typescript +```typescript fragment const user = await users.get("user-123"); if (user) { @@ -92,7 +92,7 @@ if (user) { Retrieve multiple documents by key values. When `index` is provided, the lookup uses the specified secondary index instead of the primary key. -```typescript +```typescript fragment // By primary key const selected = await users.getAll(["user-1", "user-2", "user-3"]); @@ -104,7 +104,7 @@ const admins = await users.getAll("admin", "role"); Retrieve documents where the specified index value falls within a range. The lower bound is inclusive, and the upper bound is exclusive. -```typescript +```typescript fragment const recent = await users.between( "createdAt", new Date("2024-01-01"), diff --git a/docs/1.query_types/3.selection.md b/docs/1.query_types/3.selection.md index 1f0407e..f6e98fb 100644 --- a/docs/1.query_types/3.selection.md +++ b/docs/1.query_types/3.selection.md @@ -4,7 +4,7 @@ The `Selection` class represents a set of documents and provides methods for ## Class Definition -```typescript +```typescript fragment class Selection extends Stream { update(document: DeepPartial): Query; update(document: (val: ValueProxy) => U): Query; @@ -38,7 +38,7 @@ const recentUsers = users.between( The `update` method modifies matching documents with the provided partial data. Fields not included in the update object remain unchanged. It returns the number of modified documents. -```typescript +```typescript fragment // Update with a partial object const count = await users .getAll("inactive", "status") @@ -56,7 +56,7 @@ await users.getAll("active", "status").update((user) => ({ The `replace` method substitutes entire documents with the provided data. Unlike `update`, all fields not present in the replacement are removed. -```typescript +```typescript fragment const count = await users.getAll("user-123").replace({ _id: "user-123", name: "New Name", @@ -71,7 +71,7 @@ console.log(`Replaced ${count} documents`); The `delete` method removes all documents in the selection and returns the number of deleted documents. -```typescript +```typescript fragment const count = await users.getAll("inactive", "status").delete(); console.log(`Deleted ${count} inactive users`); diff --git a/docs/1.query_types/4.single_selection.md b/docs/1.query_types/4.single_selection.md index ac446fe..3a3a3c5 100644 --- a/docs/1.query_types/4.single_selection.md +++ b/docs/1.query_types/4.single_selection.md @@ -4,7 +4,7 @@ The `SingleSelection` class represents a reference to a single document in a ## Class Definition -```typescript +```typescript fragment class SingleSelection extends Datum { update(document: DeepPartial): Query; update(document: (val: ValueProxy) => U): Query; @@ -34,7 +34,7 @@ Since `SingleSelection` extends `Datum`, you can execute it directly to retrieve The declared result is `T`, even though a provider can return `null` for a missing key. Check for absence before accessing the document; the generic type is not an existence guarantee. -```typescript +```typescript fragment const user = await users.get("user-123"); if (user) { @@ -56,7 +56,7 @@ const display = await users.get("user-123").do((u) => ({ The `update` method modifies fields in the document. Unmentioned fields remain unchanged. Returns the number of modified documents. -```typescript +```typescript fragment // Update with a partial object const count = await users.get("user-123").update({ status: "active", @@ -74,7 +74,7 @@ await users.get("user-123").update((user) => ({ The `replace` method substitutes the entire document with new data. -```typescript +```typescript fragment const count = await users.get("user-123").replace({ _id: "user-123", name: "New Name", @@ -87,7 +87,7 @@ const count = await users.get("user-123").replace({ The `delete` method removes the document from the table. -```typescript +```typescript fragment const count = await users.get("user-123").delete(); if (count === 1) { @@ -99,7 +99,7 @@ if (count === 1) { The `changes` method returns change events for the document. Each event contains a `changeType` and the `oldValue`/`newValue` of the document. -```typescript +```typescript fragment for await (const change of users.get("user-123").changes()) { console.log("Type:", change.changeType); // "added" | "removed" | "modified" console.log("Old:", change.oldValue); diff --git a/docs/1.query_types/5.stream.md b/docs/1.query_types/5.stream.md index c6d7d9b..1f19886 100644 --- a/docs/1.query_types/5.stream.md +++ b/docs/1.query_types/5.stream.md @@ -4,7 +4,7 @@ The `Stream` class represents a sequence of documents that can be transformed ## Class Definition -```typescript +```typescript fragment class Stream extends Query { // Type casting cast(): Stream; @@ -57,7 +57,7 @@ Access a specific field from all documents in the stream with `key()`, or provid `key(field, fallback)` also accepts a fallback. When it is not `undefined`, the result type excludes `null` and `undefined` from the field type and includes the fallback's type; without it, the declared field type is unchanged. -```typescript +```typescript fragment const users = schema.instance().table("users"); // Extract a single field @@ -73,7 +73,7 @@ const phones = await users.key("phone").default("N/A"); Transform each document using a mapping function. The callback receives a `ValueProxy` for constructing expressions. -```typescript +```typescript fragment const profiles = await users.map((user) => ({ id: user.key("_id"), displayName: user.key("name"), @@ -85,7 +85,7 @@ const profiles = await users.map((user) => ({ Select documents matching a predicate. The callback returns a boolean expression. -```typescript +```typescript fragment const active = await users.filter((user) => user.key("status").eq("active").and(user.key("age").ge(18)), ); @@ -95,7 +95,7 @@ const active = await users.filter((user) => Select or exclude specific fields from all documents. -```typescript +```typescript fragment // Keep only specific fields const slim = await users.pluck("_id", "name", "email"); @@ -109,7 +109,7 @@ const safe = await users.without("password", "ssn"); Concatenate two streams without deduplication. -```typescript +```typescript fragment const allPremium = await users .filter((u) => u.key("plan").eq("premium")) .union(users.filter((u) => u.key("plan").eq("enterprise"))); @@ -121,7 +121,7 @@ const allPremium = await users Perform a left join between two streams. Every document from the left stream appears in the result, with `null` for the right side when no match exists. -```typescript +```typescript fragment const usersWithOrders = await users.join( schema.instance().table("orders"), (user, order) => order.key("userId").eq(user.key("_id")), @@ -136,7 +136,7 @@ const usersWithOrders = await users.join( Perform an inner join. Only documents with matches on both sides appear in the result. -```typescript +```typescript fragment const matched = await users.joinInner( schema.instance().table("orders"), (user, order) => order.key("userId").eq(user.key("_id")), @@ -151,7 +151,7 @@ const matched = await users.joinInner( Replace a foreign key field with the referenced document from another table. See [Lookup](../2.operations/7.lookup.md) for detailed usage. -```typescript +```typescript fragment const postsWithAuthors = await schema .instance() .table("posts") @@ -162,7 +162,7 @@ const postsWithAuthors = await schema The `group` method partitions the stream by an index value, then applies a mapping function to each group. The mapper receives the group's sub-stream and the group key. -```typescript +```typescript fragment const statsByRole = await users.group("role", (stream, role) => ({ role: role, total: stream.count(), @@ -176,7 +176,7 @@ const statsByRole = await users.group("role", (stream, role) => ({ Sort the stream by an index field. -```typescript +```typescript fragment const sorted = await users.orderBy("name", "asc"); const newest = await users.orderBy("createdAt", "desc"); ``` @@ -185,7 +185,7 @@ const newest = await users.orderBy("createdAt", "desc"); Extract a subsection of the stream for pagination. -```typescript +```typescript fragment const page1 = await users.orderBy("createdAt").slice(0, 10); const page2 = await users.orderBy("createdAt").slice(10, 10); ``` @@ -194,7 +194,7 @@ const page2 = await users.orderBy("createdAt").slice(10, 10); Retrieve a single document by its position in the stream. Returns a `Datum`. -```typescript +```typescript fragment const first = await users.orderBy("createdAt").nth(0); ``` @@ -202,7 +202,7 @@ const first = await users.orderBy("createdAt").nth(0); Stream provides several aggregation methods that return a `Datum`: -```typescript +```typescript fragment const totalUsers = await users.count(); const distinctEmails = await users.count("email"); const totalSales = await schema.instance().table("orders").sum("amount"); @@ -215,7 +215,7 @@ const minOrder = await schema.instance().table("orders").min("amount"); Retrieve deduplicated documents or distinct values of a specific field. -```typescript +```typescript fragment // All distinct documents const unique = await users.distinct(); @@ -227,7 +227,7 @@ const roles = await users.distinct("role"); The `changes` method subscribes to real-time change notifications on the stream. -```typescript +```typescript fragment for await (const change of users.changes()) { console.log(change.changeType, change.oldValue, change.newValue); } diff --git a/docs/1.query_types/6.feed.md b/docs/1.query_types/6.feed.md index 5064d80..84da48d 100644 --- a/docs/1.query_types/6.feed.md +++ b/docs/1.query_types/6.feed.md @@ -39,7 +39,7 @@ const userChanges = users.get("user-123").changes(); Each change event includes a `changeType` field that indicates the nature of the change: -```typescript +```typescript fragment for await (const change of users.changes()) { if (change.changeType === "added") { console.log("New document:", change.newValue); diff --git a/docs/1.query_types/7.query.md b/docs/1.query_types/7.query.md index d557cd4..7a73d00 100644 --- a/docs/1.query_types/7.query.md +++ b/docs/1.query_types/7.query.md @@ -4,7 +4,7 @@ The `Query` class is the base class for all query types in the AQL system. It ## Class Definition -```typescript +```typescript fragment class Query extends StagedObject implements PromiseLike { run(): Promise; then( @@ -33,7 +33,7 @@ const users = await schema.instance().table("users").run(); Since `Query` implements `PromiseLike`, you can await it directly without calling `run()`: -```typescript +```typescript fragment const users = await schema.instance().table("users"); ``` @@ -47,7 +47,7 @@ Array-valued queries yield one element at a time, including one `Changes` eve Return an `AsyncGenerator` that yields results one at a time. This is useful for processing large result sets without loading everything into memory. -```typescript +```typescript fragment const cursor = schema.instance().table("users").cursor(); let result = await cursor.next(); @@ -61,7 +61,7 @@ while (!result.done) { The `[Symbol.asyncIterator]()` method enables the `for await...of` syntax: -```typescript +```typescript fragment for await (const user of schema.instance().table("users")) { console.log("Processing:", user.name); } @@ -71,7 +71,7 @@ for await (const user of schema.instance().table("users")) { Queries use lazy evaluation. Operations are recorded as pipeline stages and only execute when `run()`, `then()`, or iteration begins. This means you can build queries incrementally: -```typescript +```typescript fragment const usersTable = schema.instance().table("users"); // Build different queries from the same base diff --git a/docs/1.query_types/8.datum.md b/docs/1.query_types/8.datum.md index bd6770a..d3c29c5 100644 --- a/docs/1.query_types/8.datum.md +++ b/docs/1.query_types/8.datum.md @@ -4,7 +4,7 @@ The `Datum` class represents a single value in the query system. It extends ` ## Class Definition -```typescript +```typescript fragment class Datum extends Query { cast(): Datum; key, U = undefined>(key: K, def?: U): Datum< @@ -41,7 +41,7 @@ const phone = await users.get("user-123").key("phone", "N/A"); The `default` method provides a fallback value when the datum is null or undefined. -```typescript +```typescript fragment const user = await users.get("maybe-missing").default({ _id: "guest", name: "Guest User", @@ -53,7 +53,7 @@ const user = await users.get("maybe-missing").default({ The `do` method applies a transformation function to the datum. The callback receives a `ValueProxy` that provides operators for building expressions. -```typescript +```typescript fragment const formatted = await users.get("user-123").do((user) => ({ displayName: user.key("name"), contact: user.key("email"), @@ -66,6 +66,8 @@ const formatted = await users.get("user-123").do((user) => ({ The `lookup` method replaces a foreign key field with the referenced document from another table. ```typescript +import { Schema } from "@antelopejs/interface-database"; + interface Post { _id: string; title: string; @@ -92,7 +94,7 @@ console.log(post.authorId.name); The `pluck` method selects specific fields from a document datum, discarding the rest. -```typescript +```typescript fragment const profile = await users.get("user-123").pluck("name", "email", "avatar"); ``` @@ -100,6 +102,6 @@ const profile = await users.get("user-123").pluck("name", "email", "avatar"); The `cast` method changes the TypeScript type of the datum without performing any runtime conversion. Use this when you know the actual type differs from what the compiler infers. -```typescript +```typescript fragment const raw = users.get("user-123").cast<{ name: string; role: string }>(); ``` diff --git a/docs/1.query_types/9.valueproxy.md b/docs/1.query_types/9.valueproxy.md index a8c95c0..7c11d3f 100644 --- a/docs/1.query_types/9.valueproxy.md +++ b/docs/1.query_types/9.valueproxy.md @@ -25,7 +25,7 @@ These methods are available on all ValueProxy types. Return the given value if the proxy is null. -```typescript +```typescript fragment const phone = user.key("phone").default("N/A"); ``` @@ -33,7 +33,7 @@ const phone = user.key("phone").default("N/A"); Equality and inequality comparison. -```typescript +```typescript fragment const isActive = user.key("status").eq("active"); const isNotAdmin = user.key("role").ne("admin"); ``` @@ -42,7 +42,7 @@ const isNotAdmin = user.key("role").ne("admin"); Logical operators for combining boolean expressions. The operand of `and` and `or` compiles when it is a `boolean`, `null`, `undefined`, `any`, or a `ValueProxy` of `boolean`, `boolean | null` or `boolean | undefined`. A `string`, a `number`, a `ValueProxy`, a `ValueProxy`, a generic `ValueProxy` and a union such as `ValueProxy | undefined` are compile-time errors. The database evaluates operands by truthiness, so the type is narrower than what the runtime accepts. -```typescript +```typescript fragment const canAccess = user.key("active").and(user.key("verified")); const isSpecial = user .key("role") @@ -61,7 +61,7 @@ Available when the proxy wraps a numeric value. ### Arithmetic -```typescript +```typescript fragment const next = age.add(1); const diff = total.sub(discount); const doubled = price.mul(2); @@ -71,7 +71,7 @@ const remainder = count.mod(10); ### Rounding -```typescript +```typescript fragment const rounded = score.round(); const ceiling = value.ceil(); const floored = value.floor(); @@ -79,7 +79,7 @@ const floored = value.floor(); ### Bitwise -```typescript +```typescript fragment const bitwiseAnd = flags.band(0xff); const bitwiseOr = flags.bor(0x01); const bitwiseXor = flags.bxor(mask); @@ -93,7 +93,7 @@ const signPreserved = flags.brshiftPreserveSign(2); Available for `number`, `string`, and `Date` proxy values. -```typescript +```typescript fragment const isAdult = age.gt(17); const isMinor = age.le(17); const isRecent = date.ge(new Date("2024-01-01")); @@ -113,14 +113,14 @@ Available when the proxy wraps a `Date` value. All date component methods accept ### Arithmetic -```typescript +```typescript fragment const threeDaysLater = created.add(3 * 24 * 60 * 60); const elapsed = now.sub(created); // difference in seconds ``` ### Components -```typescript +```typescript fragment const y = created.year(); const m = created.month(); const d = created.day(); @@ -135,7 +135,7 @@ const epoch = created.epoch(); // seconds since UNIX epoch ### Range Check -```typescript +```typescript fragment const inRange = created.during( ValueProxy.constant(new Date("2024-01-01")), ValueProxy.constant(new Date("2025-01-01")), @@ -146,7 +146,7 @@ const inRange = created.during( Available when the proxy wraps a `string` value. -```typescript +```typescript fragment const upper = name.upcase(); const lower = name.downcase(); const parts = email.split("@"); @@ -168,7 +168,7 @@ const matches = email.match("^.*@gmail\\.com$"); Available when the proxy wraps an array value. -```typescript +```typescript fragment const first = tags.index(0); const hasAdmin = tags.includes("admin"); const sub = tags.slice(0, 3); @@ -202,7 +202,7 @@ Available when the proxy wraps a record/object value. `key(field, fallback)` preserves the declared field type when the fallback is omitted or `undefined`. Otherwise, the proxy's value type is `Exclude | FallbackType`. The fallback may itself be a `ValueProxy`, so its value type participates in the result. -```typescript +```typescript fragment const email = user.key("email"); const emailOrDefault = user.key("email", "none@example.com"); const merged = user.merge({ lastSeen: new Date() }); @@ -223,7 +223,7 @@ const hasRequired = user.hasfields("email", "name"); ValueProxy methods are chainable, enabling complex expressions within query callbacks: -```typescript +```typescript fragment const result = await users.filter((user) => user .key("age") diff --git a/docs/2.operations/2.database_management.md b/docs/2.operations/2.database_management.md index 0e63bc2..3d40e69 100644 --- a/docs/2.operations/2.database_management.md +++ b/docs/2.operations/2.database_management.md @@ -31,6 +31,8 @@ const schema = new Schema("myapp", { A schema definition is a record mapping table names to their `TableDefinition`: ```typescript +import type { FieldType } from "@antelopejs/interface-database/schema"; + interface TableDefinition { fields: Record; indexes: Record; @@ -39,6 +41,8 @@ interface TableDefinition { interface IndexDefinition { fields?: string[]; // For compound indexes multi?: boolean; // Multi-valued index + crossInstance?: boolean; // Also serve cross-instance queries + unique?: boolean; // No two rows of one instance share the key } ``` @@ -46,7 +50,7 @@ interface IndexDefinition { After defining a schema, create an instance to start working with data. The optional `id` parameter distinguishes multiple instances of the same schema. -```typescript +```typescript fragment // Create the default instance await schema.createInstance(); @@ -58,7 +62,7 @@ await schema.createInstance("tenant-abc"); Retrieve a `SchemaInstance` to access tables: -```typescript +```typescript fragment // Default instance const db = schema.instance(); @@ -70,7 +74,7 @@ To operate across every instance of the schema, pass the `CROSS_INSTANCE` sentin This deliberately removes the instance boundary for supported queries, including updates and deletes. Keep it out of tenant-controlled request paths. Selecting an instance is not a substitute for authorizing the caller. -```typescript +```typescript fragment import { CROSS_INSTANCE } from "@antelopejs/interface-database"; const everything = schema.instance(CROSS_INSTANCE).table("users"); @@ -80,7 +84,7 @@ const everything = schema.instance(CROSS_INSTANCE).table("users"); Remove an instance and its data: -```typescript +```typescript fragment await schema.destroyInstance("tenant-abc"); ``` @@ -88,7 +92,7 @@ await schema.destroyInstance("tenant-abc"); List the IDs of named instances of a schema: -```typescript +```typescript fragment const ids = await schema.listInstances(); // ["tenant-abc", "tenant-xyz"] ``` @@ -100,6 +104,8 @@ The default (unnamed) instance is not included. Use the static `Schema.get()` method to look up a previously defined schema by its ID: ```typescript +import { Schema } from "@antelopejs/interface-database"; + const schema = Schema.get("myapp"); if (schema) { @@ -112,6 +118,8 @@ if (schema) { Put the table-to-document mapping on `Schema`, then access tables by name. The generic parameter of `table()` is a key of that mapping, not a document type: ```typescript +import { Schema } from "@antelopejs/interface-database"; + interface User { _id: string; name: string; @@ -139,6 +147,8 @@ Use the same mapping when declaring a schema: `new Schema<{ users: User; posts: Schemas with multiple instances support multi-tenant architectures. Each tenant gets an isolated instance of the same schema: ```typescript +import { Schema } from "@antelopejs/interface-database"; + const schema = new Schema("saas", { projects: { fields: { name: "string" }, diff --git a/docs/2.operations/3.table_operations.md b/docs/2.operations/3.table_operations.md index c1b8d5b..ae94629 100644 --- a/docs/2.operations/3.table_operations.md +++ b/docs/2.operations/3.table_operations.md @@ -62,14 +62,14 @@ export async function readUser() { A table reference doubles as a stream of all its documents: -```typescript +```typescript fragment const allUsers = await users; console.log(`Total users: ${allUsers.length}`); ``` ### Retrieve by Primary Key -```typescript +```typescript fragment const user = await users.get("user-123"); if (user) { @@ -79,13 +79,13 @@ if (user) { ### Retrieve by Secondary Index -```typescript +```typescript fragment const admins = await users.getAll("admin", "role"); ``` ### Retrieve by Range -```typescript +```typescript fragment const recent = await users.between( "createdAt", new Date("2024-01-01"), @@ -97,7 +97,7 @@ const recent = await users.between( Since `Table` extends `Selection` and `Stream`, you can chain any stream operation directly: -```typescript +```typescript fragment // Filter and sort const activeByName = await users .filter((u) => u.key("status").eq("active")) @@ -116,7 +116,7 @@ const names = await users.map((u) => u.key("name")); ## Insert Documents -```typescript +```typescript fragment // Single document const ids = await users.insert({ name: "Alice", diff --git a/docs/2.operations/4.crud.md b/docs/2.operations/4.crud.md index 51a9e0e..b94eb4b 100644 --- a/docs/2.operations/4.crud.md +++ b/docs/2.operations/4.crud.md @@ -13,7 +13,7 @@ const users = schema.instance().table("users"); ### Insert a Single Document -```typescript +```typescript fragment const ids = await users.insert({ name: "Alice Johnson", email: "alice@example.com", @@ -26,7 +26,7 @@ console.log("Inserted ID:", ids[0]); ### Insert Multiple Documents -```typescript +```typescript fragment const ids = await users.insert([ { name: "Bob", email: "bob@example.com", status: "active" }, { name: "Carol", email: "carol@example.com", status: "active" }, @@ -39,7 +39,7 @@ console.log("Inserted IDs:", ids); Without a `conflict` option, inserting a row whose `_id`, or whose key in a [unique index](./5.indexes.md#unique-indexes), is already taken in the instance throws a `DuplicateKeyError`. Updates and replacements that would duplicate a unique key throw it too. When a batch insert hits a duplicate, whether its other rows are stored depends on the database. -```typescript +```typescript fragment import { IsDuplicateKeyError } from "@antelopejs/interface-database"; try { @@ -54,7 +54,7 @@ try { Use the `conflict` option to handle documents that already exist: -```typescript +```typescript fragment // Update existing fields, keep unmentioned fields await users.insert( { _id: "user-123", name: "Updated Name", lastSeen: new Date() }, @@ -72,7 +72,7 @@ await users.insert( ### Get by Primary Key -```typescript +```typescript fragment const user = await users.get("user-123"); if (user) { @@ -84,14 +84,14 @@ if (user) { ### Get by Secondary Index -```typescript +```typescript fragment const admins = await users.getAll("admin", "role"); console.log(`Found ${admins.length} admins`); ``` ### Get by Range -```typescript +```typescript fragment const recent = await users.between( "createdAt", new Date("2024-01-01"), @@ -101,7 +101,7 @@ const recent = await users.between( ### Filter Documents -```typescript +```typescript fragment // Filter with a predicate function const active = await users.filter((user) => user.key("status").eq("active").and(user.key("age").ge(18)), @@ -110,7 +110,7 @@ const active = await users.filter((user) => ### Select Specific Fields -```typescript +```typescript fragment // Keep only listed fields const profiles = await users.pluck("_id", "name", "avatar"); @@ -120,7 +120,7 @@ const safe = await users.without("password", "ssn"); ### Order and Paginate -```typescript +```typescript fragment const page1 = await users.orderBy("createdAt", "desc").slice(0, 10); const page2 = await users.orderBy("createdAt", "desc").slice(10, 10); ``` @@ -129,7 +129,7 @@ const page2 = await users.orderBy("createdAt", "desc").slice(10, 10); For large result sets, use async iteration to process documents one at a time: -```typescript +```typescript fragment for await (const user of users) { console.log("Processing:", user.name); } @@ -141,7 +141,7 @@ for await (const user of users) { The `update` method modifies specified fields while preserving all other fields. -```typescript +```typescript fragment const count = await users.get("user-123").update({ status: "active", lastLogin: new Date(), @@ -156,7 +156,7 @@ if (count === 1) { Pass a function to compute new values based on current ones: -```typescript +```typescript fragment await users.get("user-123").update((user) => ({ loginCount: user.key("loginCount").add(1), lastLogin: new Date(), @@ -165,7 +165,7 @@ await users.get("user-123").update((user) => ({ ### Update Multiple Documents -```typescript +```typescript fragment const count = await users .getAll("inactive", "status") .update({ status: "active" }); @@ -177,7 +177,7 @@ console.log(`Activated ${count} users`); The `replace` method substitutes the entire document. Fields not included in the replacement are removed. -```typescript +```typescript fragment const count = await users.get("user-123").replace({ _id: "user-123", name: "Alice Updated", @@ -191,7 +191,7 @@ const count = await users.get("user-123").replace({ ### Delete a Single Document -```typescript +```typescript fragment const count = await users.get("user-123").delete(); if (count === 1) { @@ -201,7 +201,7 @@ if (count === 1) { ### Delete Multiple Documents -```typescript +```typescript fragment const count = await users .filter((user) => user.key("status").eq("inactive")) .delete(); @@ -213,7 +213,7 @@ console.log(`Deleted ${count} inactive users`); For large-scale inserts, batch documents to manage memory and throughput: -```typescript +```typescript fragment const documents = Array.from({ length: 10000 }, (_, i) => ({ index: i, value: `Item ${i}`, diff --git a/docs/2.operations/5.indexes.md b/docs/2.operations/5.indexes.md index fda03e1..52a7c78 100644 --- a/docs/2.operations/5.indexes.md +++ b/docs/2.operations/5.indexes.md @@ -40,7 +40,7 @@ When `fields` is omitted, the index name is used as the field name. For compound Retrieve documents by index value: -```typescript +```typescript fragment const db = schema.instance(); const users = db.table("users"); @@ -55,7 +55,7 @@ const targets = await users.getAll(["admin", "moderator"], "role"); Retrieve documents where the index value falls within a range: -```typescript +```typescript fragment const results = await users.between("age", 18, 65); ``` @@ -63,7 +63,7 @@ const results = await users.between("age", 18, 65); Sort results using an indexed field for better performance: -```typescript +```typescript fragment const sorted = await users.orderBy("email", "asc"); const newest = await users.orderBy("createdAt", "desc"); ``` @@ -73,6 +73,8 @@ const newest = await users.orderBy("createdAt", "desc"); Compound indexes span multiple fields. Define them with the `fields` array: ```typescript +import { Schema } from "@antelopejs/interface-database"; + const schema = new Schema("blog", { posts: { fields: { @@ -94,6 +96,8 @@ Compound indexes support prefix-based lookups. You can query by the first field Set `multi: true` for fields that contain arrays. The database creates an index entry for each element in the array, enabling lookups by any single element: ```typescript +import { Schema } from "@antelopejs/interface-database"; + const schema = new Schema("content", { articles: { fields: { tags: ["string"] }, @@ -109,6 +113,8 @@ const schema = new Schema("content", { Indexes are optimized for queries scoped to one instance (`schema.instance(id)`). Queries run with `schema.instance(CROSS_INSTANCE)` can still use them, but may be slow. Set `crossInstance: true` on the indexes that cross-instance queries rely on, and the driver also maintains an index usable by those queries: ```typescript +import { CROSS_INSTANCE, Schema } from "@antelopejs/interface-database"; + const schema = new Schema("billing", { invoices: { fields: { status: "string" }, @@ -134,7 +140,12 @@ Set `unique: true` to refuse a second row with the same key in one instance. The ```typescript import { IsDuplicateKeyError, Schema } from "@antelopejs/interface-database"; -const schema = new Schema("myapp", { +interface User { + _id: string; + email: string; +} + +const schema = new Schema<{ users: User }>("myapp", { users: { fields: { email: "string" }, indexes: { diff --git a/docs/2.operations/6.filtering.md b/docs/2.operations/6.filtering.md index ac49ae2..ba51a12 100644 --- a/docs/2.operations/6.filtering.md +++ b/docs/2.operations/6.filtering.md @@ -13,7 +13,7 @@ const users = schema.instance().table("users"); Use the `filter` method with a predicate function. The callback receives a `ValueProxy` representing each document. -```typescript +```typescript fragment const active = await users.filter((user) => user.key("status").eq("active")); ``` @@ -21,14 +21,14 @@ const active = await users.filter((user) => user.key("status").eq("active")); ### Equality and Inequality -```typescript +```typescript fragment const admins = await users.filter((u) => u.key("role").eq("admin")); const nonAdmins = await users.filter((u) => u.key("role").ne("admin")); ``` ### Numeric Comparisons -```typescript +```typescript fragment const adults = await users.filter((u) => u.key("age").ge(18)); const seniors = await users.filter((u) => u.key("age").gt(65)); const young = await users.filter((u) => u.key("age").lt(18)); @@ -39,7 +39,7 @@ const discounted = await users.filter((u) => u.key("age").le(25)); Combine conditions with `and()`, `or()`, and `not()`: -```typescript +```typescript fragment // AND const verified = await users.filter((u) => u.key("status").eq("active").and(u.key("emailVerified").eq(true)), @@ -64,7 +64,7 @@ const target = await users.filter((u) => ## String Operations -```typescript +```typescript fragment // Case-insensitive match const bobs = await users.filter((u) => u.key("name").downcase().eq("bob")); @@ -83,7 +83,7 @@ const fullNameMatch = await users.filter((u) => ### Date Comparisons -```typescript +```typescript fragment const newUsers = await users.filter((u) => u.key("createdAt").gt(new Date("2024-01-01")), ); @@ -98,7 +98,7 @@ const q1Users = await users.filter((u) => ### Date Components -```typescript +```typescript fragment const thisYear = await users.filter((u) => u.key("createdAt").year().eq(2024)); const morningLogins = await users.filter((u) => @@ -108,7 +108,7 @@ const morningLogins = await users.filter((u) => ## Array Operations -```typescript +```typescript fragment // Check array contents const jsPosts = await schema .instance() @@ -132,7 +132,7 @@ const untagged = await schema Use `default()` to handle potentially null fields: -```typescript +```typescript fragment const activeUsers = await users.filter((u) => u.key("active").default(false).eq(true), ); @@ -144,7 +144,7 @@ const frequentVisitors = await users.filter((u) => ## Ordering and Pagination -```typescript +```typescript fragment // Sort ascending const byName = await users.orderBy("name", "asc"); @@ -163,7 +163,7 @@ const page2 = await users.orderBy("createdAt").slice(10, 10); 3. **Limit result sets** with `slice()` to avoid transferring unnecessary data. 4. **Avoid complex computations** inside filter predicates when simpler alternatives exist. -```typescript +```typescript fragment // Slower: full table scan with filter const slow = await users.filter((u) => u.key("email").eq("user@example.com")); diff --git a/docs/2.operations/7.lookup.md b/docs/2.operations/7.lookup.md index f24a9be..0a70c60 100644 --- a/docs/2.operations/7.lookup.md +++ b/docs/2.operations/7.lookup.md @@ -55,6 +55,8 @@ for (const post of postsWithAuthors) { Replace an array of foreign keys with an array of documents: ```typescript +import { Schema } from "@antelopejs/interface-database"; + interface Order { _id: string; customerId: string; @@ -87,7 +89,7 @@ for (const order of ordersWithProducts) { Lookup works seamlessly with filters, sorting, and other stream operations: -```typescript +```typescript fragment // The shop schema also declares customers and orders.createdAt. const db = Schema.get("shop")!.instance(); @@ -121,7 +123,7 @@ const post = await blog ### Comparison -```typescript +```typescript fragment // Lookup: replaces the field const withLookup = await db .table("orders") diff --git a/docs/2.operations/8.atomic_mutations.md b/docs/2.operations/8.atomic_mutations.md index 9232b5b..56743b5 100644 --- a/docs/2.operations/8.atomic_mutations.md +++ b/docs/2.operations/8.atomic_mutations.md @@ -8,7 +8,7 @@ Generic `filter(...).update(...)` and `filter(...).delete()` do **not** promise 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 +```ts fragment const outcome = await table.atomicMutation(recordId, { type: "update", revisionField: "revision", @@ -38,7 +38,7 @@ Patches contain only constant strings, booleans, finite numbers, null, valid Dat `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 +```ts fragment const outcome = await table.atomicMutation(recordId, { type: "deleteIfEqual", field: "lastSeenAt", diff --git a/docs/3.results/1.index.md b/docs/3.results/1.index.md index 5f34b3f..4c6d650 100644 --- a/docs/3.results/1.index.md +++ b/docs/3.results/1.index.md @@ -21,7 +21,14 @@ All AQL operations follow a consistent pattern: ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +interface User { + _id: string; + name: string; + email: string; + status: string; +} + +const schema = Schema.get<{ users: User }>("myapp")!; const users = schema.instance().table("users"); // Insert returns an array of generated IDs diff --git a/docs/3.results/2.write_results.md b/docs/3.results/2.write_results.md index 88d035f..d08ede7 100644 --- a/docs/3.results/2.write_results.md +++ b/docs/3.results/2.write_results.md @@ -4,7 +4,7 @@ The `insert` method on `Table` returns a `Query` that resolves to a ## Return Value -```typescript +```typescript fragment // Insert returns string[] (the IDs of inserted documents) const ids: string[] = await users.insert({ name: "Alice", @@ -19,7 +19,13 @@ const ids: string[] = await users.insert({ ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +interface User { + _id: string; + name: string; + email: string; +} + +const schema = Schema.get<{ users: User }>("myapp")!; const users = schema.instance().table("users"); const ids = await users.insert({ @@ -32,7 +38,7 @@ console.log("Inserted document ID:", ids[0]); ### Multiple Document Insert -```typescript +```typescript fragment const ids = await users.insert([ { name: "Alice", email: "alice@example.com" }, { name: "Bob", email: "bob@example.com" }, @@ -47,7 +53,7 @@ console.log("IDs:", ids); Use the `conflict` option to control behavior when a document with the same primary key already exists: -```typescript +```typescript fragment // Update matching fields on conflict const ids = await users.insert( { _id: "user-123", name: "Updated Name", lastSeen: new Date() }, @@ -65,7 +71,7 @@ const ids2 = await users.insert( The `update`, `replace`, and `delete` methods return a `Query` that resolves to the count of affected documents: -```typescript +```typescript fragment // Update returns the number of modified documents const updated = await users.get("user-123").update({ status: "active" }); console.log(`Modified: ${updated}`); diff --git a/docs/3.results/3.index_changes.md b/docs/3.results/3.index_changes.md index e4ebe7b..b60a8c4 100644 --- a/docs/3.results/3.index_changes.md +++ b/docs/3.results/3.index_changes.md @@ -36,7 +36,7 @@ const schema = new Schema("myapp", { When `fields` is omitted, the index name is used as the field name: -```typescript +```typescript fragment indexes: { email: {}, // Creates an index on the "email" field } @@ -46,7 +46,7 @@ indexes: { Provide the `fields` array for indexes spanning multiple fields: -```typescript +```typescript fragment indexes: { full_name: { fields: ["firstName", "lastName"] }, } @@ -56,7 +56,7 @@ indexes: { Set `multi: true` for array fields where each element should be individually indexed: -```typescript +```typescript fragment indexes: { tags: { multi: true }, } @@ -66,7 +66,7 @@ indexes: { Set `crossInstance: true` when queries run with `CROSS_INSTANCE` use the index. The driver then also maintains an index usable across all instances. Without it, the index is optimized for queries scoped to one instance and cross-instance queries using it may be slow. Results are never affected: -```typescript +```typescript fragment indexes: { status: { crossInstance: true }, } @@ -76,7 +76,7 @@ indexes: { Once defined, indexes are available for `getAll()` and `between()` operations: -```typescript +```typescript fragment const db = schema.instance(); const users = db.table("users"); diff --git a/docs/3.results/4.table_changes.md b/docs/3.results/4.table_changes.md index 5d2941b..c11f13d 100644 --- a/docs/3.results/4.table_changes.md +++ b/docs/3.results/4.table_changes.md @@ -5,6 +5,11 @@ In AQL, tables are defined as part of a schema. Each table definition specifies ## `TableDefinition` ```typescript +import type { + FieldType, + IndexDefinition, +} from "@antelopejs/interface-database/schema"; + interface TableDefinition { fields: Record; indexes: Record; @@ -16,6 +21,8 @@ interface TableDefinition { Field types describe the structure of each field: ```typescript +import type { StringFieldType } from "@antelopejs/interface-database/schema"; + type FieldType = | string // Primitive type name (e.g., "string", "number") | Array // Array of a given type @@ -82,7 +89,7 @@ const schema = new Schema("myapp", { After creating a schema instance, access tables by name: -```typescript +```typescript fragment await schema.createInstance(); const users = schema.instance().table("users"); @@ -92,6 +99,8 @@ const posts = schema.instance().table("posts"); For type-safe access, provide the table mapping when declaring or retrieving the schema. `table()` infers the document type from the table name: ```typescript +import { Schema } from "@antelopejs/interface-database"; + interface User { _id: string; name: string; diff --git a/docs/3.results/5.database_changes.md b/docs/3.results/5.database_changes.md index 9a48117..8e8f05d 100644 --- a/docs/3.results/5.database_changes.md +++ b/docs/3.results/5.database_changes.md @@ -6,7 +6,7 @@ This page covers the `Schema`, `SchemaInstance`, and related types that control The `Schema` class defines the structure of a database and manages its instances. -```typescript +```typescript fragment class Schema extends StagedObject { static get(id: string): Schema | undefined; constructor(id: string, definition: SchemaDefinition); @@ -20,6 +20,8 @@ class Schema extends StagedObject { ### `SchemaDefinition` ```typescript +import type { TableDefinition } from "@antelopejs/interface-database/schema"; + interface SchemaDefinition { [tableName: string]: TableDefinition; } @@ -27,7 +29,7 @@ interface SchemaDefinition { ### `InstanceId` and `CROSS_INSTANCE` -```typescript +```typescript fragment const CROSS_INSTANCE: unique symbol; type InstanceId = string | typeof CROSS_INSTANCE; ``` @@ -71,6 +73,8 @@ const ids = await schema.listInstances(); Use `Schema.get()` to look up a previously defined schema anywhere in your application: ```typescript +import { Schema } from "@antelopejs/interface-database"; + const schema = Schema.get("myapp"); if (schema) { @@ -83,13 +87,13 @@ if (schema) { The `SchemaInstance` class represents a single instance of a schema. Its primary method is `table()`, which returns a `Table` for the specified table name. -```typescript +```typescript fragment class SchemaInstance extends StagedObject { table(id: TK): Table; } ``` -```typescript +```typescript fragment const db = schema.instance(); const users = db.table("users"); const posts = db.table("posts"); diff --git a/docs/3.results/6.value_changes.md b/docs/3.results/6.value_changes.md index c2d9b71..284e595 100644 --- a/docs/3.results/6.value_changes.md +++ b/docs/3.results/6.value_changes.md @@ -53,7 +53,7 @@ for await (const change of users.changes()) { ## Monitor a Single Document -```typescript +```typescript fragment for await (const change of users.get("user-123").changes()) { if (change.changeType === "modified") { console.log("User updated:", change.newValue); diff --git a/docs/tsconfig.json b/docs/tsconfig.json index 9d687fb..98177a6 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -2,6 +2,8 @@ "extends": "../tsconfig.json", "compilerOptions": { "rootDir": "..", + "module": "esnext", + "moduleResolution": "bundler", "noEmit": true, "composite": false, "incremental": false, From 0885432fd63383a8d14510922ad25549423d1e7c Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 13:43:33 +0100 Subject: [PATCH 03/16] feat(database)!: type every query stage by name (#51) * feat(database)!: type every query stage by name QueryStage becomes a union discriminated on `stage`, built from the new QueryStageMap (options and arguments of every stage the builders record), so adapters read typed options and arguments instead of `any`. - QueryStage(name, options, ...args) and StagedObject#stage(type, stage) take a typed stage; ValidateAtomicMutation asserts the validated request. - ValueProxy#eq and #ne take a constant of the value's type or any expression. - pluck and without return Pick and Omit of the named fields; key, lookup, between, table, group and the aggregate field names are string keys. - getAll accepts a list of compound keys, lookup accepts any stream. - DeepPartial keeps scalars, null and dates, and leaves any and unknown as they are; write builders take object documents. - Schema.list() lists the schemas the process defined. BREAKING CHANGE: QueryStage is a discriminated union and QueryStage() and the protected StagedObject#stage are typed by stage name; eq and ne type their constant operand; pluck and without narrow their result; key names, table ids and index or field names must be string keys. * build: set the package version to 0.3.0 Tarballs packed from this branch carry the next minor, so consumers exercise core's runtime range check against 0.3.0 before the release. * build: keep the package version at 0.2.0 The release bumps the version from the conventional commits; campaign tarballs carry 0.3.0 through TGZ_VERSION (h9-plan decision 5). * feat(database): add IsQueryStage to narrow a recorded stage by name * feat(database): let QueryStage leave out options an argument-free stage does not require * build: set the package version to 0.3.0 The wave's brief sets the next version in package.json only: tarballs packed from this branch carry 0.3.0, so core's runtime range check runs against it before the release. * fix(database): keep DeepPartial a mapped type at the top level A conditional DeepPartial stays unresolved for a generic T, so a model generic over its row type (interface-database-decorators' BasicDataModel) could no longer pass the record it built to insert or update. Mapping T's keys at the top level, as before, keeps that assignable; the leaves keep scalars, null and dates. * refactor(database): move ValueProxy's conditions and kind guard to sibling modules valueproxy.ts sits at the size ceiling, and this wave adds to it. The exports of the module do not change: it re-exports what moved. * test(database): cover the operations the drivers answer beyond the 0.2 types min and max over strings and dates and a numeric filter body run against the driver. The operand of add, the numeric bounds of during, an expression key and the bounds of between check the stage an adapter reads. None of these compile against the types of this commit; the next one widens them. * feat(database)!: type what every driver answers for add, min, max, during, filter, key and between - add takes an operand per receiver: seconds on a date, a number on a number, a string on a string, a list on a list, or null - min and max take arrays of strings and dates and answer the element type - during takes bounds as seconds since the UNIX epoch - a list filter's predicate may answer a number, which keeps the element unless it is 0 - Stream.key takes an expression yielding the field name - between's bounds are a named IndexBound instead of unknown stage arguments, so a field that is no index value stops compiling as a bound BREAKING CHANGE: Table.between refuses a bound outside IndexBound, and adapters must answer the widened operations. * chore(guardrails): keep integration's ratchet file The repo has more than one open burn-down PR, so the ceilings move in a separate ratchet PR after this wave lands; the rows this PR removes are in its body. * fix(database): take a proxy of the receiver's type as add's operand A ValueProxy is not a ValueProxy, so mongodb's reads, which add two numeric proxies, stopped compiling against the first form of the operand. * fix(database): keep during's bounds one type and read a number as milliseconds A union of the two bound types stopped row.key("low") inferring its field, and postgres already reads a number as new Date(n) does. --- docs/1.query_types/2.table.md | 8 +- docs/1.query_types/5.stream.md | 2 +- docs/1.query_types/9.valueproxy.md | 30 +- package.json | 2 +- skills/database-interface/SKILL.md | 2 +- src/schema.ts | 13 + src/staged-query/atomic.ts | 24 +- src/staged-query/common.ts | 86 +++--- src/staged-query/datum.ts | 44 ++- src/staged-query/schema.ts | 12 +- src/staged-query/selection.ts | 90 +++--- src/staged-query/stages.ts | 228 ++++++++++++++ src/staged-query/stream.ts | 133 +++++---- src/staged-query/valueproxy-conditions.ts | 26 ++ src/staged-query/valueproxy-kind.ts | 19 ++ src/staged-query/valueproxy.ts | 348 +++++++++++++--------- src/tests/basic_operations.test.ts | 24 ++ src/tests/datasets/vehicles.ts | 1 + src/tests/filters.test.ts | 43 +++ src/tests/lookup_operations.test.ts | 12 + src/tests/recorded_stages.test.ts | 88 ++++++ 21 files changed, 883 insertions(+), 352 deletions(-) create mode 100644 src/staged-query/stages.ts create mode 100644 src/staged-query/valueproxy-conditions.ts create mode 100644 src/staged-query/valueproxy-kind.ts create mode 100644 src/tests/recorded_stages.test.ts diff --git a/docs/1.query_types/2.table.md b/docs/1.query_types/2.table.md index 9563611..9568546 100644 --- a/docs/1.query_types/2.table.md +++ b/docs/1.query_types/2.table.md @@ -19,7 +19,11 @@ class Table extends Selection { keys: string | number | boolean | (string | number | boolean)[], index?: string, ): Selection; - between(index: TK, low: T[TK], high: T[TK]): Selection; + between( + index: TK, + low: T[TK] & IndexBound, + high: T[TK] & IndexBound, + ): Selection; } ``` @@ -102,7 +106,7 @@ const admins = await users.getAll("admin", "role"); ### `between(index, low, high)` -Retrieve documents where the specified index value falls within a range. The lower bound is inclusive, and the upper bound is exclusive. +Retrieve documents where the specified index value falls within a range. The lower bound is inclusive, and the upper bound is exclusive. A bound is an `IndexBound`: a string, number, boolean, `Date` or `null`, or a list of them for a compound index. ```typescript fragment const recent = await users.between( diff --git a/docs/1.query_types/5.stream.md b/docs/1.query_types/5.stream.md index 1f19886..3919d5b 100644 --- a/docs/1.query_types/5.stream.md +++ b/docs/1.query_types/5.stream.md @@ -55,7 +55,7 @@ class Stream extends Query { Access a specific field from all documents in the stream with `key()`, or provide a default value for null fields with `default()`. -`key(field, fallback)` also accepts a fallback. When it is not `undefined`, the result type excludes `null` and `undefined` from the field type and includes the fallback's type; without it, the declared field type is unchanged. +`key(field, fallback)` takes a field name, or a `ValueProxy` yielding one. It also accepts a fallback. When it is not `undefined`, the result type excludes `null` and `undefined` from the field type and includes the fallback's type; without it, the declared field type is unchanged. ```typescript fragment const users = schema.instance().table("users"); diff --git a/docs/1.query_types/9.valueproxy.md b/docs/1.query_types/9.valueproxy.md index 7c11d3f..258d71f 100644 --- a/docs/1.query_types/9.valueproxy.md +++ b/docs/1.query_types/9.valueproxy.md @@ -142,9 +142,11 @@ const inRange = created.during( ); ``` +`during` also takes bounds as milliseconds since the UNIX epoch, as `new Date(n)` reads them. + ## String Operations -Available when the proxy wraps a `string` value. +Available when the proxy wraps a `string` value. `add` concatenates two strings, as `concat` does, and two lists on a list. A `null` operand answers `null` for any receiver of `add`. ```typescript fragment const upper = name.upcase(); @@ -182,19 +184,19 @@ const lowest = scores.min(); const highest = scores.max(); ``` -| Method | Description | -| --------------- | ----------------------------------- | -| `index(n)` | Element at position `n` | -| `includes(val)` | Check if the array contains a value | -| `slice(s, e?)` | Extract a sub-array | -| `map(fn)` | Transform each element | -| `filter(fn)` | Keep elements matching a predicate | -| `isempty()` | Check if the array is empty | -| `count()` | Number of elements | -| `sum()` | Sum of numeric elements | -| `avg()` | Average of numeric elements | -| `min()` | Minimum of numeric elements | -| `max()` | Maximum of numeric elements | +| Method | Description | +| --------------- | ----------------------------------------------------------------------------- | +| `index(n)` | Element at position `n` | +| `includes(val)` | Check if the array contains a value | +| `slice(s, e?)` | Extract a sub-array | +| `map(fn)` | Transform each element | +| `filter(fn)` | Keep elements matching a predicate; a number keeps the element unless it is 0 | +| `isempty()` | Check if the array is empty | +| `count()` | Number of elements | +| `sum()` | Sum of numeric elements | +| `avg()` | Average of numeric elements | +| `min()` | Minimum of numeric, string or date elements | +| `max()` | Maximum of numeric, string or date elements | ## Object Operations diff --git a/package.json b/package.json index 4000f10..ffc0778 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antelopejs/interface-database", - "version": "0.2.0", + "version": "0.3.0", "description": "AQL, the AntelopeJS database-agnostic query builder: schemas, CRUD, joins, aggregations and change feeds", "keywords": [ "antelopejs", diff --git a/skills/database-interface/SKILL.md b/skills/database-interface/SKILL.md index 88b1a4b..01d676a 100644 --- a/skills/database-interface/SKILL.md +++ b/skills/database-interface/SKILL.md @@ -96,7 +96,7 @@ for await (const change of novels.changes()) { - `Schema` ids are persistent: changing an id leaves previous data inaccessible. `Schema.get(id)` retrieves a previously constructed schema. `instance()` with no argument is the default instance; pass `CROSS_INSTANCE` to query across all instances. Indexes are optimized for per-instance queries; set `crossInstance: true` on the indexes that `CROSS_INSTANCE` queries use (performance only, results are unchanged). - Field types are string tokens (`"string"`, `"number"`, `"boolean"`, `"date"`, `"string[]"`, nested object literals) or a single io-ts codec at the field root — codecs are not mixed inside string-tree records. - **Recognize query kinds with `IsStream`/`IsDatum`/`IsQuery`/`IsValueProxy`, not `instanceof`,** when the object may come from another module. Each provider binding has its own copy of the classes, so `instanceof` is false for a query built by a module bound to another database; the predicates are not. `CROSS_INSTANCE` is the same value in every copy. -- Range semantics: `Table.between(index, low, high)` and `ValueProxy.during(left, right)` exclude the upper bound. +- Range semantics: `Table.between(index, low, high)` and `ValueProxy.during(left, right)` exclude the upper bound. `during` also takes bounds as milliseconds since the UNIX epoch. - `insert` accepts one document or an array, with `{ conflict: "update" | "replace" }` as options; it resolves to the inserted ids. - A write that would duplicate the key of a unique index (`_id`, or an index declared `unique: true`, unique per instance) throws `DuplicateKeyError` (`index`, `fields`); test it with `IsDuplicateKeyError(error)`, not `instanceof`. - `cast()` on `Datum`/`Stream`/`ValueProxy` only changes the TypeScript type — no runtime conversion. diff --git a/src/schema.ts b/src/schema.ts index 2e20b1f..ff34b36 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -11,8 +11,21 @@ export const Schemas = new RegisteringProxy< >(); export class Schema extends StagedSchema { + private static readonly defined = new Map(); + + /** + * Lists the schemas this process defined, by ID; a schema defined again under + * the same ID replaces the earlier one. + * + * @returns A read-only view of the defined schemas + */ + public static list(): ReadonlyMap { + return Schema.defined; + } + public constructor(id: string, definition: SchemaDefinition) { super(id, definition); + Schema.defined.set(id, this); Schemas.register(id, definition); } } diff --git a/src/staged-query/atomic.ts b/src/staged-query/atomic.ts index ea7d1a7..74da945 100644 --- a/src/staged-query/atomic.ts +++ b/src/staged-query/atomic.ts @@ -24,6 +24,18 @@ export interface AtomicDelete extends AtomicCondition { export type AtomicEqualityValue = string | number | boolean | Date; +/** The constant data an atomic patch may hold, as `ValidateAtomicMutation` checks it. */ +export type AtomicConstant = + | AtomicEqualityValue + | null + | AtomicConstant[] + | AtomicRecord; + +/** A record of constant data under literal field names. */ +export interface AtomicRecord { + [field: string]: AtomicConstant; +} + /** Deletes by one observed scalar value; does not provide revision or ABA protection. */ export interface AtomicDeleteIfEqual { type: "deleteIfEqual"; @@ -38,7 +50,6 @@ export type AtomicMutation = export type AtomicMutationOutcome = "applied" | "not-applied" | "unknown"; -const tableStages = ["schema", "instance", "table"]; const identityFields = ["id", "_id"]; function isRecord(value: Value): value is Value & object { @@ -92,7 +103,7 @@ export function ValidateAtomicMutation( key: string, request: AtomicMutation, protectedFields: readonly string[] = [], -): void { +): asserts request is AtomicMutation & AtomicMutation { if (!isToken(key) || !isRecord(request)) { throw new TypeError("Atomic mutation requires one literal record identity"); } @@ -161,10 +172,13 @@ function validateUpdate(request: AtomicUpdate, forbidden: string[]) { /** Rejects selections and cross-instance operations before adapter dispatch. */ export function ValidateAtomicMutationTable(stages: QueryStage[]): void { + const [schema, instance, table] = stages; if ( - stages.length !== tableStages.length || - stages.some((stage, index) => stage.stage !== tableStages[index]) || - (stages[1].options?.id !== undefined && !isString(stages[1].options.id)) + stages.length !== 3 || + schema.stage !== "schema" || + instance.stage !== "instance" || + table.stage !== "table" || + (instance.options?.id !== undefined && !isString(instance.options.id)) ) { throw new TypeError("Atomic mutation requires one instance-scoped table"); } diff --git a/src/staged-query/common.ts b/src/staged-query/common.ts index 125df95..cffd95a 100644 --- a/src/staged-query/common.ts +++ b/src/staged-query/common.ts @@ -1,22 +1,52 @@ import type { Datum } from "./datum"; import type { Query } from "./query"; import type { ValueProxy, ValueProxyOrValue } from "./valueproxy"; +import { QueryStage } from "./stages"; + +export { + IsQueryStage, + QueryStage, + type QueryStageMap, + type IndexBound, + type IndexKeys, + type IndexValue, + type QueryStageName, + type SelectionKey, +} from "./stages"; export type StagedConstructor = new ( newStage: QueryStage, previous?: StagedObject, ) => T; +type DeepPartialLeaf = + | string + | number + | boolean + | bigint + | symbol + | null + | undefined + | Date; + +type DeepPartialValue = unknown extends V + ? V + : V extends DeepPartialLeaf + ? V + : V extends Array + ? Array> + : V extends ReadonlyArray + ? ReadonlyArray> + : DeepPartial; + /** - * Recursive Partial generic type + * A partial document: every object field optional, recursively. Scalars, + * `null` and dates are kept as they are, a list keeps partial elements, and an + * `any` or `unknown` value stays as it is, so a full document is its own + * partial. The top level maps `T`'s keys, so a model generic over `T` can + * still pass a record it built. */ -export type DeepPartial = { - [K in keyof T]?: T[K] extends Array - ? Array> - : T[K] extends ReadonlyArray - ? ReadonlyArray> - : DeepPartial; -}; +export type DeepPartial = { [K in keyof T]?: DeepPartialValue }; /** * Change event @@ -44,20 +74,6 @@ export interface InsertOptions { conflict?: "update" | "replace"; } -export interface QueryStage { - stage: string; - options?: any; - args: any[]; -} - -export function QueryStage(stage: string, options?: any, ...args: any[]) { - return { - stage, - options, - args, - }; -} - function isStagedConstructor( candidate: Function, ): candidate is StagedConstructor { @@ -88,40 +104,24 @@ export class StagedObject { return this.stages; } - protected stage( - type: undefined, - stage: string, - options?: any, - ...args: any[] - ): this; + protected stage(type: undefined, newStage: QueryStage): this; protected stage( type: StagedConstructor, - stage: string, - options?: any, - ...args: any[] + newStage: QueryStage, ): T; protected stage( type: StagedConstructor | undefined, - stage: string, - options?: any, - ...args: any[] + newStage: QueryStage, ) { const Constructor = type ?? ownConstructor(this); - return new Constructor( - { - stage, - options, - args, - }, - this, - ); + return new Constructor(newStage, this); } private static nextargid = 0; protected callfunc( func: (...args: T) => any, ...args: (typeof StagedObject)[] - ): QueryStage { + ): QueryStage<"func"> { const argNumbers: number[] = []; const argValues: StagedObject[] = []; for (let i = 0; i < args.length; ++i) { diff --git a/src/staged-query/datum.ts b/src/staged-query/datum.ts index 74771fb..85aec3c 100644 --- a/src/staged-query/datum.ts +++ b/src/staged-query/datum.ts @@ -1,7 +1,7 @@ import { Query } from "./query"; import { ValueProxy } from "./valueproxy"; -import type { Selection } from "./selection"; -import type { ExtractType, Value } from "./common"; +import type { Stream } from "./stream"; +import { QueryStage, type ExtractType, type Value } from "./common"; import { isKindHolder, MarkKind } from "./kind"; const DATUM_KIND = Symbol.for("@antelopejs/interface-database/Datum@1"); @@ -16,9 +16,9 @@ export class Datum extends Query { public cast() { /* * SAFETY: A Datum keeps no value of type T. Its only instance state is the - * untyped `stages` array inherited from StagedObject, so the same object is - * a valid Datum for every U. Whether U matches what the query yields is - * the caller's claim, which nothing here checks. + * `stages` array inherited from StagedObject, whose type does not depend on + * T, so the same object is a valid Datum for every U. Whether U matches + * what the query yields is the caller's claim, which nothing here checks. */ return this as unknown as Datum; } @@ -33,7 +33,7 @@ export class Datum extends Query { * @param def Default value * @returns New datum with the value */ - public key, U = undefined>( + public key & string, U = undefined>( key: K, def?: U, ): Datum< @@ -45,10 +45,7 @@ export class Datum extends Query { ? NonNullable[K] : NonNullable[K]> | U >, - "key", - undefined, - key, - def, + QueryStage("key", undefined, key, def), ); } @@ -61,9 +58,7 @@ export class Datum extends Query { public default(val: Value) { return this.stage( Datum | U>, - "default", - undefined, - val, + QueryStage("default", undefined, val), ); } @@ -76,9 +71,7 @@ export class Datum extends Query { public do(mapper: (val: ValueProxy) => U) { return this.stage( Datum>, - "map", - undefined, - this.callfunc(mapper, ValueProxy), + QueryStage("map", undefined, this.callfunc(mapper, ValueProxy)), ); } @@ -89,29 +82,28 @@ export class Datum extends Query { * @param localKey Key in local object * @param otherKey Key in other table */ - public lookup( - other: Selection, + public lookup( + other: Stream, localKey: TK, - otherKey: keyof U, + otherKey: keyof U & string, ) { return this.stage( Datum & Record>, - "lookup", - { localKey, otherKey }, - other, + QueryStage("lookup", { localKey, otherKey }, other), ); } /** * Plucks fields from the documents. * - * TODO: Better typing - * * @param fields Fields to keep * @returns New datum */ - public pluck(...fields: string[]) { - return this.stage(Datum>, "pluck", undefined, fields); + public pluck & string>(...fields: K[]) { + return this.stage( + Datum, K> | Extract>, + QueryStage("pluck", undefined, fields), + ); } } diff --git a/src/staged-query/schema.ts b/src/staged-query/schema.ts index 393ff26..6e06d7a 100644 --- a/src/staged-query/schema.ts +++ b/src/staged-query/schema.ts @@ -151,7 +151,7 @@ export class Schema< * @returns Schema instance */ public instance(id?: InstanceId) { - return this.stage(SchemaInstance, "instance", { id }); + return this.stage(SchemaInstance, QueryStage("instance", { id })); } /** @@ -161,7 +161,7 @@ export class Schema< * @returns Created instance ID */ public createInstance(id?: string) { - return this.stage(Query, "createInstance", { id }); + return this.stage(Query, QueryStage("createInstance", { id })); } /** @@ -170,7 +170,7 @@ export class Schema< * @param id Instance ID */ public destroyInstance(id?: string) { - return this.stage(Query, "destroyInstance", { id }); + return this.stage(Query, QueryStage("destroyInstance", { id })); } /** @@ -181,7 +181,7 @@ export class Schema< * @returns IDs of named instances of this schema */ public listInstances() { - return this.stage(Query, "listInstances"); + return this.stage(Query, QueryStage("listInstances", undefined)); } } @@ -195,7 +195,7 @@ export class SchemaInstance extends StagedObject { * @param id Table name * @returns Table */ - public table(id: TK) { - return this.stage(Table, "table", { id }); + public table(id: TK) { + return this.stage(Table, QueryStage("table", { id })); } } diff --git a/src/staged-query/selection.ts b/src/staged-query/selection.ts index 501c984..12887b7 100644 --- a/src/staged-query/selection.ts +++ b/src/staged-query/selection.ts @@ -2,11 +2,14 @@ import { Datum } from "./datum"; import { Query } from "./query"; import { Stream } from "./stream"; import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; -import type { - Changes, - DeepPartial, - ExtractType, - InsertOptions, +import { + QueryStage, + type Changes, + type DeepPartial, + type ExtractType, + type IndexBound, + type IndexKeys, + type InsertOptions, } from "./common"; import { AtomicMutationQuery, @@ -16,12 +19,10 @@ import { type AtomicMutationOutcome, } from "./atomic"; -type SelectionKey = string | number | boolean; - type Updater = (val: ValueProxy) => U; function isUpdater( - document: DeepPartial | Updater, + document: (DeepPartial & object) | Updater, ): document is Updater { return typeof document === "function"; } @@ -36,20 +37,20 @@ export class SingleSelection extends Datum { * @param document Partial document with new values * @returns Number of modified documents */ - public update(document: DeepPartial): Query; + public update(document: DeepPartial & object): Query; public update( document: (val: ValueProxy) => U, ): ExtractType extends DeepPartial ? Query : never; - public update(document: DeepPartial | ((val: ValueProxy) => U)) { + public update( + document: (DeepPartial & object) | ((val: ValueProxy) => U), + ) { if (isUpdater(document)) { return this.stage( Query, - "update", - undefined, - this.callfunc(document, ValueProxy), + QueryStage("update", undefined, this.callfunc(document, ValueProxy)), ); } - return this.stage(Query, "update", undefined, document); + return this.stage(Query, QueryStage("update", undefined, document)); } /** @@ -58,8 +59,11 @@ export class SingleSelection extends Datum { * @param document Partial document to replace with * @returns Number of modified documents */ - public replace(document: DeepPartial) { - return this.stage(Query, "replace", undefined, document); + public replace(document: DeepPartial & object) { + return this.stage( + Query, + QueryStage("replace", undefined, document), + ); } /** @@ -68,7 +72,7 @@ export class SingleSelection extends Datum { * @returns Number of deleted documents */ public delete() { - return this.stage(Query, "delete"); + return this.stage(Query, QueryStage("delete", undefined)); } /** @@ -77,7 +81,7 @@ export class SingleSelection extends Datum { * @returns Change feed */ public changes() { - return this.stage(Query[]>, "changes"); + return this.stage(Query[]>, QueryStage("changes", undefined)); } } @@ -91,20 +95,20 @@ export class Selection extends Stream { * @param document Partial document with new values * @returns Number of modified documents */ - public update(document: DeepPartial): Query; + public update(document: DeepPartial & object): Query; public update( document: (val: ValueProxy) => U, ): ExtractType extends DeepPartial ? Query : never; - public update(document: DeepPartial | ((val: ValueProxy) => U)) { + public update( + document: (DeepPartial & object) | ((val: ValueProxy) => U), + ) { if (isUpdater(document)) { return this.stage( Query, - "update", - undefined, - this.callfunc(document, ValueProxy), + QueryStage("update", undefined, this.callfunc(document, ValueProxy)), ); } - return this.stage(Query, "update", undefined, document); + return this.stage(Query, QueryStage("update", undefined, document)); } /** @@ -113,8 +117,11 @@ export class Selection extends Stream { * @param document Partial document to replace with * @returns Number of modified documents */ - public replace(document: DeepPartial) { - return this.stage(Query, "replace", undefined, document); + public replace(document: DeepPartial & object) { + return this.stage( + Query, + QueryStage("replace", undefined, document), + ); } /** @@ -123,7 +130,7 @@ export class Selection extends Stream { * @returns Number of deleted documents */ public delete() { - return this.stage(Query, "delete"); + return this.stage(Query, QueryStage("delete", undefined)); } } @@ -140,10 +147,7 @@ export class Table extends Selection { ValidateAtomicMutation(key, request); return this.stage( AtomicMutationQuery, - "atomicMutation", - undefined, - key, - request, + QueryStage("atomicMutation", undefined, key, request), ); } @@ -154,10 +158,10 @@ export class Table extends Selection { * @returns Inserted IDs */ public insert( - obj: DeepPartial | DeepPartial[], + obj: (DeepPartial & object) | (DeepPartial & object)[], options?: InsertOptions, ) { - return this.stage(Query, "insert", options, obj); + return this.stage(Query, QueryStage("insert", options, obj)); } /** @@ -167,7 +171,7 @@ export class Table extends Selection { * @returns Single document selection */ public get(key: ValueProxyOrValue) { - return this.stage(SingleSelection, "get", undefined, key); + return this.stage(SingleSelection, QueryStage("get", undefined, key)); } /** @@ -177,11 +181,8 @@ export class Table extends Selection { * @param index Secondary index, will use the primary key if undefined * @returns Multiple document selection */ - public getAll( - keys: ValueProxyOrValue | ValueProxyOrValue[], - index?: string, - ) { - return this.stage(Selection, "getAll", { index }, keys); + public getAll(keys: IndexKeys, index?: string) { + return this.stage(Selection, QueryStage("getAll", { index }, keys)); } /** @@ -192,7 +193,14 @@ export class Table extends Selection { * @param high Highest value of the range (excluded) * @returns Multiple document selection */ - public between(index: TK, low: T[TK], high: T[TK]) { - return this.stage(Selection, "between", { index }, low, high); + public between( + index: TK, + low: T[TK] & IndexBound, + high: T[TK] & IndexBound, + ) { + return this.stage( + Selection, + QueryStage("between", { index }, low, high), + ); } } diff --git a/src/staged-query/stages.ts b/src/staged-query/stages.ts new file mode 100644 index 0000000..fb8beb1 --- /dev/null +++ b/src/staged-query/stages.ts @@ -0,0 +1,228 @@ +import type { AtomicMutation, AtomicRecord } from "./atomic"; +import type { InsertOptions, StagedObject, Value } from "./common"; +import type { InstanceId } from "./schema"; +import type { Query } from "./query"; +import type { ValueProxyOrValue } from "./valueproxy"; + +/** A scalar key `getAll` matches against an index. */ +export type SelectionKey = string | number | boolean; + +/** One value an index holds. A `null` is a value, not an open bound. */ +export type IndexValue = SelectionKey | Date | null; + +/** + * A bound `between` compares an index against: one value, or the values of a + * compound index in its field order. + */ +export type IndexBound = IndexValue | IndexValue[]; + +/** + * The keys `getAll` matches: one key, a list of keys, or a list of compound + * keys, each holding a compound index's values in its field order. + */ +export type IndexKeys = + | ValueProxyOrValue + | ValueProxyOrValue[] + | SelectionKey[][]; + +type Func = QueryStage<"func">; +type NoOptions = undefined; + +interface Recorded { + options: Options; + args: Args; +} + +type FieldOptions = { field?: string }; + +/** + * The options and arguments of every stage the builders record, by stage name. + * Adapters read a stage through `QueryStage`, which this map types. + */ +export interface QueryStageMap { + schema: Recorded<{ id: string }, []>; + instance: Recorded<{ id?: InstanceId }, []>; + createInstance: Recorded<{ id?: string }, []>; + destroyInstance: Recorded<{ id?: string }, []>; + listInstances: Recorded; + table: Recorded<{ id: string }, []>; + + arg: Recorded; + func: Recorded]>; + constant: Recorded]>; + + atomicMutation: Recorded< + NoOptions, + [key: string, request: AtomicMutation] + >; + insert: Recorded; + update: Recorded; + replace: Recorded; + delete: Recorded; + changes: Recorded; + get: Recorded]>; + getAll: Recorded<{ index?: string }, [keys: IndexKeys]>; + between: Recorded<{ index: string }, [low: IndexBound, high: IndexBound]>; + + key: Recorded< + NoOptions, + [key: StagedObject | string, defaultValue?: Value] + >; + default: Recorded]>; + map: Recorded; + filter: Recorded; + pluck: Recorded; + without: Recorded; + union: Recorded]>; + join: Recorded< + { innerOnly: boolean }, + [right: Query, predicate: Func, mapper: Func] + >; + lookup: Recorded< + { localKey: string; otherKey: string }, + [other: Query] + >; + group: Recorded<{ index: string }, [mapper: Func]>; + orderBy: Recorded<{ index: string; direction?: "asc" | "desc" }, []>; + slice: Recorded, count?: Value]>; + nth: Recorded]>; + count: Recorded; + sum: Recorded; + avg: Recorded; + min: Recorded; + max: Recorded; + distinct: Recorded; + + and: Recorded< + NoOptions, + [value: ValueProxyOrValue] + >; + or: Recorded< + NoOptions, + [value: ValueProxyOrValue] + >; + not: Recorded; + eq: Recorded]>; + ne: Recorded]>; + add: Recorded< + NoOptions, + [value: StagedObject | number | string | unknown[] | null] + >; + sub: Recorded]>; + mul: Recorded]>; + div: Recorded]>; + mod: Recorded]>; + round: Recorded; + ceil: Recorded; + floor: Recorded; + bit_and: Recorded]>; + bit_or: Recorded]>; + bit_xor: Recorded]>; + bit_not: Recorded; + bit_lshift: Recorded]>; + bit_rshift: Recorded< + { preserveSign: boolean } | undefined, + [value: ValueProxyOrValue] + >; + cmp_gt: Recorded]>; + cmp_ge: Recorded]>; + cmp_lt: Recorded]>; + cmp_le: Recorded]>; + + date_during: Recorded< + NoOptions, + [left: StagedObject | Date | number, right: StagedObject | Date | number] + >; + date_with_timezone: Recorded< + NoOptions, + [timezone: ValueProxyOrValue] + >; + date_tod: Recorded; + date_year: Recorded; + date_month: Recorded; + date_day: Recorded; + date_dow: Recorded; + date_doy: Recorded; + date_hours: Recorded; + date_minutes: Recorded; + date_seconds: Recorded; + date_epoch: Recorded; + + str_split: Recorded<{ separator?: string; maxSplits?: number }, []>; + str_concat: Recorded]>; + str_upcase: Recorded; + str_downcase: Recorded; + str_len: Recorded; + str_match: Recorded]>; + + arr_index: Recorded]>; + arr_includes: Recorded]>; + arr_slice: Recorded< + NoOptions, + [start: ValueProxyOrValue, end?: ValueProxyOrValue] + >; + arr_map: Recorded; + arr_filter: Recorded; + arr_empty: Recorded; + arr_count: Recorded; + arr_sum: Recorded; + arr_avg: Recorded; + arr_min: Recorded; + arr_max: Recorded; + + obj_index: Recorded< + NoOptions, + [key: Value, defaultValue?: Value] + >; + obj_merge: Recorded]>; + obj_keys: Recorded; + obj_values: Recorded; + obj_has: Recorded; +} + +export type QueryStageName = keyof QueryStageMap; + +type StageOptions = + undefined extends QueryStageMap[Stage]["options"] + ? { options?: QueryStageMap[Stage]["options"] } + : { options: QueryStageMap[Stage]["options"] }; + +/** + * A recorded stage. `QueryStage` alone is the union of every stage, which an + * adapter narrows on `stage`; `QueryStage<"get">` is one stage. + */ +export type QueryStage = { + [Stage in Name]: { + stage: Stage; + args: QueryStageMap[Stage]["args"]; + } & StageOptions; +}[Name]; + +type StageCall = + QueryStageMap[Name]["args"] extends [] + ? undefined extends QueryStageMap[Name]["options"] + ? [options?: QueryStageMap[Name]["options"]] + : [options: QueryStageMap[Name]["options"]] + : [ + options: QueryStageMap[Name]["options"], + ...args: QueryStageMap[Name]["args"], + ]; + +/** + * Records one stage, typed by its name. A stage that takes no arguments may + * leave out options it does not require. + */ +export function QueryStage( + stage: Name, + ...[options, ...args]: StageCall +): QueryStage { + return { stage, options, args }; +} + +/** Tells whether `stage` records the stage `name`, narrowing it to that stage. */ +export function IsQueryStage( + stage: QueryStage | undefined, + name: Name, +): stage is Extract { + return stage?.stage === name; +} diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts index d53d0e0..ec8fae7 100644 --- a/src/staged-query/stream.ts +++ b/src/staged-query/stream.ts @@ -1,7 +1,11 @@ import { Datum } from "./datum"; import { Query } from "./query"; -import type { Selection } from "./selection"; -import type { Changes, ExtractType, Value } from "./common"; +import { + QueryStage, + type Changes, + type ExtractType, + type Value, +} from "./common"; import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; import { isKindHolder, MarkKind } from "./kind"; @@ -17,9 +21,9 @@ export class Stream extends Query { public cast() { /* * SAFETY: A Stream keeps no value of type T. Its only instance state is the - * untyped `stages` array inherited from StagedObject, so the same object is - * a valid Stream for every U. Whether U matches what the query yields is - * the caller's claim, which nothing here checks. + * `stages` array inherited from StagedObject, whose type does not depend on + * T, so the same object is a valid Stream for every U. Whether U matches + * what the query yields is the caller's claim, which nothing here checks. */ return this as unknown as Stream; } @@ -29,20 +33,17 @@ export class Stream extends Query { * * TODO: Better name? * - * @param key Field name + * @param key Field name, or an expression yielding it * @param def Default value * @returns New stream */ - public key( - key: K, + public key( + key: ValueProxyOrValue, def?: U, ): Stream | U> { return this.stage( Stream | U>, - "key", - undefined, - key, - def, + QueryStage("key", undefined, key, def), ); } @@ -55,9 +56,7 @@ export class Stream extends Query { public default(val: Value) { return this.stage( Stream | U>, - "default", - undefined, - val, + QueryStage("default", undefined, val), ); } @@ -70,9 +69,7 @@ export class Stream extends Query { public map(mapper: (val: ValueProxy) => U) { return this.stage( Stream>, - "map", - undefined, - this.callfunc(mapper, ValueProxy), + QueryStage("map", undefined, this.callfunc(mapper, ValueProxy)), ); } @@ -85,9 +82,7 @@ export class Stream extends Query { public filter(predicate: (val: ValueProxy) => ValueProxyOrValue) { return this.stage( undefined, - "filter", - undefined, - this.callfunc(predicate, ValueProxy), + QueryStage("filter", undefined, this.callfunc(predicate, ValueProxy)), ); } @@ -97,8 +92,11 @@ export class Stream extends Query { * @param fields Selected fields * @returns New stream */ - public pluck(...fields: string[]) { - return this.stage(Stream>, "pluck", undefined, fields); + public pluck(...fields: K[]) { + return this.stage( + Stream>, + QueryStage("pluck", undefined, fields), + ); } /** @@ -107,8 +105,11 @@ export class Stream extends Query { * @param fields Excluded fields * @returns New stream */ - public without(...fields: string[]) { - return this.stage(Stream>, "without", undefined, fields); + public without(...fields: K[]) { + return this.stage( + Stream>, + QueryStage("without", undefined, fields), + ); } /** @@ -118,7 +119,7 @@ export class Stream extends Query { * @returns New stream containing elements from both streams */ public union(other: Stream) { - return this.stage(Stream, "union", undefined, other); + return this.stage(Stream, QueryStage("union", undefined, other)); } /** @@ -139,11 +140,13 @@ export class Stream extends Query { ) { return this.stage( Stream>, - "join", - { innerOnly: false }, - right, - this.callfunc(predicate, ValueProxy, ValueProxy), - this.callfunc(mapper, ValueProxy, ValueProxy), + QueryStage( + "join", + { innerOnly: false }, + right, + this.callfunc(predicate, ValueProxy, ValueProxy), + this.callfunc(mapper, ValueProxy, ValueProxy), + ), ); } @@ -165,11 +168,13 @@ export class Stream extends Query { ) { return this.stage( Stream>, - "join", - { innerOnly: true }, - right, - this.callfunc(predicate, ValueProxy, ValueProxy), - this.callfunc(mapper, ValueProxy, ValueProxy), + QueryStage( + "join", + { innerOnly: true }, + right, + this.callfunc(predicate, ValueProxy, ValueProxy), + this.callfunc(mapper, ValueProxy, ValueProxy), + ), ); } @@ -181,16 +186,14 @@ export class Stream extends Query { * @param otherKey Key in the other document to match against * @returns New stream */ - public lookup( - right: Selection, + public lookup( + right: Stream, localKey: TK, - otherKey: keyof U, + otherKey: keyof U & string, ) { return this.stage( Stream & Record>, - "lookup", - { localKey, otherKey }, - right, + QueryStage("lookup", { localKey, otherKey }, right), ); } @@ -207,7 +210,7 @@ export class Stream extends Query { * @param mapper Mapping function * @returns New stream of grouped data */ - public group( + public group( index: K, mapper: ( stream: Stream, @@ -216,9 +219,11 @@ export class Stream extends Query { ) { return this.stage( Stream>, - "group", - { index }, - this.callfunc(mapper, Stream, ValueProxy), + QueryStage( + "group", + { index }, + this.callfunc(mapper, Stream, ValueProxy), + ), ); } @@ -230,7 +235,7 @@ export class Stream extends Query { * @returns New (sorted) stream */ public orderBy(index: string, direction?: "asc" | "desc") { - return this.stage(undefined, "orderBy", { index, direction }); + return this.stage(undefined, QueryStage("orderBy", { index, direction })); } /** @@ -241,7 +246,7 @@ export class Stream extends Query { * @returns New stream */ public slice(offset: Value, count?: Value) { - return this.stage(undefined, "slice", undefined, offset, count); + return this.stage(undefined, QueryStage("slice", undefined, offset, count)); } /** @@ -251,7 +256,7 @@ export class Stream extends Query { * @returns Single document */ public nth(n: Value) { - return this.stage(Datum, "nth", undefined, n); + return this.stage(Datum, QueryStage("nth", undefined, n)); } /** @@ -260,8 +265,8 @@ export class Stream extends Query { * @param field Field to count distinct entries * @returns Count */ - public count(field?: keyof T) { - return this.stage(Datum, "count", { field }); + public count(field?: keyof T & string) { + return this.stage(Datum, QueryStage("count", { field })); } /** @@ -270,8 +275,8 @@ export class Stream extends Query { * @param field Field to use * @returns Sum */ - public sum(field?: keyof T) { - return this.stage(Datum, "sum", { field }); + public sum(field?: keyof T & string) { + return this.stage(Datum, QueryStage("sum", { field })); } /** @@ -280,8 +285,8 @@ export class Stream extends Query { * @param field Field to use * @returns Average */ - public avg(field?: keyof T) { - return this.stage(Datum, "avg", { field }); + public avg(field?: keyof T & string) { + return this.stage(Datum, QueryStage("avg", { field })); } /** @@ -290,8 +295,8 @@ export class Stream extends Query { * @param field Field to use * @returns Minimum value */ - public min(field?: keyof T) { - return this.stage(Datum, "min", { field }); + public min(field?: keyof T & string) { + return this.stage(Datum, QueryStage("min", { field })); } /** @@ -300,8 +305,8 @@ export class Stream extends Query { * @param field Field to use * @returns Maximum value */ - public max(field?: keyof T) { - return this.stage(Datum, "max", { field }); + public max(field?: keyof T & string) { + return this.stage(Datum, QueryStage("max", { field })); } /** @@ -318,12 +323,12 @@ export class Stream extends Query { * @param index Field to use * @returns New stream */ - public distinct(field: TK): Stream; - public distinct(field?: keyof T): Datum | Stream { + public distinct(field: TK): Stream; + public distinct(field?: keyof T & string): Datum | Stream { if (field) { - return this.stage(Stream, "distinct", { field }); + return this.stage(Stream, QueryStage("distinct", { field })); } - return this.stage(Datum, "distinct", { field }); + return this.stage(Datum, QueryStage("distinct", { field })); } /** @@ -332,7 +337,7 @@ export class Stream extends Query { * @returns Change feed */ public changes() { - return this.stage(Query[]>, "changes"); + return this.stage(Query[]>, QueryStage("changes", undefined)); } } diff --git a/src/staged-query/valueproxy-conditions.ts b/src/staged-query/valueproxy-conditions.ts new file mode 100644 index 0000000..61571c0 --- /dev/null +++ b/src/staged-query/valueproxy-conditions.ts @@ -0,0 +1,26 @@ +import type { ValueProxy } from "./valueproxy"; + +export type Is = Left extends Right ? R : never; + +export type ArrayValue = T extends (infer V)[] ? V : never; + +export type IsArray = Right extends (infer A)[] + ? Is + : never; + +export type OnlyObject = T extends object ? T : never; + +type NullableOperand = V | ValueProxy | ValueProxy | null; + +/** + * The operand `add` takes for a receiver: seconds for a date, a number for a + * number, a string for a string, a list for a list. A `null` operand answers + * null. + */ +export type AddOperand = T extends Date | number + ? NullableOperand + : T extends string + ? NullableOperand + : T extends (infer V)[] + ? NullableOperand + : never; diff --git a/src/staged-query/valueproxy-kind.ts b/src/staged-query/valueproxy-kind.ts new file mode 100644 index 0000000..7477025 --- /dev/null +++ b/src/staged-query/valueproxy-kind.ts @@ -0,0 +1,19 @@ +import { isKindHolder } from "./kind"; +import type { ValueProxy } from "./valueproxy"; + +export const VALUEPROXY_KIND = Symbol.for( + "@antelopejs/interface-database/ValueProxy@1", +); + +/** + * Whether a value is a value proxy, built by any instance of this interface. + * + * Unlike `instanceof`, it also recognises objects built by the instance of + * another provider binding. + * + * @param value Value to test + * @returns True if the value is a value proxy + */ +export function IsValueProxy(value: unknown): value is ValueProxy { + return isKindHolder(value) && value[VALUEPROXY_KIND] === true; +} diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts index 6c57064..47f979b 100644 --- a/src/staged-query/valueproxy.ts +++ b/src/staged-query/valueproxy.ts @@ -4,23 +4,26 @@ import { type ExtractType, type StagedConstructor, } from "./common"; -import { isKindHolder, MarkKind } from "./kind"; - -const VALUEPROXY_KIND = Symbol.for( - "@antelopejs/interface-database/ValueProxy@1", -); +import { MarkKind } from "./kind"; +import type { + AddOperand, + ArrayValue, + Is, + IsArray, + OnlyObject, +} from "./valueproxy-conditions"; +import { VALUEPROXY_KIND } from "./valueproxy-kind"; export type ValueProxyOrValue = ValueProxy | T; -export type Is = Left extends Right ? R : never; - -export type ArrayValue = T extends (infer V)[] ? V : never; - -export type IsArray = Right extends (infer A)[] - ? Is - : never; - -export type OnlyObject = T extends object ? T : never; +export type { + AddOperand, + ArrayValue, + Is, + IsArray, + OnlyObject, +} from "./valueproxy-conditions"; +export { IsValueProxy } from "./valueproxy-kind"; /** * Proxy to an actual value in the database @@ -46,11 +49,9 @@ export class ValueProxy extends StagedObject { static #stageOf( proxy: ValueProxy, type: StagedConstructor, - stage: string, - options?: any, - ...args: any[] + newStage: QueryStage, ): Result { - return proxy.stage(type, stage, options, ...args); + return proxy.stage(type, newStage); } /** @@ -61,10 +62,11 @@ export class ValueProxy extends StagedObject { */ public cast() { /* - * SAFETY: A ValueProxy keeps no value of type T. Its only instance state is the - * untyped `stages` array inherited from StagedObject, so the same object is - * a valid ValueProxy for every U. Whether U matches what the query yields is - * the caller's claim, which nothing here checks. + * SAFETY: A ValueProxy keeps no value of type T. Its only instance state is + * the `stages` array inherited from StagedObject, whose type does not + * depend on T, so the same object is a valid ValueProxy for every U. + * Whether U matches what the query yields is the caller's claim, which + * nothing here checks. */ return this as unknown as ValueProxy; } @@ -78,9 +80,7 @@ export class ValueProxy extends StagedObject { public default(value: ValueProxyOrValue) { return this.stage( ValueProxy | U>, - "default", - undefined, - value, + QueryStage("default", undefined, value), ); } @@ -93,7 +93,7 @@ export class ValueProxy extends StagedObject { public and( value: ValueProxyOrValue, ) { - return this.stage(ValueProxy, "and", undefined, value); + return this.stage(ValueProxy, QueryStage("and", undefined, value)); } /** @@ -103,7 +103,7 @@ export class ValueProxy extends StagedObject { * @returns A || B */ public or(value: ValueProxyOrValue) { - return this.stage(ValueProxy, "or", undefined, value); + return this.stage(ValueProxy, QueryStage("or", undefined, value)); } /** @@ -112,40 +112,45 @@ export class ValueProxy extends StagedObject { * @returns !A */ public not() { - return this.stage(ValueProxy, "not"); + return this.stage(ValueProxy, QueryStage("not", undefined)); } /** * Equality operator. * - * @param value Operand B + * @param value Operand B: a constant of this value's type, or any expression * @returns A == B */ - public eq(value: unknown) { - return this.stage(ValueProxy, "eq", undefined, value); + public eq(value: StagedObject | T) { + return this.stage(ValueProxy, QueryStage("eq", undefined, value)); } /** * Inequality operator. * - * @param value Operand B + * @param value Operand B: a constant of this value's type, or any expression * @returns A != B */ - public ne(value: unknown) { - return this.stage(ValueProxy, "ne", undefined, value); + public ne(value: StagedObject | T) { + return this.stage(ValueProxy, QueryStage("ne", undefined, value)); } /** - * Addition operator. + * Addition operator: adds seconds to a date, adds two numbers, or concatenates + * two strings or two lists. * - * @param other Operand B - * @returns New value + * @param value Operand B + * @returns New value, or null when operand B is null */ public add( - this: Is, - value: ValueProxyOrValue, + this: Is | IsArray, + value: AddOperand, ) { - return ValueProxy.#stageOf(this, ValueProxy, "add", undefined, value); + return ValueProxy.#stageOf( + this, + ValueProxy, + QueryStage("add", undefined, value), + ); } /** @@ -164,30 +169,25 @@ export class ValueProxy extends StagedObject { | (number extends U ? (T extends number ? number : Date) : never) : Extract >, - "sub", - undefined, - value, + QueryStage("sub", undefined, value), ); } /** * Determines whether or not the proxy is between the two bounds. * - * @param left Left bound (inclusive) - * @param right Right bound (exclusive) + * @param left Left bound (inclusive): a date, or milliseconds since the UNIX epoch + * @param right Right bound (exclusive): a date, or milliseconds since the UNIX epoch * @returns True if the date is within the bounds */ - public during( + public during( this: Is, - left: ValueProxyOrValue, - right: ValueProxyOrValue, + left: ValueProxyOrValue, + right: ValueProxyOrValue, ) { return this.stage( ValueProxy, - "date_during", - undefined, - left, - right, + QueryStage("date_during", undefined, left, right), ); } @@ -196,9 +196,7 @@ export class ValueProxy extends StagedObject { if (timezone) { return this.stage( ValueProxy, - "date_with_timezone", - undefined, - timezone, + QueryStage("date_with_timezone", undefined, timezone), ); } return this.cast(); @@ -213,7 +211,10 @@ export class ValueProxy extends StagedObject { this: Is, timezone?: ValueProxyOrValue, ) { - return this.withTimezone(timezone).stage(ValueProxy, "date_tod"); + return this.withTimezone(timezone).stage( + ValueProxy, + QueryStage("date_tod", undefined), + ); } /** @@ -222,7 +223,10 @@ export class ValueProxy extends StagedObject { * @returns Year */ public year(this: Is, timezone?: ValueProxyOrValue) { - return this.withTimezone(timezone).stage(ValueProxy, "date_year"); + return this.withTimezone(timezone).stage( + ValueProxy, + QueryStage("date_year", undefined), + ); } /** @@ -231,7 +235,10 @@ export class ValueProxy extends StagedObject { * @returns Month */ public month(this: Is, timezone?: ValueProxyOrValue) { - return this.withTimezone(timezone).stage(ValueProxy, "date_month"); + return this.withTimezone(timezone).stage( + ValueProxy, + QueryStage("date_month", undefined), + ); } /** @@ -240,7 +247,10 @@ export class ValueProxy extends StagedObject { * @returns Day of the month */ public day(this: Is, timezone?: ValueProxyOrValue) { - return this.withTimezone(timezone).stage(ValueProxy, "date_day"); + return this.withTimezone(timezone).stage( + ValueProxy, + QueryStage("date_day", undefined), + ); } /** @@ -252,7 +262,10 @@ export class ValueProxy extends StagedObject { this: Is, timezone?: ValueProxyOrValue, ) { - return this.withTimezone(timezone).stage(ValueProxy, "date_dow"); + return this.withTimezone(timezone).stage( + ValueProxy, + QueryStage("date_dow", undefined), + ); } /** @@ -264,7 +277,10 @@ export class ValueProxy extends StagedObject { this: Is, timezone?: ValueProxyOrValue, ) { - return this.withTimezone(timezone).stage(ValueProxy, "date_doy"); + return this.withTimezone(timezone).stage( + ValueProxy, + QueryStage("date_doy", undefined), + ); } /** @@ -273,7 +289,10 @@ export class ValueProxy extends StagedObject { * @returns Hours */ public hours(this: Is, timezone?: ValueProxyOrValue) { - return this.withTimezone(timezone).stage(ValueProxy, "date_hours"); + return this.withTimezone(timezone).stage( + ValueProxy, + QueryStage("date_hours", undefined), + ); } /** @@ -287,7 +306,7 @@ export class ValueProxy extends StagedObject { ) { return this.withTimezone(timezone).stage( ValueProxy, - "date_minutes", + QueryStage("date_minutes", undefined), ); } @@ -302,7 +321,7 @@ export class ValueProxy extends StagedObject { ) { return this.withTimezone(timezone).stage( ValueProxy, - "date_seconds", + QueryStage("date_seconds", undefined), ); } @@ -312,7 +331,7 @@ export class ValueProxy extends StagedObject { * @returns Seconds */ public epoch(this: Is) { - return this.stage(ValueProxy, "date_epoch"); + return this.stage(ValueProxy, QueryStage("date_epoch", undefined)); } /** @@ -322,7 +341,7 @@ export class ValueProxy extends StagedObject { * @returns A * B */ public mul(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "mul", undefined, other); + return this.stage(ValueProxy, QueryStage("mul", undefined, other)); } /** @@ -332,7 +351,7 @@ export class ValueProxy extends StagedObject { * @returns A / B */ public div(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "div", undefined, other); + return this.stage(ValueProxy, QueryStage("div", undefined, other)); } /** @@ -342,7 +361,7 @@ export class ValueProxy extends StagedObject { * @returns A % B */ public mod(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "mod", undefined, other); + return this.stage(ValueProxy, QueryStage("mod", undefined, other)); } /** @@ -351,7 +370,7 @@ export class ValueProxy extends StagedObject { * @returns Integer */ public round(this: Is) { - return this.stage(ValueProxy, "round"); + return this.stage(ValueProxy, QueryStage("round", undefined)); } /** @@ -360,7 +379,7 @@ export class ValueProxy extends StagedObject { * @returns Integer */ public ceil(this: Is) { - return this.stage(ValueProxy, "ceil"); + return this.stage(ValueProxy, QueryStage("ceil", undefined)); } /** @@ -369,7 +388,7 @@ export class ValueProxy extends StagedObject { * @returns Integer */ public floor(this: Is) { - return this.stage(ValueProxy, "floor"); + return this.stage(ValueProxy, QueryStage("floor", undefined)); } /** @@ -379,7 +398,10 @@ export class ValueProxy extends StagedObject { * @return A & B */ public band(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "bit_and", undefined, other); + return this.stage( + ValueProxy, + QueryStage("bit_and", undefined, other), + ); } /** @@ -389,7 +411,10 @@ export class ValueProxy extends StagedObject { * @return A | B */ public bor(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "bit_or", undefined, other); + return this.stage( + ValueProxy, + QueryStage("bit_or", undefined, other), + ); } /** @@ -399,7 +424,10 @@ export class ValueProxy extends StagedObject { * @return A ^ B */ public bxor(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "bit_xor", undefined, other); + return this.stage( + ValueProxy, + QueryStage("bit_xor", undefined, other), + ); } /** @@ -408,7 +436,7 @@ export class ValueProxy extends StagedObject { * @return ~A */ public bnot(this: Is) { - return this.stage(ValueProxy, "bit_not"); + return this.stage(ValueProxy, QueryStage("bit_not", undefined)); } /** @@ -418,7 +446,10 @@ export class ValueProxy extends StagedObject { * @returns A << B */ public blshift(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "bit_lshift", undefined, other); + return this.stage( + ValueProxy, + QueryStage("bit_lshift", undefined, other), + ); } /** @@ -428,7 +459,10 @@ export class ValueProxy extends StagedObject { * @returns A >> B */ public brshift(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "bit_rshift", undefined, other); + return this.stage( + ValueProxy, + QueryStage("bit_rshift", undefined, other), + ); } /** @@ -443,9 +477,7 @@ export class ValueProxy extends StagedObject { ) { return this.stage( ValueProxy, - "bit_rshift", - { preserveSign: true }, - other, + QueryStage("bit_rshift", { preserveSign: true }, other), ); } @@ -462,9 +494,7 @@ export class ValueProxy extends StagedObject { return ValueProxy.#stageOf( this, ValueProxy, - "cmp_gt", - undefined, - other, + QueryStage("cmp_gt", undefined, other), ); } @@ -481,9 +511,7 @@ export class ValueProxy extends StagedObject { return ValueProxy.#stageOf( this, ValueProxy, - "cmp_ge", - undefined, - other, + QueryStage("cmp_ge", undefined, other), ); } @@ -500,9 +528,7 @@ export class ValueProxy extends StagedObject { return ValueProxy.#stageOf( this, ValueProxy, - "cmp_lt", - undefined, - other, + QueryStage("cmp_lt", undefined, other), ); } @@ -519,9 +545,7 @@ export class ValueProxy extends StagedObject { return ValueProxy.#stageOf( this, ValueProxy, - "cmp_le", - undefined, - other, + QueryStage("cmp_le", undefined, other), ); } @@ -537,10 +561,13 @@ export class ValueProxy extends StagedObject { separator?: string, maxSplits?: number, ) { - return this.stage(ValueProxy, "str_split", { - separator, - maxSplits, - }); + return this.stage( + ValueProxy, + QueryStage("str_split", { + separator, + maxSplits, + }), + ); } /** @@ -550,7 +577,10 @@ export class ValueProxy extends StagedObject { * @returns Concatenated string */ public concat(this: Is, other: ValueProxyOrValue) { - return this.stage(ValueProxy, "str_concat", undefined, other); + return this.stage( + ValueProxy, + QueryStage("str_concat", undefined, other), + ); } /** @@ -559,7 +589,7 @@ export class ValueProxy extends StagedObject { * @returns New string */ public upcase(this: Is) { - return this.stage(ValueProxy, "str_upcase"); + return this.stage(ValueProxy, QueryStage("str_upcase", undefined)); } /** @@ -568,7 +598,10 @@ export class ValueProxy extends StagedObject { * @returns New string */ public downcase(this: Is) { - return this.stage(ValueProxy, "str_downcase"); + return this.stage( + ValueProxy, + QueryStage("str_downcase", undefined), + ); } /** @@ -577,7 +610,7 @@ export class ValueProxy extends StagedObject { * @returns Number of codepoints */ public strlen(this: Is) { - return this.stage(ValueProxy, "str_len"); + return this.stage(ValueProxy, QueryStage("str_len", undefined)); } /** @@ -587,7 +620,10 @@ export class ValueProxy extends StagedObject { * @returns True if the string matched */ public match(this: Is, regex: ValueProxyOrValue) { - return this.stage(ValueProxy, "str_match", undefined, regex); + return this.stage( + ValueProxy, + QueryStage("str_match", undefined, regex), + ); } /** @@ -600,7 +636,10 @@ export class ValueProxy extends StagedObject { this: IsArray, key: ValueProxyOrValue, ) { - return this.stage(ValueProxy>, "arr_index", undefined, key); + return this.stage( + ValueProxy>, + QueryStage("arr_index", undefined, key), + ); } /** @@ -613,7 +652,10 @@ export class ValueProxy extends StagedObject { this: IsArray, val: ValueProxyOrValue>, ) { - return this.stage(ValueProxy, "arr_includes", undefined, val); + return this.stage( + ValueProxy, + QueryStage("arr_includes", undefined, val), + ); } /** @@ -630,10 +672,7 @@ export class ValueProxy extends StagedObject { ) { return this.stage( ValueProxy>>, - "arr_slice", - undefined, - start, - end, + QueryStage("arr_slice", undefined, start, end), ); } @@ -649,27 +688,33 @@ export class ValueProxy extends StagedObject { ) { return this.stage( ValueProxy[]>, - "arr_map", - undefined, - this.callfunc(mapper, ValueProxy>), + QueryStage( + "arr_map", + undefined, + this.callfunc(mapper, ValueProxy>), + ), ); } /** * Filters the array using a predicate function. * - * @param predicate Predicate function. + * @param predicate Predicate function. A number keeps the element unless it is 0. * @returns Filtered array */ public filter( this: IsArray, - predicate: (val: ValueProxy>) => ValueProxyOrValue, + predicate: ( + val: ValueProxy>, + ) => ValueProxyOrValue | ValueProxyOrValue, ) { return this.stage( ValueProxy>>, - "arr_filter", - undefined, - this.callfunc(predicate, ValueProxy>), + QueryStage( + "arr_filter", + undefined, + this.callfunc(predicate, ValueProxy>), + ), ); } @@ -679,7 +724,7 @@ export class ValueProxy extends StagedObject { * @returns True if the array is empty */ public isempty(this: IsArray) { - return this.stage(ValueProxy, "arr_empty"); + return this.stage(ValueProxy, QueryStage("arr_empty", undefined)); } /** @@ -688,7 +733,7 @@ export class ValueProxy extends StagedObject { * @returns Length */ public count(this: IsArray) { - return this.stage(ValueProxy, "arr_count"); + return this.stage(ValueProxy, QueryStage("arr_count", undefined)); } /** @@ -697,7 +742,11 @@ export class ValueProxy extends StagedObject { * @returns Sum */ public sum(this: IsArray) { - return ValueProxy.#stageOf(this, ValueProxy, "arr_sum"); + return ValueProxy.#stageOf( + this, + ValueProxy, + QueryStage("arr_sum", undefined), + ); } /** @@ -706,25 +755,37 @@ export class ValueProxy extends StagedObject { * @returns Average */ public avg(this: IsArray) { - return ValueProxy.#stageOf(this, ValueProxy, "arr_avg"); + return ValueProxy.#stageOf( + this, + ValueProxy, + QueryStage("arr_avg", undefined), + ); } /** - * Gets the minimum of a number array. + * Gets the minimum of an array of numbers, strings or dates. * * @returns Minimum */ - public min(this: IsArray) { - return ValueProxy.#stageOf(this, ValueProxy, "arr_min"); + public min(this: IsArray) { + return ValueProxy.#stageOf( + this, + ValueProxy>, + QueryStage("arr_min", undefined), + ); } /** - * Gets the maxmimum of a number array. + * Gets the maximum of an array of numbers, strings or dates. * * @returns Maximum */ - public max(this: IsArray) { - return ValueProxy.#stageOf(this, ValueProxy, "arr_max"); + public max(this: IsArray) { + return ValueProxy.#stageOf( + this, + ValueProxy>, + QueryStage("arr_max", undefined), + ); } /** @@ -745,10 +806,7 @@ export class ValueProxy extends StagedObject { ValueProxy< U extends undefined ? TO[K] : Exclude | U >, - "obj_index", - undefined, - key, - def, + QueryStage("obj_index", undefined, key, def), ); } @@ -764,9 +822,7 @@ export class ValueProxy extends StagedObject { ) { return this.stage( ValueProxy & ExtractType>, - "obj_merge", - undefined, - other, + QueryStage("obj_merge", undefined, other), ); } @@ -776,7 +832,10 @@ export class ValueProxy extends StagedObject { * @returns Array of keys */ public keys>(this: Is) { - return this.stage(ValueProxy>, "obj_keys"); + return this.stage( + ValueProxy>, + QueryStage("obj_keys", undefined), + ); } /** @@ -785,7 +844,10 @@ export class ValueProxy extends StagedObject { * @returns Array of values */ public values>(this: Is) { - return this.stage(ValueProxy>, "obj_values"); + return this.stage( + ValueProxy>, + QueryStage("obj_values", undefined), + ); } /** @@ -795,21 +857,11 @@ export class ValueProxy extends StagedObject { * @returns True if the object matches */ public hasfields(this: Is, ...fields: string[]) { - return this.stage(ValueProxy, "obj_has", undefined, fields); + return this.stage( + ValueProxy, + QueryStage("obj_has", undefined, fields), + ); } } MarkKind(ValueProxy.prototype, VALUEPROXY_KIND); - -/** - * Whether a value is a value proxy, built by any instance of this interface. - * - * Unlike `instanceof`, it also recognises objects built by the instance of - * another provider binding. - * - * @param value Value to test - * @returns True if the value is a value proxy - */ -export function IsValueProxy(value: unknown): value is ValueProxy { - return isKindHolder(value) && value[VALUEPROXY_KIND] === true; -} diff --git a/src/tests/basic_operations.test.ts b/src/tests/basic_operations.test.ts index 5e40f14..bb6ec5e 100644 --- a/src/tests/basic_operations.test.ts +++ b/src/tests/basic_operations.test.ts @@ -17,6 +17,7 @@ describe("Basic Operations", () => { await schema.createInstance("default").run(); }); + it("Schema list", SchemaListTest); it("Insert", InsertTest); it("Insert conflict update (existing doc)", InsertConflictUpdateTest); it("Insert conflict replace (existing doc)", InsertConflictReplaceTest); @@ -27,6 +28,7 @@ describe("Basic Operations", () => { it("Get All by primary keys", GetAllMultiKeyByPrimaryKey); it("Get All by 3+ primary keys", GetAllManyPrimaryKeys); it("Get All by 3+ index values", GetAllManyIndexValues); + it("Get All by compound index keys", GetAllCompoundKeys); it("Get All With OrderBy", GetAllWithOrderBy); it("Update", UpdateTest); it("Replace", ReplaceTest); @@ -38,6 +40,10 @@ describe("Basic Operations", () => { }); }); +function SchemaListTest() { + expect(Schema.list().get("test-basic-operations")).to.equal(schema); +} + async function InsertTest() { const response = await table.insert(vehicles).run(); expect(response).to.be.an("array"); @@ -239,6 +245,24 @@ async function GetAllManyIndexValues() { } } +async function GetAllCompoundKeys() { + const keys = expectedVehicles + .slice(0, 2) + .map((vehicle) => [vehicle.isElectric, vehicle.price]); + const result = await table.getAll(keys, "electricPrice").run(); + + const expectedCount = expectedVehicles.filter((vehicle) => + keys.some( + ([isElectric, price]) => + vehicle.isElectric === isElectric && vehicle.price === price, + ), + ).length; + expect(result).to.have.lengthOf(expectedCount); + for (const doc of result) { + expect(keys).to.deep.include([doc.isElectric, doc.price]); + } +} + async function GetAllWithOrderBy() { const result = await table .getAll(false, "isElectric") diff --git a/src/tests/datasets/vehicles.ts b/src/tests/datasets/vehicles.ts index 972d64c..68fb4d5 100644 --- a/src/tests/datasets/vehicles.ts +++ b/src/tests/datasets/vehicles.ts @@ -19,6 +19,7 @@ export const Vehicle = { isElectric: {}, price: {}, car: {}, + electricPrice: { fields: ["isElectric", "price"] }, }, }; diff --git a/src/tests/filters.test.ts b/src/tests/filters.test.ts index df153b8..2f87992 100644 --- a/src/tests/filters.test.ts +++ b/src/tests/filters.test.ts @@ -42,6 +42,9 @@ describe("Filter Operations", () => { it("Filter with match()", FilterWithMatch); it("Filter with concat()", FilterWithConcat); it("Filter with Array isempty()", FilterWithArrayIsempty); + it("Filter with Array filter() on a number", FilterWithArrayFilterOnANumber); + it("Filter with Array min() of strings", FilterWithArrayMinOfStrings); + it("Filter with Array max() of dates", FilterWithArrayMaxOfDates); it("Filter with Array index()", FilterWithArrayIndex); it("Filter with Object hasfields()", FilterWithObjectHasfields); it("Filter with Object keys()", FilterWithObjectKeys); @@ -305,6 +308,46 @@ async function FilterWithConcat() { expect(result[0].greeting).to.equal("Antoine Hello"); } +async function FilterWithArrayFilterOnANumber() { + const result = await table + .filter(() => + ValueProxy.constant([0, 1]) + .filter((value) => value) + .includes(0) + .not(), + ) + .run(); + + expect(result).to.have.lengthOf(testData.length); +} + +async function FilterWithArrayMinOfStrings() { + const result = await table + .filter((doc) => doc.key("skills").min().eq("JavaScript")) + .run(); + + const expected = testData + .filter((user) => user.skills?.slice().sort()[0] === "JavaScript") + .map((user) => user.name); + expect(expected).to.have.lengthOf.above(0); + expect(result.map((doc) => doc.name)).to.deep.equal(expected); +} + +async function FilterWithArrayMaxOfDates() { + const bounds = [new Date("2022-01-01"), new Date("2023-06-01")]; + const result = await table + .filter((doc) => ValueProxy.constant(bounds).max().gt(doc.key("createdAt"))) + .run(); + + const latest = new Date("2023-06-01"); + const expected = testData + .filter((user) => user.createdAt !== undefined && user.createdAt < latest) + .map((user) => user.name) + .sort(); + expect(expected).to.have.lengthOf.above(0); + expect(result.map((doc) => doc.name).sort()).to.deep.equal(expected); +} + async function FilterWithArrayIsempty() { const result = await table .filter((doc) => doc.key("skills").isempty().eq(false)) diff --git a/src/tests/lookup_operations.test.ts b/src/tests/lookup_operations.test.ts index fbd16d0..52760bd 100644 --- a/src/tests/lookup_operations.test.ts +++ b/src/tests/lookup_operations.test.ts @@ -67,6 +67,7 @@ describe("Lookup Operations", () => { it("Lookup With Filter", LookupWithFilter); it("Lookup with Subquery Inside Map", LookupWithSubqueryInsideMap); it("Chained Lookup", ChainedLookup); + it("Lookup Against A Plucked Stream", LookupAgainstPluckedStream); it("Cleanup", CleanupTest); }); @@ -193,6 +194,17 @@ async function ChainedLookup() { expect(antoineOrder?.productSku).to.have.property("sku", "LAPTOP-001"); } +async function LookupAgainstPluckedStream() { + const result = await ordersTable + .lookup(usersTable.pluck("name", "age"), "customerName", "name") + .run(); + + const antoineOrder = result.find((doc) => doc.orderId === "ORD-001"); + expect(antoineOrder?.customerName).to.have.property("name", "Antoine"); + expect(antoineOrder?.customerName).to.have.property("age", 25); + expect(antoineOrder?.customerName).to.not.have.property("email"); +} + async function CleanupTest() { for (const key of insertedKeys.orders) { await ordersTable.get(key).delete().run(); diff --git a/src/tests/recorded_stages.test.ts b/src/tests/recorded_stages.test.ts new file mode 100644 index 0000000..5c3971d --- /dev/null +++ b/src/tests/recorded_stages.test.ts @@ -0,0 +1,88 @@ +import { expect } from "chai"; +import { Schema, ValueProxy } from ".."; + +type Person = { + _id?: string; + name: string; + age: number; + bornAt: Date; + tags: string[]; + scores: number[]; +}; + +const schema = new Schema<{ people: Person }>("test-recorded-stages", { + people: { + fields: { + name: "string", + age: "number", + bornAt: "date", + tags: "string[]", + scores: "number[]", + }, + indexes: {}, + }, +}); +const table = schema.instance("default").table("people"); +const row = ValueProxy.arg(0); + +describe("Recorded stages", () => { + it("Records the string operand of add on a string", () => { + expect(row.key("name").add("!").build().at(-1)).to.deep.equal({ + stage: "add", + options: undefined, + args: ["!"], + }); + }); + + it("Records the list operand of add on a list", () => { + expect(row.key("tags").add(["x"]).build().at(-1)).to.deep.equal({ + stage: "add", + options: undefined, + args: [["x"]], + }); + }); + + it("Records a proxy operand of add", () => { + const other = row.key("age"); + expect(row.key("age").add(other).build().at(-1)).to.deep.equal({ + stage: "add", + options: undefined, + args: [other], + }); + }); + + it("Records a null operand of add", () => { + expect(row.key("age").add(null).build().at(-1)).to.deep.equal({ + stage: "add", + options: undefined, + args: [null], + }); + }); + + it("Records numeric bounds of during", () => { + expect(row.key("bornAt").during(0, 1).build().at(-1)).to.deep.equal({ + stage: "date_during", + options: undefined, + args: [0, 1], + }); + }); + + it("Records an expression key on a stream", () => { + const field = ValueProxy.constant<"name">("name"); + expect(table.key(field).build().at(-1)).to.deep.equal({ + stage: "key", + options: undefined, + args: [field, undefined], + }); + }); + + it("Records the bounds of between", () => { + const low = new Date(0); + const high = new Date(1); + expect(table.between("bornAt", low, high).build().at(-1)).to.deep.equal({ + stage: "between", + options: { index: "bornAt" }, + args: [low, high], + }); + }); +}); From 4c1c2a7ffa0312380cebefe1e59fc9eb30902b19 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 17:01:52 +0100 Subject: [PATCH 04/16] chore(ratchet): lower ceilings to the measured counts (#53) --- guardrails.ratchet.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json index cc5de2c..ec69629 100644 --- a/guardrails.ratchet.json +++ b/guardrails.ratchet.json @@ -2,11 +2,8 @@ "pack": "0.2.17", "rules": { "anti-slop/no-chained-type-assertions": 3, - "anti-slop/no-unknown-parameters": 2, "anti-slop/require-safety-comment-for-type-assertion": 2, - "typescript/no-unnecessary-type-parameters": 2, - "typescript/no-unsafe-argument": 3, - "typescript/no-unsafe-assignment": 2 + "typescript/no-unnecessary-type-parameters": 2 }, "oversizeFiles": {} } From 76a8c49412bfb8e6d8a13d9b4792f9197812efd3 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 17:32:53 +0100 Subject: [PATCH 05/16] refactor(atomic): declare what validateConstant guarantees instead of a single-use type parameter (#54) --- src/staged-query/atomic.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/staged-query/atomic.ts b/src/staged-query/atomic.ts index 74da945..76ad227 100644 --- a/src/staged-query/atomic.ts +++ b/src/staged-query/atomic.ts @@ -80,7 +80,10 @@ function validateField(field: string) { } } -function validateConstant(value: Value, ancestors = new Set()) { +function validateConstant( + value: unknown, + ancestors = new Set(), +): asserts value is AtomicConstant { if (value === null || isString(value) || isBoolean(value)) return; if (Number.isFinite(value)) return; if (value instanceof Date && Number.isFinite(value.getTime())) return; From 5e9a0cd48e289554e15cb4a0311c5be328fc4895 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 17:48:42 +0100 Subject: [PATCH 06/16] test(atomic): feed the off-contract mutations without casts (#55) --- guardrails.suppressions.json | 2 +- src/tests/atomic_mutation.test.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index dbf36d5..165fb4d 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -4,7 +4,7 @@ "typescript/no-unused-vars": 1, "unicorn/no-thenable": 1 }, - "tsExpectError": 0, + "tsExpectError": 2, "tsIgnore": 0, "safety": 5, "config": {} diff --git a/src/tests/atomic_mutation.test.ts b/src/tests/atomic_mutation.test.ts index 82281f1..7fa6ec7 100644 --- a/src/tests/atomic_mutation.test.ts +++ b/src/tests/atomic_mutation.test.ts @@ -8,7 +8,6 @@ import { SchemaInstance, ValidateAtomicMutation, ValueProxy, - type AtomicMutation, type AtomicMutationOutcome, type AtomicUpdate, type InstanceId, @@ -109,8 +108,9 @@ function ValidateRevision() { expect(() => ValidateAtomicMutation("record-a", { ...request, + // @ts-expect-error expectedRevision is null, undefined, "" or a tag with an extra key, off-contract values that ValidateAtomicMutation must refuse with a TypeError expectedRevision, - } as AtomicMutation), + }), ).to.throw(TypeError); } expect(() => @@ -203,8 +203,9 @@ function ValidateEquality() { ValidateAtomicMutation("record-a", { type: "deleteIfEqual", field: "title", + // @ts-expect-error expectedValue is null, undefined, {}, [], NaN or an invalid Date, off-contract values that ValidateAtomicMutation must refuse with a TypeError expectedValue, - } as AtomicMutation), + }), ).to.throw(TypeError); } } From e642b3ee3924a99832a3dec4d42a51a4ec34871c Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 18:59:40 +0100 Subject: [PATCH 07/16] chore(ratchet): lower ceilings (#56) --- guardrails.ratchet.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json index ec69629..5dfff09 100644 --- a/guardrails.ratchet.json +++ b/guardrails.ratchet.json @@ -2,8 +2,7 @@ "pack": "0.2.17", "rules": { "anti-slop/no-chained-type-assertions": 3, - "anti-slop/require-safety-comment-for-type-assertion": 2, - "typescript/no-unnecessary-type-parameters": 2 + "typescript/no-unnecessary-type-parameters": 1 }, "oversizeFiles": {} } From 82615360580c07023eb4592f285d35d03da5eb0b Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sat, 31 Oct 2026 21:22:10 +0100 Subject: [PATCH 08/16] chore(ratchet): delete the guardrail ratchet (#58) * chore: count the last lint disables with their reasons * chore(ratchet): delete the guardrail ratchet * chore: run pnpm run sync after deleting the ratchet --- AGENTS.md | 7 +------ guardrails.ratchet.json | 8 -------- guardrails.suppressions.json | 2 ++ lefthook.yml | 3 --- package.json | 2 -- src/staged-query/datum.ts | 1 + src/staged-query/query.ts | 2 +- src/staged-query/stream.ts | 1 + src/staged-query/valueproxy.ts | 10 +++++----- 9 files changed, 11 insertions(+), 25 deletions(-) delete mode 100644 guardrails.ratchet.json diff --git a/AGENTS.md b/AGENTS.md index 802cabf..5b14862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ AQL, the AntelopeJS database query builder, published as `@antelopejs/interface- ## Guardrails -Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, ratchet, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. @@ -63,10 +63,6 @@ Verify a boundary against the imports a developer or agent would plausibly write `guardrails.suppressions.json` caps the suppressions in authored code: lint directives per rule they name (`*` for none), `@ts-expect-error`, `@ts-ignore` and `SAFETY:` comments, and under `config` each rule a lint config (`oxlint.config.*`, `.oxlintrc.json`, `eslint.config.*`) switches off or to a warning, keyed by rule and `files` glob anchored at the config's directory, so widening or moving a scope is a new ceiling of 0, while narrowing one takes over the room it gives up. Switching a rule off in a config is a suppression too: fix the code its rule reports instead. A suppression over its ceiling fails: fix the code, or remove another suppression of the same kind. A rule that keeps needing suppressions should be changed in the preset. Never raise or remove a number in that file: the check fails a ceiling higher than on the pull request base, and only the operator approves a raise. A directive disabling `anti-slop/require-safety-comment-for-type-assertion` is burn-down debt: remove the assertion, or, where it holds, replace the directive with `SAFETY: `, which takes over its room without a raise. After you remove suppressions, run `pnpm run suppressions:update` and commit the lowered file. -### Debt - -`guardrails.ratchet.json` records the lint findings and oversize files this repository already had. Its numbers only go down. After you remove debt, or after a pack upgrade, run `pnpm run ratchet:update` and commit the file: it lowers the numbers and adds only the rules the new pack release enabled, at today's count. Never edit the numbers by hand; CI fails one added or raised against the base. A rule listed there is off for `oxlint`. The ratchet counts it instead, and the pre-commit hook rejects it on every line you add. - ### What enforces what | Rule | Enforced by | @@ -108,6 +104,5 @@ Verify a boundary against the imports a developer or agent would plausibly write | Entry-only rules exempt no tracked outsider | `antelopejs-sync --check` | | A DMS layer's `build/backend-boundary.ts` is the pack's plugin | `antelopejs-sync --check` | | A test names the wrong implementation it catches | review; no tool | -| Existing debt only shrinks | `antelopejs-ratchet` | diff --git a/guardrails.ratchet.json b/guardrails.ratchet.json deleted file mode 100644 index 5dfff09..0000000 --- a/guardrails.ratchet.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "pack": "0.2.17", - "rules": { - "anti-slop/no-chained-type-assertions": 3, - "typescript/no-unnecessary-type-parameters": 1 - }, - "oversizeFiles": {} -} diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 165fb4d..a68d5ff 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,6 +1,8 @@ { "rules": { "@typescript-eslint/no-unused-expressions": 1, + "anti-slop/no-chained-type-assertions": 3, + "typescript/no-unnecessary-type-parameters": 1, "typescript/no-unused-vars": 1, "unicorn/no-thenable": 1 }, diff --git a/lefthook.yml b/lefthook.yml index ff792b5..779c267 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -8,9 +8,6 @@ pre-commit: run: pnpm exec oxlint --no-error-on-unmatched-pattern {staged_files} antelopejs-size: run: pnpm exec antelopejs-check-size --staged {staged_files} - antelopejs-ratchet: - glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" - run: pnpm exec antelopejs-ratchet --staged {staged_files} antelopejs-suppressions: glob: "*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue}" run: pnpm exec antelopejs-suppressions --staged diff --git a/package.json b/package.json index ffc0778..982e937 100644 --- a/package.json +++ b/package.json @@ -117,9 +117,7 @@ "prepare": "if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && command -v lefthook >/dev/null 2>&1; then lefthook install; fi", "checks": "antelopejs-checks", "size": "antelopejs-check-size", - "ratchet": "antelopejs-ratchet", "sync": "antelopejs-sync", - "ratchet:update": "antelopejs-ratchet --update", "sync:check": "antelopejs-sync --check", "typecheck": "tsc --noEmit", "suppressions": "antelopejs-suppressions", diff --git a/src/staged-query/datum.ts b/src/staged-query/datum.ts index 85aec3c..e97f890 100644 --- a/src/staged-query/datum.ts +++ b/src/staged-query/datum.ts @@ -20,6 +20,7 @@ export class Datum extends Query { * T, so the same object is a valid Datum for every U. Whether U matches * what the query yields is the caller's claim, which nothing here checks. */ + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- a single `as Datum` fails TS2352 because T and U are unrelated, and widening to StagedObject first is the refused route; the cast only relabels the type argument of the same object. return this as unknown as Datum; } diff --git a/src/staged-query/query.ts b/src/staged-query/query.ts index 8bdc1d5..8ce897b 100644 --- a/src/staged-query/query.ts +++ b/src/staged-query/query.ts @@ -3,7 +3,7 @@ import { isKindHolder, MarkKind } from "./kind"; const QUERY_KIND = Symbol.for("@antelopejs/interface-database/Query@1"); -// oxlint-disable-next-line typescript/no-unused-vars -- The executable entry consumes T through declaration merging. +// oxlint-disable-next-line typescript/no-unused-vars, typescript/no-unnecessary-type-parameters -- The executable entry consumes T through declaration merging (`interface Query extends QueryExecution` in src/query.ts); using T in the class needs a phantom member or merging the two entries. export class Query extends StagedObject {} MarkKind(Query.prototype, QUERY_KIND); diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts index ec8fae7..94fc57e 100644 --- a/src/staged-query/stream.ts +++ b/src/staged-query/stream.ts @@ -25,6 +25,7 @@ export class Stream extends Query { * T, so the same object is a valid Stream for every U. Whether U matches * what the query yields is the caller's claim, which nothing here checks. */ + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- a single `as Stream` fails TS2352 because T and U are unrelated, and widening to StagedObject first is the refused route; the cast only relabels the type argument of the same object. return this as unknown as Stream; } diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts index 47f979b..cc8f071 100644 --- a/src/staged-query/valueproxy.ts +++ b/src/staged-query/valueproxy.ts @@ -62,12 +62,12 @@ export class ValueProxy extends StagedObject { */ public cast() { /* - * SAFETY: A ValueProxy keeps no value of type T. Its only instance state is - * the `stages` array inherited from StagedObject, whose type does not - * depend on T, so the same object is a valid ValueProxy for every U. - * Whether U matches what the query yields is the caller's claim, which - * nothing here checks. + * SAFETY: A ValueProxy keeps no value of type T. Its only state is the + * `stages` array, whose type does not depend on T, so the same object is + * a valid ValueProxy for every U. U matching the query is the caller's + * claim. */ + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- a single `as` fails TS2352 (T and U unrelated); widening to StagedObject first is refused. return this as unknown as ValueProxy; } From 41ef2d010a9647d3c6eb39814f1d1428f1136009 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 1 Nov 2026 00:42:09 +0100 Subject: [PATCH 09/16] feat(database)!: type written documents as write values (#59) * feat(database)!: type written documents as write values `insert`, `update` and `replace` took `DeepPartial & object`, whose leaves admitted `bigint` and `symbol`, and the stages recorded the documents as `object`. No provider stores either value as given: MongoDB truncates a `bigint` to 64 bits and drops a `symbol`, RethinkDB refuses a `bigint` and drops a `symbol`, and PostgreSQL refuses both. `WriteValue` names what every provider writes and reads back: text, numbers, booleans, `null`, `undefined` (no value), dates, lists and nested documents. `DeepPartial` keeps those leaves, maps a field typed `unknown` or `any` to `WriteValue`, and maps any other non-object type to `never`. The `insert`, `update` and `replace` stages record `WriteDocument`s, so a provider reads them without an assertion. BREAKING CHANGE: a document holding a `bigint`, a `symbol`, or a value typed `unknown` no longer compiles. `WriteValue` and `WriteDocument` are new exports of `/common`. * feat(database)!: keep an any-typed field of a written document as any Mapping `any` to `WriteValue` turned `DeepPartial` into an index signature of `WriteValue`, so an interface-typed row, which has no implicit index signature, no longer went into a `Table` even when every value it holds is stored as given. `any` stays the caller's opt-out; a field typed `unknown` still takes a `WriteValue`, and the builders still record `WriteDocument`s. The first commit also dropped the TSDoc of `DeepPartial`; it is back, with the new mapping. --- docs/1.query_types/2.table.md | 2 ++ docs/index.md | 2 +- skills/database-interface/SKILL.md | 1 + src/staged-query/common.ts | 55 ++++++++++++++++++++---------- src/staged-query/stages.ts | 16 ++++++--- 5 files changed, 53 insertions(+), 23 deletions(-) diff --git a/docs/1.query_types/2.table.md b/docs/1.query_types/2.table.md index 9568546..0a1bc60 100644 --- a/docs/1.query_types/2.table.md +++ b/docs/1.query_types/2.table.md @@ -49,6 +49,8 @@ const users = schema.instance().table("users"); The `insert` method adds one or more documents to the table and returns the generated IDs. +Each field of a document holds a `WriteValue`: text, a number, a boolean, `null`, a `Date`, a list of values or a nested document. A `bigint` or a `symbol` does not compile, because no provider stores it as it was given. + ```typescript fragment // Insert a single document const ids = await users.insert({ diff --git a/docs/index.md b/docs/index.md index c545cca..7573108 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,7 +59,7 @@ export async function tryNotes() { Import `Schema`, `Query`, `Table`, `Datum`, `Stream`, and `ValueProxy` from `@antelopejs/interface-database` in an AntelopeJS module. This entry point registers schemas with the bound provider and installs query execution (`run()`, `then()`, `cursor()`, and async iteration). -Supporting types have dedicated paths: import `Changes`, `DeepPartial`, `ExtractType`, and `Value` from `@antelopejs/interface-database/common`, and `FieldType`, `TableDefinition`, and `IndexDefinition` from `@antelopejs/interface-database/schema`. Atomic mutation types are available from the package root and `/atomic`. +Supporting types have dedicated paths: import `Changes`, `DeepPartial`, `ExtractType`, `Value`, `WriteDocument`, and `WriteValue` from `@antelopejs/interface-database/common`, and `FieldType`, `TableDefinition`, and `IndexDefinition` from `@antelopejs/interface-database/schema`. Atomic mutation types are available from the package root and `/atomic`. ### Advanced: build queries without executing them diff --git a/skills/database-interface/SKILL.md b/skills/database-interface/SKILL.md index 01d676a..e4a4df7 100644 --- a/skills/database-interface/SKILL.md +++ b/skills/database-interface/SKILL.md @@ -98,6 +98,7 @@ for await (const change of novels.changes()) { - **Recognize query kinds with `IsStream`/`IsDatum`/`IsQuery`/`IsValueProxy`, not `instanceof`,** when the object may come from another module. Each provider binding has its own copy of the classes, so `instanceof` is false for a query built by a module bound to another database; the predicates are not. `CROSS_INSTANCE` is the same value in every copy. - Range semantics: `Table.between(index, low, high)` and `ValueProxy.during(left, right)` exclude the upper bound. `during` also takes bounds as milliseconds since the UNIX epoch. - `insert` accepts one document or an array, with `{ conflict: "update" | "replace" }` as options; it resolves to the inserted ids. +- A written document (`insert`, `update`, `replace`) holds `WriteValue`s only: text, numbers, booleans, `null`, `Date`s, lists and nested documents. A field typed `unknown` takes a `WriteValue`, a field typed `any` takes anything; `bigint` and `symbol` do not compile. - A write that would duplicate the key of a unique index (`_id`, or an index declared `unique: true`, unique per instance) throws `DuplicateKeyError` (`index`, `fields`); test it with `IsDuplicateKeyError(error)`, not `instanceof`. - `cast()` on `Datum`/`Stream`/`ValueProxy` only changes the TypeScript type — no runtime conversion. - `update` overloads: pass a plain `DeepPartial` for static values, or a `(val: ValueProxy) => ...` function for computed updates. diff --git a/src/staged-query/common.ts b/src/staged-query/common.ts index cffd95a..0d3b588 100644 --- a/src/staged-query/common.ts +++ b/src/staged-query/common.ts @@ -19,32 +19,51 @@ export type StagedConstructor = new ( previous?: StagedObject, ) => T; -type DeepPartialLeaf = +/** + * A value every adapter writes and reads back as it was given: text, a number, + * a boolean, `null`, a date, a list of values or a nested document. `undefined` + * writes no value. A `bigint` or a `symbol` is not one: MongoDB truncates a + * `bigint` to 64 bits and drops a `symbol`, RethinkDB refuses a `bigint` and + * drops a `symbol`, and PostgreSQL refuses both. + */ +export type WriteValue = | string | number | boolean - | bigint - | symbol | null | undefined - | Date; - -type DeepPartialValue = unknown extends V - ? V - : V extends DeepPartialLeaf - ? V - : V extends Array - ? Array> - : V extends ReadonlyArray - ? ReadonlyArray> - : DeepPartial; + | Date + | readonly WriteValue[] + | WriteDocument; + +/** A document as `insert`, `update` and `replace` record it. */ +export interface WriteDocument { + [field: string]: WriteValue; +} + +type DeepPartialLeaf = string | number | boolean | null | undefined | Date; + +type DeepPartialValue = 0 extends 1 & V + ? V & WriteValue + : unknown extends V + ? WriteValue + : V extends DeepPartialLeaf + ? V + : V extends Array + ? Array> + : V extends ReadonlyArray + ? ReadonlyArray> + : V extends object + ? DeepPartial + : never; /** * A partial document: every object field optional, recursively. Scalars, - * `null` and dates are kept as they are, a list keeps partial elements, and an - * `any` or `unknown` value stays as it is, so a full document is its own - * partial. The top level maps `T`'s keys, so a model generic over `T` can - * still pass a record it built. + * `null` and dates are kept as they are, a list keeps partial elements, a + * field typed `unknown` takes a {@link WriteValue}, and an `any` field stays + * `any`, so a full document is its own partial. A type no provider stores, + * such as `bigint` or `symbol`, becomes `never`. The top level maps `T`'s + * keys, so a model generic over `T` can still pass a record it built. */ export type DeepPartial = { [K in keyof T]?: DeepPartialValue }; diff --git a/src/staged-query/stages.ts b/src/staged-query/stages.ts index fb8beb1..ac1894e 100644 --- a/src/staged-query/stages.ts +++ b/src/staged-query/stages.ts @@ -1,5 +1,10 @@ import type { AtomicMutation, AtomicRecord } from "./atomic"; -import type { InsertOptions, StagedObject, Value } from "./common"; +import type { + InsertOptions, + StagedObject, + Value, + WriteDocument, +} from "./common"; import type { InstanceId } from "./schema"; import type { Query } from "./query"; import type { ValueProxyOrValue } from "./valueproxy"; @@ -55,9 +60,12 @@ export interface QueryStageMap { NoOptions, [key: string, request: AtomicMutation] >; - insert: Recorded; - update: Recorded; - replace: Recorded; + insert: Recorded< + InsertOptions | undefined, + [documents: WriteDocument | WriteDocument[]] + >; + update: Recorded; + replace: Recorded; delete: Recorded; changes: Recorded; get: Recorded]>; From 978362daa4b9d913511c191547e58ededa84c324 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 1 Nov 2026 00:44:18 +0100 Subject: [PATCH 10/16] feat(skills): add the verify-interface verification skill (#60) * feat(skills): add the verify-interface verification skill * fix(skills): pin the duplicate key message with two fields and type the package expectations * fix(skills): pin what an adapter validates with the atomic mutation helpers * fix(skills): expect the exports of the built head, not of a mutated build --- .agents/skills/verify-interface/SKILL.md | 137 ++++++ .../verify-interface/expected-expressions.ts | 409 ++++++++++++++++++ .../verify-interface/expected-package.ts | 170 ++++++++ .../verify-interface/expected-runtime.ts | 365 ++++++++++++++++ .../verify-interface/expected-streams.ts | 208 +++++++++ .../verify-interface/expected-tables.ts | 282 ++++++++++++ .../verify-interface/features/README.md | 26 ++ .../features/atomic-execution.md | 30 ++ .../features/atomic-requests.md | 30 ++ .../verify-interface/features/datums.md | 32 ++ .../features/duplicate-key.md | 30 ++ .../verify-interface/features/entry-points.md | 37 ++ .../verify-interface/features/execution.md | 33 ++ .../features/expressions-arithmetic.md | 32 ++ .../features/expressions-arrays.md | 30 ++ .../features/expressions-dates.md | 30 ++ .../features/expressions-logic.md | 33 ++ .../features/expressions-text.md | 30 ++ .../skills/verify-interface/features/joins.md | 33 ++ .../skills/verify-interface/features/kinds.md | 32 ++ .../verify-interface/features/schemas.md | 36 ++ .../skills/verify-interface/features/smoke.md | 30 ++ .../verify-interface/features/staged-query.md | 31 ++ .../verify-interface/features/streams.md | 37 ++ .../verify-interface/features/tables-read.md | 31 ++ .../verify-interface/features/tables-write.md | 34 ++ .../skills/verify-interface/features/types.md | 33 ++ .agents/skills/verify-interface/package.json | 4 + .agents/skills/verify-interface/probes.ts | 56 +++ .../skills/verify-interface/scenario-kit.ts | 126 ++++++ .../verify-interface/scenarios-expressions.ts | 125 ++++++ .../verify-interface/scenarios-package.ts | 40 ++ .../verify-interface/scenarios-runtime.ts | 185 ++++++++ .../verify-interface/scenarios-streams.ts | 108 +++++ .../verify-interface/scenarios-tables.ts | 92 ++++ .../seeded-breaks/atomic-execution.patch | 13 + .../seeded-breaks/atomic-requests.patch | 16 + .../seeded-breaks/datums.patch | 13 + .../seeded-breaks/duplicate-key.patch | 13 + .../seeded-breaks/entry-points.patch | 15 + .../seeded-breaks/execution.patch | 12 + .../expressions-arithmetic.patch | 13 + .../seeded-breaks/expressions-arrays.patch | 13 + .../seeded-breaks/expressions-dates.patch | 13 + .../seeded-breaks/expressions-logic.patch | 13 + .../seeded-breaks/expressions-text.patch | 13 + .../seeded-breaks/joins.patch | 13 + .../seeded-breaks/kinds.patch | 11 + .../seeded-breaks/schemas.patch | 12 + .../seeded-breaks/smoke.patch | 22 + .../seeded-breaks/staged-query.patch | 9 + .../seeded-breaks/streams.patch | 13 + .../seeded-breaks/tables-read.patch | 13 + .../seeded-breaks/tables-write.patch | 13 + .../seeded-breaks/types.patch | 13 + .../seeded-doors/contract.patch | 15 + .../seeded-doors/format.patch | 26 ++ .../seeded-doors/publish.patch | 13 + .agents/skills/verify-interface/tsconfig.json | 14 + .../skills/verify-interface/verify.config.ts | 110 +++++ .claude/skills/verify-interface | 1 + AGENTS.md | 2 + 62 files changed, 3424 insertions(+) create mode 100644 .agents/skills/verify-interface/SKILL.md create mode 100644 .agents/skills/verify-interface/expected-expressions.ts create mode 100644 .agents/skills/verify-interface/expected-package.ts create mode 100644 .agents/skills/verify-interface/expected-runtime.ts create mode 100644 .agents/skills/verify-interface/expected-streams.ts create mode 100644 .agents/skills/verify-interface/expected-tables.ts create mode 100644 .agents/skills/verify-interface/features/README.md create mode 100644 .agents/skills/verify-interface/features/atomic-execution.md create mode 100644 .agents/skills/verify-interface/features/atomic-requests.md create mode 100644 .agents/skills/verify-interface/features/datums.md create mode 100644 .agents/skills/verify-interface/features/duplicate-key.md create mode 100644 .agents/skills/verify-interface/features/entry-points.md create mode 100644 .agents/skills/verify-interface/features/execution.md create mode 100644 .agents/skills/verify-interface/features/expressions-arithmetic.md create mode 100644 .agents/skills/verify-interface/features/expressions-arrays.md create mode 100644 .agents/skills/verify-interface/features/expressions-dates.md create mode 100644 .agents/skills/verify-interface/features/expressions-logic.md create mode 100644 .agents/skills/verify-interface/features/expressions-text.md create mode 100644 .agents/skills/verify-interface/features/joins.md create mode 100644 .agents/skills/verify-interface/features/kinds.md create mode 100644 .agents/skills/verify-interface/features/schemas.md create mode 100644 .agents/skills/verify-interface/features/smoke.md create mode 100644 .agents/skills/verify-interface/features/staged-query.md create mode 100644 .agents/skills/verify-interface/features/streams.md create mode 100644 .agents/skills/verify-interface/features/tables-read.md create mode 100644 .agents/skills/verify-interface/features/tables-write.md create mode 100644 .agents/skills/verify-interface/features/types.md create mode 100644 .agents/skills/verify-interface/package.json create mode 100644 .agents/skills/verify-interface/probes.ts create mode 100644 .agents/skills/verify-interface/scenario-kit.ts create mode 100644 .agents/skills/verify-interface/scenarios-expressions.ts create mode 100644 .agents/skills/verify-interface/scenarios-package.ts create mode 100644 .agents/skills/verify-interface/scenarios-runtime.ts create mode 100644 .agents/skills/verify-interface/scenarios-streams.ts create mode 100644 .agents/skills/verify-interface/scenarios-tables.ts create mode 100644 .agents/skills/verify-interface/seeded-breaks/atomic-execution.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/atomic-requests.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/datums.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/duplicate-key.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/entry-points.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/execution.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/expressions-arithmetic.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/expressions-arrays.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/expressions-dates.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/expressions-logic.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/expressions-text.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/joins.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/kinds.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/schemas.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/smoke.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/staged-query.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/streams.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/tables-read.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/tables-write.patch create mode 100644 .agents/skills/verify-interface/seeded-breaks/types.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/contract.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/format.patch create mode 100644 .agents/skills/verify-interface/seeded-doors/publish.patch create mode 100644 .agents/skills/verify-interface/tsconfig.json create mode 100644 .agents/skills/verify-interface/verify.config.ts create mode 120000 .claude/skills/verify-interface diff --git a/.agents/skills/verify-interface/SKILL.md b/.agents/skills/verify-interface/SKILL.md new file mode 100644 index 0000000..70db01a --- /dev/null +++ b/.agents/skills/verify-interface/SKILL.md @@ -0,0 +1,137 @@ +--- +name: verify-interface +description: "Drive this AntelopeJS interface the way a module author does: pack it, load it in `ajs project dev` next to a stub provider wired through ImplementInterface and a consumer, call every declared function through core, and check the minor-bump rule against the published version. Use to prove a change to the interface works before merge, to verify a PR, or to check that a seeded break is caught." +--- + +# Verify interface + +The user of an interface is a module author: one module implements it, others import it and call its functions through core. Every deterministic step is an `antelopejs-verify` command: you choose which journeys to run and judge what they show. Never replace a command with a shell recipe. When a journey you need does not exist, add it to `verify.config.ts`, name it in its feature file, then run it. + +`antelopejs-sync` writes this file, `package.json` and `tsconfig.json` here, and keeps them in sync. Everything else in `.agents/skills/verify-interface/` is the repository's own layer, which sync never touches. + +## Launch + +```bash +pnpm exec antelopejs-verify new --base origin/main --title "" +``` + +`new` prints the run folder; pass it as `` below. `new` runs the setup (`pnpm run build` by default), then packs the interface with `pnpm pack` and extracts it, so journeys load what npm would publish, `files` and `exports` included. Every `services` entry of `verify.config.ts` then starts in its own Docker container on a free loopback port, as for a module. Every journey gets its own `HOME` and scratch folder, `CI=1`, `AJS_NO_UPDATE_CHECK=1`, `NO_COLOR=1` and a free port from the kernel, so two runs never share an instance. Run it from the repository root after installing dependencies. + +## Doctor + +```bash +pnpm exec antelopejs-verify doctor +``` + +Doctor checks that HEAD has not moved since `new`, `ajs` comes from the repository's `@antelopejs/core`, the build is newer than `src/`, what `new` prepared is there, and no process the run recorded is still alive. Do not drive a run that fails doctor. + +## Drive + +```bash +pnpm exec antelopejs-verify plan --base origin/main +pnpm exec antelopejs-verify journey [journey...] +``` + +`plan` maps the diff to features and lists unmapped surfaces. `journey` runs a feature's journeys, or the ones named. The pack ships these journeys for this kind; a feature file names them next to the repository's own: + +- `every-function`: a fixture project loads the packed interface with a stub provider and a consumer module. The consumer walks the loaded interface, and every function the `.d.ts` files declare is called through core: + - an async proxy answers the stub's echo, and the stub's call log shows the same arguments (the second view); + - a registering proxy registers and unregisters, and the stub logs both; + - an event proxy delivers the emitted arguments to a registered handler; + - a plain function runs the `interface.calls` hook of `verify.config.ts` and must return its `expect`; one only providers call is listed in `interface.providerSide` with why. + A declared function with none of these fails the journey. +- `minor-bump`: reads the declared functions of the packed head and of the published version (`npm pack @`). An added, changed or removed function needs a release of at least the next minor. The release is the head version when it is already bumped, otherwise what changelogen makes of the change title (`new --title`) or the commit subjects since the base. + +## Evidence + +Each step writes `/evidence////` with `command.txt`, `stdout.txt`, `stderr.txt`, `exit-code.txt`, `pid.txt` for background processes, `tree.txt`, `http.json` for every request, and `checks.json`. `result.json` holds a journey's result and `feature.json` the merged results of the feature. Read the evidence before you judge a PASS: a check proves only what it asserts. + +## Cleanup + +```bash +pnpm exec antelopejs-verify clean +``` + +`clean` stops what the run started, by recorded PID and container name, and removes everything but `evidence/` and `run.json`. Run it after every run, failed ones included. + +## The repository's own layer + +- `verify.config.ts` default-exports a `VerifyConfig` from `@antelopejs/tooling-configs/verify`: + - `doors`: one entry per door kind (`data`, `format`, `contract`, `outbound`, `exposure`, `publish`), each `null` when it cannot apply or `{ globs, funnels, containments, why }`. `containments` names the `feature/journey` journeys that contain a Data or Format door. Each must fail with that kind's seeded door applied and pass without it: write the journey so one of its checks reads the state the door corrupts. + - `journeys`: the repository's journeys by id, each `{ title, entryPoint, run(context) }`, using the check helpers the same import exports. + - `env`, `setup` (commands replacing the build), `userFacing` (globs replacing the kind's default) and `formal` (formal checks: `name`, `covers`, `command`, `violation`, and optionally `behaviour` and `baseCommand`). +- `services`: image, port, readiness command and a `reset` command run before every journey, as for a module. `new` starts them in Docker on free ports; a journey reads them with `context.service(name)` and `context.native(...)`. +- `interface.provide(api, services)` runs in the stub provider after the generic stubs attach, for example to hand a client pointed at `services` to `internal.SetClient`. +- `interface.calls[":"]` calls a plain exported function from the consumer and states the value it must return. +- `interface.providerSide[":"]` names a function only providers call, with the reason. +- `features/.md`: frontmatter lists `covers:` (source globs), `docs:` (pages a verdict judges) and `journeys:`, then the sections Sub-features, How to get to it (user POV), Driving it with antelopejs-verify, and Gotchas. `features/smoke.md` covers nothing and runs on every verdict; here it runs `every-function`. +- `seeded-breaks/.patch`: one minimal product bug per feature that its journeys must catch. +- `seeded-doors/.patch`: one change per door kind that applies. A Data or Format door's patch must break what its containment journeys check. + +```bash +pnpm exec antelopejs-verify map +``` + +`map` fails on a user-facing file no feature covers, a feature without journeys, docs or seeded break, an unknown journey, an applicable door kind without its seeded door, and a Data or Format door whose `containments` name no journey a feature registers. `containments` (below) then runs those journeys against the seeded doors. + +## Verdict rules + +1. **Run every feature whose `covers:` globs the diff touches, plus `smoke`.** `plan` prints that list as `run`. +2. **An unmapped surface fails the verdict.** A changed user-facing file no feature covers needs a feature entry, its journeys and its seeded break in the same PR. +3. **Every registered journey of a feature in `run` must have run and passed.** Run whole features. A journey that could not reach the product is NOT VERIFIED, and so is the verdict, whatever else passed: never PASS and never PASS+NOTES. +4. **Docs.** Judge every `docs:` page of a touched feature against what the journeys showed. A stale page fails the verdict. +5. **Formal checks.** `pnpm exec antelopejs-verify formal ` runs every formal check of `verify.config.ts` that covers the diff, or whose own `files` the diff changes, and records it under `evidence/formal//`. For each one it decides, without judgement, whether the diff changed what the covered code does. `emit` (the default) compares the structure of the covered files' JavaScript on the base and on HEAD: node kinds, names and literal values, leaving out types, comments, formatting, parentheses and `export` keywords. Each use of an imported binding stands for its origin (the resolved module and the imported name), so an import retargeted to another module or alias counts as changed; a function declaration refers to the top-level variables and classes of its own file by file and name, so moving it next to a different variable of the same name counts as changed. Exports are keyed by the origin they export, so a re-export or an `export { f as run }` retargeted to another binding counts as changed. Each file must keep its top-level statements and its side-effect imports in order; function declarations, which are hoisted, may move between covered files with their imports. So a type-only edit, a reformat or a moved function declaration counts as unchanged, while reordering statements or moving any other statement counts as changed. `emit` judges behaviour, not the public surface: exports are the api-diff's job. `probe` runs the check's `behaviour.command` with a worktree of each commit and compares what it prints. Then: + - a covered check that did not run, or exits non-zero (a tool error, or a check that no longer matches its record), fails the verdict; + - recorded findings fail the verdict only when the covered behaviour changed; when it did not, the verdict reports them as known findings; + - when the covered behaviour changed, or the diff changed the check's own `files`, `formal` also runs the check on the base (`baseCommand` when set). A rule the base broke counts as fixed only when HEAD reports it passing (`passing`); a rule the base broke that HEAD does not report at all fails the verdict. A change that leaves no violated rule passes. + + The decision never reads the check's output, so a model whose rule names stay the same still fails on a changed behaviour. It is conservative: a local rename or an extracted helper counts as changed, and code outside `covers` is invisible to it. `--all` also runs the checks that do not cover the diff, without gating. +6. **One-way doors.** The section comes right after the result line and is mandatory. `pnpm exec antelopejs-verify doors` prints the `One-way-door:` line of the diff. Every file the path rules flag needs a door row or a false positive with its reason. A Data or Format row names a `containments` journey that passed in this run, and that journey is proven by `pnpm exec antelopejs-verify containments`: it fails with the kind's seeded door applied. A containment that passes with its door contains nothing, so a Data or Format door without one that fails with its door is not contained. +7. **Result.** FAIL if any rule fails. NOT VERIFIED when any journey of a required feature is NOT VERIFIED, for example a SaaS module run without its sandbox keys, however many other journeys passed. PASS+NOTES when everything required ran and passed but the judgement lists something no journey reached or something the owner should look at. PASS otherwise. + +Write your judgement to `/evidence/judgement.json`: + +```json +{ + "doors": { + "none": "why the diff opens no door", + "rows": [], + "falsePositives": [{ "kind": "publish", "location": "package.json", "reason": "only devDependencies changed" }] + }, + "docs": [{ "page": "README.md", "matches": true, "note": "" }], + "notVerified": ["What no journey reached, and why"], + "attention": ["What the owner should look at that the journeys cannot judge"], + "verifier": "the fresh thread that judged this run, not the author" +} +``` + +A door row is `{ "kind", "location", "irreversible", "containment", "containmentJourney" }`. Then render and publish: + +```bash +pnpm exec antelopejs-verify verdict --pr +pnpm exec antelopejs-verify publish --pr +``` + +`verdict` writes `evidence/verdict.md` and exits 1 unless the result is PASS or PASS+NOTES. `publish` commits the evidence to the orphan branch `verify-evidence` under `pr-//` from a temporary worktree, posts the verdict as a PR comment, sets commit status `verify` on the head SHA (`success` for PASS and PASS+NOTES, `error` for NOT VERIFIED, `failure` for FAIL) and reads it back. It prints the error GitHub answers when the token cannot write statuses. A new push voids the status. Post a verdict only from a thread that did not write the change. + +## Trust + +The skill is not Trusted yet. It becomes Trusted when it catches every seeded break, flags every seeded door, fails every Data and Format containment with its door, passes a shadow run of 10 consecutive agent PRs, and its last maintain pass is newer than the last change to the repository or its `@antelopejs` dependencies and at most 30 days old. + +## Seeded breaks and doors + +```bash +pnpm exec antelopejs-verify seeded +pnpm exec antelopejs-verify seeded-doors +pnpm exec antelopejs-verify containments +``` + +`seeded` runs each feature's journeys in a scratch worktree of HEAD without the patch, as the control, and with `seeded-breaks/.patch` applied. It prints `CAUGHT` only when a journey that passes in the control fails one of its checks with the patch. Journeys failing in the control and failures without a failing check do not count. `seeded-doors` prints `FLAGGED` when the path rules flag each patch with its own kind. `containments` runs every Data and Format containment journey in a scratch worktree of HEAD without the door, as the control, and with `seeded-doors/.patch` applied. It prints `CONTAINED` only when the journey passes in the control and fails one of its checks with the door. `MISSED` means it passes with its door, `UNPROVEN` that it fails in the control or ends without a failing check, and `NO-DOOR` that the kind has no seeded door; the command exits 1 on anything but `CONTAINED`. + +## Gotchas + +- A function the consumer awaits forever, such as a getter no provider resolved, fails after 15 seconds with a `Timeout` value. +- `minor-bump` needs the npm registry. Without it the journey is not verified, never passed. +- A containment journey of a Data or Format door must check what that door's seeded patch changes, for example the arguments the stub provider logged. `containments` reports one that still passes with its door as `MISSED`. +- Runs live under the system temp folder (`TMPDIR`), and the shared caches under `~/.cache/antelopejs-verify`. +- A repository that outgrows this skill runs `antelopejs-sync --verify custom` and keeps its own `.agents/skills/verify-/`. diff --git a/.agents/skills/verify-interface/expected-expressions.ts b/.agents/skills/verify-interface/expected-expressions.ts new file mode 100644 index 0000000..9613914 --- /dev/null +++ b/.agents/skills/verify-interface/expected-expressions.ts @@ -0,0 +1,409 @@ +import type { Printed } from "./scenario-kit.ts"; + +/** What the packed interface prints for each scenario of scenarios-expressions.ts, reviewed against docs/. */ +export const EXPECTED = { + "expression-logic": { + "eq": [ + "arg#0", + "obj_index<\"status\">", + "eq<\"active\">" + ], + "eqProxy": [ + "arg#0", + "obj_index<\"a\">", + "eq<(arg#0 > obj_index<\"b\">)>" + ], + "ne": [ + "arg#0", + "obj_index<\"status\">", + "ne<\"gone\">" + ], + "neNull": [ + "arg#0", + "obj_index<\"status\">", + "ne" + ], + "and": [ + "arg#0", + "obj_index<\"a\">", + "eq<1>", + "and<(arg#0 > obj_index<\"b\"> > eq<2>)>" + ], + "or": [ + "arg#0", + "obj_index<\"a\">", + "eq<1>", + "or" + ], + "not": [ + "arg#0", + "obj_index<\"done\">", + "not" + ], + "gt": [ + "arg#0", + "obj_index<\"age\">", + "cmp_gt<17>" + ], + "ge": [ + "arg#0", + "obj_index<\"age\">", + "cmp_ge<18>" + ], + "lt": [ + "arg#0", + "obj_index<\"age\">", + "cmp_lt<65>" + ], + "le": [ + "arg#0", + "obj_index<\"age\">", + "cmp_le<64>" + ], + "gtProxy": [ + "arg#0", + "obj_index<\"age\">", + "cmp_gt<(arg#0 > obj_index<\"age\">)>" + ], + "defaulted": [ + "arg#0", + "obj_index<\"nickname\">", + "default<\"none\">" + ], + "defaultedKey": [ + "arg#0", + "obj_index<\"nickname\"><\"none\">" + ], + "constant": [ + "constant<\"x\">" + ], + "argument": [ + "arg#0" + ] + }, + "expression-objects": { + "key": [ + "arg#0", + "obj_index<\"name\">" + ], + "nested": [ + "arg#0", + "obj_index<\"address\">", + "obj_index<\"city\">" + ], + "keyDefault": [ + "arg#0", + "obj_index<\"phone\"><\"N/A\">" + ], + "keyByProxy": [ + "arg#0", + "obj_index<(arg#0 > obj_index<\"field\">)>" + ], + "merge": [ + "arg#0", + "obj_merge<{extra:1}>" + ], + "keys": [ + "arg#0", + "obj_keys" + ], + "values": [ + "arg#0", + "obj_values" + ], + "hasfields": [ + "arg#0", + "obj_has<[\"a\",\"b\"]>" + ], + "hasfieldsNone": [ + "arg#0", + "obj_has<[]>" + ], + "cast": [ + "arg#0" + ] + }, + "expression-arithmetic": { + "addNumber": [ + "arg#0", + "obj_index<\"age\">", + "add<1>" + ], + "addString": [ + "arg#0", + "obj_index<\"name\">", + "add<\"!\">" + ], + "addList": [ + "arg#0", + "obj_index<\"tags\">", + "add<[\"x\"]>" + ], + "addProxy": [ + "arg#0", + "obj_index<\"age\">", + "add<(arg#0 > obj_index<\"age\">)>" + ], + "addNull": [ + "arg#0", + "obj_index<\"age\">", + "add" + ], + "sub": [ + "arg#0", + "obj_index<\"age\">", + "sub<1>" + ], + "subProxy": [ + "arg#0", + "obj_index<\"bornAt\">", + "sub<(arg#0 > obj_index<\"bornAt\">)>" + ], + "mul": [ + "arg#0", + "obj_index<\"price\">", + "mul<2>" + ], + "div": [ + "arg#0", + "obj_index<\"price\">", + "div<4>" + ], + "mod": [ + "arg#0", + "obj_index<\"count\">", + "mod<3>" + ], + "round": [ + "arg#0", + "obj_index<\"price\">", + "round" + ], + "ceil": [ + "arg#0", + "obj_index<\"price\">", + "ceil" + ], + "floor": [ + "arg#0", + "obj_index<\"price\">", + "floor" + ] + }, + "expression-bits": { + "band": [ + "arg#0", + "obj_index<\"flags\">", + "bit_and<6>" + ], + "bor": [ + "arg#0", + "obj_index<\"flags\">", + "bit_or<1>" + ], + "bxor": [ + "arg#0", + "obj_index<\"flags\">", + "bit_xor<3>" + ], + "bnot": [ + "arg#0", + "obj_index<\"flags\">", + "bit_not" + ], + "blshift": [ + "arg#0", + "obj_index<\"flags\">", + "bit_lshift<2>" + ], + "brshift": [ + "arg#0", + "obj_index<\"flags\">", + "bit_rshift<2>" + ], + "brshiftSigned": [ + "arg#0", + "obj_index<\"flags\">", + "bit_rshift{preserveSign:true}<2>" + ], + "bandProxy": [ + "arg#0", + "obj_index<\"flags\">", + "bit_and<(arg#0 > obj_index<\"mask\">)>" + ] + }, + "expression-text": { + "split": [ + "arg#0", + "obj_index<\"path\">", + "str_split{separator:\"/\",maxSplits:undefined}" + ], + "splitLimit": [ + "arg#0", + "obj_index<\"path\">", + "str_split{separator:\"/\",maxSplits:2}" + ], + "splitDefault": [ + "arg#0", + "obj_index<\"path\">", + "str_split{separator:undefined,maxSplits:undefined}" + ], + "concat": [ + "arg#0", + "obj_index<\"first\">", + "str_concat<\" last\">" + ], + "upcase": [ + "arg#0", + "obj_index<\"name\">", + "str_upcase" + ], + "downcase": [ + "arg#0", + "obj_index<\"name\">", + "str_downcase" + ], + "strlen": [ + "arg#0", + "obj_index<\"name\">", + "str_len" + ], + "match": [ + "arg#0", + "obj_index<\"email\">", + "str_match<\"^a.*@\">" + ] + }, + "expression-dates": { + "during": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_during<0><1>" + ], + "duringDates": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_during" + ], + "year": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_year" + ], + "yearZone": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_with_timezone<\"Europe/Paris\">", + "date_year" + ], + "month": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_month" + ], + "day": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_day" + ], + "dayofweek": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_dow" + ], + "dayofyear": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_with_timezone<\"UTC\">", + "date_doy" + ], + "hours": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_hours" + ], + "minutes": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_minutes" + ], + "seconds": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_with_timezone<\"UTC\">", + "date_seconds" + ], + "timeofday": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_tod" + ], + "epoch": [ + "arg#0", + "obj_index<\"bornAt\">", + "date_epoch" + ] + }, + "expression-arrays": { + "index": [ + "arg#0", + "obj_index<\"tags\">", + "arr_index<0>" + ], + "includes": [ + "arg#0", + "obj_index<\"tags\">", + "arr_includes<\"x\">" + ], + "slice": [ + "arg#0", + "obj_index<\"tags\">", + "arr_slice<1><3>" + ], + "sliceOpen": [ + "arg#0", + "obj_index<\"tags\">", + "arr_slice<1>" + ], + "map": [ + "arg#0", + "obj_index<\"scores\">", + "arr_map<{func[#0](arg#0 > add<1>)}>" + ], + "filter": [ + "arg#0", + "obj_index<\"scores\">", + "arr_filter<{func[#0](arg#0 > cmp_gt<10>)}>" + ], + "isempty": [ + "arg#0", + "obj_index<\"tags\">", + "arr_empty" + ], + "count": [ + "arg#0", + "obj_index<\"tags\">", + "arr_count" + ], + "sum": [ + "arg#0", + "obj_index<\"scores\">", + "arr_sum" + ], + "avg": [ + "arg#0", + "obj_index<\"scores\">", + "arr_avg" + ], + "min": [ + "arg#0", + "obj_index<\"scores\">", + "arr_min" + ], + "max": [ + "arg#0", + "obj_index<\"scores\">", + "arr_max" + ] + } +} satisfies { [id: string]: Printed }; diff --git a/.agents/skills/verify-interface/expected-package.ts b/.agents/skills/verify-interface/expected-package.ts new file mode 100644 index 0000000..db0a419 --- /dev/null +++ b/.agents/skills/verify-interface/expected-package.ts @@ -0,0 +1,170 @@ +import type { Printed } from "./scenario-kit.ts"; + +/** What the packed interface prints for the build-only entry point and the exports map. */ +export const STAGED = { + "isolation": { + "blocked": [], + "leaks": [], + "before": { + "query": [ + false, + false, + false + ], + "iterator": false, + "atomic": false, + "schemaList": "undefined" + }, + "atomicKind": [ + true, + true + ], + "hasBuild": "function" + }, + "names": [ + "AtomicMutationQuery", + "CROSS_INSTANCE", + "Datum", + "IsDatum", + "IsQuery", + "IsQueryStage", + "IsStream", + "IsValueProxy", + "Query", + "QueryStage", + "Schema", + "SchemaInstance", + "Selection", + "SingleSelection", + "StagedObject", + "Stream", + "Table", + "ValidateAtomicMutation", + "ValidateAtomicMutationTable", + "ValueProxy" + ], + "built": "[{\"stage\":\"schema\",\"options\":{\"id\":\"parity\"},\"args\":[]},{\"stage\":\"instance\",\"options\":{\"id\":\"default\"},\"args\":[]},{\"stage\":\"table\",\"options\":{\"id\":\"posts\"},\"args\":[]},{\"stage\":\"filter\",\"args\":[{\"stage\":\"func\",\"args\":[[0],{\"stages\":[{\"stage\":\"arg\",\"args\":[0]},{\"stage\":\"obj_index\",\"args\":[\"author\",null]},{\"stage\":\"eq\",\"args\":[\"Alice\"]}]}]}]},{\"stage\":\"orderBy\",\"options\":{\"index\":\"views\",\"direction\":\"desc\"},\"args\":[]},{\"stage\":\"slice\",\"args\":[0,10]}]", + "after": { + "query": [ + true, + true, + true + ], + "iterator": true, + "sameClasses": [ + true, + true, + true, + true, + true + ] + } +} satisfies Printed; + +export const SUBPATHS = { + "exported": [ + ".", + "./atomic", + "./common", + "./datum", + "./query", + "./schema", + "./selection", + "./stream", + "./staged-query", + "./valueproxy" + ], + "typedAsExported": true, + "files": [ + "dist", + "docs", + "skills" + ], + "subpaths": { + ".": [ + "AtomicMutationQuery", + "AtomicMutationUnsupportedError", + "CROSS_INSTANCE", + "Datum", + "DuplicateKeyError", + "IsDatum", + "IsDuplicateKeyError", + "IsQuery", + "IsStream", + "IsValueProxy", + "Query", + "Schema", + "SchemaInstance", + "Selection", + "SingleSelection", + "Stream", + "Table", + "ValidateAtomicMutation", + "ValidateAtomicMutationTable", + "ValueProxy" + ], + "./atomic": [ + "AtomicMutationQuery", + "AtomicMutationUnsupportedError", + "ValidateAtomicMutation", + "ValidateAtomicMutationTable" + ], + "./common": [ + "IsQueryStage", + "QueryStage", + "StagedObject" + ], + "./datum": [ + "Datum", + "IsDatum" + ], + "./query": [ + "CloseCursor", + "IsQuery", + "Query", + "ReadCursor", + "RunQuery" + ], + "./schema": [ + "CROSS_INSTANCE", + "Schema", + "SchemaInstance", + "Schemas" + ], + "./selection": [ + "Selection", + "SingleSelection", + "Table" + ], + "./stream": [ + "IsStream", + "Stream" + ], + "./staged-query": [ + "AtomicMutationQuery", + "CROSS_INSTANCE", + "Datum", + "IsDatum", + "IsQuery", + "IsQueryStage", + "IsStream", + "IsValueProxy", + "Query", + "QueryStage", + "Schema", + "SchemaInstance", + "Selection", + "SingleSelection", + "StagedObject", + "Stream", + "Table", + "ValidateAtomicMutation", + "ValidateAtomicMutationTable", + "ValueProxy" + ], + "./valueproxy": [ + "IsValueProxy", + "ValueProxy" + ] + } +} satisfies Printed; diff --git a/.agents/skills/verify-interface/expected-runtime.ts b/.agents/skills/verify-interface/expected-runtime.ts new file mode 100644 index 0000000..366c923 --- /dev/null +++ b/.agents/skills/verify-interface/expected-runtime.ts @@ -0,0 +1,365 @@ +import type { Printed } from "./scenario-kit.ts"; + +/** What the packed interface prints for each scenario of scenarios-runtime.ts, reviewed against docs/. */ +export const EXPECTED = { + "atomic-requests": { + "update": "accepted", + "remove": "accepted", + "removeMissingRevision": "accepted", + "updateMissingRevision": "accepted", + "deleteIfEqual": "accepted", + "deleteIfEqualNumber": "accepted", + "deleteIfEqualDate": "accepted", + "emptyKey": "TypeError: Atomic mutation requires one literal record identity", + "notRequest": "TypeError: Atomic mutation requires one literal record identity", + "classRequest": "TypeError: Atomic mutation requires one literal record identity", + "unknownType": "TypeError: Unknown atomic mutation operation", + "dottedField": "TypeError: Atomic mutation fields must be literal field names", + "operatorField": "TypeError: Atomic mutation fields must be literal field names", + "identityRevision": "TypeError: Atomic mutation revision cannot be an identity field", + "emptyExpected": "TypeError: Expected revision must be a token or missing tag", + "nullExpected": "TypeError: Expected revision must be a token or missing tag", + "looseMissing": "TypeError: Expected revision must be a token or missing tag", + "sameRevision": "TypeError: Atomic mutation requires a changed revision token", + "emptyNext": "TypeError: Atomic mutation requires a changed revision token", + "patchIdentity": "TypeError: Atomic mutation patch cannot change identity or revision", + "patchPlainId": "TypeError: Atomic mutation patch cannot change identity or revision", + "patchRevision": "TypeError: Atomic mutation patch cannot change identity or revision", + "patchArray": "TypeError: Atomic mutation patch must be an object", + "patchUndefined": "TypeError: Atomic mutation patches must contain constant data", + "patchFunction": "TypeError: Atomic mutation patches must contain constant data", + "patchNaN": "TypeError: Atomic mutation patches must contain constant data", + "patchInfinity": "TypeError: Atomic mutation patches must contain constant data", + "patchBadDate": "TypeError: Atomic mutation patches must contain constant data", + "patchGoodDate": "accepted", + "patchCycle": "TypeError: Atomic mutation patches must contain constant data", + "patchHoles": "TypeError: Atomic mutation arrays must not contain holes", + "patchDottedKey": "TypeError: Atomic mutation fields must be literal field names", + "patchOperatorKey": "TypeError: Atomic mutation fields must be literal field names", + "patchNested": "accepted", + "patchProxy": "TypeError: Atomic mutation patches must contain constant data", + "equalIdentity": "TypeError: Atomic equality field cannot be an identity field", + "equalNull": "TypeError: Atomic equality requires a scalar or Date", + "equalArray": "TypeError: Atomic equality requires a scalar or Date", + "equalObject": "TypeError: Atomic equality requires a scalar or Date", + "equalNaN": "TypeError: Atomic mutation patches must contain constant data", + "equalDotted": "TypeError: Atomic mutation fields must be literal field names", + "crossInstance": "TypeError: Atomic mutation requires one instance-scoped table", + "namedInstance": "accepted", + "adapterTable": [ + "accepted", + "accepted", + "TypeError: Atomic mutation requires one instance-scoped table", + "TypeError: Atomic mutation requires one instance-scoped table", + "TypeError: Atomic mutation requires one instance-scoped table", + "TypeError: Atomic mutation requires one instance-scoped table" + ], + "adapterProtected": [ + "TypeError: Atomic mutation patch cannot change identity or revision", + "TypeError: Atomic mutation revision cannot be an identity field", + "TypeError: Atomic equality field cannot be an identity field", + "accepted" + ], + "stages": [ + "schema{id:\"notes\"}", + "instance{id:undefined}", + "table{id:\"notes\"}", + "atomicMutation<\"n-1\"><{type:\"delete\",revisionField:\"revision\",expectedRevision:\"r1\"}>" + ], + "queryKinds": [ + true, + true, + true + ] + }, + "atomic-execution": { + "outcomes": { + "applied": "applied", + "not-applied": "not-applied", + "unknown": "unknown" + }, + "bad": { + "undefined": "AtomicMutationUnsupportedError: The database adapter does not support atomicMutation", + "zero": "AtomicMutationUnsupportedError: The database adapter does not support atomicMutation", + "count": "AtomicMutationUnsupportedError: The database adapter does not support atomicMutation", + "null": "AtomicMutationUnsupportedError: The database adapter does not support atomicMutation", + "text": "AtomicMutationUnsupportedError: The database adapter does not support atomicMutation", + "object": "AtomicMutationUnsupportedError: The database adapter does not support atomicMutation" + }, + "viaRun": "applied", + "cursor": "TypeError: Atomic mutations cannot be executed as cursors", + "iterate": "TypeError: Atomic mutations cannot be executed as cursors", + "lost": "Error: link lost", + "callsAfterLoss": 1, + "canonical": [ + [ + "schema{id:\"notes\"}", + "instance{id:undefined}", + "table{id:\"notes\"}", + "atomicMutation<\"n-1\"><{type:\"delete\",revisionField:\"revision\",expectedRevision:\"r1\"}>" + ], + 4, + "atomicMutation", + true, + 2 + ], + "unsupported": [ + [ + "AtomicMutationUnsupportedError", + "The database adapter does not support atomicMutation", + true + ] + ] + }, + "query-execution": { + "lazy": 0, + "viaAwait": [ + { + "title": "A" + } + ], + "viaRun": [ + { + "title": "A" + } + ], + "viaThen": [ + { + "title": "A" + } + ], + "viaThenChain": 1, + "runs": 7, + "sameStages": true, + "rejected": "Error: provider refused", + "caught": "handled:provider refused", + "onRejected": "caught:provider refused", + "methods": [ + "function", + "function", + "function" + ], + "iterator": "function" + }, + "query-cursor": { + "firstLoop": { + "items": [ + { + "n": 1 + }, + { + "n": 2 + } + ], + "reads": [ + [ + 0, + [] + ], + [ + 0, + [] + ], + [ + 0, + [] + ] + ] + }, + "afterBreak": { + "reads": 4, + "closes": [ + 1 + ] + }, + "stopped": { + "first": { + "done": false, + "value": "m" + }, + "returned": { + "done": true + }, + "closes": [ + 1, + 2 + ] + }, + "generatorLike": [ + "function", + "function", + "function", + true + ], + "thrownResult": { + "done": true + }, + "closesAfterThrow": [ + 1, + 2, + 3 + ] + }, + "duplicate-key-error": { + "shape": [ + "DuplicateKeyError", + "Duplicate key on index \"email\" (email)", + "email", + [ + "email" + ], + true, + true + ], + "primary": [ + "_id", + "Duplicate key on index \"_id\" (_id)" + ], + "pair": [ + "owner_slug", + [ + "owner", + "slug" + ], + "Duplicate key on index \"owner_slug\" (owner, slug)" + ], + "known": [ + true, + true, + false, + false, + false, + false, + false, + false + ], + "markerHidden": 0 + }, + "query-kinds": { + "table": [ + "table", + true, + true, + false, + false + ], + "filtered": [ + "filtered", + true, + true, + false, + false + ], + "single": [ + "single", + true, + false, + true, + false + ], + "count": [ + "count", + true, + false, + true, + false + ], + "key": [ + "key", + true, + true, + false, + false + ], + "insert": [ + "insert", + true, + false, + false, + false + ], + "atomic": [ + "atomic", + true, + false, + false, + false + ], + "proxy": [ + "proxy", + false, + false, + false, + true + ], + "constant": [ + "constant", + false, + false, + false, + true + ], + "plain": [ + "plain", + false, + false, + false, + false + ], + "nothing": [ + "nothing", + false, + false, + false, + false + ], + "text": [ + "text", + false, + false, + false, + false + ], + "forged": [ + [ + "forgedQuery", + true, + false, + false, + false + ], + [ + "forgedStream", + false, + true, + false, + false + ], + [ + "forgedDatum", + false, + false, + true, + false + ], + [ + "forgedProxy", + false, + false, + false, + true + ] + ], + "chain": [ + true, + true, + true, + true, + true, + true, + false, + true, + false + ] + } +} satisfies { [id: string]: Printed }; diff --git a/.agents/skills/verify-interface/expected-streams.ts b/.agents/skills/verify-interface/expected-streams.ts new file mode 100644 index 0000000..cb86d2e --- /dev/null +++ b/.agents/skills/verify-interface/expected-streams.ts @@ -0,0 +1,208 @@ +import type { Printed } from "./scenario-kit.ts"; + +/** What the packed interface prints for each scenario of scenarios-streams.ts, reviewed against docs/. */ +export const EXPECTED = { + "stream-shape": { + "key": [ + "key<\"email\">" + ], + "keyDefault": [ + "key<\"phone\"><\"N/A\">" + ], + "defaultAfterKey": [ + "key<\"phone\">", + "default<\"N/A\">" + ], + "map": [ + "map<{func[#0]{id:(arg#0 > obj_index<\"_id\">),displayName:(arg#0 > obj_index<\"name\">)}}>" + ], + "filter": [ + "filter<{func[#0](arg#0 > obj_index<\"status\"> > eq<\"active\"> > and<(arg#0 > obj_index<\"age\"> > cmp_ge<18>)>)}>" + ], + "filterTwice": [ + "filter<{func[#0](arg#0 > obj_index<\"a\"> > eq<1>)}>", + "filter<{func[#0](arg#0 > obj_index<\"b\"> > eq<2>)}>" + ], + "pluck": [ + "pluck<[\"_id\",\"name\"]>" + ], + "without": [ + "without<[\"password\",\"ssn\"]>" + ], + "union": [ + "filter<{func[#0](arg#0 > obj_index<\"plan\"> > eq<\"premium\">)}>", + "union<(schema{id:\"store\"} > instance{id:undefined} > table{id:\"users\"} > filter<{func[#0](arg#0 > obj_index<\"plan\"> > eq<\"enterprise\">)}>)>" + ], + "castKeepsStages": [] + }, + "stream-order": { + "orderBy": [ + "orderBy{index:\"name\",direction:undefined}" + ], + "orderByAsc": [ + "orderBy{index:\"name\",direction:\"asc\"}" + ], + "orderByDesc": [ + "orderBy{index:\"createdAt\",direction:\"desc\"}" + ], + "slice": [ + "orderBy{index:\"createdAt\",direction:undefined}", + "slice<0><10>" + ], + "sliceOffset": [ + "orderBy{index:\"createdAt\",direction:undefined}", + "slice<10><10>" + ], + "sliceOpen": [ + "slice<5>" + ], + "nth": [ + "orderBy{index:\"createdAt\",direction:undefined}", + "nth<0>" + ], + "sameBase": [ + 1, + 0 + ], + "chained": [ + "filter<{func[#0](arg#0 > obj_index<\"a\"> > eq<1>)}>", + "orderBy{index:\"name\",direction:\"desc\"}", + "slice<0><3>" + ] + }, + "stream-aggregates": { + "count": [ + "count{field:undefined}" + ], + "countField": [ + "count{field:\"email\"}" + ], + "sum": [ + "sum{field:\"amount\"}" + ], + "avg": [ + "avg{field:\"amount\"}" + ], + "min": [ + "min{field:\"amount\"}" + ], + "max": [ + "max{field:\"amount\"}" + ], + "distinct": [ + "distinct{field:undefined}" + ], + "distinctUndefined": [ + "distinct{field:undefined}" + ], + "distinctField": [ + "distinct{field:\"role\"}" + ], + "changes": [ + "changes" + ] + }, + "stream-run": { + "direct": [ + { + "_id": "u1" + }, + { + "_id": "u2" + } + ], + "counted": 7, + "runs": [ + [ + "orderBy{index:\"name\",direction:undefined}" + ], + [ + "count{field:undefined}" + ] + ] + }, + "join-stages": { + "left": [ + "join{innerOnly:false}<(schema{id:\"store\"} > instance{id:undefined} > table{id:\"orders\"})><{func[#0,#1](arg#1 > obj_index<\"userId\"> > eq<(arg#0 > obj_index<\"_id\">)>)}><{func[#2,#3]{userName:(arg#2 > obj_index<\"name\">),total:(arg#3 > obj_index<\"amount\"> > default<0>)}}>" + ], + "inner": [ + "join{innerOnly:true}<(schema{id:\"store\"} > instance{id:undefined} > table{id:\"orders\"})><{func[#0,#1](arg#1 > obj_index<\"userId\"> > eq<(arg#0 > obj_index<\"_id\">)>)}><{func[#2,#3]{userName:(arg#2 > obj_index<\"name\">),amount:(arg#3 > obj_index<\"amount\">)}}>" + ], + "lookup": [ + "lookup{localKey:\"userId\",otherKey:\"_id\"}<(schema{id:\"store\"} > instance{id:undefined} > table{id:\"users\"})>" + ], + "lookupAfterFilter": [ + "filter<{func[#0](arg#0 > obj_index<\"amount\"> > cmp_gt<5>)}>", + "lookup{localKey:\"userId\",otherKey:\"_id\"}<(schema{id:\"store\"} > instance{id:undefined} > table{id:\"users\"})>" + ] + }, + "group-stages": { + "group": [ + "group{index:\"role\"}<{func[#0,#1]{role:(arg#1),total:(arg#0 > count{field:undefined}),oldest:(arg#0 > max{field:\"age\"})}}>" + ], + "groupSum": [ + "group{index:\"userId\"}<{func[#0,#1]{id:(arg#1),spent:(arg#0 > sum{field:\"amount\"})}}>" + ] + }, + "datum-stages": { + "get": [ + "get<\"user-123\">" + ], + "key": [ + "get<\"user-123\">", + "key<\"name\">" + ], + "nested": [ + "get<\"user-123\">", + "key<\"address\">", + "key<\"city\">" + ], + "keyDefault": [ + "get<\"user-123\">", + "key<\"phone\"><\"N/A\">" + ], + "defaultObject": [ + "get<\"user-123\">", + "default<{_id:\"guest\",name:\"Guest\"}>" + ], + "do": [ + "get<\"user-123\">", + "map<{func[#0]{displayName:(arg#0 > obj_index<\"name\">),contact:(arg#0 > obj_index<\"email\">),hasPhone:(arg#0 > obj_index<\"phone\"> > ne)}}>" + ], + "lookup": [ + "get<\"o-1\">", + "lookup{localKey:\"userId\",otherKey:\"_id\"}<(schema{id:\"store\"} > instance{id:undefined} > table{id:\"users\"})>" + ], + "pluck": [ + "get<\"user-123\">", + "pluck<[\"name\",\"email\"]>" + ], + "cast": [ + "get<\"user-123\">" + ], + "nthOfStream": [ + "orderBy{index:\"createdAt\",direction:undefined}", + "nth<0>" + ] + }, + "datum-run": { + "doc": { + "_id": "user-123", + "name": "Ada" + }, + "name": "Ada", + "missing": null, + "runs": [ + [ + "get<\"user-123\">" + ], + [ + "get<\"user-123\">", + "key<\"name\">" + ], + [ + "get<\"nobody\">" + ] + ] + } +} satisfies { [id: string]: Printed }; diff --git a/.agents/skills/verify-interface/expected-tables.ts b/.agents/skills/verify-interface/expected-tables.ts new file mode 100644 index 0000000..d065f79 --- /dev/null +++ b/.agents/skills/verify-interface/expected-tables.ts @@ -0,0 +1,282 @@ +import type { Printed } from "./scenario-kit.ts"; + +/** What the packed interface prints for each scenario of scenarios-tables.ts, reviewed against docs/. */ +export const EXPECTED = { + "schema-registry": { + "registered": [ + [ + "notes-demo", + { + "notes": { + "fields": { + "title": "string" + }, + "indexes": {} + } + } + ] + ], + "found": [ + true, + true + ], + "listed": [ + "notes-demo" + ], + "replaced": [ + true, + true, + 2 + ], + "listedAfter": [ + "notes-demo", + "other-demo" + ], + "identity": [ + "notes-demo", + true, + "other-demo" + ] + }, + "schema-stages": { + "defaultInstance": [ + "schema{id:\"tenants\"}", + "instance{id:undefined}", + "table{id:\"people\"}" + ], + "named": [ + "schema{id:\"tenants\"}", + "instance{id:\"acme\"}", + "table{id:\"people\"}" + ], + "cross": [ + "schema{id:\"tenants\"}", + "instance{id:Symbol(@antelopejs/interface-database/CROSS_INSTANCE)}", + "table{id:\"people\"}" + ], + "crossSymbol": [ + true, + "symbol" + ], + "create": [ + "schema{id:\"tenants\"}", + "createInstance{id:\"acme\"}" + ], + "createDefault": [ + "schema{id:\"tenants\"}", + "createInstance{id:undefined}" + ], + "destroy": [ + "schema{id:\"tenants\"}", + "destroyInstance{id:\"acme\"}" + ], + "list": [ + "schema{id:\"tenants\"}", + "listInstances" + ] + }, + "schema-run": { + "created": "acme", + "listed": [], + "runs": [ + [ + "schema{id:\"tutorial\"}", + "createInstance{id:\"acme\"}" + ], + [ + "schema{id:\"tutorial\"}", + "listInstances" + ], + [ + "schema{id:\"tutorial\"}", + "destroyInstance{id:\"acme\"}" + ] + ], + "lazy": 0 + }, + "table-reads": { + "whole": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}" + ], + "get": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "get<\"product-1\">" + ], + "getAllKey": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:undefined}<\"p-1\">" + ], + "getAllKeys": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:undefined}<[\"p-1\",\"p-2\"]>" + ], + "getAllIndex": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:\"tags\"}<\"shoes\">" + ], + "getAllNumber": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:\"price\"}<10>" + ], + "getAllCompound": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:\"pair\"}<[[\"a\",1],[\"b\",2]]>" + ], + "between": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "between{index:\"price\"}<10><20>" + ], + "betweenDates": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "between{index:\"price\"}" + ], + "betweenCompound": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "between{index:\"price\"}<[1,\"a\"]><[2,\"b\"]>" + ], + "betweenNull": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "between{index:\"price\"}<5>" + ], + "selectionFromGetAll": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:\"tags\"}<\"x\">", + "count{field:undefined}" + ] + }, + "table-writes": { + "insertOne": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "insert<{name:\"Boot\",price:80}>" + ], + "insertMany": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "insert<[{name:\"A\"},{name:\"B\"}]>" + ], + "insertUpdate": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "insert{conflict:\"update\"}<{_id:\"p-1\",name:\"New\"}>" + ], + "insertReplace": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "insert{conflict:\"replace\"}<{_id:\"p-1\"}>" + ], + "update": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:\"tags\"}<\"shoes\">", + "update<{price:5}>" + ], + "updateComputed": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "get<\"p-1\">", + "update<{func[#0]{price:(arg#0 > obj_index<\"price\"> > add<1>)}}>" + ], + "replace": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "get<\"p-1\">", + "replace<{_id:\"p-1\",name:\"R\"}>" + ], + "remove": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "get<\"p-1\">", + "delete" + ], + "removeMany": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "filter<{func[#0](arg#0 > obj_index<\"price\"> > cmp_lt<5>)}>", + "delete" + ], + "changesOne": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "get<\"p-1\">", + "changes" + ], + "changesAll": [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "changes" + ] + }, + "table-results": { + "ids": [ + "id-1", + "id-2" + ], + "updated": 3, + "missing": null, + "refused": "Error: provider refused", + "runs": [ + [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "insert{conflict:\"update\"}<[{name:\"A\"},{name:\"B\"}]>" + ], + [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "getAll{index:\"tags\"}<\"shoes\">", + "update<{price:1}>" + ], + [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "get<\"nope\">" + ], + [ + "schema{id:\"shop\"}", + "instance{id:undefined}", + "table{id:\"products\"}", + "get<\"p-1\">", + "delete" + ] + ] + } +} satisfies { [id: string]: Printed }; diff --git a/.agents/skills/verify-interface/features/README.md b/.agents/skills/verify-interface/features/README.md new file mode 100644 index 0000000..f560c4d --- /dev/null +++ b/.agents/skills/verify-interface/features/README.md @@ -0,0 +1,26 @@ +# Feature map + +One file per feature. `covers:` lists the source globs it owns, `docs:` the pages a verdict judges, `journeys:` the journeys it runs, and `seeded-breaks/.patch` the break its journeys must catch. `pnpm exec antelopejs-verify map` checks that every user-facing file is covered. + +| Feature | What the user does | Journeys | +|---|---|---| +| [smoke](smoke.md) | The fixed journey every verdict runs: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call the proxies a database module implements (`RunQuery`, `ReadCursor`, `CloseCursor`) and the schema registry. | through-core | +| [schemas](schemas.md) | A module defines a `Schema`, which registers with the bound provider, then picks an instance, a table, or creates and destroys named instances. | schema-registry, schema-stages, schema-run | +| [tables-read](tables-read.md) | A table is read whole, by primary key (`get`), by secondary index (`getAll`) or by range (`between`, low bound included, high bound excluded). | table-reads | +| [tables-write](tables-write.md) | A module inserts (with `conflict: "update"` or `"replace"`), updates with a partial document or a computed function, replaces, deletes and subscribes to changes. | table-writes, table-results | +| [streams](streams.md) | A stream is mapped, filtered, plucked, ordered, sliced and aggregated. | stream-shape, stream-order, stream-aggregates, stream-run | +| [joins](joins.md) | A stream is joined to another (left or inner), looked up through a foreign key, or grouped by an index. | join-stages, group-stages | +| [datums](datums.md) | A single document or value is read, a field is picked, defaulted, transformed with `do`, looked up or plucked. | datum-stages, datum-run | +| [expressions-logic](expressions-logic.md) | Inside a query callback a `ValueProxy` compares, combines and reads fields: `eq`, `ne`, `and`, `or`, `not`, `gt` to `le`, `default`, `key`, `merge`, `keys`, `values`, `hasfields`. | expression-logic, expression-objects | +| [expressions-arithmetic](expressions-arithmetic.md) | Inside a query callback a number is added to, subtracted from, multiplied, divided, rounded and combined bitwise. | expression-arithmetic, expression-bits | +| [expressions-text](expressions-text.md) | Inside a query callback a string is split, concatenated, case-folded, measured and matched against a regular expression. | expression-text | +| [expressions-dates](expressions-dates.md) | Inside a query callback a date is tested against a range, broken into calendar parts in an optional timezone, or turned into epoch seconds. | expression-dates | +| [expressions-arrays](expressions-arrays.md) | Inside a query callback a list is indexed, searched, sliced, mapped, filtered, counted and reduced. | expression-arrays | +| [atomic-requests](atomic-requests.md) | A module asks for one conditional change of one record: update or delete under a revision, or delete when one field still equals what was observed. | atomic-requests | +| [atomic-execution](atomic-execution.md) | Awaiting an atomic mutation resolves to `applied`, `not-applied` or `unknown`; any other provider answer is an `AtomicMutationUnsupportedError`; cursors are refused; nothing is retried. | atomic-execution | +| [execution](execution.md) | A query is lazy; `await`, `run()` and `then()` run it once, `cursor()` and `for await` read it one result at a time and close the cursor when the consumer stops. | query-execution, query-cursor | +| [duplicate-key](duplicate-key.md) | A write that repeats a unique key rejects with a `DuplicateKeyError` naming the index and its fields; `IsDuplicateKeyError` also recognises the error of another copy of the interface. | duplicate-key-error | +| [kinds](kinds.md) | `IsQuery`, `IsStream`, `IsDatum` and `IsValueProxy` tell what a value is, including a value built by another copy of the interface, which `instanceof` cannot. | query-kinds | +| [staged-query](staged-query.md) | A build tool imports `@antelopejs/interface-database/staged-query`, builds queries and reads `build()`; the entry point never loads `interface-core` or `node:async_hooks`, its queries cannot run, and the root entry later gives the same classes `run`, `then` and `cursor`. | staged-isolation | +| [entry-points](entry-points.md) | A module imports from the root entry or from one of the subpaths the exports map lists; `typesVersions` lists the same subpaths for older resolvers. | subpaths | +| [types](types.md) | A TypeScript module sees the documented result type of each call, the table names of its schema, and the values `getAll`, `between`, `insert` and `atomicMutation` accept and refuse. | usage-types | diff --git a/.agents/skills/verify-interface/features/atomic-execution.md b/.agents/skills/verify-interface/features/atomic-execution.md new file mode 100644 index 0000000..5e4b938 --- /dev/null +++ b/.agents/skills/verify-interface/features/atomic-execution.md @@ -0,0 +1,30 @@ +--- +covers: + - src/atomic.ts +docs: + - docs/2.operations/8.atomic_mutations.md +journeys: + - atomic-execution +--- + +# Atomic execution + +Awaiting an atomic mutation resolves to `applied`, `not-applied` or `unknown`; any other provider answer is an `AtomicMutationUnsupportedError`; cursors are refused; nothing is retried. + +## Sub-features + +- `atomic-execution`: an atomic mutation resolves to applied, not-applied or unknown, refuses any other answer, and is never retried. + +## How to get to it (user POV) + +- `const outcome = await table.atomicMutation(id, request)`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" atomic-execution +``` + +## Gotchas + +- A rejected run is called once: the interface never retries an uncertain mutation. diff --git a/.agents/skills/verify-interface/features/atomic-requests.md b/.agents/skills/verify-interface/features/atomic-requests.md new file mode 100644 index 0000000..4714dfc --- /dev/null +++ b/.agents/skills/verify-interface/features/atomic-requests.md @@ -0,0 +1,30 @@ +--- +covers: + - src/staged-query/atomic.ts +docs: + - docs/2.operations/8.atomic_mutations.md +journeys: + - atomic-requests +--- + +# Atomic requests + +A module asks for one conditional change of one record: update or delete under a revision, or delete when one field still equals what was observed. Anything outside that contract is refused before it runs. + +## Sub-features + +- `atomic-requests`: atomicMutation refuses every request docs/2.operations/8.atomic_mutations.md excludes, before anything is run. + +## How to get to it (user POV) + +- `await table.atomicMutation(id, { type: "delete", revisionField: "revision", expectedRevision: observed })`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" atomic-requests +``` + +## Gotchas + +- The refusals are `TypeError`s thrown synchronously by `atomicMutation`, so no provider is reached. diff --git a/.agents/skills/verify-interface/features/datums.md b/.agents/skills/verify-interface/features/datums.md new file mode 100644 index 0000000..b97c349 --- /dev/null +++ b/.agents/skills/verify-interface/features/datums.md @@ -0,0 +1,32 @@ +--- +covers: + - src/staged-query/datum.ts +docs: + - docs/1.query_types/8.datum.md +journeys: + - datum-stages + - datum-run +--- + +# Datums + +A single document or value is read, a field is picked, defaulted, transformed with `do`, looked up or plucked. + +## Sub-features + +- `datum-stages`: key, default, do, lookup, pluck and cast on one document record docs/1.query_types/8.datum.md. +- `datum-run`: awaiting a document or one of its fields returns the provider's answer, null included. + +## How to get to it (user POV) + +- `await users.get("u").key("address").key("city")`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" datums +``` + +## Gotchas + +- A missing document resolves to whatever the provider answers, `null` included, although the declared type is `T`. diff --git a/.agents/skills/verify-interface/features/duplicate-key.md b/.agents/skills/verify-interface/features/duplicate-key.md new file mode 100644 index 0000000..a74394b --- /dev/null +++ b/.agents/skills/verify-interface/features/duplicate-key.md @@ -0,0 +1,30 @@ +--- +covers: + - src/errors.ts +docs: + - docs/2.operations/4.crud.md +journeys: + - duplicate-key-error +--- + +# Duplicate key + +A write that repeats a unique key rejects with a `DuplicateKeyError` naming the index and its fields; `IsDuplicateKeyError` also recognises the error of another copy of the interface. + +## Sub-features + +- `duplicate-key-error`: DuplicateKeyError carries the index and fields, and IsDuplicateKeyError recognises another copy's error. + +## How to get to it (user POV) + +- `if (IsDuplicateKeyError(error)) show(error.index)`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" duplicate-key +``` + +## Gotchas + +- The journey builds the error the way a database module does; a provider's own throw is covered by its conformance suite. diff --git a/.agents/skills/verify-interface/features/entry-points.md b/.agents/skills/verify-interface/features/entry-points.md new file mode 100644 index 0000000..9cf0689 --- /dev/null +++ b/.agents/skills/verify-interface/features/entry-points.md @@ -0,0 +1,37 @@ +--- +covers: + - package.json + - src/index.ts + - src/common.ts + - src/datum.ts + - src/selection.ts + - src/stream.ts + - src/valueproxy.ts +docs: + - docs/index.md + - README.md +journeys: + - subpaths +--- + +# Entry points + +A module imports from the root entry or from one of the subpaths the exports map lists; `typesVersions` lists the same subpaths for older resolvers. + +## Sub-features + +- `subpaths`: every export subpath resolves through the exports map and names what docs/index.md says. + +## How to get to it (user POV) + +- `import { Changes } from "@antelopejs/interface-database/common"`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" entry-points +``` + +## Gotchas + +- A new subpath needs an entry in both `exports` and `typesVersions`. diff --git a/.agents/skills/verify-interface/features/execution.md b/.agents/skills/verify-interface/features/execution.md new file mode 100644 index 0000000..25321ea --- /dev/null +++ b/.agents/skills/verify-interface/features/execution.md @@ -0,0 +1,33 @@ +--- +covers: + - src/query.ts +docs: + - docs/1.query_types/7.query.md + - docs/1.query_types/1.index.md +journeys: + - query-execution + - query-cursor +--- + +# Execution + +A query is lazy; `await`, `run()` and `then()` run it once, `cursor()` and `for await` read it one result at a time and close the cursor when the consumer stops. + +## Sub-features + +- `query-execution`: await, run and then run a query once each, lazily, and pass the provider's failure on. +- `query-cursor`: cursor and for await read the provider one result at a time and close the cursor when the consumer stops. + +## How to get to it (user POV) + +- `for await (const user of users) { if (done(user)) break; }`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" execution +``` + +## Gotchas + +- Cursor ids count up per process; the journey reads them from a fresh process. diff --git a/.agents/skills/verify-interface/features/expressions-arithmetic.md b/.agents/skills/verify-interface/features/expressions-arithmetic.md new file mode 100644 index 0000000..edc40f5 --- /dev/null +++ b/.agents/skills/verify-interface/features/expressions-arithmetic.md @@ -0,0 +1,32 @@ +--- +covers: + - src/staged-query/valueproxy.ts +docs: + - docs/1.query_types/9.valueproxy.md +journeys: + - expression-arithmetic + - expression-bits +--- + +# Expressions arithmetic + +Inside a query callback a number is added to, subtracted from, multiplied, divided, rounded and combined bitwise. + +## Sub-features + +- `expression-arithmetic`: add, sub, mul, div, mod, round, ceil and floor record the operand a proxy, a number, a string or a list. +- `expression-bits`: band, bor, bxor, bnot and the shifts record the bit stages. + +## How to get to it (user POV) + +- `users.update((u) => ({ loginCount: u.key("loginCount").add(1) }))`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" expressions-arithmetic +``` + +## Gotchas + +- `add` also takes a string or a list, and `sub` a date. diff --git a/.agents/skills/verify-interface/features/expressions-arrays.md b/.agents/skills/verify-interface/features/expressions-arrays.md new file mode 100644 index 0000000..cc72e3b --- /dev/null +++ b/.agents/skills/verify-interface/features/expressions-arrays.md @@ -0,0 +1,30 @@ +--- +covers: + - src/staged-query/valueproxy.ts +docs: + - docs/1.query_types/9.valueproxy.md +journeys: + - expression-arrays +--- + +# Expressions arrays + +Inside a query callback a list is indexed, searched, sliced, mapped, filtered, counted and reduced. + +## Sub-features + +- `expression-arrays`: index, includes, slice, map, filter, isempty, count, sum, avg, min and max record the array stages. + +## How to get to it (user POV) + +- `users.filter((u) => u.key("tags").includes("vip"))`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" expressions-arrays +``` + +## Gotchas + +- The mapper and predicate of `map` and `filter` become a nested function stage. diff --git a/.agents/skills/verify-interface/features/expressions-dates.md b/.agents/skills/verify-interface/features/expressions-dates.md new file mode 100644 index 0000000..754e8c6 --- /dev/null +++ b/.agents/skills/verify-interface/features/expressions-dates.md @@ -0,0 +1,30 @@ +--- +covers: + - src/staged-query/valueproxy.ts +docs: + - docs/1.query_types/9.valueproxy.md +journeys: + - expression-dates +--- + +# Expressions dates + +Inside a query callback a date is tested against a range, broken into calendar parts in an optional timezone, or turned into epoch seconds. + +## Sub-features + +- `expression-dates`: during, the calendar parts and epoch record the date stages, with a timezone stage when one is given. + +## How to get to it (user POV) + +- `orders.filter((o) => o.key("createdAt").year("Europe/Paris").eq(2024))`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" expressions-dates +``` + +## Gotchas + +- A timezone adds a `date_with_timezone` stage before the part. diff --git a/.agents/skills/verify-interface/features/expressions-logic.md b/.agents/skills/verify-interface/features/expressions-logic.md new file mode 100644 index 0000000..6f4be47 --- /dev/null +++ b/.agents/skills/verify-interface/features/expressions-logic.md @@ -0,0 +1,33 @@ +--- +covers: + - src/staged-query/valueproxy.ts +docs: + - docs/1.query_types/9.valueproxy.md + - docs/2.operations/6.filtering.md +journeys: + - expression-logic + - expression-objects +--- + +# Expressions logic + +Inside a query callback a `ValueProxy` compares, combines and reads fields: `eq`, `ne`, `and`, `or`, `not`, `gt` to `le`, `default`, `key`, `merge`, `keys`, `values`, `hasfields`. + +## Sub-features + +- `expression-logic`: eq, ne, and, or, not, comparisons and default record their operand. +- `expression-objects`: key, merge, keys, values and hasfields record the field access of a ValueProxy. + +## How to get to it (user POV) + +- `users.filter((u) => u.key("status").eq("active").and(u.key("age").ge(18)))`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" expressions-logic +``` + +## Gotchas + +- A `ValueProxy` is not a fetched value; only its recorded stages travel to the provider. diff --git a/.agents/skills/verify-interface/features/expressions-text.md b/.agents/skills/verify-interface/features/expressions-text.md new file mode 100644 index 0000000..503a76d --- /dev/null +++ b/.agents/skills/verify-interface/features/expressions-text.md @@ -0,0 +1,30 @@ +--- +covers: + - src/staged-query/valueproxy.ts +docs: + - docs/1.query_types/9.valueproxy.md +journeys: + - expression-text +--- + +# Expressions text + +Inside a query callback a string is split, concatenated, case-folded, measured and matched against a regular expression. + +## Sub-features + +- `expression-text`: split, concat, upcase, downcase, strlen and match record the string stages. + +## How to get to it (user POV) + +- `users.filter((u) => u.key("email").match("^a"))`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" expressions-text +``` + +## Gotchas + +- `split` with no separator records an undefined separator. diff --git a/.agents/skills/verify-interface/features/joins.md b/.agents/skills/verify-interface/features/joins.md new file mode 100644 index 0000000..01368eb --- /dev/null +++ b/.agents/skills/verify-interface/features/joins.md @@ -0,0 +1,33 @@ +--- +covers: + - src/staged-query/stream.ts +docs: + - docs/1.query_types/5.stream.md + - docs/2.operations/7.lookup.md +journeys: + - join-stages + - group-stages +--- + +# Joins + +A stream is joined to another (left or inner), looked up through a foreign key, or grouped by an index. + +## Sub-features + +- `join-stages`: join, joinInner and lookup record the right side, the predicate and the mapper. +- `group-stages`: group records its index and a mapper over the sub-stream and the group key. + +## How to get to it (user POV) + +- `await users.joinInner(orders, (u, o) => o.key("userId").eq(u.key("_id")), (u, o) => ({ amount: o.key("amount") }))`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" joins +``` + +## Gotchas + +- The right side of a join is embedded as a nested stage list, argument ids included. diff --git a/.agents/skills/verify-interface/features/kinds.md b/.agents/skills/verify-interface/features/kinds.md new file mode 100644 index 0000000..6aa0da8 --- /dev/null +++ b/.agents/skills/verify-interface/features/kinds.md @@ -0,0 +1,32 @@ +--- +covers: + - src/staged-query/kind.ts + - src/staged-query/query.ts + - src/staged-query/valueproxy-kind.ts +docs: + - docs/1.query_types/1.index.md +journeys: + - query-kinds +--- + +# Kinds + +`IsQuery`, `IsStream`, `IsDatum` and `IsValueProxy` tell what a value is, including a value built by another copy of the interface, which `instanceof` cannot. + +## Sub-features + +- `query-kinds`: IsQuery, IsStream, IsDatum and IsValueProxy tell the staged kinds apart, copies of the interface included. + +## How to get to it (user POV) + +- `IsStream(query) ? "a sequence" : "a single value"`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" kinds +``` + +## Gotchas + +- A copy is simulated with the global marker symbol each class sets on its prototype. diff --git a/.agents/skills/verify-interface/features/schemas.md b/.agents/skills/verify-interface/features/schemas.md new file mode 100644 index 0000000..26d55b5 --- /dev/null +++ b/.agents/skills/verify-interface/features/schemas.md @@ -0,0 +1,36 @@ +--- +covers: + - src/schema.ts + - src/staged-query/schema.ts +docs: + - docs/2.operations/2.database_management.md + - docs/3.results/5.database_changes.md +journeys: + - schema-registry + - schema-stages + - schema-run +--- + +# Schemas + +A module defines a `Schema`, which registers with the bound provider, then picks an instance, a table, or creates and destroys named instances. + +## Sub-features + +- `schema-registry`: a Schema registers with the provider, and Schema.get and Schema.list find it. +- `schema-stages`: instance, createInstance, destroyInstance and listInstances record the stages docs/3.results/5.database_changes.md names. +- `schema-run`: awaiting a schema call runs it once, and building one runs nothing. + +## How to get to it (user POV) + +- `const schema = new Schema("myapp", { users: { fields: {}, indexes: {} } }); await schema.createInstance("tenant-1");` + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" schemas +``` + +## Gotchas + +- `CROSS_INSTANCE` is a global symbol (`Symbol.for`), so copies of the interface agree on it. diff --git a/.agents/skills/verify-interface/features/smoke.md b/.agents/skills/verify-interface/features/smoke.md new file mode 100644 index 0000000..e19760c --- /dev/null +++ b/.agents/skills/verify-interface/features/smoke.md @@ -0,0 +1,30 @@ +--- +covers: +docs: + - README.md + - docs/index.md +journeys: + - through-core +--- + +# Smoke + +The fixed journey every verdict runs: pack the interface, load it in `ajs project dev` with a stub provider and a consumer, and call the proxies a database module implements (`RunQuery`, `ReadCursor`, `CloseCursor`) and the schema registry. + +## Sub-features + +- `through-core`: the query proxies and the schema registry answer through core from a stub provider. + +## How to get to it (user POV) + +- A project with a database module and a module that imports `@antelopejs/interface-database`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" smoke +``` + +## Gotchas + +- The smoke feature covers no glob. The shared `every-function` journey cannot read this package's declarations (a call-stack overflow in the declaration walker), so `through-core` stands in for it. diff --git a/.agents/skills/verify-interface/features/staged-query.md b/.agents/skills/verify-interface/features/staged-query.md new file mode 100644 index 0000000..892a15d --- /dev/null +++ b/.agents/skills/verify-interface/features/staged-query.md @@ -0,0 +1,31 @@ +--- +covers: + - src/staged-query/index.ts + - src/staged-query/common.ts +docs: + - docs/index.md +journeys: + - staged-isolation +--- + +# Staged query + +A build tool imports `@antelopejs/interface-database/staged-query`, builds queries and reads `build()`; the entry point never loads `interface-core` or `node:async_hooks`, its queries cannot run, and the root entry later gives the same classes `run`, `then` and `cursor`. + +## Sub-features + +- `staged-isolation`: the build-only entry point loads without interface-core and builds queries that cannot run. + +## How to get to it (user POV) + +- `import { Schema } from "@antelopejs/interface-database/staged-query"` in a codegen tool. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" staged-query +``` + +## Gotchas + +- The repository's `pnpm test:staged-query` checks the same isolation but runs outside `pnpm run checks`. diff --git a/.agents/skills/verify-interface/features/streams.md b/.agents/skills/verify-interface/features/streams.md new file mode 100644 index 0000000..af85710 --- /dev/null +++ b/.agents/skills/verify-interface/features/streams.md @@ -0,0 +1,37 @@ +--- +covers: + - src/staged-query/stream.ts +docs: + - docs/1.query_types/5.stream.md + - docs/2.operations/6.filtering.md +journeys: + - stream-shape + - stream-order + - stream-aggregates + - stream-run +--- + +# Streams + +A stream is mapped, filtered, plucked, ordered, sliced and aggregated. This feature holds the Format door of the stage list: `stream-shape` is its containment journey. + +## Sub-features + +- `stream-shape`: key, default, map, filter, pluck, without and union record their stages in order. +- `stream-order`: orderBy, slice and nth record index, direction and bounds, and never change the stream they extend. +- `stream-aggregates`: count, sum, avg, min, max, distinct and changes record the field they work on. +- `stream-run`: awaiting a stream hands the provider the stages and returns its answer. + +## How to get to it (user POV) + +- `await users.filter((u) => u.key("age").ge(18)).orderBy("name").slice(0, 10)`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" streams +``` + +## Gotchas + +- Every operation returns a new object; the stream it extends keeps its own stages. diff --git a/.agents/skills/verify-interface/features/tables-read.md b/.agents/skills/verify-interface/features/tables-read.md new file mode 100644 index 0000000..7de871a --- /dev/null +++ b/.agents/skills/verify-interface/features/tables-read.md @@ -0,0 +1,31 @@ +--- +covers: + - src/staged-query/selection.ts +docs: + - docs/1.query_types/2.table.md + - docs/2.operations/3.table_operations.md +journeys: + - table-reads +--- + +# Tables read + +A table is read whole, by primary key (`get`), by secondary index (`getAll`) or by range (`between`, low bound included, high bound excluded). + +## Sub-features + +- `table-reads`: get, getAll and between record the key, index and bounds docs/1.query_types/2.table.md describes. + +## How to get to it (user POV) + +- `await users.getAll("admin", "role")` and `await users.between("createdAt", from, to)`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" tables-read +``` + +## Gotchas + +- The journey reads the stages the provider receives; it proves what a driver is asked, not what a database answers. diff --git a/.agents/skills/verify-interface/features/tables-write.md b/.agents/skills/verify-interface/features/tables-write.md new file mode 100644 index 0000000..3f3eebc --- /dev/null +++ b/.agents/skills/verify-interface/features/tables-write.md @@ -0,0 +1,34 @@ +--- +covers: + - src/staged-query/selection.ts +docs: + - docs/2.operations/4.crud.md + - docs/1.query_types/3.selection.md + - docs/1.query_types/4.single_selection.md +journeys: + - table-writes + - table-results +--- + +# Tables write + +A module inserts (with `conflict: "update"` or `"replace"`), updates with a partial document or a computed function, replaces, deletes and subscribes to changes. + +## Sub-features + +- `table-writes`: insert, update, replace, delete and changes record the stages docs/2.operations/4.crud.md describes. +- `table-results`: a write resolves to what the provider answered, and a provider failure rejects the caller. + +## How to get to it (user POV) + +- `await users.insert({ _id: "u" }, { conflict: "update" })` and `await users.get("u").delete()`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" tables-write +``` + +## Gotchas + +- A provider failure rejects the caller with the provider's own error. diff --git a/.agents/skills/verify-interface/features/types.md b/.agents/skills/verify-interface/features/types.md new file mode 100644 index 0000000..9446f18 --- /dev/null +++ b/.agents/skills/verify-interface/features/types.md @@ -0,0 +1,33 @@ +--- +covers: + - src/staged-query/stages.ts + - src/staged-query/valueproxy-conditions.ts + - src/staged-query/common.ts +docs: + - docs/index.md + - docs/1.query_types/1.index.md +journeys: + - usage-types +--- + +# Types + +A TypeScript module sees the documented result type of each call, the table names of its schema, and the values `getAll`, `between`, `insert` and `atomicMutation` accept and refuse. + +## Sub-features + +- `usage-types`: the documented calls resolve to the documented result types. + +## How to get to it (user POV) + +- `const ids: string[] = await users.insert({ name: "A" })`. + +## Driving it with antelopejs-verify + +```bash +pnpm exec antelopejs-verify journey "$RUN" types +``` + +## Gotchas + +- Types only; the probe compiles with `--strict` against the packed declarations. diff --git a/.agents/skills/verify-interface/package.json b/.agents/skills/verify-interface/package.json new file mode 100644 index 0000000..e986b24 --- /dev/null +++ b/.agents/skills/verify-interface/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/.agents/skills/verify-interface/probes.ts b/.agents/skills/verify-interface/probes.ts new file mode 100644 index 0000000..4bbcb1b --- /dev/null +++ b/.agents/skills/verify-interface/probes.ts @@ -0,0 +1,56 @@ +/** What a TypeScript consumer sees: result types of the documented calls, and the values the builders accept and refuse. */ +export const USAGE_TYPES = ` +import type * as Common from "$REPO/dist/common"; +import type * as QueryApi from "$REPO/dist/query"; +interface User { _id: string; name: string; age: number; createdAt: Date; tags: string[]; address: { city: string } } +declare const schema: Api.Schema<{ users: User; orders: { _id: string; total: number } }>; +const users = schema.instance().table("users"); +type Await = Q extends PromiseLike ? R : never; +type Instance = ReturnType; +async function usage() { + const ids: string[] = await users.insert({ name: "A" }, { conflict: "update" }); + const manyIds: string[] = await users.insert([{ name: "A" }, { name: "B" }]); + const user: User = await users.get("id"); + const all: User[] = await users; + const some: User[] = await users.getAll(["a", "b"], "name"); + const range: User[] = await users.between("createdAt", new Date(0), new Date(1)); + const compound: User[] = await users.between("tags", ["a", "b"], ["c", "d"]); + const total: number = await users.count(); + const names: string[] = await users.key("name"); + const cities: string[] = await users.map((u) => u.key("address").key("city")); + const sorted: User[] = await users.orderBy("name", "desc").slice(0, 10); + const first: User | null = await users.orderBy("name").nth(0); + const updated: number = await users.get("id").update({ age: 1 }); + const computed: number = await users.getAll("a", "name").update((u) => ({ age: u.key("age").add(1) })); + const replaced: number = await users.get("id").replace({ _id: "id", name: "N" }); + const removed: number = await users.filter((u) => u.key("age").lt(5)).delete(); + const outcome: "applied" | "not-applied" | "unknown" = await users.atomicMutation("id", { type: "delete", revisionField: "name", expectedRevision: "r1" }); + const instanceId: string = await schema.createInstance("tenant"); + const destroyed: void = await schema.destroyInstance("tenant"); + const listed: string[] = await schema.listInstances(); + const feed: Common.Changes[] = await users.changes(); + for await (const each of users) { const typed: User = each; void typed; } + for await (const change of users.get("id").changes()) { const kind: "added" | "removed" | "modified" = change.changeType; void kind; } + void [ids, manyIds, user, all, some, range, compound, total, names, cities, sorted, first, updated, computed, replaced, removed, outcome, instanceId, destroyed, listed, feed]; +} +type _ConflictOptions = Assert>; +type _Direction = Assert[1], "asc" | "desc" | undefined>>; +type _TableNames = Assert[0], "users" | "orders">>; +type _NoOtherTable = Assert[0], "nope">>>; +type _Instances = Assert[0], string | typeof Api.CROSS_INSTANCE | undefined>>; +type _Outcome = Assert>; +type _Mutation = Assert["type"], "update" | "delete" | "deleteIfEqual">>; +type _MissingTag = Assert>; +type _ChangeKinds = Assert["changeType"], "added" | "removed" | "modified">>; +type _DeepPartial = Assert, { address: {} }>>; +type _KeysOne = Assert[0], "a">>; +type _KeysMany = Assert[0], string[]>>; +type _KeysCompound = Assert[0], [string, number][]>>; +type _NoObjectKey = Assert[0], { id: 1 }>>>; +type _BoundDate = Assert>; +type _BoundNull = Assert>; +type _BoundList = Assert>; +type _NoBoundObject = Assert>>; +type _Reads = Assert>, User>>; +type _ExecutionMembers = Assert, "run" | "then" | "cursor" | typeof Symbol.asyncIterator>>; +`; diff --git a/.agents/skills/verify-interface/scenario-kit.ts b/.agents/skills/verify-interface/scenario-kit.ts new file mode 100644 index 0000000..a897527 --- /dev/null +++ b/.agents/skills/verify-interface/scenario-kit.ts @@ -0,0 +1,126 @@ +import { equal } from "@antelopejs/tooling-configs/verify"; +import type { Journey, JourneyContext } from "@antelopejs/tooling-configs/verify"; + +/** A value a scenario prints: the JSON a journey compares with the recorded expectation. */ +export type Printed = string | number | boolean | null | Printed[] | { [key: string]: Printed }; + +/** + * The consumer side of every scenario: the packed interface is required next to a provider that records what it is asked to run. + * `answer` is what `RunQuery` resolves to, `seen` collects the stages the provider received, and `show` renders a query or a stage list the way a driver reads it. + */ +export const PRELUDE = ` +const path = require("node:path"); +const dist = path.join(process.argv[2], "dist"); +const core = require(require.resolve("@antelopejs/interface-core", { paths: [dist] })); +const db = require(path.join(dist, "index.js")); +const queryModule = require(path.join(dist, "query.js")); +const schemaModule = require(path.join(dist, "schema.js")); +const seen = { runs: [], reads: [], closes: [], schemas: [] }; +let answer = []; +let readAnswers = []; +core.ImplementInterface(queryModule, { + RunQuery: (stages) => { seen.runs.push(stages); return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer); }, + ReadCursor: (id, stages) => { seen.reads.push([id, stages]); const next = readAnswers.shift(); return Promise.resolve(next ?? { done: true, value: undefined }); }, + CloseCursor: (id) => { seen.closes.push(id); }, +}); +core.ImplementInterface(schemaModule, { + Schemas: { register: (name, definition) => { seen.schemas.push([name, definition]); }, unregister: () => {} }, +}); +const showValue = (value, ids) => { + if (value === undefined) return "undefined"; + if (typeof value === "symbol") return String(value); + if (value instanceof Date) return "date:" + value.toISOString(); + if (value instanceof Error) return value.name + ":" + value.message; + if (Array.isArray(value)) return "[" + value.map((item) => showValue(item, ids)).join(",") + "]"; + if (value && typeof value.build === "function") return "(" + value.build().map((stage) => showStage(stage, ids)).join(" > ") + ")"; + if (value && typeof value.stage === "string" && Array.isArray(value.args)) return "{" + showStage(value, ids) + "}"; + if (value && typeof value === "object") return "{" + Object.keys(value).map((key) => key + ":" + showValue(value[key], ids)).join(",") + "}"; + return JSON.stringify(value); +}; +const argIndex = (ids, id) => { if (!ids.has(id)) ids.set(id, ids.size); return ids.get(id); }; +const showStage = (stage, ids) => { + const options = stage.options === undefined ? "" : showValue(stage.options, ids); + if (stage.stage === "arg") return "arg#" + argIndex(ids, stage.args[0]) + options; + if (stage.stage === "func") return "func[" + stage.args[0].map((id) => "#" + argIndex(ids, id)).join(",") + "]" + showValue(stage.args[1], ids); + return stage.stage + options + (stage.args.length === 0 ? "" : stage.args.map((arg) => "<" + showValue(arg, ids) + ">").join("")); +}; +const show = (query) => (Array.isArray(query) ? query : query.build()).map((stage) => showStage(stage, new Map())); +const fails = async (action) => { try { await action(); return "no error"; } catch (error) { return error.name + ": " + error.message; } }; +const sleepTick = () => new Promise((resolve) => setImmediate(resolve)); +`; + +const TAIL = ` +main().then((out) => { console.log(JSON.stringify(out)); process.exit(0); }, (error) => { console.error(error); process.exit(1); }); +`; + +/** Runs a scenario body in a Node process against the packed interface (`dist/` of the repository) and checks the JSON it prints. */ +export function scenario(title: string, label: string, body: string, expected: Printed): Journey { + return { + title, + entryPoint: "the packed interface required from a consumer, with a provider that records what it is asked to run", + async run(context: JourneyContext) { + const file = `${context.work}/scenario.js`; + context.edit("write the scenario", file, `${PRELUDE}\nasync function main() {\n${body}\n}\n${TAIL}`); + const ran = await context.command(label, "node", [file, context.repo], { cwd: context.repo, timeoutMs: 60_000 }); + context.expect(ran.folder, [ + { name: `${label} ended cleanly`, pass: ran.exitCode === 0, detail: ran.stderr.slice(-400) }, + equal(`${label} answers as docs/ states`, ran.stdout.trim().split("\n").at(-1) ?? "", JSON.stringify(expected)), + ]); + }, + }; +} + +/** Runs a script that needs no provider against the packed interface (`dist/` of the repository), for the build-only entry point and the package's exports map. */ +export function plain(title: string, label: string, script: string, expected: Printed, entryPoint: string): Journey { + return { + title, + entryPoint, + async run(context: JourneyContext) { + const file = `${context.work}/plain.js`; + context.edit("write the script", file, `const path = require("node:path");\nconst repo = process.argv[2];\nconst dist = path.join(repo, "dist");\n${script}`); + const ran = await context.command(label, "node", [file, context.repo], { cwd: context.repo, timeoutMs: 60_000 }); + context.expect(ran.folder, [ + { name: `${label} ended cleanly`, pass: ran.exitCode === 0, detail: ran.stderr.slice(-400) }, + equal(`${label} answers as docs/ states`, ran.stdout.trim().split("\n").at(-1) ?? "", JSON.stringify(expected)), + ]); + }, + }; +} + +/** The header of a type probe: `Assert>` fails to compile when A and B are not assignable both ways. */ +export const PROBE_HEADER = ` +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Accepts = [Value] extends [Target] ? true : false; +type Not = T extends true ? false : true; +type Assert = T; +`; + +/** Compiles a probe against the packed declarations in a consumer's strict mode. */ +export function declared(title: string, probe: string, label: string): Journey { + return { + title, + entryPoint: "a TypeScript consumer compiling against the packed declarations", + async run(context) { + const file = `${context.work}/probe.ts`; + context.edit("write the type probe", file, `import type * as Api from "${context.repo}/dist/index";\n${PROBE_HEADER}\n${probe.replaceAll("$REPO", context.repo)}`); + const compiled = await context.command("compile the type probe", "node", [ + `${context.repo}/node_modules/typescript/bin/tsc`, + "--noEmit", + "--strict", + "--skipLibCheck", + "--target", + "es2022", + "--module", + "commonjs", + "--moduleResolution", + "node", + "--typeRoots", + `${context.repo}/node_modules/@types`, + "--types", + "node", + file, + ]); + context.expect(compiled.folder, [{ name: label, pass: compiled.exitCode === 0, detail: compiled.stdout.slice(0, 600) }]); + }, + }; +} diff --git a/.agents/skills/verify-interface/scenarios-expressions.ts b/.agents/skills/verify-interface/scenarios-expressions.ts new file mode 100644 index 0000000..701f8aa --- /dev/null +++ b/.agents/skills/verify-interface/scenarios-expressions.ts @@ -0,0 +1,125 @@ +const ROW = ` +const row = db.ValueProxy.arg(0); +const other = db.ValueProxy.arg(1); +const t = (proxy) => show(proxy); +`; + +/** ValueProxy comparison, boolean and presence expressions. */ +export const COMPARISON_BODIES = { + "expression-logic": `${ROW} + return { + eq: t(row.key("status").eq("active")), + eqProxy: t(row.key("a").eq(other.key("b"))), + ne: t(row.key("status").ne("gone")), + neNull: t(row.key("status").ne(null)), + and: t(row.key("a").eq(1).and(row.key("b").eq(2))), + or: t(row.key("a").eq(1).or(false)), + not: t(row.key("done").not()), + gt: t(row.key("age").gt(17)), + ge: t(row.key("age").ge(18)), + lt: t(row.key("age").lt(65)), + le: t(row.key("age").le(64)), + gtProxy: t(row.key("age").gt(other.key("age"))), + defaulted: t(row.key("nickname").default("none")), + defaultedKey: t(row.key("nickname", "none")), + constant: t(db.ValueProxy.constant("x")), + argument: t(db.ValueProxy.arg(7)), + }; + `, + "expression-objects": `${ROW} + return { + key: t(row.key("name")), + nested: t(row.key("address").key("city")), + keyDefault: t(row.key("phone", "N/A")), + keyByProxy: t(row.key(other.key("field"))), + merge: t(row.merge({ extra: 1 })), + keys: t(row.keys()), + values: t(row.values()), + hasfields: t(row.hasfields("a", "b")), + hasfieldsNone: t(row.hasfields()), + cast: t(row.cast()), + }; + `, +} satisfies { [id: string]: string }; + +/** ValueProxy numeric and bitwise expressions. */ +export const ARITHMETIC_BODIES = { + "expression-arithmetic": `${ROW} + return { + addNumber: t(row.key("age").add(1)), + addString: t(row.key("name").add("!")), + addList: t(row.key("tags").add(["x"])), + addProxy: t(row.key("age").add(other.key("age"))), + addNull: t(row.key("age").add(null)), + sub: t(row.key("age").sub(1)), + subProxy: t(row.key("bornAt").sub(other.key("bornAt"))), + mul: t(row.key("price").mul(2)), + div: t(row.key("price").div(4)), + mod: t(row.key("count").mod(3)), + round: t(row.key("price").round()), + ceil: t(row.key("price").ceil()), + floor: t(row.key("price").floor()), + }; + `, + "expression-bits": `${ROW} + return { + band: t(row.key("flags").band(6)), + bor: t(row.key("flags").bor(1)), + bxor: t(row.key("flags").bxor(3)), + bnot: t(row.key("flags").bnot()), + blshift: t(row.key("flags").blshift(2)), + brshift: t(row.key("flags").brshift(2)), + brshiftSigned: t(row.key("flags").brshiftPreserveSign(2)), + bandProxy: t(row.key("flags").band(other.key("mask"))), + }; + `, +} satisfies { [id: string]: string }; + +/** ValueProxy text, date and array expressions. */ +export const TEXT_DATE_ARRAY_BODIES = { + "expression-text": `${ROW} + return { + split: t(row.key("path").split("/")), + splitLimit: t(row.key("path").split("/", 2)), + splitDefault: t(row.key("path").split()), + concat: t(row.key("first").concat(" last")), + upcase: t(row.key("name").upcase()), + downcase: t(row.key("name").downcase()), + strlen: t(row.key("name").strlen()), + match: t(row.key("email").match("^a.*@")), + }; + `, + "expression-dates": `${ROW} + return { + during: t(row.key("bornAt").during(0, 1)), + duringDates: t(row.key("bornAt").during(new Date(0), new Date(1000))), + year: t(row.key("bornAt").year()), + yearZone: t(row.key("bornAt").year("Europe/Paris")), + month: t(row.key("bornAt").month()), + day: t(row.key("bornAt").day()), + dayofweek: t(row.key("bornAt").dayofweek()), + dayofyear: t(row.key("bornAt").dayofyear("UTC")), + hours: t(row.key("bornAt").hours()), + minutes: t(row.key("bornAt").minutes()), + seconds: t(row.key("bornAt").seconds("UTC")), + timeofday: t(row.key("bornAt").timeofday()), + epoch: t(row.key("bornAt").epoch()), + }; + `, + "expression-arrays": `${ROW} + return { + index: t(row.key("tags").index(0)), + includes: t(row.key("tags").includes("x")), + slice: t(row.key("tags").slice(1, 3)), + sliceOpen: t(row.key("tags").slice(1)), + map: t(row.key("scores").map((score) => score.add(1))), + filter: t(row.key("scores").filter((score) => score.gt(10))), + isempty: t(row.key("tags").isempty()), + count: t(row.key("tags").count()), + sum: t(row.key("scores").sum()), + avg: t(row.key("scores").avg()), + min: t(row.key("scores").min()), + max: t(row.key("scores").max()), + }; + `, +} satisfies { [id: string]: string }; diff --git a/.agents/skills/verify-interface/scenarios-package.ts b/.agents/skills/verify-interface/scenarios-package.ts new file mode 100644 index 0000000..39c6a81 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios-package.ts @@ -0,0 +1,40 @@ +/** The build-only entry point and the package's exports map, run without a provider. */ +export const STAGED_SCRIPT = ` +const Module = require("node:module"); +const blocked = []; +const guard = Module.registerHooks({ + resolve(specifier, context, nextResolve) { + if (["@antelopejs/interface-core", "node:async_hooks"].includes(specifier)) blocked.push(specifier); + return nextResolve(specifier, context); + }, +}); +const staged = require(path.join(dist, "staged-query")); +guard.deregister(); +const fs = require("node:fs"); +const folder = path.join(dist, "staged-query"); +const leaks = fs.readdirSync(folder).filter((file) => file.endsWith(".js")).filter((file) => ["@antelopejs/interface-core", "node:async_hooks"].some((name) => fs.readFileSync(path.join(folder, file), "utf8").includes(name))); +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); +const atomic = schema.instance("default").table("posts").atomicMutation("post-000", { type: "deleteIfEqual", field: "author", expectedValue: "Nobody" }); +const has = (value, names) => names.map((name) => name in value); +const before = { query: has(query, ["run", "then", "cursor"]), iterator: Symbol.asyncIterator in query, atomic: "run" in atomic, schemaList: typeof staged.Schema.list }; +const names = Object.keys(staged).sort(); +const built = JSON.stringify(query.build()); +const isolation = { blocked, leaks, before, atomicKind: [atomic instanceof staged.AtomicMutationQuery, atomic instanceof staged.Query], hasBuild: typeof query.build }; +const root = require(path.join(dist, "index.js")); +const after = { query: has(query, ["run", "then", "cursor"]), iterator: Symbol.asyncIterator in query, sameClasses: [root.Query === staged.Query, root.Stream === staged.Stream, root.Table === staged.Table, root.ValueProxy === staged.ValueProxy, root.Datum === staged.Datum] }; +console.log(JSON.stringify({ isolation, names, built, after })); +`; + +export const SUBPATH_SCRIPT = ` +const manifest = require(path.join(repo, "package.json")); +const exported = Object.keys(manifest.exports).filter((key) => key !== "./package.json"); +const typed = Object.keys(manifest.typesVersions["*"]).map((key) => "./" + key); +const here = require("node:module").createRequire(path.join(repo, "consumer.js")); +const subpaths = {}; +for (const key of exported) { + const loaded = here(manifest.name + key.slice(1)); + subpaths[key] = Object.keys(loaded).filter((name) => name !== "__esModule").sort(); +} +console.log(JSON.stringify({ exported, typedAsExported: JSON.stringify(typed.sort()) === JSON.stringify(exported.filter((key) => key !== ".").sort()), files: manifest.files, subpaths })); +`; diff --git a/.agents/skills/verify-interface/scenarios-runtime.ts b/.agents/skills/verify-interface/scenarios-runtime.ts new file mode 100644 index 0000000..f0ca570 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios-runtime.ts @@ -0,0 +1,185 @@ +const NOTES = ` +const notes = new db.Schema("notes", { notes: { fields: { title: "string" }, indexes: {} } }); +const table = notes.instance().table("notes"); +const attempt = (action) => { try { action(); return "accepted"; } catch (error) { return error.name + ": " + error.message; } }; +const mutate = (key, request) => attempt(() => table.atomicMutation(key, request)); +const revise = (changes) => ({ type: "update", revisionField: "revision", expectedRevision: "r1", nextRevision: "r2", patch: { title: "T" }, ...changes }); +`; + +/** Atomic mutation requests: which ones the table refuses before anything reaches a provider, and with what message. */ +export const ATOMIC_BODIES = { + "atomic-requests": `${NOTES} + const drop = { type: "delete", revisionField: "revision", expectedRevision: "r1" }; + const cycle = { title: "x" }; + cycle.self = cycle; + const holes = [1, , 3]; + return { + update: mutate("n-1", revise({})), + remove: mutate("n-1", drop), + removeMissingRevision: mutate("n-1", { ...drop, expectedRevision: { kind: "missing" } }), + updateMissingRevision: mutate("n-1", revise({ expectedRevision: { kind: "missing" } })), + deleteIfEqual: mutate("n-1", { type: "deleteIfEqual", field: "status", expectedValue: "draft" }), + deleteIfEqualNumber: mutate("n-1", { type: "deleteIfEqual", field: "n", expectedValue: 3 }), + deleteIfEqualDate: mutate("n-1", { type: "deleteIfEqual", field: "at", expectedValue: new Date(0) }), + emptyKey: mutate("", revise({})), + notRequest: mutate("n-1", null), + classRequest: mutate("n-1", new (class Custom {})()), + unknownType: mutate("n-1", { type: "upsert", revisionField: "revision", expectedRevision: "r1" }), + dottedField: mutate("n-1", revise({ revisionField: "a.b" })), + operatorField: mutate("n-1", revise({ revisionField: "$set" })), + identityRevision: mutate("n-1", revise({ revisionField: "_id" })), + emptyExpected: mutate("n-1", revise({ expectedRevision: "" })), + nullExpected: mutate("n-1", revise({ expectedRevision: null })), + looseMissing: mutate("n-1", revise({ expectedRevision: { kind: "missing", extra: 1 } })), + sameRevision: mutate("n-1", revise({ nextRevision: "r1" })), + emptyNext: mutate("n-1", revise({ nextRevision: "" })), + patchIdentity: mutate("n-1", revise({ patch: { _id: "other" } })), + patchPlainId: mutate("n-1", revise({ patch: { id: "other" } })), + patchRevision: mutate("n-1", revise({ patch: { revision: "r9" } })), + patchArray: mutate("n-1", revise({ patch: [1] })), + patchUndefined: mutate("n-1", revise({ patch: { title: undefined } })), + patchFunction: mutate("n-1", revise({ patch: { title: () => 1 } })), + patchNaN: mutate("n-1", revise({ patch: { n: NaN } })), + patchInfinity: mutate("n-1", revise({ patch: { n: Infinity } })), + patchBadDate: mutate("n-1", revise({ patch: { at: new Date(NaN) } })), + patchGoodDate: mutate("n-1", revise({ patch: { at: new Date(0) } })), + patchCycle: mutate("n-1", revise({ patch: cycle })), + patchHoles: mutate("n-1", revise({ patch: { list: holes } })), + patchDottedKey: mutate("n-1", revise({ patch: { "a.b": 1 } })), + patchOperatorKey: mutate("n-1", revise({ patch: { $inc: 1 } })), + patchNested: mutate("n-1", revise({ patch: { deep: { list: [1, "a", null, true, new Date(0), { ok: 1 }] } } })), + patchProxy: mutate("n-1", revise({ patch: { title: db.ValueProxy.constant("x") } })), + equalIdentity: mutate("n-1", { type: "deleteIfEqual", field: "_id", expectedValue: "n-1" }), + equalNull: mutate("n-1", { type: "deleteIfEqual", field: "status", expectedValue: null }), + equalArray: mutate("n-1", { type: "deleteIfEqual", field: "status", expectedValue: ["a"] }), + equalObject: mutate("n-1", { type: "deleteIfEqual", field: "status", expectedValue: { a: 1 } }), + equalNaN: mutate("n-1", { type: "deleteIfEqual", field: "n", expectedValue: NaN }), + equalDotted: mutate("n-1", { type: "deleteIfEqual", field: "a.b", expectedValue: 1 }), + crossInstance: attempt(() => notes.instance(db.CROSS_INSTANCE).table("notes").atomicMutation("n-1", revise({}))), + namedInstance: attempt(() => notes.instance("tenant").table("notes").atomicMutation("n-1", revise({}))), + adapterTable: [ + attempt(() => db.ValidateAtomicMutationTable(table.build())), + attempt(() => db.ValidateAtomicMutationTable(notes.instance("tenant").table("notes").build())), + attempt(() => db.ValidateAtomicMutationTable([...table.build(), { stage: "filter", args: [] }])), + attempt(() => db.ValidateAtomicMutationTable(table.build().slice(0, 2))), + attempt(() => db.ValidateAtomicMutationTable([table.build()[1], table.build()[0], table.build()[2]])), + attempt(() => db.ValidateAtomicMutationTable(notes.instance(db.CROSS_INSTANCE).table("notes").build())), + ], + adapterProtected: [ + attempt(() => db.ValidateAtomicMutation("n-1", revise({ patch: { tenant: "t" } }), ["tenant"])), + attempt(() => db.ValidateAtomicMutation("n-1", revise({ revisionField: "tenant" }), ["tenant"])), + attempt(() => db.ValidateAtomicMutation("n-1", { type: "deleteIfEqual", field: "tenant", expectedValue: "t" }, ["tenant"])), + attempt(() => db.ValidateAtomicMutation("n-1", revise({}), ["tenant"])), + ], + stages: show(table.atomicMutation("n-1", drop)), + queryKinds: (() => { const q = table.atomicMutation("n-1", drop); return [db.IsQuery(q), q instanceof db.AtomicMutationQuery, q instanceof db.Query]; })(), + }; + `, + "atomic-execution": `${NOTES} + const drop = { type: "delete", revisionField: "revision", expectedRevision: "r1" }; + const outcomes = {}; + for (const reply of ["applied", "not-applied", "unknown"]) { + answer = reply; + outcomes[reply] = await table.atomicMutation("n-1", drop); + } + const bad = {}; + for (const [label, reply] of [["undefined", undefined], ["zero", 0], ["count", 1], ["null", null], ["text", "done"], ["object", { applied: true }]]) { + answer = reply; + bad[label] = await fails(() => table.atomicMutation("n-1", drop)); + } + answer = "applied"; + const viaRun = await table.atomicMutation("n-1", drop).run(); + const cursor = await fails(() => table.atomicMutation("n-1", drop).cursor()); + const iterate = await fails(async () => { for await (const item of table.atomicMutation("n-1", drop)) return item; }); + seen.runs.length = 0; + answer = new Error("link lost"); + const lost = await fails(() => table.atomicMutation("n-1", drop)); + const callsAfterLoss = seen.runs.length; + answer = "applied"; + seen.runs.length = 0; + await table.atomicMutation("n-1", drop); + const terminal = seen.runs[0]; + return { + outcomes, bad, viaRun, cursor, iterate, lost, callsAfterLoss, + canonical: [show(terminal), terminal.length, terminal.at(-1).stage, terminal.at(-1).options === undefined, terminal.at(-1).args.length], + unsupported: [(() => { const e = new db.AtomicMutationUnsupportedError(); return [e.name, e.message, e instanceof Error]; })()], + }; + `, +} satisfies { [id: string]: string }; + +/** Query execution: await, run, then, cursor and async iteration reach the provider as documented. */ +export const EXECUTION_BODIES = { + "query-execution": `${NOTES} + answer = [{ title: "A" }]; + const built = table.filter((n) => n.key("title").eq("A")); + const lazy = seen.runs.length; + const viaAwait = await built; + const viaRun = await built.run(); + const viaThen = await new Promise((resolve) => built.then(resolve)); + const viaThenChain = await built.then((rows) => rows.length); + const sameStages = seen.runs.every((stages) => JSON.stringify(show(stages)) === JSON.stringify(show(built))); + answer = new Error("provider refused"); + const rejected = await fails(() => built); + const caught = await built.then(undefined, (error) => "handled:" + error.message); + const onRejected = await built.run().catch((error) => "caught:" + error.message); + return { lazy, viaAwait, viaRun, viaThen, viaThenChain, runs: seen.runs.length, sameStages, rejected, caught, onRejected, methods: ["run", "then", "cursor"].map((name) => typeof built[name]), iterator: typeof built[Symbol.asyncIterator] }; + `, + "query-cursor": `${NOTES} + readAnswers = [{ done: false, value: { n: 1 } }, { done: false, value: { n: 2 } }, { done: true, value: undefined }]; + const items = []; + for await (const item of table) items.push(item); + const firstLoop = { items, reads: seen.reads.map(([id, stages]) => [id, show(stages).slice(3)]) }; + readAnswers = [{ done: false, value: "x" }, { done: false, value: "y" }]; + for await (const item of table) { break; } + const afterBreak = { reads: seen.reads.length, closes: [...seen.closes] }; + readAnswers = [{ done: false, value: "m" }]; + const manual = table.cursor(); + const first = await manual.next(); + const returned = await manual.return(); + const stopped = { first, returned, closes: [...seen.closes] }; + const generatorLike = [typeof manual.next, typeof manual.return, typeof manual.throw, manual[Symbol.asyncIterator]() === manual]; + const thrown = table.cursor(); + const thrownResult = await thrown.throw(new Error("stop")); + return { firstLoop, afterBreak, stopped, generatorLike, thrownResult, closesAfterThrow: [...seen.closes] }; + `, +} satisfies { [id: string]: string }; + +/** Duplicate key errors and the kind predicates, which recognise another copy of the interface. */ +export const KIND_BODIES = { + "duplicate-key-error": ` + const error = new db.DuplicateKeyError("email", ["email"]); + const compound = new db.DuplicateKeyError("_id", ["_id"]); + const pair = new db.DuplicateKeyError("owner_slug", ["owner", "slug"]); + const marker = Symbol.for("@antelopejs/interface-database/DuplicateKeyError@1"); + const foreign = Object.defineProperty(new Error("from another copy"), marker, { value: true }); + const lookalike = Object.assign(new Error("Duplicate key"), { index: "_id", fields: ["_id"] }); + return { + shape: [error.name, error.message, error.index, error.fields, error instanceof Error, error instanceof db.DuplicateKeyError], + primary: [compound.index, compound.message], + pair: [pair.index, pair.fields, pair.message], + known: [db.IsDuplicateKeyError(error), db.IsDuplicateKeyError(foreign), db.IsDuplicateKeyError(lookalike), db.IsDuplicateKeyError(new Error("x")), db.IsDuplicateKeyError(null), db.IsDuplicateKeyError(undefined), db.IsDuplicateKeyError("DuplicateKeyError"), db.IsDuplicateKeyError({})], + markerHidden: Object.keys(error).includes(String(marker)) || Object.getOwnPropertySymbols(error).length, + }; + `, + "query-kinds": `${NOTES} + const row = db.ValueProxy.arg(0); + const marks = (name, value) => [name, db.IsQuery(value), db.IsStream(value), db.IsDatum(value), db.IsValueProxy(value)]; + const forge = (name) => Object.defineProperty({}, Symbol.for("@antelopejs/interface-database/" + name + "@1"), { value: true }); + return { + table: marks("table", table), + filtered: marks("filtered", table.filter((n) => n.key("title").eq("A"))), + single: marks("single", table.get("n-1")), + count: marks("count", table.count()), + key: marks("key", table.key("title")), + insert: marks("insert", table.insert({})), + atomic: marks("atomic", table.atomicMutation("n-1", { type: "delete", revisionField: "revision", expectedRevision: "r1" })), + proxy: marks("proxy", row.key("title")), + constant: marks("constant", db.ValueProxy.constant(1)), + plain: marks("plain", {}), + nothing: marks("nothing", null), + text: marks("text", "Stream"), + forged: [marks("forgedQuery", forge("Query")), marks("forgedStream", forge("Stream")), marks("forgedDatum", forge("Datum")), marks("forgedProxy", forge("ValueProxy"))], + chain: [table instanceof db.Table, table instanceof db.Selection, table instanceof db.Stream, table instanceof db.Query, table.get("a") instanceof db.SingleSelection, table.get("a") instanceof db.Datum, table.get("a") instanceof db.Stream, table.count() instanceof db.Datum, row instanceof db.Query], + }; + `, +} satisfies { [id: string]: string }; diff --git a/.agents/skills/verify-interface/scenarios-streams.ts b/.agents/skills/verify-interface/scenarios-streams.ts new file mode 100644 index 0000000..7e889f1 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios-streams.ts @@ -0,0 +1,108 @@ +const STORE = ` +const store = new db.Schema("store", { + users: { fields: { name: "string" }, indexes: { role: {}, createdAt: {} } }, + orders: { fields: { amount: "number" }, indexes: { userId: {} } }, +}); +const users = store.instance().table("users"); +const orders = store.instance().table("orders"); +const tail = (query) => show(query).slice(3); +`; + +/** Stream operations: what map, filter, pluck, order, slice, aggregate and distinct send to the provider. */ +export const STREAM_BODIES = { + "stream-shape": `${STORE} + return { + key: tail(users.key("email")), + keyDefault: tail(users.key("phone", "N/A")), + defaultAfterKey: tail(users.key("phone").default("N/A")), + map: tail(users.map((u) => ({ id: u.key("_id"), displayName: u.key("name") }))), + filter: tail(users.filter((u) => u.key("status").eq("active").and(u.key("age").ge(18)))), + filterTwice: tail(users.filter((u) => u.key("a").eq(1)).filter((u) => u.key("b").eq(2))), + pluck: tail(users.pluck("_id", "name")), + without: tail(users.without("password", "ssn")), + union: tail(users.filter((u) => u.key("plan").eq("premium")).union(users.filter((u) => u.key("plan").eq("enterprise")))), + castKeepsStages: tail(users.cast()), + }; + `, + "stream-order": `${STORE} + return { + orderBy: tail(users.orderBy("name")), + orderByAsc: tail(users.orderBy("name", "asc")), + orderByDesc: tail(users.orderBy("createdAt", "desc")), + slice: tail(users.orderBy("createdAt").slice(0, 10)), + sliceOffset: tail(users.orderBy("createdAt").slice(10, 10)), + sliceOpen: tail(users.slice(5)), + nth: tail(users.orderBy("createdAt").nth(0)), + sameBase: [tail(users.orderBy("a")).length, tail(users).length], + chained: tail(users.filter((u) => u.key("a").eq(1)).orderBy("name", "desc").slice(0, 3)), + }; + `, + "stream-aggregates": `${STORE} + return { + count: tail(users.count()), + countField: tail(users.count("email")), + sum: tail(orders.sum("amount")), + avg: tail(orders.avg("amount")), + min: tail(orders.min("amount")), + max: tail(orders.max("amount")), + distinct: tail(users.distinct()), + distinctUndefined: tail(users.distinct(undefined)), + distinctField: tail(users.distinct("role")), + changes: tail(users.changes()), + }; + `, + "stream-run": `${STORE} + answer = [{ _id: "u1" }, { _id: "u2" }]; + const direct = await users.orderBy("name"); + answer = 7; + const counted = await users.count(); + return { direct, counted, runs: seen.runs.map((stages) => show(stages).slice(3)) }; + `, +} satisfies { [id: string]: string }; + +/** Joins, lookups and groups. */ +export const JOIN_BODIES = { + "join-stages": `${STORE} + return { + left: tail(users.join(orders, (user, order) => order.key("userId").eq(user.key("_id")), (user, order) => ({ userName: user.key("name"), total: order.key("amount").default(0) }))), + inner: tail(users.joinInner(orders, (user, order) => order.key("userId").eq(user.key("_id")), (user, order) => ({ userName: user.key("name"), amount: order.key("amount") }))), + lookup: tail(orders.lookup(users, "userId", "_id")), + lookupAfterFilter: tail(orders.filter((o) => o.key("amount").gt(5)).lookup(users, "userId", "_id")), + }; + `, + "group-stages": `${STORE} + return { + group: tail(users.group("role", (stream, role) => ({ role, total: stream.count(), oldest: stream.max("age") }))), + groupSum: tail(orders.group("userId", (stream, id) => ({ id, spent: stream.sum("amount") }))), + }; + `, +} satisfies { [id: string]: string }; + +/** Datum operations: key, default, do, lookup, pluck and cast on one document. */ +export const DATUM_BODIES = { + "datum-stages": `${STORE} + const one = users.get("user-123"); + const cut = (query) => show(query).slice(3); + return { + get: cut(one), + key: cut(one.key("name")), + nested: cut(one.key("address").key("city")), + keyDefault: cut(one.key("phone", "N/A")), + defaultObject: cut(one.default({ _id: "guest", name: "Guest" })), + do: cut(one.do((u) => ({ displayName: u.key("name"), contact: u.key("email"), hasPhone: u.key("phone").ne(undefined) }))), + lookup: cut(orders.get("o-1").lookup(users, "userId", "_id")), + pluck: cut(one.pluck("name", "email")), + cast: cut(one.cast()), + nthOfStream: cut(users.orderBy("createdAt").nth(0)), + }; + `, + "datum-run": `${STORE} + answer = { _id: "user-123", name: "Ada" }; + const doc = await users.get("user-123"); + answer = "Ada"; + const name = await users.get("user-123").key("name"); + answer = null; + const missing = await users.get("nobody"); + return { doc, name, missing, runs: seen.runs.map((stages) => show(stages).slice(3)) }; + `, +} satisfies { [id: string]: string }; diff --git a/.agents/skills/verify-interface/scenarios-tables.ts b/.agents/skills/verify-interface/scenarios-tables.ts new file mode 100644 index 0000000..f1465f7 --- /dev/null +++ b/.agents/skills/verify-interface/scenarios-tables.ts @@ -0,0 +1,92 @@ +const SHOP = ` +const shop = new db.Schema("shop", { + products: { fields: { name: "string", price: "number", tags: "string[]" }, indexes: { price: {}, tags: { multi: true }, sku: { unique: true, crossInstance: true } } }, + orders: { fields: { total: "number" }, indexes: { total: {} } }, +}); +const products = shop.instance().table("products"); +`; + +/** Scenario bodies for the schema and table features, by journey id. */ +export const SCHEMA_BODIES = { + "schema-registry": ` + const definition = { notes: { fields: { title: "string" }, indexes: {} } }; + const first = new db.Schema("notes-demo", definition); + const registered = seen.schemas.map(([name, def]) => [name, def]); + const found = [db.Schema.get("notes-demo") === first, db.Schema.get("missing-schema") === undefined]; + const listed = [...db.Schema.list().keys()]; + const again = new db.Schema("notes-demo", { notes: { fields: {}, indexes: {} } }); + const replaced = [db.Schema.get("notes-demo") === again, db.Schema.list().get("notes-demo") === again, seen.schemas.length]; + const other = new db.Schema("other-demo", definition); + return { registered, found, listed, replaced, listedAfter: [...db.Schema.list().keys()], identity: [first.id, first.definition === definition, other.id] }; + `, + "schema-stages": ` + const s = new db.Schema("tenants", { people: { fields: {}, indexes: {} } }); + return { + defaultInstance: show(s.instance().table("people")), + named: show(s.instance("acme").table("people")), + cross: show(s.instance(db.CROSS_INSTANCE).table("people")), + crossSymbol: [db.CROSS_INSTANCE === Symbol.for("@antelopejs/interface-database/CROSS_INSTANCE"), typeof db.CROSS_INSTANCE], + create: show(s.createInstance("acme")), + createDefault: show(s.createInstance()), + destroy: show(s.destroyInstance("acme")), + list: show(s.listInstances()), + }; + `, + "schema-run": ` + const s = new db.Schema("tutorial", { notes: { fields: {}, indexes: {} } }); + answer = "acme"; + const created = await s.createInstance("acme"); + answer = []; + const listed = await s.listInstances(); + answer = undefined; + await s.destroyInstance("acme"); + return { created, listed, runs: seen.runs.map(show), lazy: await (async () => { const before = seen.runs.length; s.createInstance("later"); await sleepTick(); return seen.runs.length - before; })() }; + `, +} satisfies { [id: string]: string }; + +/** Table reads and writes: what get, getAll, between, insert, update, replace and delete send to the provider. */ +export const TABLE_BODIES = { + "table-reads": `${SHOP} + const when = new Date("2024-01-01T00:00:00.000Z"); + return { + whole: show(products), + get: show(products.get("product-1")), + getAllKey: show(products.getAll("p-1")), + getAllKeys: show(products.getAll(["p-1", "p-2"])), + getAllIndex: show(products.getAll("shoes", "tags")), + getAllNumber: show(products.getAll(10, "price")), + getAllCompound: show(products.getAll([["a", 1], ["b", 2]], "pair")), + between: show(products.between("price", 10, 20)), + betweenDates: show(products.between("price", when, new Date("2024-02-01T00:00:00.000Z"))), + betweenCompound: show(products.between("price", [1, "a"], [2, "b"])), + betweenNull: show(products.between("price", null, 5)), + selectionFromGetAll: show(products.getAll("x", "tags").count()), + }; + `, + "table-writes": `${SHOP} + return { + insertOne: show(products.insert({ name: "Boot", price: 80 })), + insertMany: show(products.insert([{ name: "A" }, { name: "B" }])), + insertUpdate: show(products.insert({ _id: "p-1", name: "New" }, { conflict: "update" })), + insertReplace: show(products.insert({ _id: "p-1" }, { conflict: "replace" })), + update: show(products.getAll("shoes", "tags").update({ price: 5 })), + updateComputed: show(products.get("p-1").update((p) => ({ price: p.key("price").add(1) }))), + replace: show(products.get("p-1").replace({ _id: "p-1", name: "R" })), + remove: show(products.get("p-1").delete()), + removeMany: show(products.filter((p) => p.key("price").lt(5)).delete()), + changesOne: show(products.get("p-1").changes()), + changesAll: show(products.changes()), + }; + `, + "table-results": `${SHOP} + answer = ["id-1", "id-2"]; + const ids = await products.insert([{ name: "A" }, { name: "B" }], { conflict: "update" }); + answer = 3; + const updated = await products.getAll("shoes", "tags").update({ price: 1 }); + answer = null; + const missing = await products.get("nope"); + answer = new Error("provider refused"); + const refused = await fails(() => products.get("p-1").delete()); + return { ids, updated, missing, refused, runs: seen.runs.map(show) }; + `, +} satisfies { [id: string]: string }; diff --git a/.agents/skills/verify-interface/seeded-breaks/atomic-execution.patch b/.agents/skills/verify-interface/seeded-breaks/atomic-execution.patch new file mode 100644 index 0000000..f7fa6f1 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/atomic-execution.patch @@ -0,0 +1,13 @@ +diff --git a/src/atomic.ts b/src/atomic.ts +index 7f918d3..7c17805 100644 +--- a/src/atomic.ts ++++ b/src/atomic.ts +@@ -7,7 +7,7 @@ import { + } from "./staged-query/atomic"; + + function isOutcome(value: unknown): value is AtomicMutationOutcome { +- return value === "applied" || value === "not-applied" || value === "unknown"; ++ return value === "applied" || value === "not-applied" || value === "unknown" || typeof value === "number"; + } + + /** Signals an adapter that does not implement the atomic mutation contract. */ diff --git a/.agents/skills/verify-interface/seeded-breaks/atomic-requests.patch b/.agents/skills/verify-interface/seeded-breaks/atomic-requests.patch new file mode 100644 index 0000000..9c17493 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/atomic-requests.patch @@ -0,0 +1,16 @@ +diff --git a/src/staged-query/atomic.ts b/src/staged-query/atomic.ts +index 76ad227..39bb134 100644 +--- a/src/staged-query/atomic.ts ++++ b/src/staged-query/atomic.ts +@@ -155,10 +155,7 @@ function validateEquality( + } + + function validateUpdate(request: AtomicUpdate, forbidden: string[]) { +- if ( +- !isToken(request.nextRevision) || +- request.nextRevision === request.expectedRevision +- ) { ++ if (!isToken(request.nextRevision)) { + throw new TypeError("Atomic mutation requires a changed revision token"); + } + if (!isRecord(request.patch)) { diff --git a/.agents/skills/verify-interface/seeded-breaks/datums.patch b/.agents/skills/verify-interface/seeded-breaks/datums.patch new file mode 100644 index 0000000..e882e50 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/datums.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/datum.ts b/src/staged-query/datum.ts +index e97f890..8101f54 100644 +--- a/src/staged-query/datum.ts ++++ b/src/staged-query/datum.ts +@@ -46,7 +46,7 @@ export class Datum extends Query { + ? NonNullable[K] + : NonNullable[K]> | U + >, +- QueryStage("key", undefined, key, def), ++ QueryStage("key", undefined, key), + ); + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/duplicate-key.patch b/.agents/skills/verify-interface/seeded-breaks/duplicate-key.patch new file mode 100644 index 0000000..2c609df --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/duplicate-key.patch @@ -0,0 +1,13 @@ +diff --git a/src/errors.ts b/src/errors.ts +index 6c1e35c..d316b59 100644 +--- a/src/errors.ts ++++ b/src/errors.ts +@@ -21,7 +21,7 @@ export class DuplicateKeyError extends Error { + public readonly index: string, + public readonly fields: string[], + ) { +- super(`Duplicate key on index "${index}" (${fields.join(", ")})`); ++ super(`Duplicate key on index "${index}" (${fields.join("/")})`); + this.name = "DuplicateKeyError"; + } + } diff --git a/.agents/skills/verify-interface/seeded-breaks/entry-points.patch b/.agents/skills/verify-interface/seeded-breaks/entry-points.patch new file mode 100644 index 0000000..7f7cce9 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/entry-points.patch @@ -0,0 +1,15 @@ +diff --git a/package.json b/package.json +index 982e937..8123842 100644 +--- a/package.json ++++ b/package.json +@@ -85,10 +85,6 @@ + "types": "./dist/selection.d.ts", + "default": "./dist/selection.js" + }, +- "./stream": { +- "types": "./dist/stream.d.ts", +- "default": "./dist/stream.js" +- }, + "./staged-query": { + "types": "./dist/staged-query/index.d.ts", + "default": "./dist/staged-query/index.js" diff --git a/.agents/skills/verify-interface/seeded-breaks/execution.patch b/.agents/skills/verify-interface/seeded-breaks/execution.patch new file mode 100644 index 0000000..082305e --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/execution.patch @@ -0,0 +1,12 @@ +diff --git a/src/query.ts b/src/query.ts +index 1ec74d0..527a2c0 100644 +--- a/src/query.ts ++++ b/src/query.ts +@@ -44,7 +44,6 @@ class IterableCursor implements AsyncGenerator { + this.resolve = undefined; + this.reject = undefined; + } +- await CloseCursor(this.reqId); + return res; + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/expressions-arithmetic.patch b/.agents/skills/verify-interface/seeded-breaks/expressions-arithmetic.patch new file mode 100644 index 0000000..c6e11f0 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/expressions-arithmetic.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts +index cc8f071..7453861 100644 +--- a/src/staged-query/valueproxy.ts ++++ b/src/staged-query/valueproxy.ts +@@ -361,7 +361,7 @@ export class ValueProxy extends StagedObject { + * @returns A % B + */ + public mod(this: Is, other: ValueProxyOrValue) { +- return this.stage(ValueProxy, QueryStage("mod", undefined, other)); ++ return this.stage(ValueProxy, QueryStage("div", undefined, other)); + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/expressions-arrays.patch b/.agents/skills/verify-interface/seeded-breaks/expressions-arrays.patch new file mode 100644 index 0000000..54cb72e --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/expressions-arrays.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts +index cc8f071..c225935 100644 +--- a/src/staged-query/valueproxy.ts ++++ b/src/staged-query/valueproxy.ts +@@ -724,7 +724,7 @@ export class ValueProxy extends StagedObject { + * @returns True if the array is empty + */ + public isempty(this: IsArray) { +- return this.stage(ValueProxy, QueryStage("arr_empty", undefined)); ++ return this.stage(ValueProxy, QueryStage("arr_count", undefined)); + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/expressions-dates.patch b/.agents/skills/verify-interface/seeded-breaks/expressions-dates.patch new file mode 100644 index 0000000..5b12896 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/expressions-dates.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts +index cc8f071..403df98 100644 +--- a/src/staged-query/valueproxy.ts ++++ b/src/staged-query/valueproxy.ts +@@ -264,7 +264,7 @@ export class ValueProxy extends StagedObject { + ) { + return this.withTimezone(timezone).stage( + ValueProxy, +- QueryStage("date_dow", undefined), ++ QueryStage("date_doy", undefined), + ); + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/expressions-logic.patch b/.agents/skills/verify-interface/seeded-breaks/expressions-logic.patch new file mode 100644 index 0000000..2699ed1 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/expressions-logic.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts +index cc8f071..ef4fb2c 100644 +--- a/src/staged-query/valueproxy.ts ++++ b/src/staged-query/valueproxy.ts +@@ -511,7 +511,7 @@ export class ValueProxy extends StagedObject { + return ValueProxy.#stageOf( + this, + ValueProxy, +- QueryStage("cmp_ge", undefined, other), ++ QueryStage("cmp_gt", undefined, other), + ); + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/expressions-text.patch b/.agents/skills/verify-interface/seeded-breaks/expressions-text.patch new file mode 100644 index 0000000..141930e --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/expressions-text.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts +index cc8f071..310219d 100644 +--- a/src/staged-query/valueproxy.ts ++++ b/src/staged-query/valueproxy.ts +@@ -589,7 +589,7 @@ export class ValueProxy extends StagedObject { + * @returns New string + */ + public upcase(this: Is) { +- return this.stage(ValueProxy, QueryStage("str_upcase", undefined)); ++ return this.stage(ValueProxy, QueryStage("str_downcase", undefined)); + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/joins.patch b/.agents/skills/verify-interface/seeded-breaks/joins.patch new file mode 100644 index 0000000..4a86028 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/joins.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts +index 94fc57e..27042f0 100644 +--- a/src/staged-query/stream.ts ++++ b/src/staged-query/stream.ts +@@ -171,7 +171,7 @@ export class Stream extends Query { + Stream>, + QueryStage( + "join", +- { innerOnly: true }, ++ { innerOnly: false }, + right, + this.callfunc(predicate, ValueProxy, ValueProxy), + this.callfunc(mapper, ValueProxy, ValueProxy), diff --git a/.agents/skills/verify-interface/seeded-breaks/kinds.patch b/.agents/skills/verify-interface/seeded-breaks/kinds.patch new file mode 100644 index 0000000..33cb102 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/kinds.patch @@ -0,0 +1,11 @@ +diff --git a/src/staged-query/query.ts b/src/staged-query/query.ts +index 8ce897b..f726f26 100644 +--- a/src/staged-query/query.ts ++++ b/src/staged-query/query.ts +@@ -18,5 +18,5 @@ MarkKind(Query.prototype, QUERY_KIND); + * @returns True if the value is a query + */ + export function IsQuery(value: unknown): value is Query { +- return isKindHolder(value) && value[QUERY_KIND] === true; ++ return value instanceof Query; + } diff --git a/.agents/skills/verify-interface/seeded-breaks/schemas.patch b/.agents/skills/verify-interface/seeded-breaks/schemas.patch new file mode 100644 index 0000000..f3da07f --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/schemas.patch @@ -0,0 +1,12 @@ +diff --git a/src/schema.ts b/src/schema.ts +index ff34b36..c3f7abd 100644 +--- a/src/schema.ts ++++ b/src/schema.ts +@@ -26,7 +26,6 @@ export class Schema extends StagedSchema { + public constructor(id: string, definition: SchemaDefinition) { + super(id, definition); + Schema.defined.set(id, this); +- Schemas.register(id, definition); + } + } + diff --git a/.agents/skills/verify-interface/seeded-breaks/smoke.patch b/.agents/skills/verify-interface/seeded-breaks/smoke.patch new file mode 100644 index 0000000..2f50daf --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/smoke.patch @@ -0,0 +1,22 @@ +diff --git a/src/query.ts b/src/query.ts +index 1ec74d0..dab1eb9 100644 +--- a/src/query.ts ++++ b/src/query.ts +@@ -4,7 +4,7 @@ import { StagedObject } from "./staged-query/common"; + import { Query as StagedQuery } from "./staged-query/query"; + + /** @internal */ +-export const RunQuery = ++export const RunQueries = + InterfaceFunction<(query: StagedObject["stages"]) => any>(); + /** @internal */ + export const ReadCursor = +@@ -78,7 +78,7 @@ declare module "./staged-query/query" { + } + + function run(this: StagedQuery): Promise { +- return RunQuery(this.build()); ++ return RunQueries(this.build()); + } + + function then( diff --git a/.agents/skills/verify-interface/seeded-breaks/staged-query.patch b/.agents/skills/verify-interface/seeded-breaks/staged-query.patch new file mode 100644 index 0000000..34d1d6d --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/staged-query.patch @@ -0,0 +1,9 @@ +diff --git a/src/staged-query/index.ts b/src/staged-query/index.ts +index bdbc4fa..f785ef6 100644 +--- a/src/staged-query/index.ts ++++ b/src/staged-query/index.ts +@@ -1,3 +1,4 @@ ++import "@antelopejs/interface-core"; + export * from "./common"; + export * from "./atomic"; + export { Datum, IsDatum } from "./datum"; diff --git a/.agents/skills/verify-interface/seeded-breaks/streams.patch b/.agents/skills/verify-interface/seeded-breaks/streams.patch new file mode 100644 index 0000000..a55a984 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/streams.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts +index 94fc57e..c4a318c 100644 +--- a/src/staged-query/stream.ts ++++ b/src/staged-query/stream.ts +@@ -236,7 +236,7 @@ export class Stream extends Query { + * @returns New (sorted) stream + */ + public orderBy(index: string, direction?: "asc" | "desc") { +- return this.stage(undefined, QueryStage("orderBy", { index, direction })); ++ return this.stage(undefined, QueryStage("orderBy", { index, direction: direction ?? "desc" })); + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/tables-read.patch b/.agents/skills/verify-interface/seeded-breaks/tables-read.patch new file mode 100644 index 0000000..e5f7daf --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/tables-read.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/selection.ts b/src/staged-query/selection.ts +index 12887b7..73a898f 100644 +--- a/src/staged-query/selection.ts ++++ b/src/staged-query/selection.ts +@@ -200,7 +200,7 @@ export class Table extends Selection { + ) { + return this.stage( + Selection, +- QueryStage("between", { index }, low, high), ++ QueryStage("between", { index }, high, low), + ); + } + } diff --git a/.agents/skills/verify-interface/seeded-breaks/tables-write.patch b/.agents/skills/verify-interface/seeded-breaks/tables-write.patch new file mode 100644 index 0000000..d25f01d --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/tables-write.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/selection.ts b/src/staged-query/selection.ts +index 12887b7..614c643 100644 +--- a/src/staged-query/selection.ts ++++ b/src/staged-query/selection.ts +@@ -161,7 +161,7 @@ export class Table extends Selection { + obj: (DeepPartial & object) | (DeepPartial & object)[], + options?: InsertOptions, + ) { +- return this.stage(Query, QueryStage("insert", options, obj)); ++ return this.stage(Query, QueryStage("insert", undefined, obj)); + } + + /** diff --git a/.agents/skills/verify-interface/seeded-breaks/types.patch b/.agents/skills/verify-interface/seeded-breaks/types.patch new file mode 100644 index 0000000..7c4100f --- /dev/null +++ b/.agents/skills/verify-interface/seeded-breaks/types.patch @@ -0,0 +1,13 @@ +diff --git a/src/staged-query/common.ts b/src/staged-query/common.ts +index cffd95a..a9b8548 100644 +--- a/src/staged-query/common.ts ++++ b/src/staged-query/common.ts +@@ -71,7 +71,7 @@ export interface Changes { + } + + export interface InsertOptions { +- conflict?: "update" | "replace"; ++ conflict?: "update" | "replace" | "ignore"; + } + + function isStagedConstructor( diff --git a/.agents/skills/verify-interface/seeded-doors/contract.patch b/.agents/skills/verify-interface/seeded-doors/contract.patch new file mode 100644 index 0000000..6f205a3 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/contract.patch @@ -0,0 +1,15 @@ +diff --git a/package.json b/package.json +index 982e937..8d4aeab 100644 +--- a/package.json ++++ b/package.json +@@ -61,10 +61,6 @@ + "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/.agents/skills/verify-interface/seeded-doors/format.patch b/.agents/skills/verify-interface/seeded-doors/format.patch new file mode 100644 index 0000000..819a662 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/format.patch @@ -0,0 +1,26 @@ +diff --git a/src/staged-query/stages.ts b/src/staged-query/stages.ts +index fb8beb1..38d795d 100644 +--- a/src/staged-query/stages.ts ++++ b/src/staged-query/stages.ts +@@ -70,7 +70,7 @@ export interface QueryStageMap { + >; + default: Recorded]>; + map: Recorded; +- filter: Recorded; ++ where: Recorded; + pluck: Recorded; + without: Recorded; + union: Recorded]>; +diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts +index 94fc57e..8a5ac62 100644 +--- a/src/staged-query/stream.ts ++++ b/src/staged-query/stream.ts +@@ -83,7 +83,7 @@ export class Stream extends Query { + public filter(predicate: (val: ValueProxy) => ValueProxyOrValue) { + return this.stage( + undefined, +- QueryStage("filter", undefined, this.callfunc(predicate, ValueProxy)), ++ QueryStage("where", undefined, this.callfunc(predicate, ValueProxy)), + ); + } + diff --git a/.agents/skills/verify-interface/seeded-doors/publish.patch b/.agents/skills/verify-interface/seeded-doors/publish.patch new file mode 100644 index 0000000..8b4ca16 --- /dev/null +++ b/.agents/skills/verify-interface/seeded-doors/publish.patch @@ -0,0 +1,13 @@ +diff --git a/package.json b/package.json +index 982e937..0bafc89 100644 +--- a/package.json ++++ b/package.json +@@ -100,7 +100,7 @@ + "./package.json": "./package.json" + }, + "publishConfig": { +- "access": "public", ++ "access": "restricted", + "provenance": true + }, + "scripts": { diff --git a/.agents/skills/verify-interface/tsconfig.json b/.agents/skills/verify-interface/tsconfig.json new file mode 100644 index 0000000..8d4c934 --- /dev/null +++ b/.agents/skills/verify-interface/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "../../..", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "composite": false, + "noEmit": true + }, + "include": [ + "**/*.ts" + ] +} diff --git a/.agents/skills/verify-interface/verify.config.ts b/.agents/skills/verify-interface/verify.config.ts new file mode 100644 index 0000000..506d0ff --- /dev/null +++ b/.agents/skills/verify-interface/verify.config.ts @@ -0,0 +1,110 @@ +import { equal, interrupted } from "@antelopejs/tooling-configs/verify"; +import type { Journey, VerifyConfig } from "@antelopejs/tooling-configs/verify"; + +import { EXPECTED as EXPRESSIONS } from "./expected-expressions.ts"; +import { STAGED, SUBPATHS } from "./expected-package.ts"; +import { EXPECTED as RUNTIME } from "./expected-runtime.ts"; +import { EXPECTED as STREAMS } from "./expected-streams.ts"; +import { EXPECTED as TABLES } from "./expected-tables.ts"; +import { USAGE_TYPES } from "./probes.ts"; +import { declared, plain, scenario } from "./scenario-kit.ts"; +import { ARITHMETIC_BODIES, COMPARISON_BODIES, TEXT_DATE_ARRAY_BODIES } from "./scenarios-expressions.ts"; +import { STAGED_SCRIPT, SUBPATH_SCRIPT } from "./scenarios-package.ts"; +import { ATOMIC_BODIES, EXECUTION_BODIES, KIND_BODIES } from "./scenarios-runtime.ts"; +import { DATUM_BODIES, JOIN_BODIES, STREAM_BODIES } from "./scenarios-streams.ts"; +import { SCHEMA_BODIES, TABLE_BODIES } from "./scenarios-tables.ts"; + +/** The database interface's own proxies: a consumer module reaches them through core and the stub provider answers. */ +const through: Journey = { + title: "the query proxies and the schema registry answer through core from a stub provider", + entryPoint: "a consumer module calling the packed interface in `ajs project dev`", + async run(context) { + const app = await context.dev("ajs project dev", context.fixture()); + const stages = [ + { stage: "schema", options: { id: "demo" }, args: [] }, + { stage: "instance", options: { id: "default" }, args: [] }, + { stage: "table", options: { id: "notes" }, args: [] }, + ]; + for (const { path, args } of [ + { path: ["RunQuery"], args: [stages] }, + { path: ["ReadCursor"], args: [0, stages] }, + { path: ["CloseCursor"], args: [0] }, + ]) { + const result = await context.call(`call ${path[0]}`, app, { entry: "./query", path, args }); + context.expect(result.folder, [ + { name: `${path[0]} resolved through core`, pass: result.ok, detail: JSON.stringify(result.value) }, + equal(`${path[0]} reached the provider with its arguments`, result.value, { stub: `./query:${path[0]}`, args }), + ]); + } + const registered = await context.call("call Schemas.register", app, { + entry: "./schema", + path: ["Schemas", "register"], + args: ["demo", { notes: { fields: { title: "string" }, indexes: {} } }], + }); + context.expect(registered.folder, [{ name: "Schemas.register resolved through core", pass: registered.ok, detail: JSON.stringify(registered.value) }]); + const stopped = await context.stop("SIGINT", app); + context.expect(stopped.folder, [interrupted(stopped)]); + }, +}; + + +const config: VerifyConfig = { + doors: { + data: null, + format: { + globs: ["src/staged-query/**", "src/errors.ts"], + funnels: [], + containments: ["streams/stream-shape"], + why: "the stage names, options and arguments the builders record are what every database module decodes, and the kind markers recognise queries and errors across copies of the interface", + }, + contract: { + globs: ["src/index.ts", "src/staged-query/index.ts", "package.json"], + funnels: [], + containments: [], + why: "the export map and the root entry are what every database module and every query-writing module imports", + }, + outbound: null, + exposure: null, + publish: { + globs: ["package.json", ".release-it.json", "changelogen.config.json", ".github/workflows/release.yml"], + funnels: [], + containments: [], + why: "the package name and release workflow claim @antelopejs/interface-database on npm", + }, + }, + journeys: { + "through-core": through, + "schema-registry": scenario("a Schema registers with the provider, and Schema.get and Schema.list find it", "schema registry", SCHEMA_BODIES["schema-registry"], TABLES["schema-registry"]), + "schema-stages": scenario("instance, createInstance, destroyInstance and listInstances record the stages docs/3.results/5.database_changes.md names", "schema stages", SCHEMA_BODIES["schema-stages"], TABLES["schema-stages"]), + "schema-run": scenario("awaiting a schema call runs it once, and building one runs nothing", "schema run", SCHEMA_BODIES["schema-run"], TABLES["schema-run"]), + "table-reads": scenario("get, getAll and between record the key, index and bounds docs/1.query_types/2.table.md describes", "table reads", TABLE_BODIES["table-reads"], TABLES["table-reads"]), + "table-writes": scenario("insert, update, replace, delete and changes record the stages docs/2.operations/4.crud.md describes", "table writes", TABLE_BODIES["table-writes"], TABLES["table-writes"]), + "table-results": scenario("a write resolves to what the provider answered, and a provider failure rejects the caller", "table results", TABLE_BODIES["table-results"], TABLES["table-results"]), + "stream-shape": scenario("key, default, map, filter, pluck, without and union record their stages in order", "stream shape", STREAM_BODIES["stream-shape"], STREAMS["stream-shape"]), + "stream-order": scenario("orderBy, slice and nth record index, direction and bounds, and never change the stream they extend", "stream order", STREAM_BODIES["stream-order"], STREAMS["stream-order"]), + "stream-aggregates": scenario("count, sum, avg, min, max, distinct and changes record the field they work on", "stream aggregates", STREAM_BODIES["stream-aggregates"], STREAMS["stream-aggregates"]), + "stream-run": scenario("awaiting a stream hands the provider the stages and returns its answer", "stream run", STREAM_BODIES["stream-run"], STREAMS["stream-run"]), + "join-stages": scenario("join, joinInner and lookup record the right side, the predicate and the mapper", "join stages", JOIN_BODIES["join-stages"], STREAMS["join-stages"]), + "group-stages": scenario("group records its index and a mapper over the sub-stream and the group key", "group stages", JOIN_BODIES["group-stages"], STREAMS["group-stages"]), + "datum-stages": scenario("key, default, do, lookup, pluck and cast on one document record docs/1.query_types/8.datum.md", "datum stages", DATUM_BODIES["datum-stages"], STREAMS["datum-stages"]), + "datum-run": scenario("awaiting a document or one of its fields returns the provider's answer, null included", "datum run", DATUM_BODIES["datum-run"], STREAMS["datum-run"]), + "expression-logic": scenario("eq, ne, and, or, not, comparisons and default record their operand", "expression logic", COMPARISON_BODIES["expression-logic"], EXPRESSIONS["expression-logic"]), + "expression-objects": scenario("key, merge, keys, values and hasfields record the field access of a ValueProxy", "expression objects", COMPARISON_BODIES["expression-objects"], EXPRESSIONS["expression-objects"]), + "expression-arithmetic": scenario("add, sub, mul, div, mod, round, ceil and floor record the operand a proxy, a number, a string or a list", "expression arithmetic", ARITHMETIC_BODIES["expression-arithmetic"], EXPRESSIONS["expression-arithmetic"]), + "expression-bits": scenario("band, bor, bxor, bnot and the shifts record the bit stages", "expression bits", ARITHMETIC_BODIES["expression-bits"], EXPRESSIONS["expression-bits"]), + "expression-text": scenario("split, concat, upcase, downcase, strlen and match record the string stages", "expression text", TEXT_DATE_ARRAY_BODIES["expression-text"], EXPRESSIONS["expression-text"]), + "expression-dates": scenario("during, the calendar parts and epoch record the date stages, with a timezone stage when one is given", "expression dates", TEXT_DATE_ARRAY_BODIES["expression-dates"], EXPRESSIONS["expression-dates"]), + "expression-arrays": scenario("index, includes, slice, map, filter, isempty, count, sum, avg, min and max record the array stages", "expression arrays", TEXT_DATE_ARRAY_BODIES["expression-arrays"], EXPRESSIONS["expression-arrays"]), + "atomic-requests": scenario("atomicMutation refuses every request docs/2.operations/8.atomic_mutations.md excludes, before anything is run", "atomic requests", ATOMIC_BODIES["atomic-requests"], RUNTIME["atomic-requests"]), + "atomic-execution": scenario("an atomic mutation resolves to applied, not-applied or unknown, refuses any other answer, and is never retried", "atomic execution", ATOMIC_BODIES["atomic-execution"], RUNTIME["atomic-execution"]), + "query-execution": scenario("await, run and then run a query once each, lazily, and pass the provider's failure on", "query execution", EXECUTION_BODIES["query-execution"], RUNTIME["query-execution"]), + "query-cursor": scenario("cursor and for await read the provider one result at a time and close the cursor when the consumer stops", "query cursor", EXECUTION_BODIES["query-cursor"], RUNTIME["query-cursor"]), + "duplicate-key-error": scenario("DuplicateKeyError carries the index and fields, and IsDuplicateKeyError recognises another copy's error", "duplicate key error", KIND_BODIES["duplicate-key-error"], RUNTIME["duplicate-key-error"]), + "query-kinds": scenario("IsQuery, IsStream, IsDatum and IsValueProxy tell the staged kinds apart, copies of the interface included", "query kinds", KIND_BODIES["query-kinds"], RUNTIME["query-kinds"]), + "staged-isolation": plain("the build-only entry point loads without interface-core and builds queries that cannot run", "build-only entry", STAGED_SCRIPT, STAGED, "the packed build-only entry point required from a tool"), + "subpaths": plain("every export subpath resolves through the exports map and names what docs/index.md says", "subpaths", SUBPATH_SCRIPT, SUBPATHS, "each subpath of the packed interface required by its package name"), + "usage-types": declared("the documented calls resolve to the documented result types", USAGE_TYPES, "every call resolves to its documented type"), + }, +}; + +export default config; diff --git a/.claude/skills/verify-interface b/.claude/skills/verify-interface new file mode 120000 index 0000000..c7df9cd --- /dev/null +++ b/.claude/skills/verify-interface @@ -0,0 +1 @@ +../../.agents/skills/verify-interface \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 5b14862..c0a162f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,8 @@ AQL, the AntelopeJS database query builder, published as `@antelopejs/interface- Finish every change with `pnpm run checks`. It runs typecheck, lint, format, knip, tests, size, suppressions, and sync, and prints only what failed. The change is done when it prints no failure. +Prove a change works with the verification skill in [`.agents/skills/verify-interface/SKILL.md`](.agents/skills/verify-interface/SKILL.md) before you call it done. + The tools enforce most rules, listed in the table at the end. The sections below hold the rules no tool checks. ### Comments From 02e0360e3b0763e7752168df1689fd7f2e841b46 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 1 Nov 2026 05:02:38 +0100 Subject: [PATCH 11/16] feat(database)!: type the operands of query stages as query values (#62) * feat(database)!: type the operands of query stages as query values A stage recorded its operands as `Value`, which is `unknown`: no adapter could name what it reads. `QueryValue` names it: another staged object, text, a number, a boolean, `null`, `undefined`, a date, a list of operands or a record of operands. The builders take the part of their type a stage can record (`QueryOperand`, `T & QueryValue`), so a `bigint`, a `symbol`, a function or a record declared with `interface` no longer compiles as an operand, and an `any` operand stays `any`. The `add` stage records a list of `QueryValue`s instead of `unknown[]`. BREAKING CHANGE: an operand of `eq`, `ne`, `default`, `sub`, `gt`, `ge`, `lt`, `le`, `includes`, `key` (with a default), `merge` or `ValueProxy.constant` holding a `bigint`, a `symbol`, a function or an interface-typed record no longer compiles. `QueryValue`, `QueryRecord` and `QueryOperand` are new exports. * fix(valueproxy): infer a default from the whole union it is given `default(value: QueryOperand)` inferred `U` through the intersection `U & QueryValue`, which matched only the scalar members of a union such as a recursive field value and refused the rest. `default`, `sub`, `merge` and the default of `key` take `U extends QueryValue` instead, so `U` is the type given. `ValueProxyOrValue` moves next to the other operand types to keep `valueproxy.ts` under the size ceiling. * fix(valueproxy): leave the default of key to its argument `key` on a proxy took `U extends QueryValue = undefined`, so a call that passes the entry to a parameter typed from its context inferred `U` from that context and fell back to the whole constraint. `U` stays unconstrained as before, and only a default that is given must be a query operand. * fix(valueproxy): check a compared operand against the receiver by structure `eq`, `ne`, `gt`, `ge`, `lt`, `le` and `includes` took `T & QueryValue`, which refuses a value of a type declared with `interface` because it has no implicit index signature. `OperandValue` maps the structure of `T` instead, as `DeepPartial` does for written documents, so the operand is checked member by member and an interface-typed record compiles. A `bigint`, a `symbol` and a function still map to `never`, and an `any` stays `any`. * fix(valueproxy): keep includes open on a proxy typed any `ArrayValue` is `unknown`, so `includes` on a proxy typed `any` took a `QueryValue` where it took anything before. `any` is the caller's opt-out and stays one. * feat(database): add IsQueryValue, the guard of a query value A value typed `unknown` cannot be the operand of a stage once operands are query values. `IsQueryValue` narrows it at the boundary: it accepts a staged object, a scalar, a date, and a list or plain record of those, held twice or not, and refuses a bigint, a symbol, a function, a Map, a class instance and a cyclic structure. It walks with a stack of its own, so a deeply nested value cannot exhaust the call stack. * refactor(database): add no runtime export for query values `IsQueryValue` was the one runtime addition of this change. A consumer that calls it needs a provider that ships the new interface at test time too, because AntelopeJS loads the interface from the provider that implements it, and every consumer's tests pin the published providers. A consumer that narrows a value typed `unknown` writes the check for the values it holds, and the change stays type-only. --- docs/1.query_types/9.valueproxy.md | 4 + docs/index.md | 2 +- skills/database-interface/SKILL.md | 1 + src/staged-query/common.ts | 43 ++++++++ src/staged-query/datum.ts | 11 +- src/staged-query/stages.ts | 34 +++--- src/staged-query/stream.ts | 5 +- src/staged-query/valueproxy-conditions.ts | 29 ++++- src/staged-query/valueproxy.ts | 61 ++++------- src/tests/helpers/same-type.ts | 6 ++ src/tests/query_values.test.ts | 123 ++++++++++++++++++++++ 11 files changed, 256 insertions(+), 63 deletions(-) create mode 100644 src/tests/helpers/same-type.ts create mode 100644 src/tests/query_values.test.ts diff --git a/docs/1.query_types/9.valueproxy.md b/docs/1.query_types/9.valueproxy.md index 258d71f..43e1f15 100644 --- a/docs/1.query_types/9.valueproxy.md +++ b/docs/1.query_types/9.valueproxy.md @@ -17,6 +17,10 @@ const constant = ValueProxy.constant(42); const strConst = ValueProxy.constant("hello"); ``` +A constant, and any operand a stage records (`eq`, `ne`, `default`, `gt`, `includes`, `merge` and the others), holds a `QueryValue`: another proxy or query, text, a number, a boolean, `null`, `undefined`, a `Date`, a list of operands or a record of operands. A `bigint`, a `symbol` or a function does not compile. + +`eq`, `ne`, `gt`, `ge`, `lt`, `le` and `includes` check the operand against the type of the proxy, so a record type declared with `interface` is accepted there. `ValueProxy.constant`, `default`, `sub`, `merge` and the default of `key` infer their type from the argument and need a `QueryValue`: an `interface` has no implicit index signature and does not compile, so declare the record with `type`. + ## General Operations These methods are available on all ValueProxy types. diff --git a/docs/index.md b/docs/index.md index 7573108..d96112e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,7 +59,7 @@ export async function tryNotes() { Import `Schema`, `Query`, `Table`, `Datum`, `Stream`, and `ValueProxy` from `@antelopejs/interface-database` in an AntelopeJS module. This entry point registers schemas with the bound provider and installs query execution (`run()`, `then()`, `cursor()`, and async iteration). -Supporting types have dedicated paths: import `Changes`, `DeepPartial`, `ExtractType`, `Value`, `WriteDocument`, and `WriteValue` from `@antelopejs/interface-database/common`, and `FieldType`, `TableDefinition`, and `IndexDefinition` from `@antelopejs/interface-database/schema`. Atomic mutation types are available from the package root and `/atomic`. +Supporting types have dedicated paths: import `Changes`, `DeepPartial`, `ExtractType`, `OperandValue`, `QueryValue`, `Value`, `WriteDocument`, and `WriteValue` from `@antelopejs/interface-database/common`, and `FieldType`, `TableDefinition`, and `IndexDefinition` from `@antelopejs/interface-database/schema`. Atomic mutation types are available from the package root and `/atomic`. ### Advanced: build queries without executing them diff --git a/skills/database-interface/SKILL.md b/skills/database-interface/SKILL.md index e4a4df7..b241ffe 100644 --- a/skills/database-interface/SKILL.md +++ b/skills/database-interface/SKILL.md @@ -99,6 +99,7 @@ for await (const change of novels.changes()) { - Range semantics: `Table.between(index, low, high)` and `ValueProxy.during(left, right)` exclude the upper bound. `during` also takes bounds as milliseconds since the UNIX epoch. - `insert` accepts one document or an array, with `{ conflict: "update" | "replace" }` as options; it resolves to the inserted ids. - A written document (`insert`, `update`, `replace`) holds `WriteValue`s only: text, numbers, booleans, `null`, `Date`s, lists and nested documents. A field typed `unknown` takes a `WriteValue`, a field typed `any` takes anything; `bigint` and `symbol` do not compile. +- An operand a stage records (`ValueProxy.constant`, `eq`, `ne`, `default`, `gt/ge/lt/le`, `includes`, `merge`) holds a `QueryValue`: a proxy or query, text, numbers, booleans, `null`, `Date`s, lists and records of those. `bigint`, `symbol` and functions do not compile; `constant`, `default`, `sub`, `merge` and the default of `key` also refuse a record declared with `interface` (no implicit index signature), so declare it with `type`. - A write that would duplicate the key of a unique index (`_id`, or an index declared `unique: true`, unique per instance) throws `DuplicateKeyError` (`index`, `fields`); test it with `IsDuplicateKeyError(error)`, not `instanceof`. - `cast()` on `Datum`/`Stream`/`ValueProxy` only changes the TypeScript type — no runtime conversion. - `update` overloads: pass a plain `DeepPartial` for static values, or a `(val: ValueProxy) => ...` function for computed updates. diff --git a/src/staged-query/common.ts b/src/staged-query/common.ts index 0d3b588..eab88a3 100644 --- a/src/staged-query/common.ts +++ b/src/staged-query/common.ts @@ -36,6 +36,49 @@ export type WriteValue = | readonly WriteValue[] | WriteDocument; +/** + * What a builder records as the operand of a stage: another staged object (a + * value proxy, a datum, a stream or a query), a value every adapter reads as + * given, a list of operands or a record of operands. + */ +export type QueryValue = + | StagedObject + | string + | number + | boolean + | null + | undefined + | Date + | readonly QueryValue[] + | QueryRecord; + +/** A record whose fields are operands. */ +export interface QueryRecord { + [field: string]: QueryValue; +} + +/** + * The part of `V` that a stage records as given: a scalar or a date, a staged + * object, a list, or an object (mapped, so a type declared with `interface` + * qualifies) whose members qualify. A `bigint`, a `symbol` and a function map + * to `never`, a type `unknown` to {@link QueryValue}, and an `any` stays `any`. + */ +export type OperandValue = 0 extends 1 & V + ? V & QueryValue + : unknown extends V + ? QueryValue + : V extends DeepPartialLeaf + ? V + : V extends StagedObject + ? V + : V extends Function + ? never + : V extends readonly (infer U)[] + ? readonly OperandValue[] + : V extends object + ? { [K in keyof V]: OperandValue } + : never; + /** A document as `insert`, `update` and `replace` record it. */ export interface WriteDocument { [field: string]: WriteValue; diff --git a/src/staged-query/datum.ts b/src/staged-query/datum.ts index e97f890..4f2adf8 100644 --- a/src/staged-query/datum.ts +++ b/src/staged-query/datum.ts @@ -1,7 +1,12 @@ import { Query } from "./query"; import { ValueProxy } from "./valueproxy"; import type { Stream } from "./stream"; -import { QueryStage, type ExtractType, type Value } from "./common"; +import { + QueryStage, + type ExtractType, + type QueryValue, + type Value, +} from "./common"; import { isKindHolder, MarkKind } from "./kind"; const DATUM_KIND = Symbol.for("@antelopejs/interface-database/Datum@1"); @@ -36,7 +41,7 @@ export class Datum extends Query { */ public key & string, U = undefined>( key: K, - def?: U, + def?: U & QueryValue, ): Datum< U extends undefined ? NonNullable[K] : NonNullable[K]> | U > { @@ -56,7 +61,7 @@ export class Datum extends Query { * @param value Default value * @returns Current datum or given value */ - public default(val: Value) { + public default(val: Value) { return this.stage( Datum | U>, QueryStage("default", undefined, val), diff --git a/src/staged-query/stages.ts b/src/staged-query/stages.ts index ac1894e..8bec816 100644 --- a/src/staged-query/stages.ts +++ b/src/staged-query/stages.ts @@ -1,6 +1,7 @@ import type { AtomicMutation, AtomicRecord } from "./atomic"; import type { InsertOptions, + QueryValue, StagedObject, Value, WriteDocument, @@ -53,8 +54,8 @@ export interface QueryStageMap { table: Recorded<{ id: string }, []>; arg: Recorded; - func: Recorded]>; - constant: Recorded]>; + func: Recorded; + constant: Recorded; atomicMutation: Recorded< NoOptions, @@ -74,9 +75,9 @@ export interface QueryStageMap { key: Recorded< NoOptions, - [key: StagedObject | string, defaultValue?: Value] + [key: StagedObject | string, defaultValue?: QueryValue] >; - default: Recorded]>; + default: Recorded; map: Recorded; filter: Recorded; pluck: Recorded; @@ -110,13 +111,13 @@ export interface QueryStageMap { [value: ValueProxyOrValue] >; not: Recorded; - eq: Recorded]>; - ne: Recorded]>; + eq: Recorded; + ne: Recorded; add: Recorded< NoOptions, - [value: StagedObject | number | string | unknown[] | null] + [value: StagedObject | number | string | readonly QueryValue[] | null] >; - sub: Recorded]>; + sub: Recorded; mul: Recorded]>; div: Recorded]>; mod: Recorded]>; @@ -132,10 +133,10 @@ export interface QueryStageMap { { preserveSign: boolean } | undefined, [value: ValueProxyOrValue] >; - cmp_gt: Recorded]>; - cmp_ge: Recorded]>; - cmp_lt: Recorded]>; - cmp_le: Recorded]>; + cmp_gt: Recorded; + cmp_ge: Recorded; + cmp_lt: Recorded; + cmp_le: Recorded; date_during: Recorded< NoOptions, @@ -164,7 +165,7 @@ export interface QueryStageMap { str_match: Recorded]>; arr_index: Recorded]>; - arr_includes: Recorded]>; + arr_includes: Recorded; arr_slice: Recorded< NoOptions, [start: ValueProxyOrValue, end?: ValueProxyOrValue] @@ -178,11 +179,8 @@ export interface QueryStageMap { arr_min: Recorded; arr_max: Recorded; - obj_index: Recorded< - NoOptions, - [key: Value, defaultValue?: Value] - >; - obj_merge: Recorded]>; + obj_index: Recorded; + obj_merge: Recorded; obj_keys: Recorded; obj_values: Recorded; obj_has: Recorded; diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts index 94fc57e..ad9f983 100644 --- a/src/staged-query/stream.ts +++ b/src/staged-query/stream.ts @@ -4,6 +4,7 @@ import { QueryStage, type Changes, type ExtractType, + type QueryValue, type Value, } from "./common"; import { ValueProxy, type ValueProxyOrValue } from "./valueproxy"; @@ -40,7 +41,7 @@ export class Stream extends Query { */ public key( key: ValueProxyOrValue, - def?: U, + def?: U & QueryValue, ): Stream | U> { return this.stage( Stream | U>, @@ -54,7 +55,7 @@ export class Stream extends Query { * @param value Default value * @returns Stream with non-null value */ - public default(val: Value) { + public default(val: Value) { return this.stage( Stream | U>, QueryStage("default", undefined, val), diff --git a/src/staged-query/valueproxy-conditions.ts b/src/staged-query/valueproxy-conditions.ts index 61571c0..7798675 100644 --- a/src/staged-query/valueproxy-conditions.ts +++ b/src/staged-query/valueproxy-conditions.ts @@ -1,5 +1,9 @@ +import type { OperandValue, QueryValue, StagedObject } from "./common"; import type { ValueProxy } from "./valueproxy"; +/** A proxy of `T`, or a plain `T`. */ +export type ValueProxyOrValue = ValueProxy | T; + export type Is = Left extends Right ? R : never; export type ArrayValue = T extends (infer V)[] ? V : never; @@ -22,5 +26,28 @@ export type AddOperand = T extends Date | number : T extends string ? NullableOperand : T extends (infer V)[] - ? NullableOperand + ? NullableOperand<(V & QueryValue)[]> : never; + +/** + * An operand of the same type as a value proxy of `T`: a proxy of `T`, or a + * plain `T` that a stage can record, as {@link OperandValue} describes it. + */ +export type QueryOperand = ValueProxy | OperandValue; + +/** + * An operand whose type is inferred from the argument: a proxy of `T`, or a + * `T` that is itself a {@link QueryValue}. + */ +export type InferredOperand = ValueProxy | (T & QueryValue); + +/** The operand of `eq` and `ne`: a constant of the receiver's type, or any expression. */ +export type EqualityOperand = StagedObject | OperandValue; + +/** + * The operand of `includes`: an element of the receiver's list, or anything + * when the receiver is `any`, which stays the caller's opt-out. + */ +export type ElementOperand = QueryOperand< + 0 extends 1 & T ? any : ArrayValue +>; diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts index cc8f071..c6995ff 100644 --- a/src/staged-query/valueproxy.ts +++ b/src/staged-query/valueproxy.ts @@ -2,27 +2,24 @@ import { QueryStage, StagedObject, type ExtractType, + type QueryValue, type StagedConstructor, } from "./common"; import { MarkKind } from "./kind"; import type { AddOperand, ArrayValue, + ElementOperand, + EqualityOperand, + InferredOperand, Is, IsArray, OnlyObject, + QueryOperand, + ValueProxyOrValue, } from "./valueproxy-conditions"; import { VALUEPROXY_KIND } from "./valueproxy-kind"; - -export type ValueProxyOrValue = ValueProxy | T; - -export type { - AddOperand, - ArrayValue, - Is, - IsArray, - OnlyObject, -} from "./valueproxy-conditions"; +export type * from "./valueproxy-conditions"; export { IsValueProxy } from "./valueproxy-kind"; /** @@ -42,7 +39,7 @@ export class ValueProxy extends StagedObject { * @param value Value * @returns Proxy */ - public static constant(value: T) { + public static constant(value: T & QueryValue) { return new ValueProxy(QueryStage("constant", undefined, value)); } @@ -77,7 +74,7 @@ export class ValueProxy extends StagedObject { * @param value Value to use in case the proxy is null * @returns Non-null value. */ - public default(value: ValueProxyOrValue) { + public default(value: ValueProxyOrValue) { return this.stage( ValueProxy | U>, QueryStage("default", undefined, value), @@ -121,7 +118,7 @@ export class ValueProxy extends StagedObject { * @param value Operand B: a constant of this value's type, or any expression * @returns A == B */ - public eq(value: StagedObject | T) { + public eq(value: EqualityOperand) { return this.stage(ValueProxy, QueryStage("eq", undefined, value)); } @@ -131,7 +128,7 @@ export class ValueProxy extends StagedObject { * @param value Operand B: a constant of this value's type, or any expression * @returns A != B */ - public ne(value: StagedObject | T) { + public ne(value: EqualityOperand) { return this.stage(ValueProxy, QueryStage("ne", undefined, value)); } @@ -159,7 +156,10 @@ export class ValueProxy extends StagedObject { * @param other Operand B * @returns New value */ - public sub(this: Is, value: ValueProxyOrValue) { + public sub( + this: Is, + value: ValueProxyOrValue, + ) { return ValueProxy.#stageOf( this, ValueProxy< @@ -487,10 +487,7 @@ export class ValueProxy extends StagedObject { * @param other Operand B * @returns A > B */ - public gt( - this: Is, - other: ValueProxyOrValue, - ) { + public gt(this: Is, other: QueryOperand) { return ValueProxy.#stageOf( this, ValueProxy, @@ -504,10 +501,7 @@ export class ValueProxy extends StagedObject { * @param other Operand B * @returns A >= B */ - public ge( - this: Is, - other: ValueProxyOrValue, - ) { + public ge(this: Is, other: QueryOperand) { return ValueProxy.#stageOf( this, ValueProxy, @@ -521,10 +515,7 @@ export class ValueProxy extends StagedObject { * @param other Operand B * @returns A < B */ - public lt( - this: Is, - other: ValueProxyOrValue, - ) { + public lt(this: Is, other: QueryOperand) { return ValueProxy.#stageOf( this, ValueProxy, @@ -538,10 +529,7 @@ export class ValueProxy extends StagedObject { * @param other Operand B * @returns A <= B */ - public le( - this: Is, - other: ValueProxyOrValue, - ) { + public le(this: Is, other: QueryOperand) { return ValueProxy.#stageOf( this, ValueProxy, @@ -648,10 +636,7 @@ export class ValueProxy extends StagedObject { * @param val Value to search for. * @returns True if the value was found. */ - public includes( - this: IsArray, - val: ValueProxyOrValue>, - ) { + public includes(this: IsArray, val: ElementOperand) { return this.stage( ValueProxy, QueryStage("arr_includes", undefined, val), @@ -799,8 +784,8 @@ export class ValueProxy extends StagedObject { */ public key, K extends keyof TO = keyof TO, U = undefined>( this: Is, - key: ValueProxyOrValue, - def?: ValueProxyOrValue, + key: InferredOperand, + def?: InferredOperand, ) { return this.stage( ValueProxy< @@ -816,7 +801,7 @@ export class ValueProxy extends StagedObject { * @param value Other object * @returns `{...A, ...B}` */ - public merge>( + public merge>( this: Is, other: ValueProxyOrValue, ) { diff --git a/src/tests/helpers/same-type.ts b/src/tests/helpers/same-type.ts new file mode 100644 index 0000000..695c559 --- /dev/null +++ b/src/tests/helpers/same-type.ts @@ -0,0 +1,6 @@ +/** Resolves to `true` when each of `A` and `B` is assignable to the other, else to `false`. */ +export type Same = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; diff --git a/src/tests/query_values.test.ts b/src/tests/query_values.test.ts new file mode 100644 index 0000000..8c0e06a --- /dev/null +++ b/src/tests/query_values.test.ts @@ -0,0 +1,123 @@ +import { expect } from "chai"; +import { ValueProxy } from ".."; +import type { QueryValue } from "../common"; +import type { Same } from "./helpers/same-type"; + +type Person = { + name: string; + age: number; + bornAt: Date | null; + address: { street: string }; + tags: string[]; +}; + +interface Address { + street: string; +} + +type Field = string | number | null | Date | Field[] | FieldRecord; + +interface FieldRecord { + [field: string]: Field | undefined; +} + +const row = ValueProxy.arg(0); + +function lastStageOf(proxy: ValueProxy) { + return proxy.build().at(-1); +} + +describe("Query values", () => { + it("Records a nested record operand as it was given", () => { + const operand: QueryValue = { + at: new Date(0), + tags: ["a", null], + nested: { n: 1, none: undefined }, + }; + expect(ValueProxy.constant(operand).build().at(-1)).to.deep.equal({ + stage: "constant", + options: undefined, + args: [operand], + }); + }); + + it("Keeps the widened type of a constant", () => { + const proxy = ValueProxy.constant(25); + const widened: Same> = true; + expect(proxy.build().at(-1)).to.deep.equal({ + stage: "constant", + options: undefined, + args: [25], + }); + expect(widened).to.equal(true); + }); + + it("Records a record operand of eq on a record field", () => { + expect( + row.key("address").eq({ street: "Main" }).build().at(-1), + ).to.deep.equal({ + stage: "eq", + options: undefined, + args: [{ street: "Main" }], + }); + }); + + it("Records an interface-typed record operand of eq", () => { + const address: Address = { street: "Main" }; + const located = ValueProxy.arg<{ address: Address }>(2); + expect(located.key("address").eq(address).build().at(-1)).to.deep.equal({ + stage: "eq", + options: undefined, + args: [address], + }); + }); + + it("Records a null operand of default on a nullable date", () => { + expect(row.key("bornAt").default(null).build().at(-1)).to.deep.equal({ + stage: "default", + options: undefined, + args: [null], + }); + }); + + it("Takes a value of unknown type in the list of a proxy typed any", () => { + const listed = new Map([["k", "a"]]).get("k"); + const list = ValueProxy.arg(4); + expect(list.includes(listed).build().at(-1)).to.deep.equal({ + stage: "arr_includes", + options: undefined, + args: ["a"], + }); + }); + + it("Takes a recursive union of values as a default", () => { + const fallback: Field | undefined = ["a", { when: new Date(0) }]; + expect(row.key("name").default(fallback).build().at(-1)).to.deep.equal({ + stage: "default", + options: undefined, + args: [fallback], + }); + }); + + it("Passes the entry of a record read by a text key to a proxy of an object", () => { + const entries = ValueProxy.arg>(1); + const id: string = "visits"; + expect(lastStageOf(entries.key(id))).to.deep.equal({ + stage: "obj_index", + options: undefined, + args: [id, undefined], + }); + }); + + it("Refuses a plain operand of a type no stage records", () => { + type EqOf = Parameters["eq"]>[0]; + const takes: [ + Same<[bigint] extends [EqOf] ? true : false, false>, + Same<[symbol] extends [EqOf] ? true : false, false>, + Same<[string] extends [EqOf] ? true : false, true>, + ] = [true, true, true]; + const age = row.key("age"); + expect(age.eq(age).build().at(-1)?.args).to.deep.equal([age]); + expect(takes).to.have.length(3); + }); +}); From e533a2f089adc14c52d59004e81e90d21582d281 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Mon, 2 Nov 2026 13:43:19 +0100 Subject: [PATCH 12/16] feat(database): type the own properties of a schema codec as unknown values (#63) --- src/staged-query/schema.ts | 11 +++++++++-- src/tests/field_type.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/tests/field_type.test.ts diff --git a/src/staged-query/schema.ts b/src/staged-query/schema.ts index 6e06d7a..d9abc26 100644 --- a/src/staged-query/schema.ts +++ b/src/staged-query/schema.ts @@ -49,7 +49,14 @@ interface IoTsValidation { readonly _tag: "Left" | "Right"; } -interface IoTsCodec { +/** + * The shape of an io-ts codec that a schema field accepts + * + * Declared as a type alias so TypeScript gives it an implicit string index + * signature: `Object.entries` on a codec lists its own enumerable properties + * as `[string, unknown]`, where an interface would make them `any`. + */ +type IoTsCodec = { readonly _A: A; readonly _O: O; readonly _I: I; @@ -57,7 +64,7 @@ interface IoTsCodec { is(u: A | I): boolean; encode(a: A): O; decode(i: I): IoTsValidation; -} +}; export type FieldType = StringFieldType | IoTsCodec; diff --git a/src/tests/field_type.test.ts b/src/tests/field_type.test.ts new file mode 100644 index 0000000..b0aa796 --- /dev/null +++ b/src/tests/field_type.test.ts @@ -0,0 +1,34 @@ +import { expect } from "chai"; +import type { FieldType, StringFieldType } from "../schema"; +import type { Same } from "./helpers/same-type"; + +type IsAny = 0 extends 1 & T ? true : false; + +type Codec = Exclude; + +function propertiesOf(codec: Codec) { + return Object.entries(codec); +} + +type PropertyValue = ReturnType[number][1]; + +const valuesAreNotAny: Same, false> = true; +const valuesAreUnknown: Same = true; + +describe("Field types", () => { + it("Types the own properties of a codec as unknown values", () => { + const codec: Codec = { + _A: 0, + _O: 0, + _I: 0, + name: "number", + is: (u): boolean => u === 0, + encode: (a) => a, + decode: () => ({ _tag: "Right" }), + }; + const names = propertiesOf(codec).map(([name]) => name); + expect(names).to.include.members(["name", "is", "encode", "decode"]); + expect(valuesAreNotAny).to.equal(true); + expect(valuesAreUnknown).to.equal(true); + }); +}); From 062f88a37a28c3e5ce9d25337a7bfd1bdbe30499 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Mon, 2 Nov 2026 22:13:07 +0100 Subject: [PATCH 13/16] fix(database): resolve a table update through its type to the modified count (#65) Reading the result of `update` through its type (`ReturnType`) took the last overload, whose conditional return resolved to `any`. The overloads are reordered so that the document overload, which returns `Query`, is the one a type-level read sees. Calls type exactly as before. --- src/staged-query/selection.ts | 24 ++++++++++++++++++------ src/tests/helpers/same-type.ts | 7 +++++++ src/tests/update_result.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 src/tests/update_result.test.ts diff --git a/src/staged-query/selection.ts b/src/staged-query/selection.ts index 12887b7..b7b0981 100644 --- a/src/staged-query/selection.ts +++ b/src/staged-query/selection.ts @@ -32,15 +32,21 @@ function isUpdater( */ export class SingleSelection extends Datum { /** - * Update fields of this selection with the given values + * Update fields of this selection with values computed from each document * - * @param document Partial document with new values + * @param document Function building the partial document from the current one * @returns Number of modified documents */ - public update(document: DeepPartial & object): Query; public update( document: (val: ValueProxy) => U, ): ExtractType extends DeepPartial ? Query : never; + /** + * Update fields of this selection with the given values + * + * @param document Partial document with new values + * @returns Number of modified documents + */ + public update(document: DeepPartial & object): Query; public update( document: (DeepPartial & object) | ((val: ValueProxy) => U), ) { @@ -90,15 +96,21 @@ export class SingleSelection extends Datum { */ export class Selection extends Stream { /** - * Update fields of this selection with the given values + * Update fields of this selection with values computed from each document * - * @param document Partial document with new values + * @param document Function building the partial document from the current one * @returns Number of modified documents */ - public update(document: DeepPartial & object): Query; public update( document: (val: ValueProxy) => U, ): ExtractType extends DeepPartial ? Query : never; + /** + * Update fields of this selection with the given values + * + * @param document Partial document with new values + * @returns Number of modified documents + */ + public update(document: DeepPartial & object): Query; public update( document: (DeepPartial & object) | ((val: ValueProxy) => U), ) { diff --git a/src/tests/helpers/same-type.ts b/src/tests/helpers/same-type.ts index 695c559..5555e93 100644 --- a/src/tests/helpers/same-type.ts +++ b/src/tests/helpers/same-type.ts @@ -4,3 +4,10 @@ export type Same = [A] extends [B] ? true : false : false; + +/** Resolves to `true` when `A` is `any`, else to `false`. `Same` cannot tell, as `any` is assignable both ways. */ +type IsAny = 0 extends 1 & A ? true : false; + +/** Like `Same`, but `any` on either side resolves to `false`. */ +export type Exact = + IsAny extends true ? false : IsAny extends true ? false : Same; diff --git a/src/tests/update_result.test.ts b/src/tests/update_result.test.ts new file mode 100644 index 0000000..eb41098 --- /dev/null +++ b/src/tests/update_result.test.ts @@ -0,0 +1,33 @@ +import { expect } from "chai"; +import { Schema, type Query } from ".."; +import type { Exact } from "./helpers/same-type"; +import { Vehicle } from "./datasets/vehicles"; + +const schema = new Schema<{ vehicles: Vehicle }>("test-update-result", { + vehicles: Vehicle, +}); +const vehicles = schema.instance("default").table("vehicles"); + +describe("Update result", () => { + it("Resolves an update call to a query of the modified count", () => { + const viaDocument = vehicles.get("a").update({ price: 1 }); + const viaUpdater = vehicles.get("a").update(() => ({ price: 2 })); + const document: Exact> = true; + const updater: Exact> = true; + expect(document && updater).to.equal(true); + }); + + it("Resolves the update of a table, read through its type, to the modified count", () => { + const table: Exact< + Awaited["run"]>>, + number + > = true; + const single: Exact< + Awaited< + ReturnType["update"]>["run"]> + >, + number + > = true; + expect(table && single).to.equal(true); + }); +}); From 178776a748772adb86732516718ff2f2159a06c9 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 00:34:14 +0100 Subject: [PATCH 14/16] chore: merge main into campaign/integration (#66) * docs(readme): use one Discord invite (#57) * docs(github): send questions and ideas to Discord (#64) --- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9e41a21..9ff80ed 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,8 +4,8 @@ contact_links: url: https://antelopejs.com/ about: Read the documentation before opening a usage issue. - name: Questions and ideas - url: https://github.com/AntelopeJS/antelopejs/discussions - about: Ask for help or discuss an idea with the community. + url: https://discord.gg/g4pFt6eXqu + about: Ask for help or share an idea in #support on the AntelopeJS Discord. - name: Security vulnerability url: https://github.com/AntelopeJS/.github/blob/main/SECURITY.md about: Read the private reporting instructions. Do not open a public issue. diff --git a/README.md b/README.md index 175497a..0b34b39 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ From 897120a6361b0079599593970bf7f2c1db4c2e0f Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 03:14:07 +0100 Subject: [PATCH 15/16] docs: pass the database docs to the docs standard (#67) * docs: pass the database docs to the docs standard * docs: turn on the docs standard * docs: list the root Schema class and fix the W5 notes --- README.md | 28 ++--- docs/1.query_types/1.index.md | 50 ++++---- docs/1.query_types/2.table.md | 49 ++++---- docs/1.query_types/3.selection.md | 53 ++++++--- docs/1.query_types/4.single_selection.md | 67 ++++++----- docs/1.query_types/5.stream.md | 126 +++++++++++++-------- docs/1.query_types/6.feed.md | 49 ++++---- docs/1.query_types/7.query.md | 47 ++++---- docs/1.query_types/8.datum.md | 57 ++++++---- docs/1.query_types/9.valueproxy.md | 84 +++++++------- docs/2.operations/1.index.md | 36 +++--- docs/2.operations/2.database_management.md | 54 +++++---- docs/2.operations/3.table_operations.md | 48 ++++---- docs/2.operations/4.crud.md | 84 ++++++++------ docs/2.operations/5.indexes.md | 60 +++++----- docs/2.operations/6.filtering.md | 54 +++++---- docs/2.operations/7.lookup.md | 65 ++++++----- docs/2.operations/8.atomic_mutations.md | 36 +++--- docs/3.results/1.index.md | 36 +++--- docs/3.results/2.write_results.md | 54 +++++++-- docs/3.results/3.index_changes.md | 34 +++--- docs/3.results/4.table_changes.md | 55 ++++++--- docs/3.results/5.database_changes.md | 46 +++++--- docs/3.results/6.value_changes.md | 38 ++++--- docs/index.md | 120 ++++++++++---------- package.json | 5 +- 26 files changed, 849 insertions(+), 586 deletions(-) diff --git a/README.md b/README.md index 0b34b39..bfb92d2 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,28 @@ -# @antelopejs/interface-database +# `@antelopejs/interface-database`
-npm -License -Discord -Docs +npm +License +Discord +Docs
-AQL (Antelope Query Language), the database-agnostic query builder of AntelopeJS: write a chainable, typed query once and run it on any database module that implements the interface. +AQL (Antelope Query Language) is the database-agnostic query builder of AntelopeJS. You write a chainable, typed query once, and any database module that implements the interface runs it. ## What it offers -- `Schema` declares tables, fields and indexes; each schema can hold several isolated instances. +- `Schema` declares tables, fields and indexes. Each schema can hold several isolated instances. - `Table`, `Selection` and `SingleSelection` insert, read, update and delete documents. -- `Stream` filters, maps, joins, groups and aggregates; `ValueProxy` builds the expressions inside them. +- `Stream` filters, maps, joins, groups and aggregates. `ValueProxy` builds the expressions inside them. - `changes()` streams inserts, updates and deletions as they happen. -- Queries are lazy: nothing runs until you `await` them, call `.run()` or iterate them. +- Queries are lazy. Nothing runs until you `await` a query, call `.run()` or iterate it. ## Implementations -| Module | Notes | -| ------------------------------------------------------------------ | ------------------------------------------------------------ | -| [`@antelopejs/mongodb`](https://github.com/AntelopeJS/mongodb) | MongoDB; also implements `@antelopejs/interface-mongodb` | -| [`@antelopejs/rethinkdb`](https://github.com/AntelopeJS/rethinkdb) | RethinkDB; also implements `@antelopejs/interface-rethinkdb` | +| Module | Notes | +| ------------------------------------------------------------------ | ------------------------------------------------------------- | +| [`@antelopejs/mongodb`](https://github.com/AntelopeJS/mongodb) | MongoDB. Also implements `@antelopejs/interface-mongodb`. | +| [`@antelopejs/rethinkdb`](https://github.com/AntelopeJS/rethinkdb) | RethinkDB. Also implements `@antelopejs/interface-rethinkdb`. | ## Install @@ -69,7 +69,7 @@ export async function listLongNovels() { } ``` -Callbacks such as the `filter` above receive a `ValueProxy`, not a document: only its methods travel to the database. +Callbacks such as the `filter` above receive a `ValueProxy`, not a document. The callback runs once, when the query is built, and the query records only the `ValueProxy` calls it makes. ## Documentation diff --git a/docs/1.query_types/1.index.md b/docs/1.query_types/1.index.md index c19cbad..4faa45c 100644 --- a/docs/1.query_types/1.index.md +++ b/docs/1.query_types/1.index.md @@ -1,6 +1,10 @@ -# Query Types +--- +title: Query types +description: The AQL query classes, what each one represents, how they inherit from each other, and the execution methods they share. +mode: reference +--- -AQL provides several query classes that form a hierarchy of capabilities. Each class encapsulates a different aspect of database interactions, from full table access down to individual value manipulation. +AQL has several query classes that form a hierarchy of capabilities. Each class covers one level of a database interaction, from a whole table down to one value inside a query. ## Choose by the result you need @@ -13,13 +17,13 @@ AQL provides several query classes that form a hierarchy of capabilities. Each c | One query result | `Datum` | Read a field or transform the value. | | A value inside a query callback | `ValueProxy` | Build an expression with `eq`, `add`, or other query operators. | -`ValueProxy` describes database expressions; it is not the fetched JavaScript value. Use its operators inside query callbacks, then execute the outer query to obtain data. +`ValueProxy` describes a database expression. It is not the fetched JavaScript value. Use its operators inside query callbacks, then execute the outer query to get data. -![Compose query stages without database I/O, then execute through the runtime provider. The staged-query entry point only builds stages.](../assets/query-execution.svg){width="480"} +![Compose query stages without database I/O, then execute through the runtime provider. The staged-query entry point only builds stages.](../assets/query-execution.svg "illustration"){width="480"} -## Query Type Hierarchy +## Query type hierarchy -All query types inherit from `StagedObject`, which records operations as a pipeline of stages. The hierarchy is: +Every query type inherits from `StagedObject`, which records operations as a pipeline of stages. The hierarchy is: ``` Query @@ -32,14 +36,14 @@ Query ValueProxy (used within query callbacks) ``` -## Common Features +## Common features -All query types that extend `Query` share these capabilities: +Every query type that extends `Query` has these methods: -- **`run()`** - Execute the query and return a `Promise` with the result. -- **`then()`** - `PromiseLike` support, so queries can be awaited directly. -- **`cursor()`** - Return an `AsyncGenerator` for streaming results one at a time. -- **`[Symbol.asyncIterator]()`** - Enable `for await...of` loops over query results. +- `run()` executes the query and returns a `Promise` with the result. +- `then()` makes the query `PromiseLike`, so you can await it directly. +- `cursor()` returns an `AsyncGenerator` that yields results one at a time. +- `[Symbol.asyncIterator]()` enables `for await...of` loops over the query results. ```typescript fragment import { Schema } from "@antelopejs/interface-database"; @@ -56,7 +60,7 @@ for await (const user of schema.instance().table("users")) { } ``` -## Recognize a Query Type +## Recognize a query type `IsQuery`, `IsDatum`, `IsStream` and `IsValueProxy` tell what kind of staged object a value is: @@ -68,15 +72,15 @@ function describe(query: unknown) { } ``` -Each provider binding of the interface has its own copy of these classes, so a query built by a module bound to another database is not an `instanceof` this module's `Stream`. The predicates recognize it whichever copy built it. Keep `instanceof` for checking that a query was built for this module's own binding. +Each provider binding of the interface has its own copy of these classes. A query that a module bound to another database built is therefore not an `instanceof` this module's `Stream`. The predicates recognize a query whichever copy built it. Use `instanceof` only to check that a query was built for this module's own binding. -## Available Query Types +## Available query types -- [**Table**](./2.table.md) - Represents a database table with insert, get, getAll, and between operations -- [**Selection**](./3.selection.md) - A set of documents supporting update, replace, and delete -- [**SingleSelection**](./4.single_selection.md) - A single document with update, replace, delete, and change feed support -- [**Stream**](./5.stream.md) - A sequence of results with map, filter, join, group, aggregate, and sort operations -- [**Change Feeds**](./6.feed.md) - Real-time change notifications via the `changes()` method -- [**Query**](./7.query.md) - The base class providing execution and async iteration -- [**Datum**](./8.datum.md) - A single value with field access, transformation, and lookup capabilities -- [**ValueProxy**](./9.valueproxy.md) - Proxy for in-query value operations (arithmetic, string, date, array, object) +- [Table](./2.table.md) is a database table with `insert`, `get`, `getAll`, `between`, and `atomicMutation`. +- [Selection](./3.selection.md) is a set of documents that supports `update`, `replace`, and `delete`. +- [SingleSelection](./4.single_selection.md) is one document with `update`, `replace`, `delete`, and change feed support. +- [Stream](./5.stream.md) is a sequence of results with `map`, `filter`, `join`, `group`, aggregation, and sort operations. +- [Change feeds](./6.feed.md) are real-time change notifications from the `changes()` method. +- [Query](./7.query.md) is the base class that provides execution and async iteration. +- [Datum](./8.datum.md) is a single value with field access, transformation, and lookup. +- [ValueProxy](./9.valueproxy.md) is a proxy for in-query operations on numbers, strings, dates, arrays, and objects. diff --git a/docs/1.query_types/2.table.md b/docs/1.query_types/2.table.md index 0a1bc60..7299b80 100644 --- a/docs/1.query_types/2.table.md +++ b/docs/1.query_types/2.table.md @@ -1,25 +1,26 @@ -# Table +--- +title: Table +description: The Table query type and its methods for inserting documents, reading them by primary key, secondary index, or range, and applying atomic mutations. +mode: reference +--- -The `Table` class represents a database table and provides methods for inserting documents and retrieving them by primary key, secondary index, or range. It extends `Selection`, inheriting all multi-document operations and stream capabilities. +The `Table` class represents a database table. It inserts documents and retrieves them by primary key, secondary index, or range. It extends `Selection`, so it also has every multi-document operation and stream method. -## Class Definition +## Class definition ```typescript fragment class Table extends Selection { atomicMutation( - id: string, + key: string, request: AtomicMutation, ): Query; insert( - obj: DeepPartial | DeepPartial[], + obj: (DeepPartial & object) | (DeepPartial & object)[], options?: InsertOptions, ): Query; - get(key: string): SingleSelection; - getAll( - keys: string | number | boolean | (string | number | boolean)[], - index?: string, - ): Selection; - between( + get(key: ValueProxyOrValue): SingleSelection; + getAll(keys: IndexKeys, index?: string): Selection; + between( index: TK, low: T[TK] & IndexBound, high: T[TK] & IndexBound, @@ -27,9 +28,9 @@ class Table extends Selection { } ``` -## Access a Table +## Access a table -Tables are accessed through a `SchemaInstance`. Define a schema, create an instance, then call `table()`: +You reach a table through a `SchemaInstance`. Define a schema, create an instance, then call `table()`: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -45,7 +46,7 @@ await schema.createInstance(); const users = schema.instance().table("users"); ``` -## Insert Documents +## Insert documents The `insert` method adds one or more documents to the table and returns the generated IDs. @@ -78,13 +79,13 @@ const upsertIds = await users.insert( | ---------- | ----------------------- | ------------------------------------------------- | | `conflict` | `"update" \| "replace"` | Behavior when a document with the same ID exists. | -## Retrieve Documents +## Retrieve documents ### `get(key)` -Retrieve a single document by its primary key. Returns a `SingleSelection`. +Retrieves a single document by its primary key, given as a string or a `ValueProxy` of a string. Returns a `SingleSelection`. -Awaiting this selection is declared as `T`, not `T | null`. The declaration does not prove a row exists; providers can return `null` for missing keys, so check before reading fields. +Awaiting this selection is declared as `T`, not `T | null`. The declaration does not prove that a row exists. A provider can return `null` or `undefined` for a missing key, so check the result before you read its fields. ```typescript fragment const user = await users.get("user-123"); @@ -96,7 +97,7 @@ if (user) { ### `getAll(keys, index?)` -Retrieve multiple documents by key values. When `index` is provided, the lookup uses the specified secondary index instead of the primary key. +Retrieves multiple documents by key values. `keys` is an `IndexKeys`: one key, a list of keys, or a list of compound keys that each hold a compound index's values in its field order. A key is a string, a number, or a boolean. When you pass `index`, the lookup uses that secondary index instead of the primary key. ```typescript fragment // By primary key @@ -108,7 +109,7 @@ const admins = await users.getAll("admin", "role"); ### `between(index, low, high)` -Retrieve documents where the specified index value falls within a range. The lower bound is inclusive, and the upper bound is exclusive. A bound is an `IndexBound`: a string, number, boolean, `Date` or `null`, or a list of them for a compound index. +Retrieves the documents whose value for the index falls within a range. The lower bound is inclusive, and the upper bound is exclusive. A bound is an `IndexBound`: a string, number, boolean, `Date` or `null`, or a list of them for a compound index. ```typescript fragment const recent = await users.between( @@ -118,11 +119,11 @@ const recent = await users.between( ); ``` -## Inherited Methods +## Inherited methods -For conditional single-record writes, see [Atomic Mutations](../2.operations/8.atomic_mutations.md). `atomicMutation()` is a table method, not a selection method; it returns an explicit outcome rather than a modified count. +For conditional single-record writes, see [Atomic mutations](../2.operations/8.atomic_mutations.md). `atomicMutation()` is a table method, not a selection method. It returns an explicit outcome, `"applied"`, `"not-applied"`, or `"unknown"`, rather than a modified count. -Since `Table` extends `Selection`, which extends `Stream`, it inherits: +`Table` extends `Selection`, which extends `Stream`, so it inherits: -- **Selection methods**: `update()`, `replace()`, `delete()` -- see [Selection](./3.selection.md) -- **Stream methods**: `map()`, `filter()`, `orderBy()`, `count()`, `join()`, and more -- see [Stream](./5.stream.md) +- The selection methods `update()`, `replace()`, and `delete()`. See [Selection](./3.selection.md). +- The stream methods `map()`, `filter()`, `orderBy()`, `count()`, `join()`, and the others listed on [Stream](./5.stream.md). diff --git a/docs/1.query_types/3.selection.md b/docs/1.query_types/3.selection.md index f6e98fb..0eca855 100644 --- a/docs/1.query_types/3.selection.md +++ b/docs/1.query_types/3.selection.md @@ -1,26 +1,40 @@ -# Selection +--- +title: Selection +description: The Selection query type and its bulk update, replace, and delete methods, including how the update overloads resolve their return type. +mode: reference +--- -The `Selection` class represents a set of documents and provides methods for bulk mutation operations. It extends `Stream`, inheriting all transformation, filtering, and aggregation capabilities. +The `Selection` class represents a set of documents and provides bulk mutation methods. It extends `Stream`, so it also has every transformation, filtering, and aggregation method. -## Class Definition +## Class definition ```typescript fragment class Selection extends Stream { - update(document: DeepPartial): Query; - update(document: (val: ValueProxy) => U): Query; - replace(document: DeepPartial): Query; + update( + document: (val: ValueProxy) => U, + ): ExtractType extends DeepPartial ? Query : never; + update(document: DeepPartial & object): Query; + replace(document: DeepPartial & object): Query; delete(): Query; } ``` -## Create a Selection +The updater overload comes first and the document overload last: -Selections are produced by table methods such as `getAll()` and `between()`, or by chaining stream operations like `filter()`: +- A function argument resolves to the updater overload. It returns `Query` when the type the updater builds fits `DeepPartial`, and `never` otherwise. +- An object argument resolves to the document overload, which returns `Query`. +- An argument typed `any` resolves to the updater overload, and the conditional gives `never`. The result has no `.run()` and cannot be awaited for a count. Give the argument a real type. +- A type-level read such as `ReturnType` takes the last overload and reads `Query`. + +## Create a selection + +Table methods such as `getAll()` and `between()` produce selections. So does a chained stream operation such as `filter()`: ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error("Define the myapp schema first"); const users = schema.instance().table("users"); // Selection from getAll @@ -34,9 +48,9 @@ const recentUsers = users.between( ); ``` -## Update Documents +## Update documents -The `update` method modifies matching documents with the provided partial data. Fields not included in the update object remain unchanged. It returns the number of modified documents. +The `update` method changes the matching documents with the given partial data. Fields that the update object leaves out stay unchanged. It returns the number of modified documents. ```typescript fragment // Update with a partial object @@ -52,9 +66,9 @@ await users.getAll("active", "status").update((user) => ({ })); ``` -## Replace Documents +## Replace documents -The `replace` method substitutes entire documents with the provided data. Unlike `update`, all fields not present in the replacement are removed. +The `replace` method replaces each whole document with the given data. Unlike `update`, it removes every field that the replacement leaves out. ```typescript fragment const count = await users.getAll("user-123").replace({ @@ -67,9 +81,9 @@ const count = await users.getAll("user-123").replace({ console.log(`Replaced ${count} documents`); ``` -## Delete Documents +## Delete documents -The `delete` method removes all documents in the selection and returns the number of deleted documents. +The `delete` method removes every document in the selection and returns the number of deleted documents. ```typescript fragment const count = await users.getAll("inactive", "status").delete(); @@ -77,9 +91,9 @@ const count = await users.getAll("inactive", "status").delete(); console.log(`Deleted ${count} inactive users`); ``` -## Inherited Methods +## Inherited methods -Since `Selection` extends `Stream`, it inherits all stream operations: +`Selection` extends `Stream`, so it inherits every stream operation. These are grouped by purpose: - Transformation: `map()`, `pluck()`, `without()` - Filtering: `filter()` @@ -88,5 +102,8 @@ Since `Selection` extends `Stream`, it inherits all stream operations: - Ordering: `orderBy()`, `slice()`, `nth()` - Grouping: `group()` - Deduplication: `distinct()` +- Fields and fallbacks: `key()`, `default()` +- Combining and typing: `union()`, `cast()` +- Change feeds: `changes()` -See [Stream](./5.stream.md) for complete details on these methods. +See [Stream](./5.stream.md) for the details of these methods. diff --git a/docs/1.query_types/4.single_selection.md b/docs/1.query_types/4.single_selection.md index 3a3a3c5..85791b1 100644 --- a/docs/1.query_types/4.single_selection.md +++ b/docs/1.query_types/4.single_selection.md @@ -1,38 +1,47 @@ -# SingleSelection +--- +title: SingleSelection +description: The SingleSelection query type for one document, with its read, update, replace, delete, and change feed methods. +mode: reference +--- -The `SingleSelection` class represents a reference to a single document in a table. It extends `Datum` and provides methods for updating, replacing, deleting, and monitoring changes to an individual document. +The `SingleSelection` class represents a reference to one document in a table. It extends `Datum` and adds methods that update, replace, delete, and watch that document. -## Class Definition +## Class definition ```typescript fragment class SingleSelection extends Datum { - update(document: DeepPartial): Query; - update(document: (val: ValueProxy) => U): Query; - replace(document: DeepPartial): Query; + update( + document: (val: ValueProxy) => U, + ): ExtractType extends DeepPartial ? Query : never; + update(document: DeepPartial & object): Query; + replace(document: DeepPartial & object): Query; delete(): Query; changes(): Query[]>; } ``` -## Create a SingleSelection +The `update` overloads resolve as they do on [Selection](./3.selection.md). An argument typed `any` resolves to `never`, with no `.run()`. A type-level read such as `ReturnType["update"]>` reads `Query`. -SingleSelection objects are produced by calling `get()` on a table: +## Create a `SingleSelection` + +Calling `get()` on a table produces a `SingleSelection`: ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error("Define the myapp schema first"); const users = schema.instance().table("users"); // Get a document by primary key const user = users.get("user-123"); ``` -## Read a Document +## Read a document -Since `SingleSelection` extends `Datum`, you can execute it directly to retrieve the document value: +`SingleSelection` extends `Datum`, so you can execute it directly to retrieve the document. -The declared result is `T`, even though a provider can return `null` for a missing key. Check for absence before accessing the document; the generic type is not an existence guarantee. +The declared result is `T`, even though a provider can return `null` or `undefined` for a missing key. Check for absence before you access the document. The generic type does not guarantee that the document exists. ```typescript fragment const user = await users.get("user-123"); @@ -52,9 +61,9 @@ const display = await users.get("user-123").do((u) => ({ })); ``` -## Update a Document +## Update a document -The `update` method modifies fields in the document. Unmentioned fields remain unchanged. Returns the number of modified documents. +The `update` method changes fields in the document. Fields it does not mention stay unchanged. It returns the number of modified documents. ```typescript fragment // Update with a partial object @@ -70,9 +79,9 @@ await users.get("user-123").update((user) => ({ })); ``` -## Replace a Document +## Replace a document -The `replace` method substitutes the entire document with new data. +The `replace` method replaces the whole document with new data. ```typescript fragment const count = await users.get("user-123").replace({ @@ -83,9 +92,9 @@ const count = await users.get("user-123").replace({ }); ``` -## Delete a Document +## Delete a document -The `delete` method removes the document from the table. +The `delete` method removes the document from the table and returns the number of deleted documents. ```typescript fragment const count = await users.get("user-123").delete(); @@ -95,9 +104,9 @@ if (count === 1) { } ``` -## Monitor Changes +## Monitor changes -The `changes` method returns change events for the document. Each event contains a `changeType` and the `oldValue`/`newValue` of the document. +The `changes` method returns change events for the document. Each event holds a `changeType`, plus the `oldValue` and `newValue` of the document. ```typescript fragment for await (const change of users.get("user-123").changes()) { @@ -107,15 +116,15 @@ for await (const change of users.get("user-123").changes()) { } ``` -## Inherited Methods +## Inherited methods -Since `SingleSelection` extends `Datum`, it inherits: +`SingleSelection` extends `Datum`, so it inherits: -- **`key()`** - Access a field by name -- **`default()`** - Provide a fallback value for null -- **`do()`** - Apply a transformation function -- **`lookup()`** - Perform a foreign key lookup -- **`pluck()`** - Select specific fields -- **`cast()`** - Change the TypeScript type +- `key()` reads a field by name. +- `default()` gives a fallback value for `null`. +- `do()` applies a transformation function. +- `lookup()` performs a foreign key lookup. +- `pluck()` selects specific fields. +- `cast()` changes the TypeScript type. -See [Datum](./8.datum.md) for complete details. +See [Datum](./8.datum.md) for the details. diff --git a/docs/1.query_types/5.stream.md b/docs/1.query_types/5.stream.md index 3919d5b..9090112 100644 --- a/docs/1.query_types/5.stream.md +++ b/docs/1.query_types/5.stream.md @@ -1,8 +1,12 @@ -# Stream +--- +title: Stream +description: The Stream query type and its methods for transforming, filtering, joining, grouping, ordering, aggregating, and watching a sequence of documents. +mode: reference +--- -The `Stream` class represents a sequence of documents that can be transformed, filtered, joined, grouped, and aggregated. It extends `Query` and serves as the foundation for collection-based operations in AQL. +The `Stream` class represents a sequence of documents that you can transform, filter, join, group, and aggregate. It extends `Query` and is the base of every collection operation in AQL. -## Class Definition +## Class definition ```typescript fragment class Stream extends Query { @@ -10,27 +14,54 @@ class Stream extends Query { cast(): Stream; // Field access - key(key: K, def?: U): Stream< - U extends undefined ? T[K] : Exclude | U - >; - default(val: Value): Stream | U>; + key( + key: ValueProxyOrValue, + def?: U & QueryValue, + ): Stream | U>; + default( + val: Value, + ): Stream | U>; // Transformation map(mapper: (val: ValueProxy) => U): Stream>; filter(predicate: (val: ValueProxy) => ValueProxyOrValue): this; - pluck(...fields: string[]): Stream>; - without(...fields: string[]): Stream>; + pluck(...fields: K[]): Stream>; + without(...fields: K[]): Stream>; // Combining union(other: Stream): Stream; // Joining - join(right: Stream, predicate, mapper): Stream>; - joinInner(right: Stream, predicate, mapper): Stream>; - lookup(right: Selection, localKey: TK, otherKey: keyof U): Stream<...>; + join( + right: Stream, + predicate: ( + left: ValueProxy, + right: ValueProxy, + ) => ValueProxyOrValue, + mapper: (left: ValueProxy, right: ValueProxy) => V, + ): Stream>; + joinInner( + right: Stream, + predicate: ( + left: ValueProxy, + right: ValueProxy, + ) => ValueProxyOrValue, + mapper: (left: ValueProxy, right: ValueProxy) => V, + ): Stream>; + lookup( + right: Stream, + localKey: TK, + otherKey: keyof U & string, + ): Stream & Record>; // Grouping - group(index: K, mapper: (stream: Stream, group: ValueProxy) => U): Stream>; + group( + index: K, + mapper: ( + stream: Stream, + group: ValueProxy, + ) => U, + ): Stream>; // Ordering and pagination orderBy(index: string, direction?: "asc" | "desc"): this; @@ -38,24 +69,25 @@ class Stream extends Query { nth(n: Value): Datum; // Aggregation - count(field?: keyof T): Datum; - sum(field?: keyof T): Datum; - avg(field?: keyof T): Datum; - min(field?: keyof T): Datum; - max(field?: keyof T): Datum; + count(field?: keyof T & string): Datum; + sum(field?: keyof T & string): Datum; + avg(field?: keyof T & string): Datum; + min(field?: keyof T & string): Datum; + max(field?: keyof T & string): Datum; distinct(): Datum; - distinct(field: TK): Stream; + distinct(field: undefined): Datum; + distinct(field: TK): Stream; // Change feeds changes(): Query[]>; } ``` -## Field Access +## Field access -Access a specific field from all documents in the stream with `key()`, or provide a default value for null fields with `default()`. +`key()` reads one field from every document in the stream. `default()` gives a fallback value for `null` values. -`key(field, fallback)` takes a field name, or a `ValueProxy` yielding one. It also accepts a fallback. When it is not `undefined`, the result type excludes `null` and `undefined` from the field type and includes the fallback's type; without it, the declared field type is unchanged. +`key(field, fallback)` takes a field name, or a `ValueProxy` that yields one, and an optional fallback. When the fallback is not `undefined`, the result type drops `null` and `undefined` from the field type and adds the fallback's type. Without a fallback, the declared field type is unchanged. ```typescript fragment const users = schema.instance().table("users"); @@ -67,11 +99,11 @@ const emails = await users.key("email"); const phones = await users.key("phone").default("N/A"); ``` -## Transform Data +## Transform data ### `map()` -Transform each document using a mapping function. The callback receives a `ValueProxy` for constructing expressions. +Transforms each document with a mapping function. The callback receives a `ValueProxy` to build expressions with. ```typescript fragment const profiles = await users.map((user) => ({ @@ -83,7 +115,7 @@ const profiles = await users.map((user) => ({ ### `filter()` -Select documents matching a predicate. The callback returns a boolean expression. +Keeps the documents that match a predicate. The callback returns a boolean expression. ```typescript fragment const active = await users.filter((user) => @@ -93,7 +125,7 @@ const active = await users.filter((user) => ### `pluck()` and `without()` -Select or exclude specific fields from all documents. +`pluck()` keeps only the named fields of every document, and `without()` removes them. Both accept only field names of `T`. ```typescript fragment // Keep only specific fields @@ -103,11 +135,11 @@ const slim = await users.pluck("_id", "name", "email"); const safe = await users.without("password", "ssn"); ``` -## Combine Streams +## Combine streams ### `union()` -Concatenate two streams without deduplication. +Concatenates two streams without deduplication. ```typescript fragment const allPremium = await users @@ -115,11 +147,11 @@ const allPremium = await users .union(users.filter((u) => u.key("plan").eq("enterprise"))); ``` -## Join Data +## Join data -### `join()` - Left Join +### `join()` for a left join -Perform a left join between two streams. Every document from the left stream appears in the result, with `null` for the right side when no match exists. +Performs a left join between two streams. Every document from the left stream appears in the result. When no right document matches, the right side is `null`. ```typescript fragment const usersWithOrders = await users.join( @@ -132,9 +164,9 @@ const usersWithOrders = await users.join( ); ``` -### `joinInner()` - Inner Join +### `joinInner()` for an inner join -Perform an inner join. Only documents with matches on both sides appear in the result. +Performs an inner join. Only documents with a match on both sides appear in the result. ```typescript fragment const matched = await users.joinInner( @@ -149,7 +181,7 @@ const matched = await users.joinInner( ### `lookup()` -Replace a foreign key field with the referenced document from another table. See [Lookup](../2.operations/7.lookup.md) for detailed usage. +Replaces a foreign key field, or a list of foreign keys, with the referenced documents from another stream. See [Lookup](../2.operations/7.lookup.md) for the details. ```typescript fragment const postsWithAuthors = await schema @@ -158,9 +190,9 @@ const postsWithAuthors = await schema .lookup(schema.instance().table("users"), "authorId", "_id"); ``` -## Group Data +## Group data -The `group` method partitions the stream by an index value, then applies a mapping function to each group. The mapper receives the group's sub-stream and the group key. +The `group` method splits the stream by an index value, then applies a mapping function to each group. The mapper receives the stream of the group's documents and the group key. Each mapper result is one element of the new stream. ```typescript fragment const statsByRole = await users.group("role", (stream, role) => ({ @@ -170,11 +202,11 @@ const statsByRole = await users.group("role", (stream, role) => ({ })); ``` -## Order and Paginate +## Order and paginate ### `orderBy()` -Sort the stream by an index field. +Sorts the stream by an index field, in ascending or descending order. ```typescript fragment const sorted = await users.orderBy("name", "asc"); @@ -183,7 +215,7 @@ const newest = await users.orderBy("createdAt", "desc"); ### `slice()` -Extract a subsection of the stream for pagination. +Takes a subsection of the stream, from an offset and for a number of documents, for pagination. ```typescript fragment const page1 = await users.orderBy("createdAt").slice(0, 10); @@ -192,15 +224,15 @@ const page2 = await users.orderBy("createdAt").slice(10, 10); ### `nth()` -Retrieve a single document by its position in the stream. Returns a `Datum`. +Retrieves one document by its position in the stream. Returns a `Datum`. ```typescript fragment const first = await users.orderBy("createdAt").nth(0); ``` -## Aggregate Data +## Aggregate data -Stream provides several aggregation methods that return a `Datum`: +These aggregation methods return a `Datum`. `count(field)` counts the distinct values of the field, and `count()` counts the documents. ```typescript fragment const totalUsers = await users.count(); @@ -213,7 +245,7 @@ const minOrder = await schema.instance().table("orders").min("amount"); ### `distinct()` -Retrieve deduplicated documents or distinct values of a specific field. +Without a field, `distinct()` returns a `Datum` of the deduplicated documents. With a field, it returns a `Stream` of that field's distinct values. ```typescript fragment // All distinct documents @@ -223,9 +255,9 @@ const unique = await users.distinct(); const roles = await users.distinct("role"); ``` -## Change Feeds +## Change feeds -The `changes` method subscribes to real-time change notifications on the stream. +The `changes` method turns the stream into a feed of real-time change notifications. ```typescript fragment for await (const change of users.changes()) { @@ -233,6 +265,6 @@ for await (const change of users.changes()) { } ``` -An open-ended feed may never produce a completed array. Consume events incrementally, and stop the loop when the consumer no longer needs them. +An open-ended feed may never produce a completed array. Consume the events one at a time, and stop the loop when the consumer no longer needs them. -See [Change Feeds](./6.feed.md) for more details. +See [Change feeds](./6.feed.md) for the details. diff --git a/docs/1.query_types/6.feed.md b/docs/1.query_types/6.feed.md index 84da48d..bc79dca 100644 --- a/docs/1.query_types/6.feed.md +++ b/docs/1.query_types/6.feed.md @@ -1,8 +1,12 @@ -# Change Feeds +--- +title: Change feeds +description: How changes() turns a stream or a single selection into a feed of change events, the Changes event shape, and how to consume a feed. +mode: reference +--- -Change feeds provide real-time notifications when documents are inserted, updated, or deleted. Both `Stream` and `SingleSelection` expose a `changes()` method that returns change events. +A change feed sends a notification when a document is inserted, updated, or deleted. Both `Stream` and `SingleSelection` expose a `changes()` method that returns change events. -## The Changes Interface +## The `Changes` interface ```typescript interface Changes { @@ -12,20 +16,21 @@ interface Changes { } ``` -| Property | Description | -| ------------ | ------------------------------------------------------------------ | -| `changeType` | The type of change: `"added"`, `"removed"`, or `"modified"`. | -| `oldValue` | The document value before the change. Undefined for new documents. | -| `newValue` | The document value after the change. Undefined for deletions. | +| Property | Description | +| ------------ | -------------------------------------------------------------------- | +| `changeType` | The type of change: `"added"`, `"removed"`, or `"modified"`. | +| `oldValue` | The document value before the change. `undefined` for new documents. | +| `newValue` | The document value after the change. `undefined` for deletions. | -## Create a Change Feed +## Create a change feed -Call `changes()` on a stream or single selection to build a feed query. Consume it with async iteration to receive events as they arrive: +Calling `changes()` on a stream or a single selection builds a feed query. Async iteration over the feed receives the events as they arrive: ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error("Define the myapp schema first"); const users = schema.instance().table("users"); // Monitor all changes to a table @@ -35,9 +40,9 @@ const allChanges = users.changes(); const userChanges = users.get("user-123").changes(); ``` -## Process Change Events +## Process change events -Each change event includes a `changeType` field that indicates the nature of the change: +Each change event has a `changeType` field that tells what kind of change happened: ```typescript fragment for await (const change of users.changes()) { @@ -52,9 +57,9 @@ for await (const change of users.changes()) { } ``` -## Use with Async Iteration +## Use with async iteration -`changes()` returns `Query[]>`, but `Query`'s cursor and async iterator yield each array element: one `Changes` event, not a batch. A direct `await` uses `run()` and asks the provider for a completed result; an open-ended feed may never complete, so use iteration for continuous monitoring. +`changes()` returns `Query[]>`, but the cursor and the async iterator of `Query` yield each array element. Each iteration gives one `Changes` event, not a batch. A direct `await` calls `run()` and asks the provider for a completed result. An open-ended feed may never complete, so use iteration to watch a feed continuously. ```ts docs-check import { Schema } from "@antelopejs/interface-database"; @@ -77,13 +82,13 @@ export async function watchUsers(onChange: (change: Changes) => boolean) { } ``` -Breaking out of the loop closes its cursor. When using `cursor()` manually, call `return()` when finished. +Breaking out of the loop closes its cursor. If you call `cursor()` yourself, call `return()` on the cursor when you are done. -## Use Cases +## Use cases -Change feeds are well suited for: +Change feeds fit these uses: -- **Real-time UI updates** - Push changes to connected clients as they happen. -- **Event-driven architectures** - React to data changes with downstream processing. -- **Change observation** - Observe writes while a feed is active. Durable auditing requires provider-specific delivery and recovery guarantees; this interface alone does not promise a complete audit log. -- **Cache invalidation** - Clear or update caches when the underlying data changes. +- Real-time UI updates, which push changes to connected clients as they happen. +- Event-driven architectures, which react to data changes with downstream processing. +- Change observation, which sees writes while a feed is active. Durable auditing needs delivery and recovery guarantees from the provider. This interface alone does not promise a complete audit log. +- Cache invalidation, which clears or updates caches when the underlying data changes. diff --git a/docs/1.query_types/7.query.md b/docs/1.query_types/7.query.md index 7a73d00..a753e6f 100644 --- a/docs/1.query_types/7.query.md +++ b/docs/1.query_types/7.query.md @@ -1,51 +1,60 @@ -# Query +--- +title: Query +description: The Query base class and the ways to execute a query, with run(), await, a cursor, or for await...of, plus lazy composition. +mode: reference +--- -The `Query` class is the base class for all query types in the AQL system. It implements `PromiseLike`, so queries can be awaited directly, and provides cursor-based async iteration for streaming results. +The `Query` class is the base class of every AQL query type. It implements `PromiseLike`, so you can await a query directly. It also provides cursor-based async iteration that yields results one at a time. -## Class Definition +## Class definition ```typescript fragment class Query extends StagedObject implements PromiseLike { run(): Promise; - then( - onfulfilled?: (value: T) => TResult1 | PromiseLike, - onrejected?: (reason: any) => TResult2 | PromiseLike, + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): PromiseLike; - cursor(): AsyncGenerator ? T1 : T, void, unknown>; - [Symbol.asyncIterator](): AsyncGenerator<...>; + cursor(): AsyncGenerator ? U : T, void, unknown>; + [Symbol.asyncIterator](): AsyncGenerator< + T extends Array ? U : T, + void, + unknown + >; } ``` -## Execute a Query +## Execute a query ### `run()` -Execute the query pipeline and return the result as a promise. +Executes the query pipeline and returns the result as a promise. ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error("Define the myapp schema first"); const users = await schema.instance().table("users").run(); ``` ### Direct `await` -Since `Query` implements `PromiseLike`, you can await it directly without calling `run()`: +`Query` implements `PromiseLike`, so you can await it directly without calling `run()`: ```typescript fragment const users = await schema.instance().table("users"); ``` -Both approaches are equivalent. Use whichever reads better in context. +Both forms run the same stages: `then()` calls `run()`. -## Async Iteration +## Async iteration -Array-valued queries yield one element at a time, including one `Changes` event per iteration of a change feed. [Atomic mutations](../2.operations/8.atomic_mutations.md) are an exception: they reject cursor execution and must be awaited directly or executed with `run()`. +A query whose result is an array yields one element at a time. A change feed yields one `Changes` event per iteration. [Atomic mutations](../2.operations/8.atomic_mutations.md) are the exception. Their `cursor()` throws a `TypeError`, so await them directly or execute them with `run()`. ### `cursor()` -Return an `AsyncGenerator` that yields results one at a time. This is useful for processing large result sets without loading everything into memory. +Returns an `AsyncGenerator` that yields results one at a time. Each `next()` call reads one result from the provider, so you can process a large result set without holding all of it at once. ```typescript fragment const cursor = schema.instance().table("users").cursor(); @@ -59,7 +68,7 @@ while (!result.done) { ### `for await...of` -The `[Symbol.asyncIterator]()` method enables the `for await...of` syntax: +The `[Symbol.asyncIterator]()` method returns the query's cursor, which enables the `for await...of` syntax: ```typescript fragment for await (const user of schema.instance().table("users")) { @@ -67,9 +76,9 @@ for await (const user of schema.instance().table("users")) { } ``` -## Query Composition +## Query composition -Queries use lazy evaluation. Operations are recorded as pipeline stages and only execute when `run()`, `then()`, or iteration begins. This means you can build queries incrementally: +Queries are lazy. Each operation records a pipeline stage, and the stages execute only when `run()`, `then()`, or iteration starts. You can therefore build queries step by step: ```typescript fragment const usersTable = schema.instance().table("users"); diff --git a/docs/1.query_types/8.datum.md b/docs/1.query_types/8.datum.md index d3c29c5..5640183 100644 --- a/docs/1.query_types/8.datum.md +++ b/docs/1.query_types/8.datum.md @@ -1,30 +1,46 @@ -# Datum +--- +title: Datum +description: The Datum query type for a single value, with its field access, default, transformation, lookup, pluck, and cast methods. +mode: reference +--- -The `Datum` class represents a single value in the query system. It extends `Query` and provides methods for accessing fields, applying transformations, and performing foreign key lookups on individual values. +The `Datum` class represents a single value in a query. It extends `Query` and adds methods that read fields, apply transformations, and perform foreign key lookups on that value. -## Class Definition +## Class definition ```typescript fragment class Datum extends Query { cast(): Datum; - key, U = undefined>(key: K, def?: U): Datum< + key & string, U = undefined>( + key: K, + def?: U & QueryValue, + ): Datum< U extends undefined ? NonNullable[K] : NonNullable[K]> | U >; - default(val: Value): Datum | U>; + default( + val: Value, + ): Datum | U>; do(mapper: (val: ValueProxy) => U): Datum>; - lookup(other: Selection, localKey: keyof T, otherKey: keyof U): Datum<...>; - pluck(...fields: string[]): Datum>; + lookup( + other: Stream, + localKey: TK, + otherKey: keyof U & string, + ): Datum & Record>; + pluck & string>( + ...fields: K[] + ): Datum, K> | Extract>; } ``` -## Field Access +## Field access -The `key` method retrieves a field from a document datum. You can chain `key` calls for nested field access. A non-undefined fallback removes `null` and `undefined` from the declared field result and adds the fallback's type. Omitting the fallback preserves the declared field type. +The `key` method reads a field from a document datum. Chain `key` calls to read a nested field. A fallback that is not `undefined` removes `null` and `undefined` from the declared field type and adds the fallback's type. Without a fallback, the declared field type is unchanged. ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error("Define the myapp schema first"); const users = schema.instance().table("users"); // Access a field @@ -37,9 +53,9 @@ const city = await users.get("user-123").key("address").key("city"); const phone = await users.get("user-123").key("phone", "N/A"); ``` -## Default Values +## Default values -The `default` method provides a fallback value when the datum is null or undefined. +The `default` method gives a fallback value for a datum that is `null`. ```typescript fragment const user = await users.get("maybe-missing").default({ @@ -51,7 +67,7 @@ const user = await users.get("maybe-missing").default({ ## Transform with `do()` -The `do` method applies a transformation function to the datum. The callback receives a `ValueProxy` that provides operators for building expressions. +The `do` method applies a transformation function to the datum. The callback receives a `ValueProxy`, whose operators build expressions. ```typescript fragment const formatted = await users.get("user-123").do((user) => ({ @@ -61,9 +77,9 @@ const formatted = await users.get("user-123").do((user) => ({ })); ``` -## Foreign Key Lookup +## Foreign key lookup -The `lookup` method replaces a foreign key field with the referenced document from another table. +The `lookup` method replaces a foreign key field with the referenced document from another stream, such as a table. A field that holds a list of keys becomes a list of documents. ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -79,7 +95,8 @@ interface User { name: string; } -const blog = Schema.get<{ posts: Post; users: User }>("blog")!; +const blog = Schema.get<{ posts: Post; users: User }>("blog"); +if (!blog) throw new Error("Define the blog schema first"); const post = await blog .instance() .table("posts") @@ -90,17 +107,17 @@ const post = await blog console.log(post.authorId.name); ``` -## Field Selection +## Field selection -The `pluck` method selects specific fields from a document datum, discarding the rest. +The `pluck` method keeps the named fields of a document datum and discards the rest. ```typescript fragment const profile = await users.get("user-123").pluck("name", "email", "avatar"); ``` -## Type Casting +## Type casting -The `cast` method changes the TypeScript type of the datum without performing any runtime conversion. Use this when you know the actual type differs from what the compiler infers. +The `cast` method changes the TypeScript type of the datum and performs no runtime conversion. Use it when the actual type differs from what the compiler infers. Nothing checks that the new type matches what the query returns. ```typescript fragment const raw = users.get("user-123").cast<{ name: string; role: string }>(); diff --git a/docs/1.query_types/9.valueproxy.md b/docs/1.query_types/9.valueproxy.md index 43e1f15..86be34f 100644 --- a/docs/1.query_types/9.valueproxy.md +++ b/docs/1.query_types/9.valueproxy.md @@ -1,14 +1,18 @@ -# ValueProxy +--- +title: ValueProxy +description: The ValueProxy class that query callbacks receive, and its operators on any value, numbers, dates, strings, arrays, and objects. +mode: reference +--- -The `ValueProxy` class acts as a proxy to a database value. Operations on a ValueProxy are not executed immediately but are recorded as query stages. ValueProxy objects appear inside callback functions for `map`, `filter`, `do`, `update`, `join`, and other query methods. +The `ValueProxy` class is a proxy to a value in the database. An operation on a `ValueProxy` does not run right away. The proxy records it as a query stage. Callbacks of `map`, `filter`, `do`, `update`, `join`, and other query methods receive `ValueProxy` objects. -## Class Definition +## Class definition -ValueProxy provides type-specific methods based on the underlying value type. The available operations are organized by category. +Which `ValueProxy` methods compile depends on the type of the underlying value. This page groups the operations by that type. -## Create a ValueProxy +## Create a `ValueProxy` -ValueProxy instances are created automatically when you use callback-based query methods. You can also create one from a constant value: +Query methods that take a callback create the `ValueProxy` instances they pass to it. `ValueProxy.constant()` creates one from a constant value: ```typescript import { ValueProxy } from "@antelopejs/interface-database"; @@ -21,19 +25,19 @@ A constant, and any operand a stage records (`eq`, `ne`, `default`, `gt`, `inclu `eq`, `ne`, `gt`, `ge`, `lt`, `le` and `includes` check the operand against the type of the proxy, so a record type declared with `interface` is accepted there. `ValueProxy.constant`, `default`, `sub`, `merge` and the default of `key` infer their type from the argument and need a `QueryValue`: an `interface` has no implicit index signature and does not compile, so declare the record with `type`. -## General Operations +## General operations -These methods are available on all ValueProxy types. +These methods are available on every `ValueProxy`. ### `default(value)` -Return the given value if the proxy is null. +Returns the given value if the proxy is `null`. ```typescript fragment const phone = user.key("phone").default("N/A"); ``` -### `eq(value)` / `ne(value)` +### `eq(value)` and `ne(value)` Equality and inequality comparison. @@ -42,9 +46,9 @@ const isActive = user.key("status").eq("active"); const isNotAdmin = user.key("role").ne("admin"); ``` -### `and(value)` / `or(value)` / `not()` +### `and(value)`, `or(value)`, and `not()` -Logical operators for combining boolean expressions. The operand of `and` and `or` compiles when it is a `boolean`, `null`, `undefined`, `any`, or a `ValueProxy` of `boolean`, `boolean | null` or `boolean | undefined`. A `string`, a `number`, a `ValueProxy`, a `ValueProxy`, a generic `ValueProxy` and a union such as `ValueProxy | undefined` are compile-time errors. The database evaluates operands by truthiness, so the type is narrower than what the runtime accepts. +Logical operators that combine boolean expressions. The operand of `and` and `or` compiles when it is a `boolean`, `null`, `undefined`, `any`, or a `ValueProxy` of `boolean`, `boolean | null` or `boolean | undefined`. A `string`, a `number`, a `ValueProxy`, a `ValueProxy`, a generic `ValueProxy` and a union such as `ValueProxy | undefined` are compile-time errors. The database evaluates operands by truthiness, so the type accepts less than the runtime does. ```typescript fragment const canAccess = user.key("active").and(user.key("verified")); @@ -57,11 +61,11 @@ const isInactive = user.key("active").not(); ### `cast()` -Change the TypeScript type without runtime conversion. +Changes the TypeScript type without a runtime conversion. Nothing checks that the new type matches the value. -## Number Operations +## Number operations -Available when the proxy wraps a numeric value. +These methods compile when the proxy wraps a number. ### Arithmetic @@ -93,9 +97,9 @@ const unshifted = flags.brshift(2); const signPreserved = flags.brshiftPreserveSign(2); ``` -## Comparison Operations +## Comparison operations -Available for `number`, `string`, and `Date` proxy values. +These methods compile for `number`, `string`, and `Date` proxy values. ```typescript fragment const isAdult = age.gt(17); @@ -111,9 +115,9 @@ const isBefore = date.lt(deadline); | `lt(v)` | Less than | | `le(v)` | Less than or equal | -## Date Operations +## Date operations -Available when the proxy wraps a `Date` value. All date component methods accept an optional `timezone` parameter. +These methods compile when the proxy wraps a `Date` value. Every component method except `epoch()` accepts an optional `timezone` parameter. `add` takes a number of seconds. ### Arithmetic @@ -137,7 +141,7 @@ const tod = created.timeofday(); // seconds since midnight const epoch = created.epoch(); // seconds since UNIX epoch ``` -### Range Check +### Range check ```typescript fragment const inRange = created.during( @@ -146,11 +150,11 @@ const inRange = created.during( ); ``` -`during` also takes bounds as milliseconds since the UNIX epoch, as `new Date(n)` reads them. +The lower bound of `during` is inclusive, and the upper bound is exclusive. `during` also takes plain `Date` bounds, or bounds as milliseconds since the UNIX epoch, as `new Date(n)` reads them. -## String Operations +## String operations -Available when the proxy wraps a `string` value. `add` concatenates two strings, as `concat` does, and two lists on a list. A `null` operand answers `null` for any receiver of `add`. +These methods compile when the proxy wraps a `string` value. `add` concatenates two strings, as `concat` does, and two lists on a list. A `null` operand answers `null` for any receiver of `add`. ```typescript fragment const upper = name.upcase(); @@ -161,18 +165,18 @@ const len = name.strlen(); const matches = email.match("^.*@gmail\\.com$"); ``` -| Method | Description | -| ------------------ | ------------------------------- | -| `upcase()` | Convert to uppercase | -| `downcase()` | Convert to lowercase | -| `split(sep, max?)` | Split by separator | -| `concat(other)` | Concatenate with another string | -| `strlen()` | Number of Unicode codepoints | -| `match(regex)` | Test against a regex pattern | +| Method | Description | +| ------------------- | ------------------------------- | +| `upcase()` | Convert to uppercase | +| `downcase()` | Convert to lowercase | +| `split(sep?, max?)` | Split by separator | +| `concat(other)` | Concatenate with another string | +| `strlen()` | Number of Unicode codepoints | +| `match(regex)` | Test against a regex pattern | -## Array Operations +## Array operations -Available when the proxy wraps an array value. +These methods compile when the proxy wraps an array value. ```typescript fragment const first = tags.index(0); @@ -192,9 +196,9 @@ const highest = scores.max(); | --------------- | ----------------------------------------------------------------------------- | | `index(n)` | Element at position `n` | | `includes(val)` | Check if the array contains a value | -| `slice(s, e?)` | Extract a sub-array | +| `slice(s, e?)` | Extract a sub-array from `s` (inclusive) to `e` (exclusive) | | `map(fn)` | Transform each element | -| `filter(fn)` | Keep elements matching a predicate; a number keeps the element unless it is 0 | +| `filter(fn)` | Keep elements matching a predicate. A number keeps the element unless it is 0 | | `isempty()` | Check if the array is empty | | `count()` | Number of elements | | `sum()` | Sum of numeric elements | @@ -202,11 +206,11 @@ const highest = scores.max(); | `min()` | Minimum of numeric, string or date elements | | `max()` | Maximum of numeric, string or date elements | -## Object Operations +## Object operations -Available when the proxy wraps a record/object value. +These methods compile when the proxy wraps an object value. -`key(field, fallback)` preserves the declared field type when the fallback is omitted or `undefined`. Otherwise, the proxy's value type is `Exclude | FallbackType`. The fallback may itself be a `ValueProxy`, so its value type participates in the result. +`key(field, fallback)` keeps the declared field type when the fallback is omitted or `undefined`. Otherwise, the proxy's value type is `Exclude | FallbackType`. The fallback can itself be a `ValueProxy`, and then its value type is part of the result. ```typescript fragment const email = user.key("email"); @@ -225,9 +229,9 @@ const hasRequired = user.hasfields("email", "name"); | `values()` | Array of field values | | `hasfields(...fs)` | Check if all specified fields exist | -## Compose Expressions +## Compose expressions -ValueProxy methods are chainable, enabling complex expressions within query callbacks: +You can chain `ValueProxy` methods to build larger expressions inside query callbacks: ```typescript fragment const result = await users.filter((user) => diff --git a/docs/2.operations/1.index.md b/docs/2.operations/1.index.md index 7c5bab5..b250ffe 100644 --- a/docs/2.operations/1.index.md +++ b/docs/2.operations/1.index.md @@ -1,35 +1,39 @@ -# Operations +--- +title: Operations +description: The AQL operation guides, grouped by what they act on, from schemas and instances to documents, indexes, and queries. +mode: reference +--- -This section covers practical operations you can perform with the AQL interface. These guides walk through schema management, table access, document CRUD, index usage, filtering, and foreign key lookups. +The pages in this section show the operations of the AQL interface: schema management, table access, document CRUD, index use, filtering, and foreign key lookups. -## Schema and Instance Management +## Schema and instance management AQL organizes data through schemas and instances: -- [2. Schema Management](./2.database_management.md) - Define schemas, create and destroy instances +- [2. Schema management](./2.database_management.md). Define schemas, then create and destroy instances. -## Table Access +## Table access Tables live inside schema instances: -- [3. Table Operations](./3.table_operations.md) - Access tables and work with their contents +- [3. Table operations](./3.table_operations.md). Access tables and work with their contents. -## Document Operations +## Document operations -Documents are the fundamental unit of data in AQL: +Documents are the unit of data in AQL: -- [4. CRUD Operations](./4.crud.md) - Insert, read, update, replace, and delete documents -- [8. Atomic Mutations](./8.atomic_mutations.md) - Conditional single-record updates and deletes, with explicit outcomes +- [4. CRUD operations](./4.crud.md). Insert, read, update, replace, and delete documents. +- [8. Atomic single-record mutations](./8.atomic_mutations.md). Conditional single-record updates and deletes, with explicit outcomes. -## Index Operations +## Index operations -Indexes accelerate queries by enabling efficient lookups: +Indexes let the database find documents without a full scan: -- [5. Index Management](./5.indexes.md) - Define secondary indexes in schema definitions +- [5. Index management](./5.indexes.md). Define secondary indexes in schema definitions. -## Query Operations +## Query operations Transform and filter data with the query builder: -- [6. Filtering and Querying](./6.filtering.md) - Filter, compare, and transform documents -- [7. Lookup](./7.lookup.md) - Foreign key joins and data population +- [6. Filtering and querying](./6.filtering.md). Filter, compare, and transform documents. +- [7. Lookup](./7.lookup.md). Foreign key joins that replace a key with the document it references. diff --git a/docs/2.operations/2.database_management.md b/docs/2.operations/2.database_management.md index 3d40e69..8f8bacd 100644 --- a/docs/2.operations/2.database_management.md +++ b/docs/2.operations/2.database_management.md @@ -1,10 +1,14 @@ -# Schema Management +--- +title: Schema management +description: Define an AQL schema, then create, access, list, and destroy its instances, one per tenant if you need isolation. +mode: how-to +--- -AQL organizes data through schemas and instances. A `Schema` defines the structure of your database (tables, fields, and indexes). Each schema can have multiple instances, and the underlying module implementation determines how instances map to physical storage. +AQL organizes data through schemas and instances. A `Schema` defines the structure of your database: tables, fields, and indexes. Each schema can have many instances, and the database module that implements the interface decides how instances map to physical storage. -## Define a Schema +## Define a schema -Create a schema by providing an ID and a definition of tables with their fields and indexes: +Create a schema from an ID and a definition of its tables, each with its fields and indexes: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -26,9 +30,11 @@ const schema = new Schema("myapp", { }); ``` +The ID names the stored data. Changing it leaves the data stored under the previous ID out of reach. + ### `SchemaDefinition` -A schema definition is a record mapping table names to their `TableDefinition`: +A schema definition is a record that maps each table name to its `TableDefinition`: ```typescript import type { FieldType } from "@antelopejs/interface-database/schema"; @@ -46,9 +52,9 @@ interface IndexDefinition { } ``` -## Create an Instance +## Create an instance -After defining a schema, create an instance to start working with data. The optional `id` parameter distinguishes multiple instances of the same schema. +After you define a schema, create an instance to start working with data. The optional `id` parameter tells instances of the same schema apart, and `createInstance()` resolves to the ID of the created instance. ```typescript fragment // Create the default instance @@ -58,9 +64,9 @@ await schema.createInstance(); await schema.createInstance("tenant-abc"); ``` -## Access an Instance +## Access an instance -Retrieve a `SchemaInstance` to access tables: +Get a `SchemaInstance` to access its tables: ```typescript fragment // Default instance @@ -70,9 +76,7 @@ const db = schema.instance(); const tenantDb = schema.instance("tenant-abc"); ``` -To operate across every instance of the schema, pass the `CROSS_INSTANCE` sentinel: - -This deliberately removes the instance boundary for supported queries, including updates and deletes. Keep it out of tenant-controlled request paths. Selecting an instance is not a substitute for authorizing the caller. +To operate across every instance of the schema, pass the `CROSS_INSTANCE` sentinel. It removes the instance boundary for the queries that support it, updates and deletes included. Keep it out of request paths that a tenant controls. Selecting an instance does not authorize the caller. ```typescript fragment import { CROSS_INSTANCE } from "@antelopejs/interface-database"; @@ -80,7 +84,7 @@ import { CROSS_INSTANCE } from "@antelopejs/interface-database"; const everything = schema.instance(CROSS_INSTANCE).table("users"); ``` -## Destroy an Instance +## Destroy an instance Remove an instance and its data: @@ -88,20 +92,20 @@ Remove an instance and its data: await schema.destroyInstance("tenant-abc"); ``` -## List Instances +## List instances -List the IDs of named instances of a schema: +List the IDs of the named instances of a schema: ```typescript fragment const ids = await schema.listInstances(); // ["tenant-abc", "tenant-xyz"] ``` -The default (unnamed) instance is not included. +The list leaves out the default (unnamed) instance. -## Retrieve a Schema +## Retrieve a schema -Use the static `Schema.get()` method to look up a previously defined schema by its ID: +Look up a schema defined elsewhere by its ID with the static `Schema.get()` method. It returns `undefined` when no schema has that ID: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -113,9 +117,9 @@ if (schema) { } ``` -## Work with Tables +## Work with tables -Put the table-to-document mapping on `Schema`, then access tables by name. The generic parameter of `table()` is a key of that mapping, not a document type: +Put the table-to-document mapping on `Schema`, then access tables by name. The type parameter of `table()` is a key of that mapping, not a document type: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -134,17 +138,19 @@ interface Post { authorId: string; } -const typedSchema = Schema.get<{ users: User; posts: Post }>("myapp")!; +const typedSchema = Schema.get<{ users: User; posts: Post }>("myapp"); +if (!typedSchema) throw new Error('Schema "myapp" is not defined'); + const db = typedSchema.instance(); const users = db.table("users"); // Table const posts = db.table("posts"); // Table ``` -Use the same mapping when declaring a schema: `new Schema<{ users: User; posts: Post }>("myapp", definition)`. This type argument describes the schema; it does not validate stored data. +Use the same mapping when you declare a schema: `new Schema<{ users: User; posts: Post }>("myapp", definition)`. The type argument describes the schema. It does not validate stored data, and `Schema.get()` does not check it against the type arguments of the constructor call that registered the ID. -## Multi-Tenant Pattern +## Multi-tenant pattern -Schemas with multiple instances support multi-tenant architectures. Each tenant gets an isolated instance of the same schema: +A schema with many instances suits a multi-tenant application. Each tenant gets an isolated instance of the same schema: ```typescript import { Schema } from "@antelopejs/interface-database"; diff --git a/docs/2.operations/3.table_operations.md b/docs/2.operations/3.table_operations.md index ae94629..37abd68 100644 --- a/docs/2.operations/3.table_operations.md +++ b/docs/2.operations/3.table_operations.md @@ -1,22 +1,28 @@ -# Table Operations +--- +title: Table operations +description: Get a table from a schema instance, type it through the schema mapping, and read, chain, and insert documents on it. +mode: how-to +--- -Tables are the primary containers for documents in AQL. They are accessed through a `SchemaInstance` and provide methods for inserting, retrieving, and querying documents. +A table holds documents in AQL. You get one from a `SchemaInstance`, and it has methods to insert, retrieve, and query documents. -## Access a Table +## Access a table -Retrieve a table reference from a schema instance: +Get a table reference from a schema instance: ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error('Schema "myapp" is not defined'); + const db = schema.instance(); const users = db.table("users"); ``` -## Type-Safe Tables +## Type-safe tables -Provide the table-to-document mapping to `Schema`, not to `table()`: +Give the table-to-document mapping to `Schema`, not to `table()`: ```ts docs-check import { Schema } from "@antelopejs/interface-database"; @@ -46,7 +52,7 @@ const schema = new Schema<{ users: User }>("myapp", { }); const users = schema.instance().table("users"); -// TypeScript now validates field names and types +// TypeScript now checks field names and types export async function readUser() { const user = await users.get("user-123"); // Declared as User const emails = await users.key("email"); // string[] @@ -54,20 +60,20 @@ export async function readUser() { } ``` -`get()` declares `SingleSelection`, so awaiting it is typed as `User`, not `User | null`. This declaration does not guarantee that a row exists: a provider may return `null` for a missing key. Check the result before accessing fields. `Schema`'s generic mapping describes documents but does not validate database contents. +`get()` returns `SingleSelection`, so awaiting it is typed as `User`, not `User | null`. The type does not guarantee that a row exists: a provider may return `null` or `undefined` for a missing key. Check the result before you read its fields. The mapping on `Schema` describes documents. It does not validate what the database holds. -## Basic Operations +## Basic operations -### Retrieve All Documents +### Retrieve all documents -A table reference doubles as a stream of all its documents: +A table reference is also a stream of all its documents: ```typescript fragment const allUsers = await users; console.log(`Total users: ${allUsers.length}`); ``` -### Retrieve by Primary Key +### Retrieve by primary key ```typescript fragment const user = await users.get("user-123"); @@ -77,13 +83,17 @@ if (user) { } ``` -### Retrieve by Secondary Index +### Retrieve by secondary index ```typescript fragment const admins = await users.getAll("admin", "role"); ``` -### Retrieve by Range +Without an index name, `getAll()` matches the primary key. + +### Retrieve by range + +`between()` takes an index name, a lowest value, and a highest value. The highest value is excluded. ```typescript fragment const recent = await users.between( @@ -93,9 +103,9 @@ const recent = await users.between( ); ``` -## Chain Operations +## Chain operations -Since `Table` extends `Selection` and `Stream`, you can chain any stream operation directly: +`Table` extends `Selection`, which extends `Stream`, so you can chain any stream operation on a table: ```typescript fragment // Filter and sort @@ -114,7 +124,7 @@ const avgAge = await users.avg("age"); const names = await users.map((u) => u.key("name")); ``` -## Insert Documents +## Insert documents ```typescript fragment // Single document @@ -137,4 +147,4 @@ await users.insert( ); ``` -For complete CRUD documentation, see [CRUD Operations](./4.crud.md). +For every create, read, update, and delete operation, see [CRUD operations](./4.crud.md). diff --git a/docs/2.operations/4.crud.md b/docs/2.operations/4.crud.md index b94eb4b..25c37e8 100644 --- a/docs/2.operations/4.crud.md +++ b/docs/2.operations/4.crud.md @@ -1,17 +1,25 @@ -# CRUD Operations +--- +title: CRUD operations +description: Insert, read, update, replace, and delete AQL documents, including upserts, duplicate keys, and batch inserts. +mode: how-to +--- -CRUD operations (Create, Read, Update, Delete) form the foundation of document management in AQL. All examples assume a schema is already defined and an instance is available. +CRUD stands for create, read, update, and delete. The examples on this page assume a schema is already defined and use its default instance: ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error('Schema "myapp" is not defined'); + const users = schema.instance().table("users"); ``` -## Create Documents +## Create documents + +### Insert a single document -### Insert a Single Document +`insert()` resolves to the IDs of the inserted documents. ```typescript fragment const ids = await users.insert({ @@ -24,7 +32,7 @@ const ids = await users.insert({ console.log("Inserted ID:", ids[0]); ``` -### Insert Multiple Documents +### Insert multiple documents ```typescript fragment const ids = await users.insert([ @@ -35,9 +43,9 @@ const ids = await users.insert([ console.log("Inserted IDs:", ids); ``` -### Duplicate Keys +### Duplicate keys -Without a `conflict` option, inserting a row whose `_id`, or whose key in a [unique index](./5.indexes.md#unique-indexes), is already taken in the instance throws a `DuplicateKeyError`. Updates and replacements that would duplicate a unique key throw it too. When a batch insert hits a duplicate, whether its other rows are stored depends on the database. +Without a `conflict` option, an insert throws a `DuplicateKeyError` when the instance already holds the row's `_id` or its key in a [unique index](./5.indexes.md#unique-indexes). An update or a replacement that would duplicate a unique key throws it too. Nothing of the refused write is stored for the row that hit the duplicate. When a batch insert hits a duplicate, the database decides whether its other rows are stored. ```typescript fragment import { IsDuplicateKeyError } from "@antelopejs/interface-database"; @@ -50,9 +58,9 @@ try { } ``` -### Upsert (Insert or Update) +### Upsert (insert or update) -Use the `conflict` option to handle documents that already exist: +To handle documents that already exist, set the `conflict` option to `"update"` or `"replace"`: ```typescript fragment // Update existing fields, keep unmentioned fields @@ -68,9 +76,9 @@ await users.insert( ); ``` -## Read Documents +## Read documents -### Get by Primary Key +### Get by primary key ```typescript fragment const user = await users.get("user-123"); @@ -82,14 +90,16 @@ if (user) { } ``` -### Get by Secondary Index +### Get by secondary index ```typescript fragment const admins = await users.getAll("admin", "role"); console.log(`Found ${admins.length} admins`); ``` -### Get by Range +### Get by range + +The upper bound of `between()` is excluded. ```typescript fragment const recent = await users.between( @@ -99,7 +109,7 @@ const recent = await users.between( ); ``` -### Filter Documents +### Filter documents ```typescript fragment // Filter with a predicate function @@ -108,7 +118,7 @@ const active = await users.filter((user) => ); ``` -### Select Specific Fields +### Select specific fields ```typescript fragment // Keep only listed fields @@ -118,16 +128,18 @@ const profiles = await users.pluck("_id", "name", "avatar"); const safe = await users.without("password", "ssn"); ``` -### Order and Paginate +### Order and paginate + +`slice()` takes an offset and a count. ```typescript fragment const page1 = await users.orderBy("createdAt", "desc").slice(0, 10); const page2 = await users.orderBy("createdAt", "desc").slice(10, 10); ``` -### Stream Results +### Stream results -For large result sets, use async iteration to process documents one at a time: +For a large result set, iterate with `for await` to process one document at a time: ```typescript fragment for await (const user of users) { @@ -135,11 +147,13 @@ for await (const user of users) { } ``` -## Update Documents +## Update documents -### Update a Single Document +`update()` takes either a partial document or a function that builds one. It returns a `Query` that resolves to the number of modified documents. -The `update` method modifies specified fields while preserving all other fields. +### Update a single document + +Pass a partial document. `update()` changes the fields it names and keeps every other field. ```typescript fragment const count = await users.get("user-123").update({ @@ -152,9 +166,11 @@ if (count === 1) { } ``` -### Update with Computed Values +Give the partial document a real type. A value typed `any` matches the function overload of `update()`, and that call is typed `never`, so it has no `.run()`. + +### Update with computed values -Pass a function to compute new values based on current ones: +Pass a function to compute new values from the current ones: ```typescript fragment await users.get("user-123").update((user) => ({ @@ -163,7 +179,9 @@ await users.get("user-123").update((user) => ({ })); ``` -### Update Multiple Documents +The call is typed `Query` when the object the function returns fits the document type, and `never` otherwise. + +### Update multiple documents ```typescript fragment const count = await users @@ -173,9 +191,9 @@ const count = await users console.log(`Activated ${count} users`); ``` -## Replace Documents +## Replace documents -The `replace` method substitutes the entire document. Fields not included in the replacement are removed. +`replace()` swaps in the whole document. It removes the fields that the replacement leaves out, and it resolves to the number of modified documents. ```typescript fragment const count = await users.get("user-123").replace({ @@ -187,9 +205,11 @@ const count = await users.get("user-123").replace({ }); ``` -## Delete Documents +## Delete documents + +`delete()` resolves to the number of deleted documents. -### Delete a Single Document +### Delete a single document ```typescript fragment const count = await users.get("user-123").delete(); @@ -199,7 +219,7 @@ if (count === 1) { } ``` -### Delete Multiple Documents +### Delete multiple documents ```typescript fragment const count = await users @@ -209,9 +229,9 @@ const count = await users console.log(`Deleted ${count} inactive users`); ``` -## Batch Operations +## Batch operations -For large-scale inserts, batch documents to manage memory and throughput: +For a large insert, split the documents into batches to bound memory use and the size of each write: ```typescript fragment const documents = Array.from({ length: 10000 }, (_, i) => ({ diff --git a/docs/2.operations/5.indexes.md b/docs/2.operations/5.indexes.md index 52a7c78..402adf9 100644 --- a/docs/2.operations/5.indexes.md +++ b/docs/2.operations/5.indexes.md @@ -1,10 +1,14 @@ -# Index Management +--- +title: Index management +description: Declare simple, compound, multi, cross-instance, and unique indexes in an AQL schema, and query through them. +mode: how-to +--- -Indexes improve query performance by enabling the database to locate documents quickly based on indexed fields. In AQL, indexes are defined as part of the schema definition. +An index lets the database find documents by the indexed fields without scanning the whole table. In AQL, you declare indexes in the schema definition. -## Define Indexes in a Schema +## Define indexes in a schema -Indexes are declared in the `indexes` property of each table definition: +Declare indexes in the `indexes` property of each table definition: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -32,13 +36,13 @@ interface IndexDefinition { } ``` -When `fields` is omitted, the index name is used as the field name. For compound indexes, provide the `fields` array explicitly. +When you omit `fields`, the index name is the field name. For a compound index, list its fields in the `fields` array. -## Use Indexes for Queries +## Use indexes for queries -### `getAll()` with an Index +### `getAll()` with an index -Retrieve documents by index value: +Get the documents whose index value matches one or more keys: ```typescript fragment const db = schema.instance(); @@ -51,26 +55,26 @@ const admins = await users.getAll("admin", "role"); const targets = await users.getAll(["admin", "moderator"], "role"); ``` -### `between()` with an Index +### `between()` with an index -Retrieve documents where the index value falls within a range: +Get the documents whose index value falls in a range. The upper bound is excluded: ```typescript fragment const results = await users.between("age", 18, 65); ``` -### `orderBy()` with an Index +### `orderBy()` with an index -Sort results using an indexed field for better performance: +Sort results on an indexed field for better performance: ```typescript fragment const sorted = await users.orderBy("email", "asc"); const newest = await users.orderBy("createdAt", "desc"); ``` -## Compound Indexes +## Compound indexes -Compound indexes span multiple fields. Define them with the `fields` array: +A compound index spans several fields. Define it with the `fields` array: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -89,11 +93,11 @@ const schema = new Schema("blog", { }); ``` -Compound indexes support prefix-based lookups. You can query by the first field alone or by both fields together. +A compound index supports prefix lookups. You can query by the first field alone or by both fields together. -## Multi Indexes +## Multi indexes -Set `multi: true` for fields that contain arrays. The database creates an index entry for each element in the array, enabling lookups by any single element: +Set `multi: true` for a field that holds an array. The database creates an index entry for each element of the array, so a lookup by any single element finds the document: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -108,9 +112,9 @@ const schema = new Schema("content", { }); ``` -## Cross-Instance Indexes +## Cross-instance indexes -Indexes are optimized for queries scoped to one instance (`schema.instance(id)`). Queries run with `schema.instance(CROSS_INSTANCE)` can still use them, but may be slow. Set `crossInstance: true` on the indexes that cross-instance queries rely on, and the driver also maintains an index usable by those queries: +An index is optimized for queries scoped to one instance (`schema.instance(id)`). A query run with `schema.instance(CROSS_INSTANCE)` can still use it, but may be slow. Set `crossInstance: true` on the indexes that cross-instance queries rely on, and the driver also maintains an index usable by those queries: ```typescript import { CROSS_INSTANCE, Schema } from "@antelopejs/interface-database"; @@ -131,9 +135,9 @@ await schema .getAll("unpaid", "status"); ``` -The flag defaults to `false`. It never changes query results, only performance. Each flagged index costs extra storage and write overhead, so only set it where cross-instance queries use the index. +The flag defaults to `false`. It never changes query results, only performance. Each flagged index costs extra storage and write overhead, so set it only where cross-instance queries use the index. -## Unique Indexes +## Unique indexes Set `unique: true` to refuse a second row with the same key in one instance. The write that would store it throws a `DuplicateKeyError`, which names the index and its fields. The primary key `_id` is always unique and throws the same error, with `index` set to `"_id"`: @@ -165,13 +169,13 @@ try { } ``` -The key is unique within each instance: two instances may hold the same email. `crossInstance` does not change that. Give every row a value for the fields of a unique index, since databases differ on whether rows missing them conflict. +The key is unique within each instance, so two instances may hold the same email. `crossInstance` does not change that. Give every row a value for the fields of a unique index, because databases differ on whether rows that lack them conflict. -Recognize the error with `IsDuplicateKeyError` rather than `instanceof`: the database module may be bound to another copy of the interface. +Recognize the error with `IsDuplicateKeyError` rather than `instanceof`. The database module may be bound to another copy of the interface. -## Index Design Guidelines +## Index design guidelines -- **Index fields used in `getAll()` and `between()` calls** for best performance. -- **Compound indexes** are useful when you frequently filter or sort by multiple fields together. -- **Avoid over-indexing.** Each index adds storage overhead and slows down writes. -- **Filter early** in your query chain to reduce the number of documents processed. +- Index the fields that your `getAll()` and `between()` calls use. +- Add a compound index when you often filter or sort by the same fields together. +- Avoid over-indexing. Each index adds storage and slows down writes. +- Filter early in the query chain to reduce the number of documents processed. diff --git a/docs/2.operations/6.filtering.md b/docs/2.operations/6.filtering.md index ba51a12..88265e0 100644 --- a/docs/2.operations/6.filtering.md +++ b/docs/2.operations/6.filtering.md @@ -1,32 +1,38 @@ -# Filtering and Querying +--- +title: Filtering and querying +description: Select, compare, and transform AQL documents with filter predicates and ValueProxy operators, then sort and paginate the result. +mode: how-to +--- -This guide covers techniques for selecting, comparing, and transforming documents using AQL's filter and ValueProxy capabilities. +This guide shows how to select, compare, and transform documents with `filter()` and the `ValueProxy` operators. The examples use this table: ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error('Schema "myapp" is not defined'); + const users = schema.instance().table("users"); ``` -## Basic Filtering +## Basic filtering -Use the `filter` method with a predicate function. The callback receives a `ValueProxy` representing each document. +Call `filter()` with a predicate function. The predicate receives a `ValueProxy` that stands for each document. ```typescript fragment const active = await users.filter((user) => user.key("status").eq("active")); ``` -## Comparison Operators +## Comparison operators -### Equality and Inequality +### Equality and inequality ```typescript fragment const admins = await users.filter((u) => u.key("role").eq("admin")); const nonAdmins = await users.filter((u) => u.key("role").ne("admin")); ``` -### Numeric Comparisons +### Numeric comparisons ```typescript fragment const adults = await users.filter((u) => u.key("age").ge(18)); @@ -35,7 +41,7 @@ const young = await users.filter((u) => u.key("age").lt(18)); const discounted = await users.filter((u) => u.key("age").le(25)); ``` -## Logical Operators +## Logical operators Combine conditions with `and()`, `or()`, and `not()`: @@ -62,7 +68,9 @@ const target = await users.filter((u) => ); ``` -## String Operations +## String operations + +`match()` takes a regular expression as a string. ```typescript fragment // Case-insensitive match @@ -79,9 +87,9 @@ const fullNameMatch = await users.filter((u) => ); ``` -## Date Filtering +## Date filtering -### Date Comparisons +### Date comparisons ```typescript fragment const newUsers = await users.filter((u) => @@ -96,7 +104,7 @@ const q1Users = await users.filter((u) => ); ``` -### Date Components +### Date components ```typescript fragment const thisYear = await users.filter((u) => u.key("createdAt").year().eq(2024)); @@ -106,7 +114,7 @@ const morningLogins = await users.filter((u) => ); ``` -## Array Operations +## Array operations ```typescript fragment // Check array contents @@ -128,9 +136,9 @@ const untagged = await schema .filter((post) => post.key("tags").isempty()); ``` -## Default Values in Filters +## Default values in filters -Use `default()` to handle potentially null fields: +To compare a field that may be null, give it a fallback with `default()`: ```typescript fragment const activeUsers = await users.filter((u) => @@ -142,7 +150,9 @@ const frequentVisitors = await users.filter((u) => ); ``` -## Ordering and Pagination +## Ordering and pagination + +`orderBy()` takes the name of the index to sort by and an optional direction, `"asc"` or `"desc"`. `slice()` takes an offset and a count. ```typescript fragment // Sort ascending @@ -156,12 +166,12 @@ const page1 = await users.orderBy("createdAt").slice(0, 10); const page2 = await users.orderBy("createdAt").slice(10, 10); ``` -## Performance Tips +## Performance tips -1. **Use `getAll()` and `between()` with indexes** instead of `filter()` when possible. Index-based lookups are significantly faster than full scans. -2. **Filter early** in the query chain to reduce the dataset before applying transformations or aggregations. -3. **Limit result sets** with `slice()` to avoid transferring unnecessary data. -4. **Avoid complex computations** inside filter predicates when simpler alternatives exist. +1. Use `getAll()` and `between()` with an index instead of `filter()` where you can. An index lookup avoids the full table scan that a `filter()` runs. +2. Filter early in the query chain, so later transformations and aggregations see fewer documents. +3. Limit result sets with `slice()` to avoid transferring data you do not use. +4. Keep filter predicates simple when a simpler predicate gives the same result. ```typescript fragment // Slower: full table scan with filter diff --git a/docs/2.operations/7.lookup.md b/docs/2.operations/7.lookup.md index 0a70c60..95b515f 100644 --- a/docs/2.operations/7.lookup.md +++ b/docs/2.operations/7.lookup.md @@ -1,24 +1,30 @@ -# Lookup +--- +title: Lookup +description: Replace a foreign key field of AQL documents with the document, or the array of documents, it references in another table. +mode: how-to +--- -The `lookup` method performs a foreign key join, replacing a field in your documents with the referenced data from another table. It is available on both `Stream` and `Datum`. +`lookup()` runs a foreign key join. It replaces a field of your documents with the documents that field references in another table. Both `Stream` and `Datum` have it. ## Parameters -| Parameter | Type | Description | -| ---------- | -------------- | --------------------------------------------------------- | -| `other` | `Selection` | The table containing the referenced documents | -| `localKey` | `keyof T` | The field in the current document holding the foreign key | -| `otherKey` | `keyof U` | The field in the other table to match against | +| Parameter | Type | Description | +| ---------- | ------------------ | --------------------------------------------------------- | +| `other` | `Stream` | The table containing the referenced documents | +| `localKey` | `keyof T & string` | The field in the current document holding the foreign key | +| `otherKey` | `keyof U & string` | The field in the other table to match against | + +`Stream.lookup()` names its first parameter `right`. `Datum.lookup()` names it `other`. ## Behavior -The `lookup` method replaces the local key field with the populated data: +`lookup()` replaces the local key field with the referenced data: -- If the local key contains a **single value**, the field becomes a **single document** from the other table. -- If the local key contains an **array of values**, the field becomes an **array of documents** from the other table. -- TypeScript automatically tracks the type transformation. +- If the local key holds a **single value**, the field becomes a **single document** from the other table. +- If the local key holds an **array of values**, the field becomes an **array of documents** from the other table. +- The result type follows the same rule: `Omit & Record`, where `TK` is the local key. -## One-to-One Relationship +## One-to-one relationship Replace a single foreign key with the referenced document: @@ -37,7 +43,9 @@ interface User { email: string; } -const schema = Schema.get<{ posts: Post; users: User }>("blog")!; +const schema = Schema.get<{ posts: Post; users: User }>("blog"); +if (!schema) throw new Error('Schema "blog" is not defined'); + const db = schema.instance(); const postsWithAuthors = await db @@ -50,7 +58,7 @@ for (const post of postsWithAuthors) { } ``` -## One-to-Many Relationship +## One-to-many relationship Replace an array of foreign keys with an array of documents: @@ -70,9 +78,10 @@ interface Product { price: number; } -const shop = Schema.get<{ orders: Order; products: Product }>( - "shop", -)!.instance(); +const shopSchema = Schema.get<{ orders: Order; products: Product }>("shop"); +if (!shopSchema) throw new Error('Schema "shop" is not defined'); + +const shop = shopSchema.instance(); const ordersWithProducts = await shop .table("orders") .lookup(shop.table("products"), "productIds", "_id"); @@ -85,13 +94,15 @@ for (const order of ordersWithProducts) { } ``` -## Combine with Other Operations +## Combine with other operations -Lookup works seamlessly with filters, sorting, and other stream operations: +`lookup()` chains with filters, sorting, and the other stream operations: ```typescript fragment // The shop schema also declares customers and orders.createdAt. -const db = Schema.get("shop")!.instance(); +const shopSchema = Schema.get("shop"); +if (!shopSchema) throw new Error('Schema "shop" is not defined'); +const db = shopSchema.instance(); // Filter before lookup const recentWithCustomers = await db @@ -100,25 +111,27 @@ const recentWithCustomers = await db .lookup(db.table("customers"), "customerId", "_id"); // Lookup on a single document -const blog = Schema.get<{ posts: Post; users: User }>("blog")!.instance(); +const blogSchema = Schema.get<{ posts: Post; users: User }>("blog"); +if (!blogSchema) throw new Error('Schema "blog" is not defined'); +const blog = blogSchema.instance(); const post = await blog .table("posts") .get("post-1") .lookup(blog.table("users"), "authorId", "_id"); ``` -## Lookup vs Join +## Lookup vs join ### Use `lookup` when: -- You have a straightforward foreign key relationship. +- You have a plain foreign key relationship. - You want the referenced data to replace the foreign key field. -- You are working with one-to-one or one-to-many relationships. +- You work with one-to-one or one-to-many relationships. ### Use `join` or `joinInner` when: -- You need custom join predicates beyond simple equality. -- You want to keep both original and joined data as separate fields. +- You need a join predicate other than equality of two fields. +- You want to keep the original and the joined data as separate fields. - You need to transform the result into a custom structure. ### Comparison diff --git a/docs/2.operations/8.atomic_mutations.md b/docs/2.operations/8.atomic_mutations.md index 56743b5..1a44b9b 100644 --- a/docs/2.operations/8.atomic_mutations.md +++ b/docs/2.operations/8.atomic_mutations.md @@ -1,12 +1,16 @@ -# Atomic single-record mutations +--- +title: Atomic single-record mutations +description: The contract of Table.atomicMutation, which checks one record's revision or one field value and patches or deletes that record in one database mutation. +mode: reference +--- -`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. +`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 coordination of external effects. -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. +Generic `filter(...).update(...)` and `filter(...).delete()` do not give this guarantee. Call it on 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. +`@antelopejs/interface-database` and `@antelopejs/interface-database/atomic` export the public types. An update checks the current revision and installs the patch and the next revision together. A delete checks the revision inside the deletion. ```ts fragment const outcome = await table.atomicMutation(recordId, { @@ -24,19 +28,19 @@ const deletion = await table.atomicMutation(recordId, { }); ``` -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. +Revision tokens are nonempty strings. Each update must install a changed token that the caller guarantees was never used for that identity. Every writer of protected state must advance the revision. If one does not, 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. +`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 add revisions to existing unversioned records atomically. A delete can use it when the domain permits deleting an unversioned record. A backend that cannot tell absence from null must reject this expectation rather than weaken it. In PostgreSQL, declared SQL columns lose this distinction, and an overflow JSON field can keep it. Review how your mapper serializes the record before you choose where to store the revision. ## 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. +The patch replaces each top-level field it supplies. 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. +A patch holds only constant strings, booleans, finite numbers, null, valid Dates, dense arrays, and plain objects. `ValidateAtomicMutation` rejects undefined, functions, query proxies, cycles, and field names that contain `.` or start with `$`. The patch cannot contain `id`, `_id`, the revision field, or scope fields that the adapter owns. Adapters must keep their normal Date storage representation, and must not run objects that resemble query stages as 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. +`deleteIfEqual` is a narrower condition for domains where equality of one field is enough. An example is deleting an expired record whose observed timestamp has not changed. ```ts fragment const outcome = await table.atomicMutation(recordId, { @@ -46,9 +50,9 @@ const outcome = await table.atomicMutation(recordId, { }); ``` -`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. +`expectedValue` accepts a string, a finite number, a boolean, or a valid Date. Equality is exact and type-sensitive, and Dates compare by their stored instant. A scalar must not match an array that contains 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. +This variant does not check or advance a revision, and it does not prevent ABA: a value can change and later return to its observed value. The domain must prove that equality alone authorizes the deletion. It does not replace revision checks on state transitions. ## Handle all outcomes @@ -58,14 +62,14 @@ This variant does not check or advance a revision and does not prevent ABA: a va | `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. +Definitive validation errors and unsupported-operation errors throw. Neither an exception nor `unknown` means a 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 passed verification before you rely 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. +The interface and the adapter must not retry an uncertain mutation on their own. Do not repeat external effects, and do not 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 it 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`. +The canonical terminal stage is `{ stage: "atomicMutation", options: undefined, args: [id, request] }`, right 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. +Adapters can reuse `ValidateAtomicMutation(id, request, protectedFields)` and `ValidateAtomicMutationTable(prefixStages)`. They must also validate the terminal envelope and the restrictions of their schema. Scope, immutable identity, and the revision or equality condition must take part in the native mutation on the target record, not only in an earlier selection or identity subquery. Backend tests must prove behavior under contention, scope isolation, the distinction between a missing field and null, top-level replacement, and behavior on acknowledgement loss. -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. +The metadata of an adapter's package does not show whether the adapter implements this contract. Check its implementation and its backend tests. diff --git a/docs/3.results/1.index.md b/docs/3.results/1.index.md index 4c6d650..044d552 100644 --- a/docs/3.results/1.index.md +++ b/docs/3.results/1.index.md @@ -1,22 +1,26 @@ -# Results +--- +title: Results +description: The values AQL queries resolve to, and the schema, table, and index definitions and change events they rest on. +mode: reference +--- -AQL operations return structured result values that communicate what happened during execution. This section covers the result types produced by write operations and change feeds. +An AQL query resolves to a value that says what the operation did. The pages in this section cover the results of write operations, the change events of change feeds, and the schema, table, and index definitions. -## Result Types +## Result types -- [2. Insert Results](./2.write_results.md) - Return values from insert operations -- [3. Index Definitions](./3.index_changes.md) - Index configuration in schema definitions -- [4. Table Definitions](./4.table_changes.md) - Table structure and field types -- [5. Schema and Instance Management](./5.database_changes.md) - Schema lifecycle and instance management -- [6. Change Events](./6.value_changes.md) - Structure of change feed notifications +- [2. Write results](./2.write_results.md). Return values of insert, update, replace, and delete. +- [3. Index definitions](./3.index_changes.md). Index configuration in schema definitions. +- [4. Table definitions](./4.table_changes.md). Table structure and field types. +- [5. Schema and instance management](./5.database_changes.md). Schema lifecycle and instance management. +- [6. Change events](./6.value_changes.md). Structure of change feed notifications. -## Common Patterns +## Common patterns -All AQL operations follow a consistent pattern: +Every AQL operation follows the same pattern: 1. Build a query by chaining operations. -2. Execute the query with `run()` or `await` to get the result. -3. Process the structured result. +2. Run the query with `run()` or `await` to get the result. +3. Process the result. ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -28,14 +32,16 @@ interface User { status: string; } -const schema = Schema.get<{ users: User }>("myapp")!; +const schema = Schema.get<{ users: User }>("myapp"); +if (!schema) throw new Error('Schema "myapp" is not defined'); + const users = schema.instance().table("users"); -// Insert returns an array of generated IDs +// Insert returns the IDs of the inserted documents const ids = await users.insert({ name: "Alice", email: "alice@example.com" }); console.log("New ID:", ids[0]); -// Update/delete return the number of affected documents +// Update returns the number of modified documents const count = await users.get("user-123").update({ status: "active" }); console.log(`Modified ${count} documents`); diff --git a/docs/3.results/2.write_results.md b/docs/3.results/2.write_results.md index d08ede7..1ee45ca 100644 --- a/docs/3.results/2.write_results.md +++ b/docs/3.results/2.write_results.md @@ -1,8 +1,12 @@ -# Insert Results +--- +title: Write results +description: What insert, update, replace, and delete resolve to in AQL, from the inserted IDs to the count of modified documents. +mode: reference +--- -The `insert` method on `Table` returns a `Query` that resolves to an array of the generated or provided primary keys for the inserted documents. +`Table.insert()` returns a `Query` that resolves to the primary keys of the inserted documents, generated or provided. -## Return Value +## Return value ```typescript fragment // Insert returns string[] (the IDs of inserted documents) @@ -14,7 +18,7 @@ const ids: string[] = await users.insert({ ## Examples -### Single Document Insert +### Single document insert ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -25,7 +29,9 @@ interface User { email: string; } -const schema = Schema.get<{ users: User }>("myapp")!; +const schema = Schema.get<{ users: User }>("myapp"); +if (!schema) throw new Error('Schema "myapp" is not defined'); + const users = schema.instance().table("users"); const ids = await users.insert({ @@ -36,7 +42,7 @@ const ids = await users.insert({ console.log("Inserted document ID:", ids[0]); ``` -### Multiple Document Insert +### Multiple document insert ```typescript fragment const ids = await users.insert([ @@ -49,9 +55,9 @@ console.log(`Inserted ${ids.length} documents`); console.log("IDs:", ids); ``` -### Insert with Conflict Handling +### Insert with conflict handling -Use the `conflict` option to control behavior when a document with the same primary key already exists: +The `conflict` option, `"update"` or `"replace"`, sets what happens when a document with the same primary key already exists: ```typescript fragment // Update matching fields on conflict @@ -67,16 +73,16 @@ const ids2 = await users.insert( ); ``` -## Update and Delete Results +## Update and delete results -The `update`, `replace`, and `delete` methods return a `Query` that resolves to the count of affected documents: +`update()`, `replace()`, and `delete()` on a `Selection` or a `SingleSelection` return a `Query`. For `update()` and `replace()`, the number is the count of modified documents. For `delete()`, it is the count of deleted documents. ```typescript fragment // Update returns the number of modified documents const updated = await users.get("user-123").update({ status: "active" }); console.log(`Modified: ${updated}`); -// Replace returns the number of replaced documents +// Replace returns the number of modified documents const replaced = await users.get("user-123").replace({ _id: "user-123", name: "New", @@ -90,3 +96,29 @@ const deleted = await users .delete(); console.log(`Deleted: ${deleted}`); ``` + +`update()` has two overloads. A function argument returns `Query` when the object it builds fits the document type, and `never` otherwise. A partial document returns `Query`. A value typed `any` matches the function overload, so that call is typed `never`. + +Read through its type, `update()` resolves to the plain document overload. `ReturnType` is `Query`, so the result of `run()` is a `number`: + +```typescript +import { Schema } from "@antelopejs/interface-database"; + +interface User { + _id: string; + name: string; + status: string; +} + +const schema = new Schema<{ users: User }>("myapp", { + users: { fields: { name: "string", status: "string" }, indexes: {} }, +}); +const users = schema.instance().table("users"); + +type Modified = Awaited["run"]>>; + +const modified: Modified = await users + .get("user-123") + .update({ status: "active" }); +console.log(`Modified: ${modified}`); +``` diff --git a/docs/3.results/3.index_changes.md b/docs/3.results/3.index_changes.md index b60a8c4..aa640d6 100644 --- a/docs/3.results/3.index_changes.md +++ b/docs/3.results/3.index_changes.md @@ -1,6 +1,10 @@ -# Index Definitions +--- +title: Index definitions +description: The IndexDefinition fields of an AQL schema, for simple, compound, multi, cross-instance, and unique indexes. +mode: reference +--- -In AQL, indexes are defined as part of the schema definition rather than created at runtime. The schema declaration specifies which fields to index and what type of index to use. +In AQL, the schema definition declares indexes. You do not create them at runtime. The declaration names the fields to index and the kind of index. ## `IndexDefinition` @@ -13,9 +17,9 @@ interface IndexDefinition { } ``` -## Define Indexes +## Define indexes -Indexes are declared in the `indexes` property of each table definition: +Each table definition declares its indexes in its `indexes` property: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -32,9 +36,9 @@ const schema = new Schema("myapp", { }); ``` -### Simple Indexes +### Simple indexes -When `fields` is omitted, the index name is used as the field name: +Without `fields`, the index name is the field name: ```typescript fragment indexes: { @@ -42,9 +46,9 @@ indexes: { } ``` -### Compound Indexes +### Compound indexes -Provide the `fields` array for indexes spanning multiple fields: +`fields` lists the fields of an index that spans several fields: ```typescript fragment indexes: { @@ -52,9 +56,9 @@ indexes: { } ``` -### Multi Indexes +### Multi indexes -Set `multi: true` for array fields where each element should be individually indexed: +`multi: true` indexes each element of an array field on its own: ```typescript fragment indexes: { @@ -62,9 +66,9 @@ indexes: { } ``` -### Cross-Instance Indexes +### Cross-instance indexes -Set `crossInstance: true` when queries run with `CROSS_INSTANCE` use the index. The driver then also maintains an index usable across all instances. Without it, the index is optimized for queries scoped to one instance and cross-instance queries using it may be slow. Results are never affected: +`crossInstance: true` marks an index that queries run with `CROSS_INSTANCE` use. The driver then also maintains an index usable across all instances. Without it, the index is optimized for queries scoped to one instance, and cross-instance queries that use it may be slow. The flag never changes results: ```typescript fragment indexes: { @@ -72,9 +76,9 @@ indexes: { } ``` -## Use Indexes in Queries +## Use indexes in queries -Once defined, indexes are available for `getAll()` and `between()` operations: +`getAll()` and `between()` query through a defined index: ```typescript fragment const db = schema.instance(); @@ -91,4 +95,4 @@ const recent = await users.between( ); ``` -For more details, see [Index Management](../2.operations/5.indexes.md). +For unique indexes and more examples, see [Index management](../2.operations/5.indexes.md). diff --git a/docs/3.results/4.table_changes.md b/docs/3.results/4.table_changes.md index c11f13d..3cc5ef1 100644 --- a/docs/3.results/4.table_changes.md +++ b/docs/3.results/4.table_changes.md @@ -1,6 +1,10 @@ -# Table Definitions +--- +title: Table definitions +description: The TableDefinition and FieldType shapes of an AQL schema, with string field tokens or io-ts codecs, and typed access to tables. +mode: reference +--- -In AQL, tables are defined as part of a schema. Each table definition specifies the fields and indexes for that table. +In AQL, a schema defines its tables. Each table definition lists the fields and indexes of that table. ## `TableDefinition` @@ -18,19 +22,34 @@ interface TableDefinition { ### `FieldType` -Field types describe the structure of each field: +A field type describes the structure of one field: ```typescript -import type { StringFieldType } from "@antelopejs/interface-database/schema"; - -type FieldType = - | string // Primitive type name (e.g., "string", "number") +type StringFieldType = + | string // Primitive type name, such as "string" or "number" | Array // Array of a given type - | { [subfield: string]: StringFieldType } // Nested object - | import("io-ts").Mixed; // io-ts codec at the field root + | { [subfield: string]: StringFieldType }; // Nested object + +interface IoTsValidation { + readonly _tag: "Left" | "Right"; +} + +type IoTsCodec = { + readonly _A: A; + readonly _O: O; + readonly _I: I; + readonly name: string; + is(u: A | I): boolean; + encode(a: A): O; + decode(i: I): IoTsValidation; +}; + +type FieldType = StringFieldType | IoTsCodec; // io-ts codec at the field root ``` -A field can be described either with the string-token vocabulary (primitives, arrays, nested records) or with a single [io-ts](https://github.com/gcanti/io-ts) codec at the root of the field. A codec is any io-ts `Type`: the interface only relies on its `name`, `is`, `encode` and `decode`, and `decode` must return an `Either`. Codecs are not mixed inside string-tree records — use io-ts combinators (`t.type`, `t.array`, …) to nest within a codec. +`@antelopejs/interface-database/schema` exports `StringFieldType` and `FieldType`. `IoTsCodec` and `IoTsValidation` are not exported. They are shown here as the shape a codec must have. + +A field uses either the string-token vocabulary (primitives, arrays, nested records) or a single [io-ts](https://github.com/gcanti/io-ts) codec at the root of the field. Any io-ts `Type` fits. The type requires its `_A`, `_O`, `_I`, `name`, `is`, `encode`, and `decode` members, and `decode` must return a value tagged `Left` or `Right`, as an io-ts `Either` is. A string-tree record cannot hold a codec. To nest inside a codec, use io-ts combinators such as `t.type` and `t.array`. ```typescript import * as t from "io-ts"; @@ -45,9 +64,9 @@ const Order = { }; ``` -## Define Tables in a Schema +## Define tables in a schema -Tables are declared in the `SchemaDefinition` passed to the `Schema` constructor: +The `SchemaDefinition` passed to the `Schema` constructor declares the tables: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -85,9 +104,9 @@ const schema = new Schema("myapp", { }); ``` -## Access Tables +## Access tables -After creating a schema instance, access tables by name: +After you create a schema instance, access its tables by name: ```typescript fragment await schema.createInstance(); @@ -96,7 +115,7 @@ const users = schema.instance().table("users"); const posts = schema.instance().table("posts"); ``` -For type-safe access, provide the table mapping when declaring or retrieving the schema. `table()` infers the document type from the table name: +For typed access, give the table mapping when you declare or retrieve the schema. `table()` infers the document type from the table name: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -108,8 +127,10 @@ interface User { age: number; } -const typedSchema = Schema.get<{ users: User }>("myapp")!; +const typedSchema = Schema.get<{ users: User }>("myapp"); +if (!typedSchema) throw new Error('Schema "myapp" is not defined'); + const users = typedSchema.instance().table("users"); // Table ``` -For more details on table operations, see [Table Operations](../2.operations/3.table_operations.md). +For more on table operations, see [Table operations](../2.operations/3.table_operations.md). diff --git a/docs/3.results/5.database_changes.md b/docs/3.results/5.database_changes.md index 8e8f05d..c840941 100644 --- a/docs/3.results/5.database_changes.md +++ b/docs/3.results/5.database_changes.md @@ -1,15 +1,29 @@ -# Schema and Instance Management +--- +title: Schema and instance management +description: The Schema and SchemaInstance classes, SchemaDefinition, InstanceId, and CROSS_INSTANCE, which set the structure and lifecycle of an AQL database. +mode: reference +--- -This page covers the `Schema`, `SchemaInstance`, and related types that control database structure and lifecycle. +This page covers `Schema`, `SchemaInstance`, and the types that set the structure and lifecycle of a database. ## Schema -The `Schema` class defines the structure of a database and manages its instances. +The `Schema` class that `@antelopejs/interface-database` exports defines the structure of a database and manages its instances. `T` maps table names to document types. ```typescript fragment -class Schema extends StagedObject { - static get(id: string): Schema | undefined; +class Schema extends StagedSchema { + static list(): ReadonlyMap; constructor(id: string, definition: SchemaDefinition); +} + +// Inherited from StagedSchema +class StagedSchema< + T = any, + Definition = SchemaDefinition, +> extends StagedObject { + static get( + id: string, + ): StagedSchema | undefined; instance(id?: InstanceId): SchemaInstance; createInstance(id?: string): Query; destroyInstance(id?: string): Query; @@ -17,6 +31,10 @@ class Schema extends StagedObject { } ``` +The constructor registers the schema, so `Schema.list()` returns every schema this process defined, by ID. A schema defined again under the same ID replaces the earlier one. `createInstance()` resolves to the ID of the created instance. `listInstances()` resolves to the IDs of the named instances, without the default (unnamed) instance. `Schema.get()` returns `undefined` when no schema has the ID, and it does not check its type arguments against the ones the schema was declared with. + +`StagedSchema` is the `Schema` class of the `@antelopejs/interface-database/staged-query` entry. It takes the definition type as a second type parameter. The root `Schema` fixes it to `SchemaDefinition`. + ### `SchemaDefinition` ```typescript @@ -34,11 +52,11 @@ const CROSS_INSTANCE: unique symbol; type InstanceId = string | typeof CROSS_INSTANCE; ``` -`schema.instance(CROSS_INSTANCE)` returns a query stage that operates across every instance of the schema. Adapters skip the per-instance filter on read/update/delete and reject inserts. +`schema.instance(CROSS_INSTANCE)` returns a `SchemaInstance` that operates across every instance of the schema. Adapters skip the per-instance filter on reads, updates, and deletes, and reject inserts. -Secondary indexes are optimized for queries scoped to one instance. Set `crossInstance: true` in the `IndexDefinition` of the indexes that cross-instance queries rely on (see [Index Management](../2.operations/5.indexes.md)). +Secondary indexes are optimized for queries scoped to one instance. Set `crossInstance: true` in the `IndexDefinition` of the indexes that cross-instance queries rely on. See [Index management](../2.operations/5.indexes.md). -## Create and Manage Instances +## Create and manage instances ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -68,9 +86,9 @@ await schema.destroyInstance("tenant-1"); const ids = await schema.listInstances(); ``` -## Retrieve a Schema +## Retrieve a schema -Use `Schema.get()` to look up a previously defined schema anywhere in your application: +`Schema.get()` looks up a schema defined anywhere in your application by its ID: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -83,13 +101,13 @@ if (schema) { } ``` -## SchemaInstance +## `SchemaInstance` -The `SchemaInstance` class represents a single instance of a schema. Its primary method is `table()`, which returns a `Table` for the specified table name. +The `SchemaInstance` class is one instance of a schema. Its own method is `table()`, which returns the `Table` with the given name. ```typescript fragment class SchemaInstance extends StagedObject { - table(id: TK): Table; + table(id: TK): Table; } ``` @@ -99,4 +117,4 @@ const users = db.table("users"); const posts = db.table("posts"); ``` -For practical usage patterns, see [Schema Management](../2.operations/2.database_management.md). +For usage patterns, see [Schema management](../2.operations/2.database_management.md). diff --git a/docs/3.results/6.value_changes.md b/docs/3.results/6.value_changes.md index 284e595..716378a 100644 --- a/docs/3.results/6.value_changes.md +++ b/docs/3.results/6.value_changes.md @@ -1,8 +1,12 @@ -# Change Events +--- +title: Change events +description: The Changes event that an AQL change feed yields, with its change type and the document before and after the change. +mode: reference +--- -The `Changes` interface represents a change event emitted by change feeds. It includes the type of change and the document values before and after the change. +A change feed yields `Changes` events. Each event holds the type of change and the document values before and after the change. `changes()` on a `Stream` or a `SingleSelection` returns a `Query[]>`, and `for await` iterates its events one at a time. -## Interface Definition +## Interface definition ```typescript interface Changes { @@ -20,20 +24,22 @@ interface Changes { | `oldValue` | `T \| undefined` | The document before the change. Undefined for inserts. | | `newValue` | `T \| undefined` | The document after the change. Undefined for deletes. | -## Determine the Change Type +## Determine the change type -The `changeType` field indicates what happened: +The `changeType` field says what happened: -- **`"added"`** - A new document was inserted. `newValue` contains the document; `oldValue` is undefined. -- **`"modified"`** - An existing document was updated. Both `oldValue` and `newValue` are present. -- **`"removed"`** - A document was deleted. `oldValue` contains the deleted document; `newValue` is undefined. +- `"added"`. A new document was inserted. `newValue` holds the document, and `oldValue` is undefined. +- `"modified"`. An existing document was updated. Both `oldValue` and `newValue` are present. +- `"removed"`. A document was deleted. `oldValue` holds the deleted document, and `newValue` is undefined. -## Process Change Events +## Process change events ```typescript import { Schema } from "@antelopejs/interface-database"; -const schema = Schema.get("myapp")!; +const schema = Schema.get("myapp"); +if (!schema) throw new Error('Schema "myapp" is not defined'); + const users = schema.instance().table("users"); for await (const change of users.changes()) { @@ -51,7 +57,7 @@ for await (const change of users.changes()) { } ``` -## Monitor a Single Document +## Monitor a single document ```typescript fragment for await (const change of users.get("user-123").changes()) { @@ -63,9 +69,9 @@ for await (const change of users.get("user-123").changes()) { } ``` -## Use Cases +## Use cases -- **Real-time notifications** - Push updates to connected clients when data changes. -- **Change observation** - Inspect before/after values while subscribed. A durable audit log also needs verified delivery, retention, and recovery guarantees from the provider. -- **Cache invalidation** - Clear or refresh caches when underlying data is modified. -- **Event-driven workflows** - Trigger downstream processing based on data changes. +- Real-time notifications. Push updates to connected clients when data changes. +- Change observation. Inspect the values before and after a change while subscribed. A durable audit log also needs verified delivery, retention, and recovery guarantees from the provider. +- Cache invalidation. Clear or refresh caches when the underlying data changes. +- Event-driven workflows. Start downstream processing when data changes. diff --git a/docs/index.md b/docs/index.md index d96112e..05860e2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,21 +1,25 @@ -# Query data with AQL +--- +title: Query data with AQL +description: Define a schema, run a first AQL query from an AntelopeJS module, and find the reference page for each query type, operation, and result. +mode: how-to +--- ## Overview -The Database interface provides AQL (Antelope Query Language), a typed query API for AntelopeJS database providers. Build a query from table access, filters, and transformations, then execute it through the provider bound to your module. +The Database interface provides AQL (Antelope Query Language), a typed query API for AntelopeJS database providers. You build a query from table access, filters, and transformations, then execute it through the provider bound to your module. ## Run your first query -Start in an AntelopeJS module with a configured database provider. Installing the interface supplies query builders and contracts, not a database server or credentials. +Start in an AntelopeJS module with a configured database provider. The interface package supplies query builders and contracts. It does not supply a database server or credentials. -1. [Define a schema and create an instance](./2.operations/2.database_management.md). The schema describes tables; the instance selects where their data lives. +1. [Define a schema and create an instance](./2.operations/2.database_management.md). The schema describes tables, and the instance selects where their data lives. 2. [Open a table](./2.operations/3.table_operations.md) and compose a read with [filtering](./2.operations/6.filtering.md). Building the query does not execute it. -3. [Execute with `await` or `run()`](./1.query_types/7.query.md), or consume a cursor for incremental results. -4. [Inspect write results](./3.results/2.write_results.md) when adding mutations. Inserts return IDs; updates and deletes return counts. +3. [Execute the query with `await` or `run()`](./1.query_types/7.query.md), or consume a cursor for incremental results. +4. [Inspect write results](./3.results/2.write_results.md) when you add mutations. Inserts return IDs. Updates and deletes return counts. ### Insert and read one record -Run this function from your module after its database provider is configured. It creates a disposable schema instance and writes one record; do not point this exercise at production data. +Run this function from your module after its database provider is configured. It creates a disposable schema instance and writes one record. Do not point it at production data. ```ts docs-check import { Schema } from "@antelopejs/interface-database"; @@ -43,7 +47,7 @@ export async function tryNotes() { } ``` -`notes` is a query builder; `saved` is a fetched record. Calling the function again inserts another record. When you no longer need this disposable data, explicitly run `await notesSchema.destroyInstance("tutorial")`; it deletes the instance's data. +`notes` is a query builder, and `saved` is a fetched record. Each call of the function inserts another record. To delete the instance and its data when you no longer need them, run `await notesSchema.destroyInstance("tutorial")`. ## Find the right reference @@ -55,58 +59,58 @@ export async function tryNotes() { | How do I conditionally change one record? | [Atomic mutations](./2.operations/8.atomic_mutations.md) | | What did the provider return? | [Results](./3.results/1.index.md) | -## Import Paths - -Import `Schema`, `Query`, `Table`, `Datum`, `Stream`, and `ValueProxy` from `@antelopejs/interface-database` in an AntelopeJS module. This entry point registers schemas with the bound provider and installs query execution (`run()`, `then()`, `cursor()`, and async iteration). - -Supporting types have dedicated paths: import `Changes`, `DeepPartial`, `ExtractType`, `OperandValue`, `QueryValue`, `Value`, `WriteDocument`, and `WriteValue` from `@antelopejs/interface-database/common`, and `FieldType`, `TableDefinition`, and `IndexDefinition` from `@antelopejs/interface-database/schema`. Atomic mutation types are available from the package root and `/atomic`. - -### Advanced: build queries without executing them - -`@antelopejs/interface-database/staged-query` is the build-only entry point for tools that construct and inspect query stages without the AntelopeJS runtime. Its schema does not register with a provider, and its queries expose `build()` but no execution methods when loaded on their own. Do not `await` a build-only query expecting database results. The runtime entry point augments the same staged classes; keep build-only consumers separate from runtime imports when that isolation matters. - -## Documentation Structure - -- [**1. Query Types**](./1.query_types/1.index.md) - Detailed reference for each query type: - - - [Table](./1.query_types/2.table.md) - Table-level operations (insert, get, getAll, between) - - [Selection](./1.query_types/3.selection.md) - Multi-document operations (update, replace, delete) - - [SingleSelection](./1.query_types/4.single_selection.md) - Single-document operations - - [Stream](./1.query_types/5.stream.md) - Transformation, filtering, aggregation, joins, and grouping - - [Change Feeds](./1.query_types/6.feed.md) - Real-time change notifications - - [Query](./1.query_types/7.query.md) - Base query execution and async iteration - - [Datum](./1.query_types/8.datum.md) - Single-value manipulation - - [ValueProxy](./1.query_types/9.valueproxy.md) - In-query value operations (arithmetic, string, date, array, object) - -- [**2. Operations**](./2.operations/1.index.md) - Practical guides: - - - [Schema Management](./2.operations/2.database_management.md) - Create and manage schema instances - - [Table Operations](./2.operations/3.table_operations.md) - Access and work with tables - - [CRUD Operations](./2.operations/4.crud.md) - Create, read, update, and delete documents - - [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 - - [Index Definitions](./3.results/3.index_changes.md) - Index configuration in schema definitions - - [Table Definitions](./3.results/4.table_changes.md) - Table structure and field types - - [Schema and Instance Management](./3.results/5.database_changes.md) - Schema lifecycle and instance management - - [Change Events](./3.results/6.value_changes.md) - Change feed event structure +## Import paths + +Import `Schema`, `Query`, `Table`, `Datum`, `Stream`, and `ValueProxy` from `@antelopejs/interface-database` in an AntelopeJS module. This entry point registers schemas with the bound provider and installs query execution: `run()`, `then()`, `cursor()`, and async iteration. + +Supporting types have their own paths: + +- `@antelopejs/interface-database/common` exports `Changes`, `DeepPartial`, `ExtractType`, `OperandValue`, `QueryValue`, `Value`, `WriteDocument`, and `WriteValue`. +- `@antelopejs/interface-database/schema` exports `FieldType`, `TableDefinition`, and `IndexDefinition`. +- The package root and `@antelopejs/interface-database/atomic` both export the atomic mutation types. + +### Build queries without executing them + +`@antelopejs/interface-database/staged-query` is the build-only entry point for tools that construct and inspect query stages without the AntelopeJS runtime. Its schema does not register with a provider. Loaded on their own, its queries expose `build()` and no execution methods, so awaiting a build-only query does not return database results. The runtime entry point adds execution to the same staged classes. If a build-only consumer must stay isolated from execution, keep it apart from code that imports the runtime entry point. + +## Documentation structure + +- [Query types](./1.query_types/1.index.md) is the reference for each query type: + - [Table](./1.query_types/2.table.md) covers table-level operations: `insert`, `get`, `getAll`, `between`, and `atomicMutation`. + - [Selection](./1.query_types/3.selection.md) covers multi-document operations: `update`, `replace`, and `delete`. + - [SingleSelection](./1.query_types/4.single_selection.md) covers single-document operations. + - [Stream](./1.query_types/5.stream.md) covers transformation, filtering, aggregation, joins, and grouping. + - [Change feeds](./1.query_types/6.feed.md) covers real-time change notifications. + - [Query](./1.query_types/7.query.md) covers base query execution and async iteration. + - [Datum](./1.query_types/8.datum.md) covers single-value manipulation. + - [ValueProxy](./1.query_types/9.valueproxy.md) covers in-query value operations on numbers, strings, dates, arrays, and objects. +- [Operations](./2.operations/1.index.md) holds the practical guides: + - [Schema management](./2.operations/2.database_management.md) creates and manages schema instances. + - [Table operations](./2.operations/3.table_operations.md) opens tables and works with them. + - [CRUD operations](./2.operations/4.crud.md) creates, reads, updates, and deletes documents. + - [Index management](./2.operations/5.indexes.md) defines and uses secondary indexes. + - [Filtering and querying](./2.operations/6.filtering.md) filters, compares, and transforms data. + - [Lookup](./2.operations/7.lookup.md) joins on foreign keys and populates data. + - [Atomic mutations](./2.operations/8.atomic_mutations.md) covers conditional single-record mutations and their outcomes. +- [Results](./3.results/1.index.md) describes result types and how to handle them: + - [Insert results](./3.results/2.write_results.md) describes what insert operations return. + - [Index definitions](./3.results/3.index_changes.md) describes index configuration in schema definitions. + - [Table definitions](./3.results/4.table_changes.md) describes table structure and field types. + - [Schema and instance management](./3.results/5.database_changes.md) describes the schema lifecycle and instance management. + - [Change events](./3.results/6.value_changes.md) describes the structure of a change feed event. ## Features -- Database-agnostic query interface -- Consistent API across different database implementations -- Type-safe operations using TypeScript generics -- Schema-based database structure definition -- Multi-instance support per schema -- Secondary index support -- Change feeds for real-time updates -- Async iteration with cursor support -- Chainable query builder with lazy evaluation +- A query interface that does not depend on one database. +- One API across database implementations. +- Typed operations through TypeScript generics. +- Database structure defined by a schema. +- Several instances per schema. +- Secondary indexes. +- Change feeds for real-time updates. +- Async iteration with cursors. +- A chainable query builder with lazy evaluation. -## Database Interoperability +## Database interoperability -AQL separates query construction from the database engine. A provider translates the shared operations into its backend's behavior. Before moving an application between providers, check that provider's supported operations and exercise the queries, indexes, and change feeds your application uses; a shared TypeScript contract is not a guarantee of identical backend capabilities or performance. +AQL separates query construction from the database engine. A provider translates the shared operations into its backend's behavior. A shared TypeScript contract does not guarantee identical backend capabilities or performance. Before you move an application to another provider, check which operations that provider supports, and test the queries, indexes, and change feeds your application uses. diff --git a/package.json b/package.json index 982e937..7141c5b 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,9 @@ "test": "src/antelope.test.ts", "skills": [ "./skills" - ] + ], + "docs": { + "standard": true + } } } From 6e807ac945f3da586565aa63d71b6ebc7da0802b Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 04:04:54 +0100 Subject: [PATCH 16/16] docs: release notes for interface-database 0.3.0 (#70) * docs: release notes for interface-database 0.3.0 * docs: cover every breaking change in the interface-database 0.3.0 release notes --- UPGRADING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 UPGRADING.md diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..301f7ed --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,11 @@ +# Upgrade notes + +Each section is the upgrade-notes block of one release. Paste it into the GitHub release body and under the release's heading in `CHANGELOG.md`. The changelog generator writes only commit subjects, and some of these changes have no `!` in theirs. + +## v0.3.0 + +- **`update()` with an `any`-typed argument resolves to `never`.** Reading its result, for example `.run()`, fails to compile (TS2339). A function argument still matches only the updater overload and an object argument only the document overload, so typed calls are unchanged. Unchecked `any` writes become compile errors. `ReturnType` is now `Query`, so `await ...run()` is a `number` instead of `any` ([#65](https://github.com/AntelopeJS/interface-database/pull/65)). +- **The entries of a `FieldType` codec are typed `unknown`.** `IoTsCodec` is now a type alias with the same members, so `Object.entries` on a codec gives `[string, unknown]` instead of `[string, any]`. A consumer that passed those entries on as `FieldType` stops compiling. Narrow each entry first. Every value assignable to `FieldType` before still is ([#63](https://github.com/AntelopeJS/interface-database/pull/63)). +- **Stage operands are checked as query values.** A generic `T` operand, or a class instance with methods such as `ObjectId`, no longer compiles as a stage operand. Neither does an operand that holds a `bigint`, a `symbol`, a function or a value typed `unknown`. `eq`, `ne`, `gt`, `ge`, `lt`, `le` and `includes` check their operand against the receiver's type. `constant`, `default`, `sub`, `merge` and the default of `key` refuse an `interface`-typed record: declare it with `type`. A generic `K extends keyof T` no longer compiles as a `ValueProxy.key`. `any` stays `any` ([#62](https://github.com/AntelopeJS/interface-database/pull/62)). +- **Query stages are typed by name.** `QueryStage` is a union discriminated on `stage`, built from the new exported `QueryStageMap`, and `QueryStage(name, options, ...args)` is typed by name. The protected `StagedObject#stage(type, stage)` takes a built `QueryStage`. `ValueProxy#eq` and `#ne` take `StagedObject | T` instead of `unknown`. `pluck` answers `Pick` and `without` answers `Omit`. Key, table, index and field names are `keyof T & string`. `Table#between` bounds are `T[TK] & IndexBound`, so a field that holds no index value stops compiling as a bound. `ValueProxy#add` takes an operand that fits its receiver, so two dates, or a string on a number, stop at compile time ([#51](https://github.com/AntelopeJS/interface-database/pull/51)). +- **Written documents are typed as write values.** `DeepPartial` drops `bigint` and `symbol` leaves, maps a field typed `unknown` to `WriteValue`, keeps a field typed `any` as `any`, and maps any other non-object type to `never`. The `insert`, `update` and `replace` builders compile only while every leaf of the document is a `WriteValue`: text, a number, a boolean, `null`, `undefined`, a `Date`, a list or a nested document ([#59](https://github.com/AntelopeJS/interface-database/pull/59)).
npm License -Discord +Discord Docs