diff --git a/.agents/skills/verify-interface/expected-package.ts b/.agents/skills/verify-interface/expected-package.ts index db0a419..0a3e519 100644 --- a/.agents/skills/verify-interface/expected-package.ts +++ b/.agents/skills/verify-interface/expected-package.ts @@ -47,7 +47,7 @@ export const STAGED = { "after": { "query": [ true, - true, + false, true ], "iterator": true, diff --git a/.agents/skills/verify-interface/expected-runtime.ts b/.agents/skills/verify-interface/expected-runtime.ts index 366c923..98d8d7e 100644 --- a/.agents/skills/verify-interface/expected-runtime.ts +++ b/.agents/skills/verify-interface/expected-runtime.ts @@ -113,30 +113,20 @@ export const EXPECTED = { }, "query-execution": { "lazy": 0, - "viaAwait": [ - { - "title": "A" - } - ], + "awaitedIsQuery": true, + "awaitRuns": 0, "viaRun": [ { "title": "A" } ], - "viaThen": [ - { - "title": "A" - } - ], - "viaThenChain": 1, - "runs": 7, + "runs": 3, "sameStages": true, "rejected": "Error: provider refused", - "caught": "handled:provider refused", "onRejected": "caught:provider refused", "methods": [ "function", - "function", + "undefined", "function" ], "iterator": "function" diff --git a/.agents/skills/verify-interface/features/README.md b/.agents/skills/verify-interface/features/README.md index ad8f807..943d291 100644 --- a/.agents/skills/verify-interface/features/README.md +++ b/.agents/skills/verify-interface/features/README.md @@ -17,10 +17,10 @@ One file per feature. `covers:` lists the source globs it owns, `docs:` the page | [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 | +| [atomic-execution](atomic-execution.md) | Running 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; `run()` runs it once and `await` alone runs nothing, `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 | +| [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` 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 index b727730..2a01c83 100644 --- a/.agents/skills/verify-interface/features/atomic-execution.md +++ b/.agents/skills/verify-interface/features/atomic-execution.md @@ -11,7 +11,7 @@ journeys: # 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. +Running 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 @@ -19,7 +19,7 @@ Awaiting an atomic mutation resolves to `applied`, `not-applied` or `unknown`; a ## How to get to it (user POV) -- `const outcome = await table.atomicMutation(id, request)`. +- `const outcome = await table.atomicMutation(id, request).run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/atomic-requests.md b/.agents/skills/verify-interface/features/atomic-requests.md index 03b6816..a529f8b 100644 --- a/.agents/skills/verify-interface/features/atomic-requests.md +++ b/.agents/skills/verify-interface/features/atomic-requests.md @@ -19,7 +19,7 @@ A module asks for one conditional change of one record: update or delete under a ## How to get to it (user POV) -- `await table.atomicMutation(id, { type: "delete", revisionField: "revision", expectedRevision: observed })`. +- `await table.atomicMutation(id, { type: "delete", revisionField: "revision", expectedRevision: observed }).run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/datums.md b/.agents/skills/verify-interface/features/datums.md index 1d1440d..5004671 100644 --- a/.agents/skills/verify-interface/features/datums.md +++ b/.agents/skills/verify-interface/features/datums.md @@ -17,11 +17,11 @@ A single document or value is read, a field is picked, defaulted, transformed wi ## 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. +- `datum-run`: running 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")`. +- `await users.get("u").key("address").key("city").run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/execution.md b/.agents/skills/verify-interface/features/execution.md index 4ff8688..79b1277 100644 --- a/.agents/skills/verify-interface/features/execution.md +++ b/.agents/skills/verify-interface/features/execution.md @@ -13,11 +13,11 @@ journeys: # 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. +A query is lazy; `run()` runs it once and `await` alone runs nothing, `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-execution`: run runs a query once, lazily, and passes the provider's failure on; awaiting a query runs nothing. - `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) diff --git a/.agents/skills/verify-interface/features/joins.md b/.agents/skills/verify-interface/features/joins.md index 2208384..b649bbb 100644 --- a/.agents/skills/verify-interface/features/joins.md +++ b/.agents/skills/verify-interface/features/joins.md @@ -22,7 +22,7 @@ A stream is joined to another (left or inner), looked up through a foreign 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") }))`. +- `await users.joinInner(orders, (u, o) => o.key("userId").eq(u.key("_id")), (u, o) => ({ amount: o.key("amount") })).run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/schemas.md b/.agents/skills/verify-interface/features/schemas.md index ce90788..2fb15bd 100644 --- a/.agents/skills/verify-interface/features/schemas.md +++ b/.agents/skills/verify-interface/features/schemas.md @@ -21,11 +21,11 @@ A module defines a `Schema`, which registers with the bound provider, then picks - `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. +- `schema-run`: running 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");` +- `const schema = new Schema("myapp", { users: { fields: {}, indexes: {} } }); await schema.createInstance("tenant-1").run();` ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/staged-query.md b/.agents/skills/verify-interface/features/staged-query.md index ddfe4cb..cad4060 100644 --- a/.agents/skills/verify-interface/features/staged-query.md +++ b/.agents/skills/verify-interface/features/staged-query.md @@ -11,7 +11,7 @@ journeys: # 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`. +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` and `cursor`. ## Sub-features diff --git a/.agents/skills/verify-interface/features/streams.md b/.agents/skills/verify-interface/features/streams.md index 3cffd54..fb54677 100644 --- a/.agents/skills/verify-interface/features/streams.md +++ b/.agents/skills/verify-interface/features/streams.md @@ -22,11 +22,11 @@ A stream is mapped, filtered, plucked, ordered, sliced and aggregated. This feat - `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. +- `stream-run`: running 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)`. +- `await users.filter((u) => u.key("age").ge(18)).orderBy("name").slice(0, 10).run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/tables-read.md b/.agents/skills/verify-interface/features/tables-read.md index ee937f2..2949c70 100644 --- a/.agents/skills/verify-interface/features/tables-read.md +++ b/.agents/skills/verify-interface/features/tables-read.md @@ -20,7 +20,7 @@ A table is read whole, by primary key (`get`), by secondary index (`getAll`) or ## How to get to it (user POV) -- `await users.getAll("admin", "role")` and `await users.between("createdAt", from, to)`. +- `await users.getAll("admin", "role").run()` and `await users.between("createdAt", from, to).run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/tables-write.md b/.agents/skills/verify-interface/features/tables-write.md index 8e52c7e..1730d52 100644 --- a/.agents/skills/verify-interface/features/tables-write.md +++ b/.agents/skills/verify-interface/features/tables-write.md @@ -23,7 +23,7 @@ A module inserts (with `conflict: "update"` or `"replace"`), updates with a part ## How to get to it (user POV) -- `await users.insert({ _id: "u" }, { conflict: "update" })` and `await users.get("u").delete()`. +- `await users.insert({ _id: "u" }, { conflict: "update" }).run()` and `await users.get("u").delete().run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/features/types.md b/.agents/skills/verify-interface/features/types.md index 9446f18..e54ee17 100644 --- a/.agents/skills/verify-interface/features/types.md +++ b/.agents/skills/verify-interface/features/types.md @@ -20,7 +20,7 @@ A TypeScript module sees the documented result type of each call, the table name ## How to get to it (user POV) -- `const ids: string[] = await users.insert({ name: "A" })`. +- `const ids: string[] = await users.insert({ name: "A" }).run()`. ## Driving it with antelopejs-verify diff --git a/.agents/skills/verify-interface/probes.ts b/.agents/skills/verify-interface/probes.ts index 4bbcb1b..5bfbdd1 100644 --- a/.agents/skills/verify-interface/probes.ts +++ b/.agents/skills/verify-interface/probes.ts @@ -5,30 +5,30 @@ 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 Result = Awaited>; 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(); + const ids: string[] = await users.insert({ name: "A" }, { conflict: "update" }).run(); + const manyIds: string[] = await users.insert([{ name: "A" }, { name: "B" }]).run(); + const user: User = await users.get("id").run(); + const all: User[] = await users.run(); + const some: User[] = await users.getAll(["a", "b"], "name").run(); + const range: User[] = await users.between("createdAt", new Date(0), new Date(1)).run(); + const compound: User[] = await users.between("tags", ["a", "b"], ["c", "d"]).run(); + const total: number = await users.count().run(); + const names: string[] = await users.key("name").run(); + const cities: string[] = await users.map((u) => u.key("address").key("city")).run(); + const sorted: User[] = await users.orderBy("name", "desc").slice(0, 10).run(); + const first: User | null = await users.orderBy("name").nth(0).run(); + const updated: number = await users.get("id").update({ age: 1 }).run(); + const computed: number = await users.getAll("a", "name").update((u) => ({ age: u.key("age").add(1) })).run(); + const replaced: number = await users.get("id").replace({ _id: "id", name: "N" }).run(); + const removed: number = await users.filter((u) => u.key("age").lt(5)).delete().run(); + const outcome: "applied" | "not-applied" | "unknown" = await users.atomicMutation("id", { type: "delete", revisionField: "name", expectedRevision: "r1" }).run(); + const instanceId: string = await schema.createInstance("tenant").run(); + const destroyed: void = await schema.destroyInstance("tenant").run(); + const listed: string[] = await schema.listInstances().run(); + const feed: Common.Changes[] = await users.changes().run(); 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]; @@ -51,6 +51,7 @@ 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>>; +type _Reads = Assert>, User>>; +type _ExecutionMembers = Assert, "run" | "cursor" | typeof Symbol.asyncIterator>>; +type _NotThenable = Assert, typeof users>>>; `; diff --git a/.agents/skills/verify-interface/scenarios-runtime.ts b/.agents/skills/verify-interface/scenarios-runtime.ts index f0ca570..b04c299 100644 --- a/.agents/skills/verify-interface/scenarios-runtime.ts +++ b/.agents/skills/verify-interface/scenarios-runtime.ts @@ -80,12 +80,12 @@ export const ATOMIC_BODIES = { const outcomes = {}; for (const reply of ["applied", "not-applied", "unknown"]) { answer = reply; - outcomes[reply] = await table.atomicMutation("n-1", drop); + outcomes[reply] = await table.atomicMutation("n-1", drop).run(); } 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)); + bad[label] = await fails(() => table.atomicMutation("n-1", drop).run()); } answer = "applied"; const viaRun = await table.atomicMutation("n-1", drop).run(); @@ -93,11 +93,11 @@ export const ATOMIC_BODIES = { 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 lost = await fails(() => table.atomicMutation("n-1", drop).run()); const callsAfterLoss = seen.runs.length; answer = "applied"; seen.runs.length = 0; - await table.atomicMutation("n-1", drop); + await table.atomicMutation("n-1", drop).run(); const terminal = seen.runs[0]; return { outcomes, bad, viaRun, cursor, iterate, lost, callsAfterLoss, @@ -107,22 +107,20 @@ export const ATOMIC_BODIES = { `, } satisfies { [id: string]: string }; -/** Query execution: await, run, then, cursor and async iteration reach the provider as documented. */ +/** Query execution: run, cursor and async iteration reach the provider as documented; awaiting a query runs nothing. */ 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 awaited = await built; + const awaitRuns = seen.runs.length; 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 rejected = await fails(() => built.run()); 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] }; + return { lazy, awaitedIsQuery: awaited === built, awaitRuns, viaRun, runs: seen.runs.length, sameStages, rejected, 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 }]; diff --git a/.agents/skills/verify-interface/scenarios-streams.ts b/.agents/skills/verify-interface/scenarios-streams.ts index 7e889f1..72003b6 100644 --- a/.agents/skills/verify-interface/scenarios-streams.ts +++ b/.agents/skills/verify-interface/scenarios-streams.ts @@ -53,9 +53,9 @@ export const STREAM_BODIES = { `, "stream-run": `${STORE} answer = [{ _id: "u1" }, { _id: "u2" }]; - const direct = await users.orderBy("name"); + const direct = await users.orderBy("name").run(); answer = 7; - const counted = await users.count(); + const counted = await users.count().run(); return { direct, counted, runs: seen.runs.map((stages) => show(stages).slice(3)) }; `, } satisfies { [id: string]: string }; @@ -98,11 +98,11 @@ export const DATUM_BODIES = { `, "datum-run": `${STORE} answer = { _id: "user-123", name: "Ada" }; - const doc = await users.get("user-123"); + const doc = await users.get("user-123").run(); answer = "Ada"; - const name = await users.get("user-123").key("name"); + const name = await users.get("user-123").key("name").run(); answer = null; - const missing = await users.get("nobody"); + const missing = await users.get("nobody").run(); 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 index f1465f7..1ae1f8a 100644 --- a/.agents/skills/verify-interface/scenarios-tables.ts +++ b/.agents/skills/verify-interface/scenarios-tables.ts @@ -35,11 +35,11 @@ export const SCHEMA_BODIES = { "schema-run": ` const s = new db.Schema("tutorial", { notes: { fields: {}, indexes: {} } }); answer = "acme"; - const created = await s.createInstance("acme"); + const created = await s.createInstance("acme").run(); answer = []; - const listed = await s.listInstances(); + const listed = await s.listInstances().run(); answer = undefined; - await s.destroyInstance("acme"); + await s.destroyInstance("acme").run(); 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 }; @@ -80,13 +80,13 @@ export const TABLE_BODIES = { `, "table-results": `${SHOP} answer = ["id-1", "id-2"]; - const ids = await products.insert([{ name: "A" }, { name: "B" }], { conflict: "update" }); + const ids = await products.insert([{ name: "A" }, { name: "B" }], { conflict: "update" }).run(); answer = 3; - const updated = await products.getAll("shoes", "tags").update({ price: 1 }); + const updated = await products.getAll("shoes", "tags").update({ price: 1 }).run(); answer = null; - const missing = await products.get("nope"); + const missing = await products.get("nope").run(); answer = new Error("provider refused"); - const refused = await fails(() => products.get("p-1").delete()); + const refused = await fails(() => products.get("p-1").delete().run()); return { ids, updated, missing, refused, runs: seen.runs.map(show) }; `, } satisfies { [id: string]: string }; diff --git a/.agents/skills/verify-interface/verify.config.ts b/.agents/skills/verify-interface/verify.config.ts index 84b9146..9833336 100644 --- a/.agents/skills/verify-interface/verify.config.ts +++ b/.agents/skills/verify-interface/verify.config.ts @@ -73,18 +73,18 @@ const config: VerifyConfig = { "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"]), + "schema-run": scenario("running 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"]), + "stream-run": scenario("running 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"]), + "datum-run": scenario("running 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"]), @@ -94,7 +94,7 @@ const config: VerifyConfig = { "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-execution": scenario("run runs a query once, lazily, and passes the provider's failure on; awaiting a query runs nothing", "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"]), diff --git a/README.md b/README.md index e062cbe..3126fc3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ AQL (Antelope Query Language) is the database-agnostic query builder of Antelope - `Table`, `Selection` and `SingleSelection` insert, read, update and delete documents. - `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` a query, call `.run()` or iterate it. +- Queries are lazy. Nothing runs until you call `.run()` or iterate a query. ## Implementations @@ -66,13 +66,14 @@ const schema = new Schema<{ novels: Novel }>("library", { const novels = schema.instance().table("novels"); export async function addNovel(title: string, pageCount: number) { - return await novels.insert({ title, pageCount }); + return await novels.insert({ title, pageCount }).run(); } export async function listLongNovels() { return await novels .filter((novel) => novel.key("pageCount").ge(300)) - .orderBy("pageCount", "desc"); + .orderBy("pageCount", "desc") + .run(); } ``` diff --git a/UPGRADING.md b/UPGRADING.md index 301f7ed..a1a338b 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -2,6 +2,57 @@ 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.4.0 + +- **A query is no longer a promise.** `Query` has no `then()`, and `QueryExecution` no longer extends `PromiseLike`. `await query` gives back the query object and sends nothing to the provider. Run a query with `run()`, or read it with `cursor()` or `for await`. A query in `Promise.all([...])`, or returned from an `async` function, is no longer run either. TypeScript rejects a query passed where a `PromiseLike` is expected, and the `typescript/await-thenable` lint rule reports `await` on a query and a query passed to `Promise.all`, `Promise.race` or another promise aggregator. The conformance suite in `dist/tests` runs every query with `run()`. A database module implements nothing new. + + ```ts + // Before + const adults = await users.filter((u) => u.key("age").ge(18)); + // After + const adults = await users.filter((u) => u.key("age").ge(18)).run(); + ``` + +- **`cast()` returns a new object.** `Datum#cast`, `Stream#cast` and `ValueProxy#cast` return a new object with the same stages, built by the new `StagedObject` constructor form `new X(undefined, previous)`, which records no stage. `cast()` on a `Table`, a `Selection` or a `SingleSelection` returns a plain `Stream` or `Datum`, the class its type already declared. Code that compared the result with its receiver, or relied on it keeping the subclass methods at runtime, such as a table's `insert` or `getAll`, keeps the receiver instead. + + ```ts + // Before: the same object, still a Table at runtime + table.cast() === table; // true + // After: a new Stream with the same stages + table.cast() === table; // false + table.cast().build(); // equal to table.build() + ``` + +- **`Schema.get()` takes no type arguments.** The registry keeps none, so `Schema.get(id)` returns `Schema | undefined`: tables hold `any` documents, and the definition is typed `unknown`. A typed lookup such as `Schema.get<{ users: User }>(id)` fails to compile (TS2558). Query typed tables through the schema object the constructor returned. On the runtime entry, `Schema.list().get(id)` returns the schema with its definition typed `SchemaDefinition`. + + ```ts + // Before + const schema = Schema.get<{ users: User }>("myapp")!; + // After: export the schema you define, and import it + export const schema = new Schema<{ users: User }>("myapp", definition); + ``` + +- **The build-only `Query` keeps its result type.** Loaded from `@antelopejs/interface-database/staged-query` alone, `Query` now uses `T`, so `ExtractType` reads the result type of a `Datum` or a `Stream` a mapper returns, and a `Query` is no longer assignable to a `Query`. Code that relied on that assignment fails to compile. + + ```ts + // Before, build-only entry: compiled, typed unknown + const q: Query = schema.createInstance(); // Query + // After: TS2322 + ``` + +- **`StagedObject#callfunc` checks its argument classes.** The protected method takes one or two `StagedConstructor`s, and each must build the parameter type `func` declares. A subclass that passed `ValueProxy` for a parameter typed `ValueProxy` passes `ValueProxy`. + + ```ts + // Before + this.callfunc(mapper, Stream, ValueProxy); + // After + this.callfunc( + mapper, + Stream, + ValueProxy, + ); + ``` + ## 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)). diff --git a/docs/1.query_types/1.index.md b/docs/1.query_types/1.index.md index 4faa45c..f298773 100644 --- a/docs/1.query_types/1.index.md +++ b/docs/1.query_types/1.index.md @@ -41,18 +41,16 @@ ValueProxy (used within query callbacks) Every query type that extends `Query` has these methods: - `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. +A query is not a promise: awaiting it without `run()` executes nothing. + ```typescript fragment import { Schema } from "@antelopejs/interface-database"; -// Execute a query explicitly -const result = await schema.instance().table("users").run(); - -// Await the query directly (uses PromiseLike) -const users = await schema.instance().table("users"); +// Execute a query +const users = await schema.instance().table("users").run(); // Iterate with async generator for await (const user of schema.instance().table("users")) { diff --git a/docs/1.query_types/2.table.md b/docs/1.query_types/2.table.md index 7299b80..dcc93fe 100644 --- a/docs/1.query_types/2.table.md +++ b/docs/1.query_types/2.table.md @@ -42,7 +42,7 @@ const schema = new Schema("myapp", { }, }); -await schema.createInstance(); +await schema.createInstance().run(); const users = schema.instance().table("users"); ``` @@ -54,23 +54,26 @@ Each field of a document holds a `WriteValue`: text, a number, a boolean, `null` ```typescript fragment // Insert a single document -const ids = await users.insert({ - name: "Alice Johnson", - email: "alice@example.com", -}); +const ids = await users + .insert({ + name: "Alice Johnson", + email: "alice@example.com", + }) + .run(); console.log("Inserted ID:", ids[0]); // Insert multiple documents -const batchIds = await users.insert([ - { name: "Bob", email: "bob@example.com" }, - { name: "Carol", email: "carol@example.com" }, -]); +const batchIds = await users + .insert([ + { name: "Bob", email: "bob@example.com" }, + { name: "Carol", email: "carol@example.com" }, + ]) + .run(); // Insert with conflict handling -const upsertIds = await users.insert( - { _id: "user-123", name: "Updated Name" }, - { conflict: "update" }, -); +const upsertIds = await users + .insert({ _id: "user-123", name: "Updated Name" }, { conflict: "update" }) + .run(); ``` ### `InsertOptions` @@ -85,10 +88,10 @@ const upsertIds = await users.insert( 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 that a row exists. A provider can return `null` or `undefined` for a missing key, so check the result before you read its fields. +Running 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"); +const user = await users.get("user-123").run(); if (user) { console.log("Found:", user.name); @@ -101,10 +104,10 @@ Retrieves multiple documents by key values. `keys` is an `IndexKeys`: one key, a ```typescript fragment // By primary key -const selected = await users.getAll(["user-1", "user-2", "user-3"]); +const selected = await users.getAll(["user-1", "user-2", "user-3"]).run(); // By secondary index -const admins = await users.getAll("admin", "role"); +const admins = await users.getAll("admin", "role").run(); ``` ### `between(index, low, high)` @@ -112,11 +115,9 @@ const admins = await users.getAll("admin", "role"); 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( - "createdAt", - new Date("2024-01-01"), - new Date(), -); +const recent = await users + .between("createdAt", new Date("2024-01-01"), new Date()) + .run(); ``` ## Inherited methods diff --git a/docs/1.query_types/3.selection.md b/docs/1.query_types/3.selection.md index 0eca855..fd16d50 100644 --- a/docs/1.query_types/3.selection.md +++ b/docs/1.query_types/3.selection.md @@ -23,7 +23,7 @@ The updater overload comes first and the document overload last: - 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. +- An argument typed `any` resolves to the updater overload, and the conditional gives `never`. The result has no `.run()` and cannot be run 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 @@ -56,14 +56,18 @@ The `update` method changes the matching documents with the given partial data. // Update with a partial object const count = await users .getAll("inactive", "status") - .update({ status: "active" }); + .update({ status: "active" }) + .run(); console.log(`Activated ${count} users`); // Update with a function for computed values -await users.getAll("active", "status").update((user) => ({ - loginCount: user.key("loginCount").add(1), -})); +await users + .getAll("active", "status") + .update((user) => ({ + loginCount: user.key("loginCount").add(1), + })) + .run(); ``` ## Replace documents @@ -71,12 +75,15 @@ await users.getAll("active", "status").update((user) => ({ 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({ - _id: "user-123", - name: "New Name", - email: "new@example.com", - role: "user", -}); +const count = await users + .getAll("user-123") + .replace({ + _id: "user-123", + name: "New Name", + email: "new@example.com", + role: "user", + }) + .run(); console.log(`Replaced ${count} documents`); ``` @@ -86,7 +93,7 @@ console.log(`Replaced ${count} 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(); +const count = await users.getAll("inactive", "status").delete().run(); 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 85791b1..8c1d689 100644 --- a/docs/1.query_types/4.single_selection.md +++ b/docs/1.query_types/4.single_selection.md @@ -44,21 +44,24 @@ const user = users.get("user-123"); 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"); +const user = await users.get("user-123").run(); if (user) { console.log("Found:", user.name); } // Access specific fields -const email = await users.get("user-123").key("email"); +const email = await users.get("user-123").key("email").run(); console.log("Email:", email); // Transform with do() -const display = await users.get("user-123").do((u) => ({ - label: u.key("name"), - contact: u.key("email"), -})); +const display = await users + .get("user-123") + .do((u) => ({ + label: u.key("name"), + contact: u.key("email"), + })) + .run(); ``` ## Update a document @@ -67,16 +70,22 @@ The `update` method changes fields in the document. Fields it does not mention s ```typescript fragment // Update with a partial object -const count = await users.get("user-123").update({ - status: "active", - lastLogin: new Date(), -}); +const count = await users + .get("user-123") + .update({ + status: "active", + lastLogin: new Date(), + }) + .run(); // Update with a function for computed values -await users.get("user-123").update((user) => ({ - loginCount: user.key("loginCount").add(1), - lastLogin: new Date(), -})); +await users + .get("user-123") + .update((user) => ({ + loginCount: user.key("loginCount").add(1), + lastLogin: new Date(), + })) + .run(); ``` ## Replace a document @@ -84,12 +93,15 @@ await users.get("user-123").update((user) => ({ The `replace` method replaces the whole document with new data. ```typescript fragment -const count = await users.get("user-123").replace({ - _id: "user-123", - name: "New Name", - email: "new@example.com", - role: "user", -}); +const count = await users + .get("user-123") + .replace({ + _id: "user-123", + name: "New Name", + email: "new@example.com", + role: "user", + }) + .run(); ``` ## Delete a document @@ -97,7 +109,7 @@ const count = await users.get("user-123").replace({ 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(); +const count = await users.get("user-123").delete().run(); if (count === 1) { console.log("User deleted successfully"); diff --git a/docs/1.query_types/5.stream.md b/docs/1.query_types/5.stream.md index 5ef4af3..71b8d2e 100644 --- a/docs/1.query_types/5.stream.md +++ b/docs/1.query_types/5.stream.md @@ -93,10 +93,10 @@ class Stream extends Query { const users = schema.instance().table("users"); // Extract a single field -const emails = await users.key("email"); +const emails = await users.key("email").run(); // Provide a default value -const phones = await users.key("phone").default("N/A"); +const phones = await users.key("phone").default("N/A").run(); ``` ## Transform data @@ -106,11 +106,13 @@ const phones = await users.key("phone").default("N/A"); Transforms each document with a mapping function. The callback receives a `ValueProxy` to build expressions with. ```typescript fragment -const profiles = await users.map((user) => ({ - id: user.key("_id"), - displayName: user.key("name"), - contact: user.key("email"), -})); +const profiles = await users + .map((user) => ({ + id: user.key("_id"), + displayName: user.key("name"), + contact: user.key("email"), + })) + .run(); ``` ### `filter()` @@ -118,9 +120,9 @@ const profiles = await users.map((user) => ({ Keeps the documents that match a predicate. The callback returns a boolean expression. ```typescript fragment -const active = await users.filter((user) => - user.key("status").eq("active").and(user.key("age").ge(18)), -); +const active = await users + .filter((user) => user.key("status").eq("active").and(user.key("age").ge(18))) + .run(); ``` ### `pluck()` and `without()` @@ -129,10 +131,10 @@ const active = await users.filter((user) => ```typescript fragment // Keep only specific fields -const slim = await users.pluck("_id", "name", "email"); +const slim = await users.pluck("_id", "name", "email").run(); // Remove sensitive fields -const safe = await users.without("password", "ssn"); +const safe = await users.without("password", "ssn").run(); ``` ## Combine streams @@ -144,7 +146,8 @@ Concatenates two streams without deduplication. ```typescript fragment const allPremium = await users .filter((u) => u.key("plan").eq("premium")) - .union(users.filter((u) => u.key("plan").eq("enterprise"))); + .union(users.filter((u) => u.key("plan").eq("enterprise"))) + .run(); ``` ## Join data @@ -154,14 +157,16 @@ const allPremium = await users 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( - schema.instance().table("orders"), - (user, order) => order.key("userId").eq(user.key("_id")), - (user, order) => ({ - userName: user.key("name"), - orderTotal: order.key("amount").default(0), - }), -); +const usersWithOrders = await users + .join( + schema.instance().table("orders"), + (user, order) => order.key("userId").eq(user.key("_id")), + (user, order) => ({ + userName: user.key("name"), + orderTotal: order.key("amount").default(0), + }), + ) + .run(); ``` ### `joinInner()` for an inner join @@ -169,14 +174,16 @@ const usersWithOrders = await users.join( Performs an inner join. Only documents with a match on both sides appear in the result. ```typescript fragment -const matched = await users.joinInner( - schema.instance().table("orders"), - (user, order) => order.key("userId").eq(user.key("_id")), - (user, order) => ({ - userName: user.key("name"), - amount: order.key("amount"), - }), -); +const matched = await users + .joinInner( + schema.instance().table("orders"), + (user, order) => order.key("userId").eq(user.key("_id")), + (user, order) => ({ + userName: user.key("name"), + amount: order.key("amount"), + }), + ) + .run(); ``` ### `lookup()` @@ -187,7 +194,8 @@ Replaces a foreign key field, or a list of foreign keys, with the referenced doc const postsWithAuthors = await schema .instance() .table("posts") - .lookup(schema.instance().table("users"), "authorId", "_id"); + .lookup(schema.instance().table("users"), "authorId", "_id") + .run(); ``` ## Group data @@ -195,11 +203,13 @@ const postsWithAuthors = await schema 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) => ({ - role: role, - total: stream.count(), - avgAge: stream.avg("age"), -})); +const statsByRole = await users + .group("role", (stream, role) => ({ + role: role, + total: stream.count(), + avgAge: stream.avg("age"), + })) + .run(); ``` ## Order and paginate @@ -209,8 +219,8 @@ const statsByRole = await users.group("role", (stream, role) => ({ Sorts the stream by an index field, in ascending or descending order. The name is an index of the table, or else a field. A dotted name is a nested path, so `orderBy("title.fr")` sorts on `fr` inside `title`. ```typescript fragment -const sorted = await users.orderBy("name", "asc"); -const newest = await users.orderBy("createdAt", "desc"); +const sorted = await users.orderBy("name", "asc").run(); +const newest = await users.orderBy("createdAt", "desc").run(); ``` ### `slice()` @@ -218,8 +228,8 @@ const newest = await users.orderBy("createdAt", "desc"); 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); -const page2 = await users.orderBy("createdAt").slice(10, 10); +const page1 = await users.orderBy("createdAt").slice(0, 10).run(); +const page2 = await users.orderBy("createdAt").slice(10, 10).run(); ``` ### `nth()` @@ -227,7 +237,7 @@ const page2 = await users.orderBy("createdAt").slice(10, 10); Retrieves one document by its position in the stream. Returns a `Datum`. ```typescript fragment -const first = await users.orderBy("createdAt").nth(0); +const first = await users.orderBy("createdAt").nth(0).run(); ``` ## Aggregate data @@ -235,12 +245,12 @@ const first = await users.orderBy("createdAt").nth(0); 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(); -const distinctEmails = await users.count("email"); -const totalSales = await schema.instance().table("orders").sum("amount"); -const avgOrder = await schema.instance().table("orders").avg("amount"); -const maxOrder = await schema.instance().table("orders").max("amount"); -const minOrder = await schema.instance().table("orders").min("amount"); +const totalUsers = await users.count().run(); +const distinctEmails = await users.count("email").run(); +const totalSales = await schema.instance().table("orders").sum("amount").run(); +const avgOrder = await schema.instance().table("orders").avg("amount").run(); +const maxOrder = await schema.instance().table("orders").max("amount").run(); +const minOrder = await schema.instance().table("orders").min("amount").run(); ``` ### `distinct()` @@ -249,10 +259,10 @@ Without a field, `distinct()` returns a `Datum` of the deduplicated documents. W ```typescript fragment // All distinct documents -const unique = await users.distinct(); +const unique = await users.distinct().run(); // Distinct values of a field -const roles = await users.distinct("role"); +const roles = await users.distinct("role").run(); ``` ## Change feeds diff --git a/docs/1.query_types/6.feed.md b/docs/1.query_types/6.feed.md index bc79dca..20d5df8 100644 --- a/docs/1.query_types/6.feed.md +++ b/docs/1.query_types/6.feed.md @@ -59,7 +59,7 @@ for await (const change of users.changes()) { ## Use with async iteration -`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. +`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. `run()` 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"; @@ -70,9 +70,11 @@ interface User { name: string; } +const schema = new Schema<{ users: User }>("myapp", { + users: { fields: { name: "string" }, indexes: {} }, +}); + export async function watchUsers(onChange: (change: Changes) => boolean) { - const schema = Schema.get<{ users: User }>("myapp"); - if (!schema) throw new Error("Register the myapp schema first"); const feed = schema.instance().table("users").changes(); for await (const change of feed) { diff --git a/docs/1.query_types/7.query.md b/docs/1.query_types/7.query.md index a753e6f..d800701 100644 --- a/docs/1.query_types/7.query.md +++ b/docs/1.query_types/7.query.md @@ -1,20 +1,16 @@ --- 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. +description: The Query base class and the ways to execute a query, with run(), a cursor, or for await...of, plus lazy composition. mode: reference --- -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. +The `Query` class is the base class of every AQL query type. `run()` executes it and resolves to `T`. It also provides cursor-based async iteration that yields results one at a time. ## Class definition ```typescript fragment -class Query extends StagedObject implements PromiseLike { +class Query extends StagedObject { run(): Promise; - then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, - ): PromiseLike; cursor(): AsyncGenerator ? U : T, void, unknown>; [Symbol.asyncIterator](): AsyncGenerator< T extends Array ? U : T, @@ -38,19 +34,11 @@ if (!schema) throw new Error("Define the myapp schema first"); const users = await schema.instance().table("users").run(); ``` -### Direct `await` - -`Query` implements `PromiseLike`, so you can await it directly without calling `run()`: - -```typescript fragment -const users = await schema.instance().table("users"); -``` - -Both forms run the same stages: `then()` calls `run()`. +A query is not a promise. It has no `then()`, so `await` on a query without `run()` gives back the query itself and sends nothing to the provider. ## Async iteration -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()`. +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 execute them with `run()`. ### `cursor()` @@ -78,7 +66,7 @@ for await (const user of schema.instance().table("users")) { ## Query composition -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: +Queries are lazy. Each operation records a pipeline stage, and the stages execute only when `run()` or iteration starts. You can therefore build queries step by step: ```typescript fragment const usersTable = schema.instance().table("users"); @@ -88,6 +76,6 @@ const activeUsers = usersTable.filter((u) => u.key("status").eq("active")); const inactiveUsers = usersTable.filter((u) => u.key("status").eq("inactive")); // Execute them independently -const active = await activeUsers; -const inactive = await inactiveUsers; +const active = await activeUsers.run(); +const inactive = await inactiveUsers.run(); ``` diff --git a/docs/1.query_types/8.datum.md b/docs/1.query_types/8.datum.md index e233bfa..3675876 100644 --- a/docs/1.query_types/8.datum.md +++ b/docs/1.query_types/8.datum.md @@ -44,13 +44,13 @@ if (!schema) throw new Error("Define the myapp schema first"); const users = schema.instance().table("users"); // Access a field -const name = await users.get("user-123").key("name"); +const name = await users.get("user-123").key("name").run(); // Access a nested field -const city = await users.get("user-123").key("address").key("city"); +const city = await users.get("user-123").key("address").key("city").run(); // Provide a default value -const phone = await users.get("user-123").key("phone", "N/A"); +const phone = await users.get("user-123").key("phone", "N/A").run(); ``` ## Default values @@ -58,11 +58,14 @@ const phone = await users.get("user-123").key("phone", "N/A"); The `default` method gives a fallback value for a datum that is `null`. ```typescript fragment -const user = await users.get("maybe-missing").default({ - _id: "guest", - name: "Guest User", - role: "visitor", -}); +const user = await users + .get("maybe-missing") + .default({ + _id: "guest", + name: "Guest User", + role: "visitor", + }) + .run(); ``` ## Transform with `do()` @@ -70,11 +73,14 @@ const user = await users.get("maybe-missing").default({ 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) => ({ - displayName: user.key("name"), - contact: user.key("email"), - hasPhone: user.key("phone").ne(undefined), -})); +const formatted = await users + .get("user-123") + .do((user) => ({ + displayName: user.key("name"), + contact: user.key("email"), + hasPhone: user.key("phone").ne(undefined), + })) + .run(); ``` ## Foreign key lookup @@ -95,13 +101,16 @@ interface User { name: string; } -const blog = Schema.get<{ posts: Post; users: User }>("blog"); -if (!blog) throw new Error("Define the blog schema first"); +const blog = new Schema<{ posts: Post; users: User }>("blog", { + posts: { fields: { title: "string", authorId: "string" }, indexes: {} }, + users: { fields: { name: "string" }, indexes: {} }, +}); const post = await blog .instance() .table("posts") .get("post-1") - .lookup(blog.instance().table("users"), "authorId", "_id"); + .lookup(blog.instance().table("users"), "authorId", "_id") + .run(); // post.authorId is now the full User object console.log(post.authorId.name); @@ -112,12 +121,15 @@ console.log(post.authorId.name); 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"); +const profile = await users + .get("user-123") + .pluck("name", "email", "avatar") + .run(); ``` ## Type casting -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. +The `cast` method returns a new datum with the same stages and another TypeScript type. It performs no runtime conversion and records no stage. 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 86be34f..b5162a7 100644 --- a/docs/1.query_types/9.valueproxy.md +++ b/docs/1.query_types/9.valueproxy.md @@ -61,7 +61,7 @@ const isInactive = user.key("active").not(); ### `cast()` -Changes the TypeScript type without a runtime conversion. Nothing checks that the new type matches the value. +Returns a new proxy with the same stages and another TypeScript type, without a runtime conversion. Nothing checks that the new type matches the value. ## Number operations @@ -234,11 +234,13 @@ const hasRequired = user.hasfields("email", "name"); You can chain `ValueProxy` methods to build larger expressions inside query callbacks: ```typescript fragment -const result = await users.filter((user) => - user - .key("age") - .ge(18) - .and(user.key("status").eq("active")) - .and(user.key("role").eq("admin").or(user.key("plan").eq("premium"))), -); +const result = await users + .filter((user) => + user + .key("age") + .ge(18) + .and(user.key("status").eq("active")) + .and(user.key("role").eq("admin").or(user.key("plan").eq("premium"))), + ) + .run(); ``` diff --git a/docs/2.operations/2.database_management.md b/docs/2.operations/2.database_management.md index 8f8bacd..a1016e2 100644 --- a/docs/2.operations/2.database_management.md +++ b/docs/2.operations/2.database_management.md @@ -58,10 +58,10 @@ After you define a schema, create an instance to start working with data. The op ```typescript fragment // Create the default instance -await schema.createInstance(); +await schema.createInstance().run(); // Create a named instance -await schema.createInstance("tenant-abc"); +await schema.createInstance("tenant-abc").run(); ``` ## Access an instance @@ -89,7 +89,7 @@ const everything = schema.instance(CROSS_INSTANCE).table("users"); Remove an instance and its data: ```typescript fragment -await schema.destroyInstance("tenant-abc"); +await schema.destroyInstance("tenant-abc").run(); ``` ## List instances @@ -97,7 +97,7 @@ await schema.destroyInstance("tenant-abc"); List the IDs of the named instances of a schema: ```typescript fragment -const ids = await schema.listInstances(); +const ids = await schema.listInstances().run(); // ["tenant-abc", "tenant-xyz"] ``` @@ -138,15 +138,23 @@ interface Post { authorId: string; } -const typedSchema = Schema.get<{ users: User; posts: Post }>("myapp"); -if (!typedSchema) throw new Error('Schema "myapp" is not defined'); +const typedSchema = new Schema<{ users: User; posts: Post }>("myapp", { + users: { + fields: { name: "string", email: "string", role: "string" }, + indexes: {}, + }, + posts: { + fields: { title: "string", content: "string", authorId: "string" }, + indexes: {}, + }, +}); const db = typedSchema.instance(); const users = db.table("users"); // Table const posts = db.table("posts"); // Table ``` -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. +The type argument describes the schema. It does not validate stored data. `Schema.get()` keeps no type arguments: the schema it returns maps each table to `any` documents. Export the schema object you define and import it where you query typed tables. ## Multi-tenant pattern @@ -163,8 +171,8 @@ const schema = new Schema("saas", { }); // Create one instance per tenant -await schema.createInstance("tenant-1"); -await schema.createInstance("tenant-2"); +await schema.createInstance("tenant-1").run(); +await schema.createInstance("tenant-2").run(); // Access tenant-specific data const tenant1Projects = schema.instance("tenant-1").table("projects"); diff --git a/docs/2.operations/3.table_operations.md b/docs/2.operations/3.table_operations.md index 37abd68..53fda67 100644 --- a/docs/2.operations/3.table_operations.md +++ b/docs/2.operations/3.table_operations.md @@ -54,13 +54,13 @@ const users = schema.instance().table("users"); // 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[] + const user = await users.get("user-123").run(); // Declared as User + const emails = await users.key("email").run(); // string[] return { user, emails }; } ``` -`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. +`get()` returns `SingleSelection`, so running 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 @@ -69,14 +69,14 @@ export async function readUser() { A table reference is also a stream of all its documents: ```typescript fragment -const allUsers = await users; +const allUsers = await users.run(); console.log(`Total users: ${allUsers.length}`); ``` ### Retrieve by primary key ```typescript fragment -const user = await users.get("user-123"); +const user = await users.get("user-123").run(); if (user) { console.log("Found:", user.name); @@ -86,7 +86,7 @@ if (user) { ### Retrieve by secondary index ```typescript fragment -const admins = await users.getAll("admin", "role"); +const admins = await users.getAll("admin", "role").run(); ``` Without an index name, `getAll()` matches the primary key. @@ -96,11 +96,9 @@ Without an index name, `getAll()` matches the primary key. `between()` takes an index name, a lowest value, and a highest value. The highest value is excluded. ```typescript fragment -const recent = await users.between( - "createdAt", - new Date("2024-01-01"), - new Date(), -); +const recent = await users + .between("createdAt", new Date("2024-01-01"), new Date()) + .run(); ``` ## Chain operations @@ -111,40 +109,44 @@ const recent = await users.between( // Filter and sort const activeByName = await users .filter((u) => u.key("status").eq("active")) - .orderBy("name"); + .orderBy("name") + .run(); // Paginate -const page = await users.orderBy("createdAt").slice(0, 10); +const page = await users.orderBy("createdAt").slice(0, 10).run(); // Aggregate -const count = await users.count(); -const avgAge = await users.avg("age"); +const count = await users.count().run(); +const avgAge = await users.avg("age").run(); // Transform -const names = await users.map((u) => u.key("name")); +const names = await users.map((u) => u.key("name")).run(); ``` ## Insert documents ```typescript fragment // Single document -const ids = await users.insert({ - name: "Alice", - email: "alice@example.com", - status: "active", -}); +const ids = await users + .insert({ + name: "Alice", + email: "alice@example.com", + status: "active", + }) + .run(); // Multiple documents -const batchIds = await users.insert([ - { name: "Bob", email: "bob@example.com", status: "active" }, - { name: "Carol", email: "carol@example.com", status: "active" }, -]); +const batchIds = await users + .insert([ + { name: "Bob", email: "bob@example.com", status: "active" }, + { name: "Carol", email: "carol@example.com", status: "active" }, + ]) + .run(); // Upsert (update on conflict) -await users.insert( - { _id: "user-123", name: "Updated" }, - { conflict: "update" }, -); +await users + .insert({ _id: "user-123", name: "Updated" }, { conflict: "update" }) + .run(); ``` 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 25c37e8..5f8a761 100644 --- a/docs/2.operations/4.crud.md +++ b/docs/2.operations/4.crud.md @@ -22,12 +22,14 @@ const users = schema.instance().table("users"); `insert()` resolves to the IDs of the inserted documents. ```typescript fragment -const ids = await users.insert({ - name: "Alice Johnson", - email: "alice@example.com", - status: "active", - createdAt: new Date(), -}); +const ids = await users + .insert({ + name: "Alice Johnson", + email: "alice@example.com", + status: "active", + createdAt: new Date(), + }) + .run(); console.log("Inserted ID:", ids[0]); ``` @@ -35,10 +37,12 @@ console.log("Inserted ID:", ids[0]); ### Insert multiple documents ```typescript fragment -const ids = await users.insert([ - { name: "Bob", email: "bob@example.com", status: "active" }, - { name: "Carol", email: "carol@example.com", status: "active" }, -]); +const ids = await users + .insert([ + { name: "Bob", email: "bob@example.com", status: "active" }, + { name: "Carol", email: "carol@example.com", status: "active" }, + ]) + .run(); console.log("Inserted IDs:", ids); ``` @@ -51,7 +55,7 @@ Without a `conflict` option, an insert throws a `DuplicateKeyError` when the ins import { IsDuplicateKeyError } from "@antelopejs/interface-database"; try { - await users.insert({ _id: "user-123", name: "Alice" }); + await users.insert({ _id: "user-123", name: "Alice" }).run(); } catch (error) { if (!IsDuplicateKeyError(error)) throw error; // error.index === "_id", error.fields deep-equals ["_id"] @@ -64,16 +68,20 @@ To handle documents that already exist, set the `conflict` option to `"update"` ```typescript fragment // Update existing fields, keep unmentioned fields -await users.insert( - { _id: "user-123", name: "Updated Name", lastSeen: new Date() }, - { conflict: "update" }, -); +await users + .insert( + { _id: "user-123", name: "Updated Name", lastSeen: new Date() }, + { conflict: "update" }, + ) + .run(); // Replace the entire document -await users.insert( - { _id: "user-123", name: "Replaced", email: "new@example.com" }, - { conflict: "replace" }, -); +await users + .insert( + { _id: "user-123", name: "Replaced", email: "new@example.com" }, + { conflict: "replace" }, + ) + .run(); ``` ## Read documents @@ -81,7 +89,7 @@ await users.insert( ### Get by primary key ```typescript fragment -const user = await users.get("user-123"); +const user = await users.get("user-123").run(); if (user) { console.log("Found:", user.name); @@ -93,7 +101,7 @@ if (user) { ### Get by secondary index ```typescript fragment -const admins = await users.getAll("admin", "role"); +const admins = await users.getAll("admin", "role").run(); console.log(`Found ${admins.length} admins`); ``` @@ -102,30 +110,28 @@ console.log(`Found ${admins.length} admins`); The upper bound of `between()` is excluded. ```typescript fragment -const recent = await users.between( - "createdAt", - new Date("2024-01-01"), - new Date(), -); +const recent = await users + .between("createdAt", new Date("2024-01-01"), new Date()) + .run(); ``` ### Filter documents ```typescript fragment // Filter with a predicate function -const active = await users.filter((user) => - user.key("status").eq("active").and(user.key("age").ge(18)), -); +const active = await users + .filter((user) => user.key("status").eq("active").and(user.key("age").ge(18))) + .run(); ``` ### Select specific fields ```typescript fragment // Keep only listed fields -const profiles = await users.pluck("_id", "name", "avatar"); +const profiles = await users.pluck("_id", "name", "avatar").run(); // Remove sensitive fields -const safe = await users.without("password", "ssn"); +const safe = await users.without("password", "ssn").run(); ``` ### Order and paginate @@ -133,8 +139,8 @@ const safe = await users.without("password", "ssn"); `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); +const page1 = await users.orderBy("createdAt", "desc").slice(0, 10).run(); +const page2 = await users.orderBy("createdAt", "desc").slice(10, 10).run(); ``` ### Stream results @@ -156,10 +162,13 @@ for await (const user of users) { 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({ - status: "active", - lastLogin: new Date(), -}); +const count = await users + .get("user-123") + .update({ + status: "active", + lastLogin: new Date(), + }) + .run(); if (count === 1) { console.log("User updated"); @@ -173,10 +182,13 @@ Give the partial document a real type. A value typed `any` matches the function Pass a function to compute new values from the current ones: ```typescript fragment -await users.get("user-123").update((user) => ({ - loginCount: user.key("loginCount").add(1), - lastLogin: new Date(), -})); +await users + .get("user-123") + .update((user) => ({ + loginCount: user.key("loginCount").add(1), + lastLogin: new Date(), + })) + .run(); ``` The call is typed `Query` when the object the function returns fits the document type, and `never` otherwise. @@ -186,7 +198,8 @@ The call is typed `Query` when the object the function returns fits the ```typescript fragment const count = await users .getAll("inactive", "status") - .update({ status: "active" }); + .update({ status: "active" }) + .run(); console.log(`Activated ${count} users`); ``` @@ -196,13 +209,16 @@ console.log(`Activated ${count} users`); `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({ - _id: "user-123", - name: "Alice Updated", - email: "alice.new@example.com", - status: "active", - updatedAt: new Date(), -}); +const count = await users + .get("user-123") + .replace({ + _id: "user-123", + name: "Alice Updated", + email: "alice.new@example.com", + status: "active", + updatedAt: new Date(), + }) + .run(); ``` ## Delete documents @@ -212,7 +228,7 @@ const count = await users.get("user-123").replace({ ### Delete a single document ```typescript fragment -const count = await users.get("user-123").delete(); +const count = await users.get("user-123").delete().run(); if (count === 1) { console.log("User deleted"); @@ -224,7 +240,8 @@ if (count === 1) { ```typescript fragment const count = await users .filter((user) => user.key("status").eq("inactive")) - .delete(); + .delete() + .run(); console.log(`Deleted ${count} inactive users`); ``` @@ -243,7 +260,7 @@ const documents = Array.from({ length: 10000 }, (_, i) => ({ const batchSize = 1000; for (let i = 0; i < documents.length; i += batchSize) { const batch = documents.slice(i, i + batchSize); - await schema.instance().table("items").insert(batch); + await schema.instance().table("items").insert(batch).run(); console.log(`Inserted batch ${Math.floor(i / batchSize) + 1}`); } ``` diff --git a/docs/2.operations/5.indexes.md b/docs/2.operations/5.indexes.md index dc194ce..da2b28c 100644 --- a/docs/2.operations/5.indexes.md +++ b/docs/2.operations/5.indexes.md @@ -67,10 +67,10 @@ const db = schema.instance(); const users = db.table("users"); // Query by the "role" index -const admins = await users.getAll("admin", "role"); +const admins = await users.getAll("admin", "role").run(); // Query multiple values -const targets = await users.getAll(["admin", "moderator"], "role"); +const targets = await users.getAll(["admin", "moderator"], "role").run(); ``` ### `between()` with an index @@ -78,7 +78,7 @@ const targets = await users.getAll(["admin", "moderator"], "role"); 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); +const results = await users.between("age", 18, 65).run(); ``` ### `orderBy()` with an index @@ -86,8 +86,8 @@ const results = await users.between("age", 18, 65); 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"); +const sorted = await users.orderBy("email", "asc").run(); +const newest = await users.orderBy("createdAt", "desc").run(); ``` `orderBy()` takes an index name, or else a field. A dotted field is a nested path, so `orderBy("meta.rank")` sorts on `rank` inside `meta` when no index has that name. @@ -152,7 +152,8 @@ const schema = new Schema("billing", { await schema .instance(CROSS_INSTANCE) .table("invoices") - .getAll("unpaid", "status"); + .getAll("unpaid", "status") + .run(); ``` 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. @@ -182,7 +183,8 @@ try { await schema .instance("acme") .table("users") - .insert({ email: "ada@example.com" }); + .insert({ email: "ada@example.com" }) + .run(); } catch (error) { if (!IsDuplicateKeyError(error)) throw error; console.log(`Taken: ${error.fields.join(", ")} on index ${error.index}`); diff --git a/docs/2.operations/6.filtering.md b/docs/2.operations/6.filtering.md index 88265e0..0fae552 100644 --- a/docs/2.operations/6.filtering.md +++ b/docs/2.operations/6.filtering.md @@ -20,7 +20,9 @@ const users = schema.instance().table("users"); 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")); +const active = await users + .filter((user) => user.key("status").eq("active")) + .run(); ``` ## Comparison operators @@ -28,17 +30,17 @@ const active = await users.filter((user) => user.key("status").eq("active")); ### 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")); +const admins = await users.filter((u) => u.key("role").eq("admin")).run(); +const nonAdmins = await users.filter((u) => u.key("role").ne("admin")).run(); ``` ### Numeric comparisons ```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)); -const discounted = await users.filter((u) => u.key("age").le(25)); +const adults = await users.filter((u) => u.key("age").ge(18)).run(); +const seniors = await users.filter((u) => u.key("age").gt(65)).run(); +const young = await users.filter((u) => u.key("age").lt(18)).run(); +const discounted = await users.filter((u) => u.key("age").le(25)).run(); ``` ## Logical operators @@ -47,25 +49,31 @@ Combine conditions with `and()`, `or()`, and `not()`: ```typescript fragment // AND -const verified = await users.filter((u) => - u.key("status").eq("active").and(u.key("emailVerified").eq(true)), -); +const verified = await users + .filter((u) => + u.key("status").eq("active").and(u.key("emailVerified").eq(true)), + ) + .run(); // OR -const important = await users.filter((u) => - u.key("role").eq("admin").or(u.key("plan").eq("premium")), -); +const important = await users + .filter((u) => u.key("role").eq("admin").or(u.key("plan").eq("premium"))) + .run(); // NOT -const standard = await users.filter((u) => u.key("plan").eq("premium").not()); +const standard = await users + .filter((u) => u.key("plan").eq("premium").not()) + .run(); // Complex combinations -const target = await users.filter((u) => - u - .key("status") - .eq("active") - .and(u.key("plan").eq("premium").or(u.key("totalPurchases").gt(500))), -); +const target = await users + .filter((u) => + u + .key("status") + .eq("active") + .and(u.key("plan").eq("premium").or(u.key("totalPurchases").gt(500))), + ) + .run(); ``` ## String operations @@ -74,17 +82,19 @@ const target = await users.filter((u) => ```typescript fragment // Case-insensitive match -const bobs = await users.filter((u) => u.key("name").downcase().eq("bob")); +const bobs = await users + .filter((u) => u.key("name").downcase().eq("bob")) + .run(); // Regex matching -const gmailUsers = await users.filter((u) => - u.key("email").match("@gmail\\.com$"), -); +const gmailUsers = await users + .filter((u) => u.key("email").match("@gmail\\.com$")) + .run(); // String concatenation in filter -const fullNameMatch = await users.filter((u) => - u.key("firstName").concat(u.key("lastName")).eq("JohnSmith"), -); +const fullNameMatch = await users + .filter((u) => u.key("firstName").concat(u.key("lastName")).eq("JohnSmith")) + .run(); ``` ## Date filtering @@ -92,26 +102,30 @@ const fullNameMatch = await users.filter((u) => ### Date comparisons ```typescript fragment -const newUsers = await users.filter((u) => - u.key("createdAt").gt(new Date("2024-01-01")), -); - -const q1Users = await users.filter((u) => - u - .key("createdAt") - .ge(new Date("2024-01-01")) - .and(u.key("createdAt").lt(new Date("2024-04-01"))), -); +const newUsers = await users + .filter((u) => u.key("createdAt").gt(new Date("2024-01-01"))) + .run(); + +const q1Users = await users + .filter((u) => + u + .key("createdAt") + .ge(new Date("2024-01-01")) + .and(u.key("createdAt").lt(new Date("2024-04-01"))), + ) + .run(); ``` ### Date components ```typescript fragment -const thisYear = await users.filter((u) => u.key("createdAt").year().eq(2024)); +const thisYear = await users + .filter((u) => u.key("createdAt").year().eq(2024)) + .run(); -const morningLogins = await users.filter((u) => - u.key("lastLogin").hours().lt(12), -); +const morningLogins = await users + .filter((u) => u.key("lastLogin").hours().lt(12)) + .run(); ``` ## Array operations @@ -121,19 +135,22 @@ const morningLogins = await users.filter((u) => const jsPosts = await schema .instance() .table("posts") - .filter((post) => post.key("tags").includes("javascript")); + .filter((post) => post.key("tags").includes("javascript")) + .run(); // Check array length const wellTagged = await schema .instance() .table("posts") - .filter((post) => post.key("tags").count().gt(3)); + .filter((post) => post.key("tags").count().gt(3)) + .run(); // Check if empty const untagged = await schema .instance() .table("posts") - .filter((post) => post.key("tags").isempty()); + .filter((post) => post.key("tags").isempty()) + .run(); ``` ## Default values in filters @@ -141,13 +158,13 @@ const untagged = await schema To compare a field that may be null, give it a fallback with `default()`: ```typescript fragment -const activeUsers = await users.filter((u) => - u.key("active").default(false).eq(true), -); +const activeUsers = await users + .filter((u) => u.key("active").default(false).eq(true)) + .run(); -const frequentVisitors = await users.filter((u) => - u.key("visits").default(0).gt(10), -); +const frequentVisitors = await users + .filter((u) => u.key("visits").default(0).gt(10)) + .run(); ``` ## Ordering and pagination @@ -156,14 +173,14 @@ const frequentVisitors = await users.filter((u) => ```typescript fragment // Sort ascending -const byName = await users.orderBy("name", "asc"); +const byName = await users.orderBy("name", "asc").run(); // Sort descending -const newest = await users.orderBy("createdAt", "desc"); +const newest = await users.orderBy("createdAt", "desc").run(); // Paginate -const page1 = await users.orderBy("createdAt").slice(0, 10); -const page2 = await users.orderBy("createdAt").slice(10, 10); +const page1 = await users.orderBy("createdAt").slice(0, 10).run(); +const page2 = await users.orderBy("createdAt").slice(10, 10).run(); ``` ## Performance tips @@ -175,8 +192,10 @@ const page2 = await users.orderBy("createdAt").slice(10, 10); ```typescript fragment // Slower: full table scan with filter -const slow = await users.filter((u) => u.key("email").eq("user@example.com")); +const slow = await users + .filter((u) => u.key("email").eq("user@example.com")) + .run(); // Faster: index-based lookup -const fast = await users.getAll("user@example.com", "email"); +const fast = await users.getAll("user@example.com", "email").run(); ``` diff --git a/docs/2.operations/7.lookup.md b/docs/2.operations/7.lookup.md index 922f7ba..90c9403 100644 --- a/docs/2.operations/7.lookup.md +++ b/docs/2.operations/7.lookup.md @@ -43,14 +43,17 @@ interface User { email: string; } -const schema = Schema.get<{ posts: Post; users: User }>("blog"); -if (!schema) throw new Error('Schema "blog" is not defined'); +const schema = new Schema<{ posts: Post; users: User }>("blog", { + posts: { fields: { title: "string", authorId: "string" }, indexes: {} }, + users: { fields: { name: "string", email: "string" }, indexes: {} }, +}); const db = schema.instance(); const postsWithAuthors = await db .table("posts") - .lookup(db.table("users"), "authorId", "_id"); + .lookup(db.table("users"), "authorId", "_id") + .run(); // Result type: { _id: string, title: string, authorId: User } for (const post of postsWithAuthors) { @@ -78,13 +81,19 @@ interface Product { price: number; } -const shopSchema = Schema.get<{ orders: Order; products: Product }>("shop"); -if (!shopSchema) throw new Error('Schema "shop" is not defined'); +const shopSchema = new Schema<{ orders: Order; products: Product }>("shop", { + orders: { + fields: { customerId: "string", productIds: "string[]", total: "number" }, + indexes: {}, + }, + products: { fields: { name: "string", price: "number" }, indexes: {} }, +}); const shop = shopSchema.instance(); const ordersWithProducts = await shop .table("orders") - .lookup(shop.table("products"), "productIds", "_id"); + .lookup(shop.table("products"), "productIds", "_id") + .run(); // Result type: { ..., productIds: Product[] } for (const order of ordersWithProducts) { @@ -111,16 +120,20 @@ interface Product { meta: { sku: string }; } -const catalogSchema = Schema.get<{ lines: OrderLine; products: Product }>( +const catalogSchema = new Schema<{ lines: OrderLine; products: Product }>( "catalog", + { + lines: { fields: { productRef: "string" }, indexes: {} }, + products: { fields: { meta: { sku: "string" } }, indexes: {} }, + }, ); -if (!catalogSchema) throw new Error('Schema "catalog" is not defined'); const catalog = catalogSchema.instance(); const linesWithProducts = await catalog .table("lines") - .lookup(catalog.table("products"), "productRef", "meta.sku"); + .lookup(catalog.table("products"), "productRef", "meta.sku") + .run(); // Result type: { _id: string, productRef: Product } for (const line of linesWithProducts) { @@ -142,16 +155,16 @@ const db = shopSchema.instance(); const recentWithCustomers = await db .table("orders") .filter((order) => order.key("createdAt").gt(new Date("2024-01-01"))) - .lookup(db.table("customers"), "customerId", "_id"); + .lookup(db.table("customers"), "customerId", "_id") + .run(); -// Lookup on a single document -const blogSchema = Schema.get<{ posts: Post; users: User }>("blog"); -if (!blogSchema) throw new Error('Schema "blog" is not defined'); -const blog = blogSchema.instance(); +// Lookup on a single document, on the blog schema defined above +const blog = schema.instance(); const post = await blog .table("posts") .get("post-1") - .lookup(blog.table("users"), "authorId", "_id"); + .lookup(blog.table("users"), "authorId", "_id") + .run(); ``` ## Lookup vs join @@ -174,18 +187,22 @@ const post = await blog // Lookup: replaces the field const withLookup = await db .table("orders") - .lookup(db.table("customers"), "customerId", "_id"); + .lookup(db.table("customers"), "customerId", "_id") + .run(); // order.customerId is now the full customer object // Join: custom output structure -const withJoin = await db.table("orders").joinInner( - db.table("customers"), - (order, customer) => order.key("customerId").eq(customer.key("_id")), - (order, customer) => ({ - orderId: order.key("_id"), - orderTotal: order.key("total"), - customerName: customer.key("name"), - }), -); +const withJoin = await db + .table("orders") + .joinInner( + db.table("customers"), + (order, customer) => order.key("customerId").eq(customer.key("_id")), + (order, customer) => ({ + orderId: order.key("_id"), + orderTotal: order.key("total"), + customerName: customer.key("name"), + }), + ) + .run(); // Result is a custom object with selected fields from both tables ``` diff --git a/docs/2.operations/8.atomic_mutations.md b/docs/2.operations/8.atomic_mutations.md index 1a44b9b..5d458b5 100644 --- a/docs/2.operations/8.atomic_mutations.md +++ b/docs/2.operations/8.atomic_mutations.md @@ -13,19 +13,23 @@ Generic `filter(...).update(...)` and `filter(...).delete()` do not give this gu `@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, { - type: "update", - revisionField: "revision", - expectedRevision: observed.revision, - nextRevision: freshRevision, - patch: { title: "Updated title" }, -}); - -const deletion = await table.atomicMutation(recordId, { - type: "delete", - revisionField: "revision", - expectedRevision: observed.revision, -}); +const outcome = await table + .atomicMutation(recordId, { + type: "update", + revisionField: "revision", + expectedRevision: observed.revision, + nextRevision: freshRevision, + patch: { title: "Updated title" }, + }) + .run(); + +const deletion = await table + .atomicMutation(recordId, { + type: "delete", + revisionField: "revision", + expectedRevision: observed.revision, + }) + .run(); ``` 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. @@ -43,11 +47,13 @@ A patch holds only constant strings, booleans, finite numbers, null, valid Dates `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, { - type: "deleteIfEqual", - field: "lastSeenAt", - expectedValue: observed.lastSeenAt, -}); +const outcome = await table + .atomicMutation(recordId, { + type: "deleteIfEqual", + field: "lastSeenAt", + expectedValue: observed.lastSeenAt, + }) + .run(); ``` `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. diff --git a/docs/3.results/1.index.md b/docs/3.results/1.index.md index 044d552..7ad037a 100644 --- a/docs/3.results/1.index.md +++ b/docs/3.results/1.index.md @@ -19,7 +19,7 @@ An AQL query resolves to a value that says what the operation did. The pages in Every AQL operation follows the same pattern: 1. Build a query by chaining operations. -2. Run the query with `run()` or `await` to get the result. +2. Run the query with `run()` to get the result. 3. Process the result. ```typescript @@ -32,20 +32,26 @@ interface User { status: string; } -const schema = Schema.get<{ users: User }>("myapp"); -if (!schema) throw new Error('Schema "myapp" is not defined'); +const schema = new Schema<{ users: User }>("myapp", { + users: { + fields: { name: "string", email: "string", status: "string" }, + indexes: {}, + }, +}); const users = schema.instance().table("users"); // Insert returns the IDs of the inserted documents -const ids = await users.insert({ name: "Alice", email: "alice@example.com" }); +const ids = await users + .insert({ name: "Alice", email: "alice@example.com" }) + .run(); console.log("New ID:", ids[0]); // Update returns the number of modified documents -const count = await users.get("user-123").update({ status: "active" }); +const count = await users.get("user-123").update({ status: "active" }).run(); console.log(`Modified ${count} documents`); // Delete returns the number of deleted documents -const deleted = await users.get("user-123").delete(); +const deleted = await users.get("user-123").delete().run(); console.log(`Deleted ${deleted} documents`); ``` diff --git a/docs/3.results/2.write_results.md b/docs/3.results/2.write_results.md index 1ee45ca..50b3307 100644 --- a/docs/3.results/2.write_results.md +++ b/docs/3.results/2.write_results.md @@ -10,10 +10,12 @@ mode: reference ```typescript fragment // Insert returns string[] (the IDs of inserted documents) -const ids: string[] = await users.insert({ - name: "Alice", - email: "alice@example.com", -}); +const ids: string[] = await users + .insert({ + name: "Alice", + email: "alice@example.com", + }) + .run(); ``` ## Examples @@ -29,15 +31,18 @@ interface User { email: string; } -const schema = Schema.get<{ users: User }>("myapp"); -if (!schema) throw new Error('Schema "myapp" is not defined'); +const schema = new Schema<{ users: User }>("myapp", { + users: { fields: { name: "string", email: "string" }, indexes: {} }, +}); const users = schema.instance().table("users"); -const ids = await users.insert({ - name: "John Doe", - email: "john@example.com", -}); +const ids = await users + .insert({ + name: "John Doe", + email: "john@example.com", + }) + .run(); console.log("Inserted document ID:", ids[0]); ``` @@ -45,11 +50,13 @@ console.log("Inserted document ID:", ids[0]); ### Multiple document insert ```typescript fragment -const ids = await users.insert([ - { name: "Alice", email: "alice@example.com" }, - { name: "Bob", email: "bob@example.com" }, - { name: "Carol", email: "carol@example.com" }, -]); +const ids = await users + .insert([ + { name: "Alice", email: "alice@example.com" }, + { name: "Bob", email: "bob@example.com" }, + { name: "Carol", email: "carol@example.com" }, + ]) + .run(); console.log(`Inserted ${ids.length} documents`); console.log("IDs:", ids); @@ -61,16 +68,20 @@ The `conflict` option, `"update"` or `"replace"`, sets what happens when a docum ```typescript fragment // Update matching fields on conflict -const ids = await users.insert( - { _id: "user-123", name: "Updated Name", lastSeen: new Date() }, - { conflict: "update" }, -); +const ids = await users + .insert( + { _id: "user-123", name: "Updated Name", lastSeen: new Date() }, + { conflict: "update" }, + ) + .run(); // Replace the entire document on conflict -const ids2 = await users.insert( - { _id: "user-123", name: "Replaced", email: "new@example.com" }, - { conflict: "replace" }, -); +const ids2 = await users + .insert( + { _id: "user-123", name: "Replaced", email: "new@example.com" }, + { conflict: "replace" }, + ) + .run(); ``` ## Update and delete results @@ -79,21 +90,25 @@ const ids2 = await users.insert( ```typescript fragment // Update returns the number of modified documents -const updated = await users.get("user-123").update({ status: "active" }); +const updated = await users.get("user-123").update({ status: "active" }).run(); console.log(`Modified: ${updated}`); // Replace returns the number of modified documents -const replaced = await users.get("user-123").replace({ - _id: "user-123", - name: "New", - email: "new@example.com", -}); +const replaced = await users + .get("user-123") + .replace({ + _id: "user-123", + name: "New", + email: "new@example.com", + }) + .run(); console.log(`Replaced: ${replaced}`); // Delete returns the number of deleted documents const deleted = await users .filter((u) => u.key("status").eq("inactive")) - .delete(); + .delete() + .run(); console.log(`Deleted: ${deleted}`); ``` @@ -119,6 +134,7 @@ type Modified = Awaited["run"]>>; const modified: Modified = await users .get("user-123") - .update({ status: "active" }); + .update({ status: "active" }) + .run(); console.log(`Modified: ${modified}`); ``` diff --git a/docs/3.results/3.index_changes.md b/docs/3.results/3.index_changes.md index aa640d6..4282084 100644 --- a/docs/3.results/3.index_changes.md +++ b/docs/3.results/3.index_changes.md @@ -85,14 +85,12 @@ const db = schema.instance(); const users = db.table("users"); // Lookup by secondary index -const admins = await users.getAll("admin", "role"); +const admins = await users.getAll("admin", "role").run(); // Range query on an indexed field -const recent = await users.between( - "createdAt", - new Date("2024-01-01"), - new Date(), -); +const recent = await users + .between("createdAt", new Date("2024-01-01"), new Date()) + .run(); ``` 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 3cc5ef1..01b9df0 100644 --- a/docs/3.results/4.table_changes.md +++ b/docs/3.results/4.table_changes.md @@ -109,13 +109,13 @@ const schema = new Schema("myapp", { After you create a schema instance, access its tables by name: ```typescript fragment -await schema.createInstance(); +await schema.createInstance().run(); const users = schema.instance().table("users"); const posts = schema.instance().table("posts"); ``` -For typed access, give the table mapping when you declare or retrieve the schema. `table()` infers the document type from the table name: +For typed access, give the table mapping when you declare the schema. `table()` infers the document type from the table name: ```typescript import { Schema } from "@antelopejs/interface-database"; @@ -127,8 +127,12 @@ interface User { age: number; } -const typedSchema = Schema.get<{ users: User }>("myapp"); -if (!typedSchema) throw new Error('Schema "myapp" is not defined'); +const typedSchema = new Schema<{ users: User }>("myapp", { + users: { + fields: { name: "string", email: "string", age: "number" }, + indexes: {}, + }, +}); const users = typedSchema.instance().table("users"); // Table ``` diff --git a/docs/3.results/5.database_changes.md b/docs/3.results/5.database_changes.md index c840941..d65176b 100644 --- a/docs/3.results/5.database_changes.md +++ b/docs/3.results/5.database_changes.md @@ -31,7 +31,7 @@ class StagedSchema< } ``` -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. +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. It takes no type arguments, since the registry keeps none: the schema it returns maps each table to `any` documents, and its definition is typed `unknown`. `Schema.list()` types each schema it returns with `SchemaDefinition`. `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`. @@ -70,20 +70,20 @@ const schema = new Schema("myapp", { }); // Create the default instance -const instanceId = await schema.createInstance(); +const instanceId = await schema.createInstance().run(); // Create a named instance -await schema.createInstance("tenant-1"); +await schema.createInstance("tenant-1").run(); // Access instances const defaultDb = schema.instance(); const tenantDb = schema.instance("tenant-1"); // Destroy an instance -await schema.destroyInstance("tenant-1"); +await schema.destroyInstance("tenant-1").run(); // List named instances -const ids = await schema.listInstances(); +const ids = await schema.listInstances().run(); ``` ## Retrieve a schema diff --git a/docs/assets/query-execution.svg b/docs/assets/query-execution.svg index f65817f..6dacf2e 100644 --- a/docs/assets/query-execution.svg +++ b/docs/assets/query-execution.svg @@ -1,6 +1,6 @@ Build a query, then execute it - Table access, filters, and transforms build stages. The runtime entry point executes these stages through a provider using await, run, or a cursor. The isolated staged-query entry point only builds. + Table access, filters, and transforms build stages. The runtime entry point executes these stages through a provider using run or a cursor. The isolated staged-query entry point only builds. A query is a plan before it is data @@ -11,10 +11,10 @@ Execute · runtime entry point - await / run() → bound provider → results + run() → bound provider → results cursor() / async iteration → incremental reads Building only? The isolated staged-query entry point has no - execution methods. Awaiting it does not fetch data. + execution methods: it builds, it never fetches. diff --git a/docs/index.md b/docs/index.md index 05860e2..9e2d707 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ Start in an AntelopeJS module with a configured database provider. The interface 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 the query with `await` or `run()`](./1.query_types/7.query.md), or consume a cursor for incremental results. +3. [Execute the query with `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 @@ -38,16 +38,16 @@ const notesSchema = new Schema<{ notes: Note }>("notes-demo", { }); export async function tryNotes() { - await notesSchema.createInstance("tutorial"); + await notesSchema.createInstance("tutorial").run(); const notes = notesSchema.instance("tutorial").table("notes"); - const [id] = await notes.insert({ title: "Learn AQL", done: false }); - const saved = await notes.get(id); + const [id] = await notes.insert({ title: "Learn AQL", done: false }).run(); + const saved = await notes.get(id).run(); if (!saved) throw new Error("The inserted note was not found"); return saved.title; // "Learn AQL" } ``` -`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")`. +`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").run()`. ## Find the right reference @@ -61,7 +61,7 @@ export async function tryNotes() { ## 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. +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()`, `cursor()`, and async iteration. Supporting types have their own paths: @@ -71,7 +71,7 @@ Supporting types have their own paths: ### 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. +`@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. 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 diff --git a/guardrails.suppressions.json b/guardrails.suppressions.json index 7d466c5..73e3a51 100644 --- a/guardrails.suppressions.json +++ b/guardrails.suppressions.json @@ -1,14 +1,14 @@ { "rules": { "@typescript-eslint/no-unused-expressions": 0, - "anti-slop/no-chained-type-assertions": 1, - "typescript/no-unnecessary-type-parameters": 1, - "typescript/no-unused-vars": 1, - "unicorn/no-thenable": 1 + "anti-slop/no-chained-type-assertions": 0, + "typescript/no-unnecessary-type-parameters": 0, + "typescript/no-unused-vars": 0, + "unicorn/no-thenable": 0 }, "tsExpectError": 0, "tsIgnore": 0, - "safety": 5, + "safety": 0, "config": {}, "agentFiles": true } diff --git a/scripts/test-staged-query.cjs b/scripts/test-staged-query.cjs index 19798ad..51baa5a 100644 --- a/scripts/test-staged-query.cjs +++ b/scripts/test-staged-query.cjs @@ -89,6 +89,45 @@ for (const file of fs.readdirSync(distDirectory)) { } } +const ts = require("typescript"); +const typeProbeFile = path.join(__dirname, "../staged-query-types.ts"); +const typeProbe = ` +import { Datum, Query, Stream, ValueProxy, type ExtractType } from "./dist/staged-query"; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +export const datum: Same>, number> = true; +export const stream: Same>, string[]> = true; +export const record: Same; s: ValueProxy }>, { n: number; s: string }> = true; +export const distinct: Same<[Query] extends [Query] ? true : false, false> = true; +`; +const typeHost = ts.createCompilerHost({}); +const readDiskFile = typeHost.readFile.bind(typeHost); +const diskFileExists = typeHost.fileExists.bind(typeHost); +const diskSourceFile = typeHost.getSourceFile.bind(typeHost); +typeHost.readFile = (file) => + file === typeProbeFile ? typeProbe : readDiskFile(file); +typeHost.fileExists = (file) => file === typeProbeFile || diskFileExists(file); +typeHost.getSourceFile = (file, version, onError, fresh) => + file === typeProbeFile + ? ts.createSourceFile(file, typeProbe, version, true) + : diskSourceFile(file, version, onError, fresh); +const typeProgram = ts.createProgram({ + rootNames: [typeProbeFile], + options: { + strict: true, + noEmit: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + types: [], + }, + host: typeHost, +}); +const typeErrors = ts + .getPreEmitDiagnostics(typeProgram) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ); +assert.deepEqual(typeErrors, [], "staged-query types read T back from queries"); + const executable = require("../dist/index"); const executableTable = new executable.Schema("runtime", { records: { fields: {}, indexes: {} }, @@ -102,6 +141,7 @@ assert.equal(executableTable instanceof executable.Selection, true); assert.equal(executableTable instanceof executable.Stream, true); assert.equal(executableTable instanceof executable.Query, true); assert.equal("run" in executableTable, true); +assert.equal("then" in executableTable, false, "root query is not thenable"); assert.equal("atomicMutation" in executableTable, true); assert.equal(executableInsert instanceof executable.Query, true); assert.equal("run" in executableInsert, true); diff --git a/skills/database-interface/SKILL.md b/skills/database-interface/SKILL.md index 35acfe8..8ca77b6 100644 --- a/skills/database-interface/SKILL.md +++ b/skills/database-interface/SKILL.md @@ -7,7 +7,7 @@ tags: [antelopejs, database, query, schema, aql] # Database interface (AQL) -AQL is a lazy, chainable query builder. Every class here (`Schema`, `Table`, `Selection`, `Stream`, `Datum`, `ValueProxy`) is a consumer-side staged object: chaining records stages, nothing touches the database. Apart from schema registration (done once by the `Schema` constructor), the only proxy crossings are execution (`.run()` / `await` / `for await`), which ships the staged query to whatever loaded module implements this interface (e.g. `@antelopejs/mongodb`). Consumers never call `ImplementInterface` for this interface — providing it is the job of database driver modules. +AQL is a lazy, chainable query builder. Every class here (`Schema`, `Table`, `Selection`, `Stream`, `Datum`, `ValueProxy`) is a consumer-side staged object: chaining records stages, nothing touches the database. Apart from schema registration (done once by the `Schema` constructor), the only proxy crossings are execution (`.run()` / `.cursor()` / `for await`), which ships the staged query to whatever loaded module implements this interface (e.g. `@antelopejs/mongodb`). Consumers never call `ImplementInterface` for this interface — providing it is the job of database driver modules. ## Imports @@ -72,15 +72,18 @@ const schema = new Schema<{ novels: Novel }>("library", { const novels = schema.instance().table("novels"); // default instance -const ids = await novels.insert({ - title: "The Glass Meridian", - pageCount: 412, - available: true, -}); // string[] of inserted ids +const ids = await novels + .insert({ + title: "The Glass Meridian", + pageCount: 412, + available: true, + }) + .run(); // string[] of inserted ids const longNovels = await novels .filter((n) => n.key("pageCount").ge(300).and(n.key("available"))) - .orderBy("pageCount", "desc"); // await executes: Novel[] -await novels.get(ids[0]).update({ available: false }); // number of modified docs + .orderBy("pageCount", "desc") + .run(); // run() executes: Novel[] +await novels.get(ids[0]).update({ available: false }).run(); // number of modified docs for await (const change of novels.changes()) { // change feed via async iteration console.log(change.changeType, change.newValue); // "added" | "removed" | "modified" @@ -89,11 +92,11 @@ for await (const change of novels.changes()) { ## Gotchas -- **Lazy everywhere.** No chain call executes anything. Execution happens only via `.run()`, `await` (every `Query` is `PromiseLike`), or async iteration (`for await` / `.cursor()`). This includes `Schema.createInstance()` / `destroyInstance()` / `listInstances()` — they return `Query` objects and do nothing until awaited. +- **Lazy everywhere.** No chain call executes anything. Execution happens only via `.run()` or async iteration (`for await` / `.cursor()`). A `Query` is not a promise: `await query` without `.run()` returns the query and executes nothing. This includes `Schema.createInstance()` / `destroyInstance()` / `listInstances()` — they return `Query` objects and do nothing until run. - **Callbacks receive `ValueProxy`, not values.** Functions passed to `filter`, `map`, `do`, `update(fn)`, `join`, `group` are recorded, then replayed by the backend. Only `ValueProxy` methods (`key`, `eq`, `ne`, `gt/ge/lt/le`, `and/or/not`, `add/sub/mul/div`, string/date/array/object helpers, `ValueProxy.constant`) become part of the query — plain JS (`if`, `===`, `Math.*`, template strings) does not travel. Exception: `group`'s mapper receives a staged `Stream` (the group's documents) as its first parameter and a `ValueProxy` (the index value) second — staged `Stream` methods also travel. - **Staged objects are immutable.** Each chained call returns a new object; a prefix like `novels.getAll(true, "available")` can be safely reused for several derived queries. - A provider module must be loaded in the project or startup fails with unresolved interface dependencies; queries themselves need no defensive "is the DB ready" checks (calls queue until the provider attaches). -- `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). +- `Schema` ids are persistent: changing an id leaves previous data inaccessible. `Schema.get(id)` retrieves a previously constructed schema, untyped: it takes no type arguments, and its tables hold `any` documents. Query typed tables through the schema object you constructed. `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. `during` also takes bounds as milliseconds since the UNIX epoch. @@ -101,7 +104,7 @@ for await (const change of novels.changes()) { - 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. +- `cast()` on `Datum`/`Stream`/`ValueProxy` returns a new object with the same stages and another TypeScript type — no runtime conversion, no stage recorded. - `update` overloads: pass a plain `DeepPartial` for static values, or a `(val: ValueProxy) => ...` function for computed updates. ## Deeper reference diff --git a/src/query.ts b/src/query.ts index 1ec74d0..b2c5fd5 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,7 +1,7 @@ import { InterfaceFunction } from "@antelopejs/interface-core"; import { StagedObject } from "./staged-query/common"; -import { Query as StagedQuery } from "./staged-query/query"; +import { Query as StagedQuery, type QueryYield } from "./staged-query/query"; /** @internal */ export const RunQuery = @@ -63,11 +63,11 @@ class IterableCursor implements AsyncGenerator { } } -export interface QueryExecution extends PromiseLike { +export interface QueryExecution { run(): Promise; - cursor(): AsyncGenerator ? U : T, void, unknown>; + cursor(): AsyncGenerator["item"], void, unknown>; [Symbol.asyncIterator](): AsyncGenerator< - T extends Array ? U : T, + QueryYield["item"], void, unknown >; @@ -81,30 +81,20 @@ function run(this: StagedQuery): Promise { return RunQuery(this.build()); } -function then( - this: StagedQuery, - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, -): PromiseLike { - return this.run().then(onfulfilled, onrejected); -} - function cursor( this: StagedQuery, -): AsyncGenerator ? U : T, void, unknown> { +): AsyncGenerator["item"], void, unknown> { return new IterableCursor(this.build()); } function iterate( this: StagedQuery, -): AsyncGenerator ? U : T, void, unknown> { +): AsyncGenerator["item"], void, unknown> { return this.cursor(); } Object.defineProperties(StagedQuery.prototype, { run: { configurable: true, value: run, writable: true }, - // oxlint-disable-next-line unicorn/no-thenable -- Query is deliberately PromiseLike so `await query` executes it. - then: { configurable: true, value: then, writable: true }, cursor: { configurable: true, value: cursor, writable: true }, [Symbol.asyncIterator]: { configurable: true, diff --git a/src/staged-query/common.ts b/src/staged-query/common.ts index b242b32..6b7d8b1 100644 --- a/src/staged-query/common.ts +++ b/src/staged-query/common.ts @@ -165,11 +165,45 @@ function ownConstructor(instance: StagedObject): StagedConstructor { return constructor; } +function argument( + type: StagedConstructor, + id: number, +) { + return { id, value: new type(QueryStage("arg", undefined, id)) }; +} + export class StagedObject { protected readonly stages: QueryStage[]; - public constructor(newStage: QueryStage, previous?: StagedObject) { - this.stages = previous ? [...previous.stages, newStage] : [newStage]; + /** + * Records `newStage` after the stages of `previous`, or as the first stage. + * + * @param newStage Stage to record + * @param previous Object whose stages come first + */ + public constructor(newStage: QueryStage, previous?: StagedObject); + /** + * Records no stage: the new object holds the stages of `previous`, so the + * same query reads as another class or type. + * + * @param newStage `undefined`, as no stage is recorded + * @param previous Object whose stages are copied + */ + public constructor(newStage: undefined, previous: StagedObject); + public constructor( + newStage: QueryStage | undefined, + previous?: StagedObject, + ) { + if (newStage !== undefined) { + this.stages = previous ? [...previous.stages, newStage] : [newStage]; + return; + } + if (!previous) { + throw new TypeError( + "A staged object that records no stage copies a previous one", + ); + } + this.stages = [...previous.stages]; } /** @internal */ @@ -191,28 +225,53 @@ export class StagedObject { } private static nextargid = 0; - protected callfunc( - func: (...args: T) => any, - ...args: (typeof StagedObject)[] + + /** + * Records `func` as a function stage: `func` runs once, on an argument of + * class `a`, and the stage records what it returns. + * + * @param func Function to record + * @param a Class of its argument + * @returns The function stage + */ + protected callfunc( + func: (a: A) => any, + a: StagedConstructor, + ): QueryStage<"func">; + /** + * Records `func` as a function stage: `func` runs once, on arguments of + * classes `a` and `b`, and the stage records what it returns. + * + * @param func Function to record + * @param a Class of its first argument + * @param b Class of its second argument + * @returns The function stage + */ + protected callfunc( + func: (a: A, b: B) => any, + a: StagedConstructor, + b: StagedConstructor, + ): QueryStage<"func">; + protected callfunc( + ...call: + | [func: (a: A) => any, a: StagedConstructor] + | [ + func: (a: A, b: B) => any, + a: StagedConstructor, + b: StagedConstructor, + ] ): QueryStage<"func"> { - const argNumbers: number[] = []; - const argValues: StagedObject[] = []; - for (let i = 0; i < args.length; ++i) { - const id = StagedObject.nextargid++; - argNumbers[i] = id; - argValues[i] = new args[i](QueryStage("arg", undefined, id)); + if (call.length === 2) { + const [func, a] = call; + const first = argument(a, StagedObject.nextargid++); + return { stage: "func", args: [[first.id], func(first.value)] }; } - /* - * SAFETY: argValues[i] is built by `new args[i]`, so it is an instance of - * the i-th class passed. T is inferred from func alone, so the cast holds - * when func's i-th parameter is an instance of that class; every caller in - * staged-query passes ValueProxy or Stream for the parameter typed - * ValueProxy or Stream, and only a type argument, which has no runtime - * form, may differ. - */ + const [func, a, b] = call; + const first = argument(a, StagedObject.nextargid++); + const second = argument(b, StagedObject.nextargid++); return { stage: "func", - args: [argNumbers, func(...(argValues as T))], + args: [[first.id, second.id], func(first.value, second.value)], }; } } diff --git a/src/staged-query/datum.ts b/src/staged-query/datum.ts index c288063..2a11c4c 100644 --- a/src/staged-query/datum.ts +++ b/src/staged-query/datum.ts @@ -17,16 +17,10 @@ export class Datum extends Query { * Changes the type of this datum. * This does not actually perform any conversion, it only changes the typescript type. * - * @returns Same datum with a different type + * @returns A new datum with the same stages, typed `Datum` */ public cast() { - /* - * SAFETY: A Datum 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 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; + return new Datum(undefined, this); } /** diff --git a/src/staged-query/query.ts b/src/staged-query/query.ts index 8ce897b..5a5aa50 100644 --- a/src/staged-query/query.ts +++ b/src/staged-query/query.ts @@ -3,8 +3,32 @@ import { isKindHolder, MarkKind } from "./kind"; const QUERY_KIND = Symbol.for("@antelopejs/interface-database/Query@1"); -// 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 {} +declare const YIELDS: unique symbol; + +/** + * What running a query whose result is `T` yields: `result` is what `run()` + * resolves to, and `item` is what its cursor reads at a time, each element of + * a list result, else the whole result. + */ +export interface QueryYield { + result: T; + item: T extends Array ? U : T; +} + +/** + * A query whose result is `T`. This entry point records its stages; the + * runtime entry point runs them. + */ +export class Query extends StagedObject { + /** + * What the query yields when it runs. A query holds stages, never a result, + * so no instance has this property: it is declared for the type checker, + * which tells a `Query` from a `Query` by it and reads `T` + * back from any query type. Its key is a symbol no other module can name, + * so no subclass property collides with it. + */ + declare protected readonly [YIELDS]?: QueryYield; +} MarkKind(Query.prototype, QUERY_KIND); diff --git a/src/staged-query/schema.ts b/src/staged-query/schema.ts index 998331f..21454e8 100644 --- a/src/staged-query/schema.ts +++ b/src/staged-query/schema.ts @@ -125,20 +125,15 @@ export class Schema< /** * Retrieves a previously defined schema by its ID * + * The registry keeps no type arguments, so the schema's document mapping is + * `any` and its definition `unknown`. To query typed tables, use the schema + * object the constructor returned. + * * @param id Schema ID * @returns The schema instance, or undefined if not found */ - public static get( - id: string, - ): Schema | undefined { - /* - * SAFETY: The registry only holds instances stored by the Schema - * constructor, so a hit is a Schema. T and Definition are chosen by the - * caller and hold only when they are the type arguments of the constructor - * call that registered this id; the registry keeps them nowhere, so - * nothing here checks them. - */ - return Schema.registry.get(id) as Schema | undefined; + public static get(id: string): Schema | undefined { + return Schema.registry.get(id); } /** diff --git a/src/staged-query/stream.ts b/src/staged-query/stream.ts index 8a619cb..ebbc5cb 100644 --- a/src/staged-query/stream.ts +++ b/src/staged-query/stream.ts @@ -18,16 +18,10 @@ export class Stream extends Query { * Changes the type of the value in this stream. * This does not actually perform any conversion, it only changes the typescript type. * - * @returns Same stream with a different type + * @returns A new stream with the same stages, typed `Stream` */ public cast() { - /* - * SAFETY: A Stream 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 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; + return new Stream(undefined, this); } /** @@ -225,7 +219,11 @@ export class Stream extends Query { QueryStage( "group", { index }, - this.callfunc(mapper, Stream, ValueProxy), + this.callfunc( + mapper, + Stream, + ValueProxy, + ), ), ); } diff --git a/src/staged-query/valueproxy.ts b/src/staged-query/valueproxy.ts index c6995ff..787c5f7 100644 --- a/src/staged-query/valueproxy.ts +++ b/src/staged-query/valueproxy.ts @@ -55,17 +55,10 @@ export class ValueProxy extends StagedObject { * Changes the type of this proxy. * This does not actually perform any conversion, it only changes the typescript type. * - * @returns Same proxy with a different type + * @returns A new proxy with the same stages, typed `ValueProxy` */ public cast() { - /* - * 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; + return new ValueProxy(undefined, this); } /** diff --git a/src/tests/atomic_mutation.test.ts b/src/tests/atomic_mutation.test.ts index 5f22b67..56a31d2 100644 --- a/src/tests/atomic_mutation.test.ts +++ b/src/tests/atomic_mutation.test.ts @@ -217,7 +217,9 @@ async function PreserveOutcomes() { ]; for (const result of outcomes) { await WithResult(result, async () => { - expect(await table.atomicMutation("record-a", request)).to.equal(result); + expect(await table.atomicMutation("record-a", request).run()).to.equal( + result, + ); }); } } diff --git a/src/tests/atomic_mutation_operations.test.ts b/src/tests/atomic_mutation_operations.test.ts index 489da5b..4bf12c0 100644 --- a/src/tests/atomic_mutation_operations.test.ts +++ b/src/tests/atomic_mutation_operations.test.ts @@ -65,25 +65,27 @@ const equalityDeletion: AtomicMutation = { async function Insert(data: Partial = {}) { const key = randomUUID(); - await owner.insert({ - _id: key, - revision: initialRevision, - value: "original", - ...data, - }); + await owner + .insert({ + _id: key, + revision: initialRevision, + value: "original", + ...data, + }) + .run(); return key; } describe("Atomic mutation provider conformance", () => { before(async () => { - await schema.createInstance("owner"); - await schema.createInstance("other"); - await schema.createInstance(); + await schema.createInstance("owner").run(); + await schema.createInstance("other").run(); + await schema.createInstance().run(); }); after(async () => { - await schema.destroyInstance("owner"); - await schema.destroyInstance("other"); - await schema.destroyInstance(); + await schema.destroyInstance("owner").run(); + await schema.destroyInstance("other").run(); + await schema.destroyInstance().run(); }); it("persists revisions and replaces fields literally", ReplaceFields); it("advances revision when the payload is unchanged", UnchangedPatch); @@ -102,11 +104,11 @@ describe("Atomic mutation provider conformance", () => { async function ReplaceFields() { const key = await Insert({ nested: { removed: true, retained: "old" } }); - expect((await owner.get(key)).revision).to.equal(initialRevision); + expect((await owner.get(key).run()).revision).to.equal(initialRevision); const nested = { retained: "$literal", stage: "delete", args: ["data"] }; const request = { ...Update("unused"), patch: { nested } }; - expect(await owner.atomicMutation(key, request)).to.equal("applied"); - const stored = await owner.get(key); + expect(await owner.atomicMutation(key, request).run()).to.equal("applied"); + const stored = await owner.get(key).run(); expect(stored.nested).to.deep.equal(nested); expect(stored.value).to.equal("original"); expect(stored.revision).to.equal(request.nextRevision); @@ -114,10 +116,10 @@ async function ReplaceFields() { async function UnchangedPatch() { const key = await Insert(); - expect(await owner.atomicMutation(key, Update("original"))).to.equal( + expect(await owner.atomicMutation(key, Update("original")).run()).to.equal( "applied", ); - expect(await owner.get(key)).to.include({ + expect(await owner.get(key).run()).to.include({ value: "original", revision: "revision-original", }); @@ -125,49 +127,61 @@ async function UnchangedPatch() { async function StaleRevision() { const key = await Insert(); - const count = await owner.count(); - expect(await owner.atomicMutation(key, Update("fresh"))).to.equal("applied"); - expect(await owner.atomicMutation(key, Update("stale"))).to.equal( + const count = await owner.count().run(); + expect(await owner.atomicMutation(key, Update("fresh")).run()).to.equal( + "applied", + ); + expect(await owner.atomicMutation(key, Update("stale")).run()).to.equal( "not-applied", ); - expect(await owner.atomicMutation(key, deletion)).to.equal("not-applied"); - expect(await owner.count()).to.equal(count); - expect(await owner.get(key)).to.include({ + expect(await owner.atomicMutation(key, deletion).run()).to.equal( + "not-applied", + ); + expect(await owner.count().run()).to.equal(count); + expect(await owner.get(key).run()).to.include({ value: "fresh", revision: "revision-fresh", }); expect( - await owner.atomicMutation(key, { - ...deletion, - expectedRevision: "revision-fresh", - }), + await owner + .atomicMutation(key, { + ...deletion, + expectedRevision: "revision-fresh", + }) + .run(), ).to.equal("applied"); - expect(await owner.get(key)).to.equal(undefined); - expect(await owner.count()).to.equal(count - 1); - expect(await owner.atomicMutation(key, deletion)).to.equal("not-applied"); + expect(await owner.get(key).run()).to.equal(undefined); + expect(await owner.count().run()).to.equal(count - 1); + expect(await owner.atomicMutation(key, deletion).run()).to.equal( + "not-applied", + ); } async function MissingIdentity() { const key = randomUUID(); - const count = await owner.count(); + const count = await owner.count().run(); for (const request of [Update("absent"), deletion, equalityDeletion]) { - expect(await owner.atomicMutation(key, request)).to.equal("not-applied"); - expect(await owner.get(key)).to.equal(undefined); - expect(await owner.count()).to.equal(count); + expect(await owner.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); + expect(await owner.get(key).run()).to.equal(undefined); + expect(await owner.count().run()).to.equal(count); } } async function IsolateInstances() { const key = await Insert(); - const ownerCount = await owner.count(); + const ownerCount = await owner.count().run(); for (const table of [other, defaults]) { - const count = await table.count(); + const count = await table.count().run(); for (const request of [Update("intruder"), deletion, equalityDeletion]) { - expect(await table.atomicMutation(key, request)).to.equal("not-applied"); - expect(await table.get(key)).to.equal(undefined); - expect(await table.count()).to.equal(count); - expect(await owner.count()).to.equal(ownerCount); - expect(await owner.get(key)).to.include({ + expect(await table.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); + expect(await table.get(key).run()).to.equal(undefined); + expect(await table.count().run()).to.equal(count); + expect(await owner.count().run()).to.equal(ownerCount); + expect(await owner.get(key).run()).to.include({ value: "original", revision: initialRevision, }); @@ -177,50 +191,58 @@ async function IsolateInstances() { async function DefaultInstance() { const key = randomUUID(); - expect(await defaults.atomicMutation(key, Update("default"))).to.equal( + expect(await defaults.atomicMutation(key, Update("default")).run()).to.equal( "not-applied", ); - await defaults.insert({ - _id: key, - revision: initialRevision, - value: "original", - }); - expect(await defaults.atomicMutation(key, Update("default"))).to.equal( + await defaults + .insert({ + _id: key, + revision: initialRevision, + value: "original", + }) + .run(); + expect(await defaults.atomicMutation(key, Update("default")).run()).to.equal( "applied", ); - expect(await defaults.get(key)).to.include({ + expect(await defaults.get(key).run()).to.include({ value: "default", revision: "revision-default", }); expect( - await defaults.atomicMutation(key, { - ...deletion, - expectedRevision: "revision-default", - }), + await defaults + .atomicMutation(key, { + ...deletion, + expectedRevision: "revision-default", + }) + .run(), ).to.equal("applied"); - expect(await defaults.get(key)).to.equal(undefined); + expect(await defaults.get(key).run()).to.equal(undefined); } async function MissingRevision() { const absent = randomUUID(); const storedNull = await Insert({ revision: null }); const missingRow = randomUUID(); - await owner.insert({ _id: absent, value: "legacy" }); + await owner.insert({ _id: absent, value: "legacy" }).run(); const request: AtomicUpdate = { ...Update("adopted"), expectedRevision: { kind: "missing" }, }; for (const key of [storedNull, missingRow]) { - expect(await owner.atomicMutation(key, request)).to.equal("not-applied"); + expect(await owner.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); } - expect(await owner.atomicMutation(absent, request)).to.equal("applied"); - expect(await owner.atomicMutation(absent, request)).to.equal("not-applied"); - expect(await owner.get(absent)).to.include({ + expect(await owner.atomicMutation(absent, request).run()).to.equal("applied"); + expect(await owner.atomicMutation(absent, request).run()).to.equal( + "not-applied", + ); + expect(await owner.get(absent).run()).to.include({ value: "adopted", revision: "revision-adopted", }); - expect((await owner.get(storedNull)).revision).to.equal(null); - expect(await owner.get(missingRow)).to.equal(undefined); + expect((await owner.get(storedNull).run()).revision).to.equal(null); + expect(await owner.get(missingRow).run()).to.equal(undefined); } async function DeleteMissingRevision() { @@ -228,19 +250,21 @@ async function DeleteMissingRevision() { const storedNull = await Insert({ revision: null }); const present = await Insert(); const missingRow = randomUUID(); - await owner.insert({ _id: absent, value: "legacy" }); + await owner.insert({ _id: absent, value: "legacy" }).run(); const request: AtomicMutation = { ...deletion, expectedRevision: { kind: "missing" }, }; for (const key of [storedNull, present, missingRow]) { - expect(await owner.atomicMutation(key, request)).to.equal("not-applied"); + expect(await owner.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); } - expect(await owner.atomicMutation(absent, request)).to.equal("applied"); - expect(await owner.get(absent)).to.equal(undefined); - expect((await owner.get(storedNull)).revision).to.equal(null); - expect((await owner.get(present)).revision).to.equal(initialRevision); - expect(await owner.get(missingRow)).to.equal(undefined); + expect(await owner.atomicMutation(absent, request).run()).to.equal("applied"); + expect(await owner.get(absent).run()).to.equal(undefined); + expect((await owner.get(storedNull).run()).revision).to.equal(null); + expect((await owner.get(present).run()).revision).to.equal(initialRevision); + expect(await owner.get(missingRow).run()).to.equal(undefined); } async function ScalarEquality() { @@ -251,27 +275,39 @@ async function ScalarEquality() { field: "equal", expectedValue, }; - expect(await owner.atomicMutation(key, request)).to.equal("not-applied"); - await owner.get(key).update({ equal: null }); - expect(await owner.atomicMutation(key, request)).to.equal("not-applied"); - await owner.get(key).update({ equal: expectedValue }); - expect(await other.atomicMutation(key, request)).to.equal("not-applied"); + expect(await owner.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); + await owner.get(key).update({ equal: null }).run(); + expect(await owner.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); + await owner.get(key).update({ equal: expectedValue }).run(); + expect(await other.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); expect( - await owner.atomicMutation(key, { - ...request, - expectedValue: `${expectedValue}-different`, - }), + await owner + .atomicMutation(key, { + ...request, + expectedValue: `${expectedValue}-different`, + }) + .run(), ).to.equal("not-applied"); expect( - await owner.atomicMutation(key, { - ...request, - expectedValue: expectedValue === 0 ? "0" : 0, - }), + await owner + .atomicMutation(key, { + ...request, + expectedValue: expectedValue === 0 ? "0" : 0, + }) + .run(), ).to.equal("not-applied"); - expect((await owner.get(key)).equal).to.equal(expectedValue); - expect(await owner.atomicMutation(key, request)).to.equal("applied"); - expect(await owner.get(key)).to.equal(undefined); - expect(await owner.atomicMutation(key, request)).to.equal("not-applied"); + expect((await owner.get(key).run()).equal).to.equal(expectedValue); + expect(await owner.atomicMutation(key, request).run()).to.equal("applied"); + expect(await owner.get(key).run()).to.equal(undefined); + expect(await owner.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); } } @@ -282,17 +318,21 @@ async function DateEquality() { field: "expires", expectedValue: sameExpiry, }; - expect(await owner.atomicMutation(key, request)).to.equal("not-applied"); - await owner.get(key).update({ expires: expiry }); + expect(await owner.atomicMutation(key, request).run()).to.equal( + "not-applied", + ); + await owner.get(key).update({ expires: expiry }).run(); expect( - await owner.atomicMutation(key, { - ...request, - expectedValue: differentExpiry, - }), + await owner + .atomicMutation(key, { + ...request, + expectedValue: differentExpiry, + }) + .run(), ).to.equal("not-applied"); - expect((await owner.get(key)).expires).to.deep.equal(expiry); - expect(await owner.atomicMutation(key, request)).to.equal("applied"); - expect(await owner.get(key)).to.equal(undefined); + expect((await owner.get(key).run()).expires).to.deep.equal(expiry); + expect(await owner.atomicMutation(key, request).run()).to.equal("applied"); + expect(await owner.get(key).run()).to.equal(undefined); } async function CompetingUpdates() { @@ -306,11 +346,13 @@ async function CompetingUpdates() { ); expect([...outcomes].sort()).to.deep.equal(["applied", "not-applied"]); const winner = requests[outcomes.indexOf("applied")]; - expect(await owner.get(key)).to.include({ + expect(await owner.get(key).run()).to.include({ revision: winner.nextRevision, value: winner.patch.value, }); - expect((await owner.get(key)).nested).to.deep.equal(winner.patch.nested); + expect((await owner.get(key).run()).nested).to.deep.equal( + winner.patch.nested, + ); } } @@ -323,7 +365,7 @@ async function CompeteDeletion(request: AtomicMutation) { requests.map((mutation) => owner.atomicMutation(key, mutation).run()), ); expect([...outcomes].sort()).to.deep.equal(["applied", "not-applied"]); - const stored = await owner.get(key); + const stored = await owner.get(key).run(); if (requests[outcomes.indexOf("applied")].type !== "update") { expect(stored).to.equal(undefined); continue; diff --git a/src/tests/basic_operations.test.ts b/src/tests/basic_operations.test.ts index bb6ec5e..36e5ceb 100644 --- a/src/tests/basic_operations.test.ts +++ b/src/tests/basic_operations.test.ts @@ -2,6 +2,7 @@ import { expect } from "chai"; import { Schema } from ".."; import { Vehicle, vehicles } from "./datasets/vehicles"; +import type { Same } from "./helpers/same-type"; const tableName = "test-table"; const schema = new Schema<{ [tableName]: Vehicle }>("test-basic-operations", { @@ -18,6 +19,7 @@ describe("Basic Operations", () => { }); it("Schema list", SchemaListTest); + it("Schema get claims no definition type", SchemaGetTest); it("Insert", InsertTest); it("Insert conflict update (existing doc)", InsertConflictUpdateTest); it("Insert conflict replace (existing doc)", InsertConflictReplaceTest); @@ -44,6 +46,16 @@ function SchemaListTest() { expect(Schema.list().get("test-basic-operations")).to.equal(schema); } +function SchemaGetTest() { + const found = Schema.get("test-basic-operations"); + const definitionUnchecked: Same< + NonNullable["definition"], + unknown + > = true; + expect(found).to.equal(schema); + expect(definitionUnchecked).to.equal(true); +} + async function InsertTest() { const response = await table.insert(vehicles).run(); expect(response).to.be.an("array"); diff --git a/src/tests/change_feeds.test.ts b/src/tests/change_feeds.test.ts index 50a58af..14ed947 100644 --- a/src/tests/change_feeds.test.ts +++ b/src/tests/change_feeds.test.ts @@ -75,7 +75,7 @@ async function InsertEventTest() { }; const results = await ReadChanges(table.changes(), async () => { - inserted = await table.insert(newDocument); + inserted = await table.insert(newDocument).run(); }); expect(results).to.be.an("array").of.length(1); @@ -91,7 +91,8 @@ async function UpdateEventTest() { async () => { await table .filter((doc) => doc.key("car").eq("Renault")) - .update({ price: 0 }); + .update({ price: 0 }) + .run(); }, ); @@ -104,7 +105,7 @@ async function UpdateEventTest() { async function DeleteEventTest() { const results = await ReadChanges(table.changes(), async () => { - await table.get(inserted[0]).delete(); + await table.get(inserted[0]).delete().run(); }); expect(results).to.be.an("array").of.length(1); @@ -133,7 +134,7 @@ async function BulkInsertEventTest() { ]; const results = await ReadChanges(table.changes(), async () => { - await table.insert(newDocs); + await table.insert(newDocs).run(); }); expect(results).to.be.an("array").of.length(newDocs.length); diff --git a/src/tests/query_execution.test.ts b/src/tests/query_execution.test.ts new file mode 100644 index 0000000..346fb66 --- /dev/null +++ b/src/tests/query_execution.test.ts @@ -0,0 +1,18 @@ +import { expect } from "chai"; +import { Schema } from ".."; + +const schema = new Schema<{ items: { _id?: string; n: number } }>( + "test-query-execution", + { items: { fields: { n: "number" }, indexes: {} } }, +); +const items = schema.instance().table("items"); + +describe("Query execution", () => { + it("Awaiting a query gives back the query and runs nothing", AwaitTest); +}); + +async function AwaitTest() { + const count = items.count(); + expect("then" in count).to.equal(false); + expect(await Promise.resolve(count)).to.equal(count); +} diff --git a/src/tests/recorded_stages.test.ts b/src/tests/recorded_stages.test.ts index 5c3971d..356c60b 100644 --- a/src/tests/recorded_stages.test.ts +++ b/src/tests/recorded_stages.test.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import { Schema, ValueProxy } from ".."; +import { IsDatum, IsStream, IsValueProxy, Schema, ValueProxy } from ".."; type Person = { _id?: string; @@ -76,6 +76,29 @@ describe("Recorded stages", () => { }); }); + it("Casts a stream to a new stream with the same stages", () => { + const cast = table.cast<{ name: string }>(); + expect(cast).to.not.equal(table); + expect(IsStream(cast)).to.equal(true); + expect(cast.build()).to.deep.equal(table.build()); + }); + + it("Casts a datum to a new datum with the same stages", () => { + const one = table.get("p-1"); + const cast = one.cast<{ name: string }>(); + expect(cast).to.not.equal(one); + expect(IsDatum(cast)).to.equal(true); + expect(cast.build()).to.deep.equal(one.build()); + }); + + it("Casts a proxy to a new proxy with the same stages", () => { + const age = row.key("age"); + const cast = age.cast(); + expect(cast).to.not.equal(age); + expect(IsValueProxy(cast)).to.equal(true); + expect(cast.build()).to.deep.equal(age.build()); + }); + it("Records the bounds of between", () => { const low = new Date(0); const high = new Date(1);