@jskit-ai/resource-runtime 0.0.0-stage → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1892 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { RestApiLockOutsideTransactionError, RestApiScopedQueryReadOnlyError } from "json-rest-api";
5
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
6
+ import {
7
+ DuplicateOwnerError,
8
+ MissingScopeError,
9
+ PageSizeError,
10
+ RowLimitError,
11
+ StaleRecordError,
12
+ UnpagedQueryError
13
+ } from "../src/server/index.js";
14
+ import { createActionCatalogue, createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
15
+ import { emptyInputValidator, resolveActorId, resolveAuditActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
16
+ import { requireAuth } from "@jskit-ai/kernel/server/runtime";
17
+ import { normalizeOpaqueId } from "@jskit-ai/kernel/shared/support/normalize";
18
+ import { countQueries } from "@jskit-ai/database-runtime/server/testDatabases";
19
+ import { isLostDuplicateInsertRace } from "@jskit-ai/database-runtime/shared";
20
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
21
+ import { createTestResourceRuntime } from "../src/test/index.js";
22
+
23
+ const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
24
+ const WORKSPACE_B = Object.freeze({ workspace: { id: "2" } });
25
+ // json-rest-api's not-found error, classified by its code (404 at the HTTP edge).
26
+ const notFound = (error) => error.code === "REST_API_RESOURCE" && error.subtype === "not_found";
27
+
28
+ function noteResource(extra = {}) {
29
+ return defineTableResource({
30
+ name: "notes",
31
+ tableName: "notes",
32
+ ownership: "workspace",
33
+ defaultSort: ["id"],
34
+ queryMaxLimit: 20,
35
+ lockVersionField: "lockVersion",
36
+ schema: {
37
+ id: { type: "id", primary: true },
38
+ workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
39
+ title: { type: "string", required: true, search: true },
40
+ status: { type: "string", defaultTo: "open", search: true },
41
+ lockVersion: { type: "integer", defaultTo: 0, storage: { column: "lock_version" } }
42
+ },
43
+ ...extra
44
+ });
45
+ }
46
+
47
+ async function createFixture(t, { requestRowLimit, owns = [noteResource()] } = {}) {
48
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
49
+ t.after(() => knex.destroy());
50
+ await knex.schema.createTable("notes", (table) => {
51
+ table.increments("id");
52
+ table.integer("workspace_id").nullable();
53
+ table.string("title").notNullable();
54
+ table.string("status").notNullable().defaultTo("open");
55
+ table.integer("lock_version").notNullable().defaultTo(0);
56
+ });
57
+ const runtime = await createTestResourceRuntime({ knex, requestRowLimit });
58
+ const resources = await runtime.own("notes.feature", owns);
59
+ const systemContext = runtime.declareSystemScope("notes.feature", "tests")("read every note");
60
+ return { knex, runtime, resources, notes: resources.notes, systemContext };
61
+ }
62
+
63
+ test("own() answers a handle per resource and refuses a second owner of a table or name", async (t) => {
64
+ const { runtime, resources } = await createFixture(t);
65
+ assert.deepEqual(Object.keys(resources), ["notes"]);
66
+ assert.equal(typeof resources.contributeRowPolicy, "function");
67
+ await assert.rejects(runtime.own("other.feature", [noteResource()]), DuplicateOwnerError);
68
+ await assert.rejects(runtime.own("other.feature", [noteResource({ name: "otherNotes" })]), DuplicateOwnerError);
69
+ await assert.rejects(runtime.own("other.feature", [{ resource: noteResource({ name: "x", tableName: "x" }), autofilter: "workspace" }]), /does not take "autofilter"/u);
70
+ assert.deepEqual(runtime.ownedTables(), [{ name: "notes", tableName: "notes", featureId: "notes.feature" }]);
71
+ });
72
+
73
+ test("a call without a context is refused before anything runs", async (t) => {
74
+ const { notes } = await createFixture(t);
75
+ await assert.rejects(notes.create({ title: "x" }), MissingScopeError);
76
+ await assert.rejects(notes.query({ page: { size: 5 } }), MissingScopeError);
77
+ });
78
+
79
+ test("reads and single writes stay in the context's workspace", async (t) => {
80
+ const { notes, knex } = await createFixture(t);
81
+ const mine = await notes.create({ title: "mine" }, { context: WORKSPACE_A });
82
+ const theirs = await notes.create({ title: "theirs" }, { context: WORKSPACE_B });
83
+ assert.equal(mine.title, "mine");
84
+ assert.equal(String(mine.workspaceId), "1");
85
+ assert.equal(await notes.create({ title: "quiet" }, { context: WORKSPACE_A, returning: "none" }), undefined);
86
+ assert.match(String(await notes.create({ title: "by id" }, { context: WORKSPACE_A, returning: "id" })), /^\d+$/u);
87
+
88
+ const { records, page } = await notes.query({ page: { size: 10 } }, { context: WORKSPACE_A });
89
+ assert.deepEqual(records.map(({ title }) => title), ["mine", "quiet", "by id"]);
90
+ assert.deepEqual(page, { size: 10, hasMore: false, next: null });
91
+ // Another workspace's record is missing to this caller: get, patch and delete throw, or answer null with missing: "skip".
92
+ await assert.rejects(notes.get(theirs.id, {}, { context: WORKSPACE_A }), notFound);
93
+ await assert.rejects(notes.patch(theirs.id, { title: "taken" }, { context: WORKSPACE_A }), notFound);
94
+ await assert.rejects(notes.delete(theirs.id, { context: WORKSPACE_A }), notFound);
95
+ assert.equal(await notes.get(theirs.id, {}, { context: WORKSPACE_A, missing: "skip" }), null);
96
+ assert.equal(await notes.patch(theirs.id, { title: "taken" }, { context: WORKSPACE_A, missing: "skip" }), null);
97
+ assert.equal(await notes.patch(theirs.id, { title: "taken" }, { context: WORKSPACE_A, missing: "skip", returning: "id" }), null);
98
+ assert.equal(await notes.delete(theirs.id, { context: WORKSPACE_A, missing: "skip" }), null);
99
+ // Inside a transaction a skipped row fails nothing: the transaction goes on and commits.
100
+ await notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
101
+ assert.equal(await notes.patch(theirs.id, { title: "taken" }, { ...txOptions, missing: "skip" }), null);
102
+ assert.equal(await notes.delete(theirs.id, { ...txOptions, missing: "skip" }), null);
103
+ await notes.create({ title: "after the skipped row" }, { ...txOptions, returning: "none" });
104
+ });
105
+ assert.equal((await knex("notes").where({ id: theirs.id }).first()).title, "theirs");
106
+ assert.equal((await knex("notes").where({ title: "after the skipped row" })).length, 1);
107
+ await assert.rejects(notes.get(mine.id, { context: WORKSPACE_A }), /options go third: get\(id, \{\}, options\)/u);
108
+ await assert.rejects(notes.get(mine.id, {}, { context: WORKSPACE_A, missing: "ignore" }), /missing is "throw" \(the default\) or "skip"/u);
109
+
110
+ const patched = await notes.patch(mine.id, { title: "mine, edited" }, { context: WORKSPACE_A });
111
+ assert.equal(patched.title, "mine, edited");
112
+ assert.equal(await notes.patch(mine.id, { title: "mine, again" }, { context: WORKSPACE_A, missing: "skip", returning: "id" }), mine.id);
113
+ assert.equal((await notes.patch(mine.id, { status: "done" }, { context: WORKSPACE_A, missing: "skip" })).status, "done");
114
+ assert.equal(await notes.patch(mine.id, { title: "x" }, { context: WORKSPACE_A, missing: "skip", onlyIf: { status: "open" } }), null);
115
+ assert.equal(await notes.delete(mine.id, { context: WORKSPACE_A }), mine.id);
116
+ assert.equal(await notes.get(mine.id, {}, { context: WORKSPACE_A, missing: "skip" }), null);
117
+ });
118
+
119
+ test("query() pages with page.size and page.after, and refuses an unpaged or oversized read", async (t) => {
120
+ const { notes } = await createFixture(t);
121
+ await notes.createMany(Array.from({ length: 5 }, (_, index) => ({ title: `n${index}` })), { context: WORKSPACE_A });
122
+ await assert.rejects(notes.query({}, { context: WORKSPACE_A }), UnpagedQueryError);
123
+ await assert.rejects(notes.query({ page: { size: 21 } }, { context: WORKSPACE_A }), PageSizeError);
124
+ await assert.rejects(notes.query({ limit: 2 }, { context: WORKSPACE_A }), /does not take limit/u);
125
+ const first = await notes.query({ page: { size: 2 } }, { context: WORKSPACE_A });
126
+ assert.deepEqual(first.records.map(({ title }) => title), ["n0", "n1"]);
127
+ assert.equal(first.page.hasMore, true);
128
+ const second = await notes.query({ page: { size: 2, after: first.page.next } }, { context: WORKSPACE_A });
129
+ assert.deepEqual(second.records.map(({ title }) => title), ["n2", "n3"]);
130
+ const filtered = await notes.query({ filters: { title: "n4" }, page: { size: 2 } }, { context: WORKSPACE_A });
131
+ assert.deepEqual(filtered.records.map(({ title }) => title), ["n4"]);
132
+ assert.deepEqual(notes.pageLimits(), { request: 20, server: 1000 });
133
+ });
134
+
135
+ test("query() serves a numbered page in one statement, with no count, and refuses page.number with page.after", async (t) => {
136
+ const { knex, notes } = await createFixture(t);
137
+ await notes.createMany(Array.from({ length: 5 }, (_, index) => ({ title: `n${index}` })), { context: WORKSPACE_A });
138
+ const { result: second, count } = await countQueries(knex, () => notes.query({ page: { size: 2, number: 2 } }, { context: WORKSPACE_A }));
139
+ assert.equal(count, 1);
140
+ assert.deepEqual(second.records.map(({ title }) => title), ["n2", "n3"]);
141
+ assert.deepEqual(second.page, { size: 2, number: 2, hasMore: true, next: 3 });
142
+ const last = await notes.query({ page: { size: 2, number: 3 } }, { context: WORKSPACE_A });
143
+ assert.deepEqual(last.records.map(({ title }) => title), ["n4"]);
144
+ assert.deepEqual(last.page, { size: 2, number: 3, hasMore: false, next: null });
145
+ assert.deepEqual((await notes.query({ page: { size: 2, number: 9 } }, { context: WORKSPACE_A })).records, []);
146
+ await assert.rejects(notes.query({ page: { number: 2 } }, { context: WORKSPACE_A }), UnpagedQueryError);
147
+ await assert.rejects(notes.query({ page: { size: 21, number: 1 } }, { context: WORKSPACE_A }), PageSizeError);
148
+ await assert.rejects(notes.query({ page: { size: 2, number: 0 } }, { context: WORKSPACE_A }), /page.number as a whole number from 1/u);
149
+ const first = await notes.query({ page: { size: 2 } }, { context: WORKSPACE_A });
150
+ await assert.rejects(notes.query({ page: { size: 2, number: 2, after: first.page.next } }, { context: WORKSPACE_A }), /not with page.after/u);
151
+ });
152
+
153
+ test("queryAll() stops at the request row limit, which background work does not have", async (t) => {
154
+ const { notes, runtime } = await createFixture(t, { requestRowLimit: 3 });
155
+ await notes.createMany(Array.from({ length: 5 }, (_, index) => ({ title: `n${index}` })), { context: WORKSPACE_A });
156
+ // A request learns it asked for too much (a 400 with a code and a translatable sentence); the developer reads why.
157
+ await assert.rejects(notes.queryAll({}, { context: WORKSPACE_A }), (error) => error instanceof RowLimitError &&
158
+ error.status === 400 && error.code === "RESOURCE_ROW_LIMIT" && error.messageCode === "resource.rowLimit" &&
159
+ error.messageParams.rowLimit === 3 && /background job/u.test(error.message));
160
+ const system = runtime.declareSystemScope("notes.feature", "tests")("nightly digest");
161
+ assert.equal((await notes.queryAll({}, { context: system })).length, 5);
162
+ assert.equal((await notes.queryAll({}, { context: system.forWorkspace("1") })).length, 5);
163
+ assert.equal((await notes.queryAll({}, { context: system.forWorkspace("2") })).length, 0);
164
+ assert.throws(() => runtime.declareSystemScope("notes.feature", "tests")(" "), /needs the reason/u);
165
+ });
166
+
167
+ test("a system context comes only from a declared system scope, one per feature, and the runtime lists them", async (t) => {
168
+ const { runtime } = await createFixture(t);
169
+ assert.equal(Object.hasOwn(runtime, "createSystemContext"), false);
170
+ const systemContext = runtime.declareSystemScope("digest.feature", " nightly digests ");
171
+ assert.equal(systemContext("send the digest").reason, "send the digest");
172
+ assert.equal(typeof runtime.declareSystemScope("digest.feature", "nightly digests"), "function");
173
+ assert.throws(() => runtime.declareSystemScope("digest.feature", "something else"),
174
+ /Feature "digest\.feature" declares one system scope, and it already declared "nightly digests"\./u);
175
+ assert.throws(() => runtime.declareSystemScope("other.feature", " "), /systemScope must say why/u);
176
+ assert.throws(() => systemContext(), /systemContext\(reason\) needs the reason/u);
177
+ assert.deepEqual(runtime.systemScopes(), [
178
+ { featureId: "notes.feature", systemScope: "tests" },
179
+ { featureId: "digest.feature", systemScope: "nightly digests" }
180
+ ]);
181
+ });
182
+
183
+ test("forWorkspaceUser() reaches one user's rows in one workspace, of a workspace_user table and of a user table", async (t) => {
184
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
185
+ t.after(() => knex.destroy());
186
+ await knex.schema.createTable("drafts", (table) => {
187
+ table.increments("id");
188
+ table.integer("workspace_id").notNullable();
189
+ table.integer("user_id").notNullable();
190
+ table.string("title").notNullable();
191
+ });
192
+ await knex.schema.createTable("bookmarks", (table) => {
193
+ table.increments("id");
194
+ table.integer("user_id").notNullable();
195
+ table.string("title").notNullable();
196
+ });
197
+ await knex("drafts").insert([
198
+ { workspace_id: 1, user_id: 7, title: "mine" },
199
+ { workspace_id: 1, user_id: 8, title: "a colleague's" },
200
+ { workspace_id: 2, user_id: 7, title: "mine elsewhere" }
201
+ ]);
202
+ await knex("bookmarks").insert([{ user_id: 7, title: "mine" }, { user_id: 8, title: "someone else's" }]);
203
+ const userField = { type: "id", search: true, storage: { column: "user_id" } };
204
+ const drafts = defineTableResource({
205
+ name: "drafts",
206
+ tableName: "drafts",
207
+ ownership: "workspace_user",
208
+ schema: {
209
+ workspaceId: { type: "id", search: true, storage: { column: "workspace_id" } },
210
+ userId: userField,
211
+ title: { type: "string", required: true }
212
+ }
213
+ });
214
+ const bookmarks = defineTableResource({
215
+ name: "bookmarks",
216
+ tableName: "bookmarks",
217
+ ownership: "user",
218
+ schema: { userId: userField, title: { type: "string", required: true } }
219
+ });
220
+ const runtime = await createTestResourceRuntime({ knex });
221
+ const resources = await runtime.own("drafts.feature", [drafts, bookmarks]);
222
+ const system = runtime.declareSystemScope("drafts.feature", "tests")("deliver one user's event");
223
+ const context = system.forWorkspaceUser("1", "7");
224
+
225
+ assert.deepEqual((await resources.drafts.queryAll({}, { context })).map(({ title }) => title), ["mine"]);
226
+ assert.deepEqual((await resources.bookmarks.queryAll({}, { context })).map(({ title }) => title), ["mine"]);
227
+ assert.throws(() => system.forWorkspaceUser("", "7"), /forWorkspaceUser needs the id of the workspace/u);
228
+ assert.throws(() => system.forWorkspaceUser("1", null), /forWorkspaceUser needs the id of the user/u);
229
+ });
230
+
231
+ test("many-row writes run per chunk in the caller's workspace, and answer nothing, ids, records or a count", async (t) => {
232
+ const { notes, knex } = await createFixture(t);
233
+ const ids = await notes.createMany([{ title: "a" }, { title: "b" }, { title: "c" }], { context: WORKSPACE_A, returning: "ids" });
234
+ assert.equal(ids.length, 3);
235
+ const [other] = await notes.createMany([{ title: "other" }], { context: WORKSPACE_B, returning: "records" });
236
+ assert.equal(other.title, "other");
237
+
238
+ await notes.patchMany([{ id: ids[0], status: "done" }, { id: ids[1], status: "done" }], { context: WORKSPACE_A });
239
+ await assert.rejects(notes.patchMany([{ id: other.id, status: "done" }], { context: WORKSPACE_A }), /not found|RecordsNotFound/iu);
240
+ await notes.patchMany([{ id: other.id, status: "done" }], { context: WORKSPACE_A, missing: "skip" });
241
+ assert.equal((await knex("notes").where({ id: other.id }).first()).status, "open");
242
+
243
+ // `filters: { id }` takes a list of ids, or one, and stays in the caller's rows.
244
+ const changed = await notes.patchWhere({ id: [...ids, other.id] }, { title: "same" }, { context: WORKSPACE_A, returning: "records" });
245
+ assert.deepEqual(changed.map(({ title }) => title), ["same", "same", "same"]);
246
+ assert.equal((await knex("notes").where({ id: other.id }).first()).title, "other");
247
+ await notes.patchWhere({ status: "done" }, { title: "finished" }, { context: WORKSPACE_A });
248
+ assert.deepEqual((await knex("notes").where({ title: "finished" }).pluck("id")).map(String).sort(), [ids[0], ids[1]].map(String).sort());
249
+
250
+ await assert.rejects(notes.patchWhere({}, { title: "every" }, { context: WORKSPACE_A }), /pass all: true/u);
251
+ await assert.rejects(notes.deleteWhere({}, { context: WORKSPACE_A }), /pass all: true/u);
252
+ await assert.rejects(notes.patchWhere({ status: "done" }, { title: "every" }, { context: WORKSPACE_A, all: true }), /filters or all: true, not both/u);
253
+ await assert.rejects(notes.deleteWhere({ status: "done" }, { context: WORKSPACE_A, all: true }), /filters or all: true, not both/u);
254
+ // all: true reaches every row the caller owns, and no other workspace's.
255
+ await notes.patchWhere({}, { status: "done" }, { context: WORKSPACE_A, all: true });
256
+ assert.equal((await knex("notes").where({ id: other.id }).first()).status, "open");
257
+ assert.deepEqual(await notes.deleteWhere({ id: ids[2] }, { context: WORKSPACE_A, returning: "ids" }), [String(ids[2])]);
258
+ // A selection write changes the rows its filters select as it runs, so it takes no missing; a count reads nothing back.
259
+ await assert.rejects(notes.patchWhere({ status: "done" }, { title: "x" }, { context: WORKSPACE_A, missing: "skip" }), /takes no missing/u);
260
+ await assert.rejects(notes.deleteWhere({ status: "done" }, { context: WORKSPACE_A, missing: "throw" }), /takes no missing/u);
261
+ assert.equal(await notes.patchWhere({ status: "done" }, { title: "counted" }, { context: WORKSPACE_A, returning: "count" }), 2);
262
+ assert.equal(await notes.patchWhere({ status: "gone" }, { title: "none" }, { context: WORKSPACE_A, returning: "count" }), 0);
263
+ assert.equal(await notes.deleteWhere({ status: "done" }, { context: WORKSPACE_A, returning: "count" }), 2);
264
+ assert.deepEqual(await knex("notes").pluck("title"), ["other"]);
265
+ });
266
+
267
+ test("a table keyed by a uuid or a string takes its ids as text without its own normalizeId", async (t) => {
268
+ const tokens = defineTableResource({
269
+ name: "tokens",
270
+ tableName: "tokens",
271
+ ownership: "public",
272
+ schema: { id: { type: "uuid", primary: true }, label: { type: "string", required: true } }
273
+ });
274
+ const { knex, resources, systemContext } = await createFixture(t, { owns: [noteResource(), tokens] });
275
+ await knex.schema.createTable("tokens", (table) => {
276
+ table.uuid("id").primary();
277
+ table.string("label").notNullable();
278
+ });
279
+ const id = "0b8f2f1e-6a51-4c1e-9d2f-2b1c3e4d5f60";
280
+ const options = { context: systemContext };
281
+ await resources.tokens.create({ id, label: "first" }, options);
282
+ assert.equal((await resources.tokens.get(id, {}, options)).label, "first");
283
+ assert.equal((await resources.tokens.patch(id, { label: "second" }, options)).label, "second");
284
+ });
285
+
286
+ test("a null filter value matches the rows where the field is null", async (t) => {
287
+ const tasks = defineTableResource({
288
+ name: "tasks",
289
+ tableName: "tasks",
290
+ ownership: "public",
291
+ defaultSort: ["id"],
292
+ schema: { id: { type: "id", primary: true }, title: { type: "string", required: true }, archivedAt: { type: "dateTime", nullable: true, search: true, storage: { column: "archived_at" } } }
293
+ });
294
+ const { knex, resources, systemContext } = await createFixture(t, { owns: [noteResource(), tasks] });
295
+ await knex.schema.createTable("tasks", (table) => {
296
+ table.increments("id");
297
+ table.string("title").notNullable();
298
+ table.string("archived_at");
299
+ });
300
+ await knex("tasks").insert([{ title: "open" }, { title: "archived", archived_at: "2026-11-08 01:00:00.000" }]);
301
+ const open = await resources.tasks.queryAll({ filters: { archivedAt: null } }, { context: systemContext });
302
+ assert.deepEqual(open.map(({ title }) => title), ["open"]);
303
+ });
304
+
305
+ test("an upsert on a table with no owners may key on the id alone; an owned table's may not", async (t) => {
306
+ const settings = defineTableResource({
307
+ name: "settings",
308
+ tableName: "settings",
309
+ ownership: "public",
310
+ schema: { id: { type: "string", primary: true }, value: { type: "string", nullable: true }, note: { type: "string", nullable: true } }
311
+ });
312
+ const { knex, resources, notes, systemContext } = await createFixture(t, {
313
+ owns: [noteResource(), settings]
314
+ });
315
+ await knex.schema.createTable("settings", (table) => {
316
+ table.string("id").primary();
317
+ table.string("value");
318
+ table.string("note");
319
+ });
320
+ const system = { context: systemContext };
321
+ await resources.settings.createMany([{ id: "theme", value: "dark", note: "kept" }], system);
322
+ // Merge replaces the fields each row gives and keeps the rest; a new id is created.
323
+ await resources.settings.createMany([{ id: "theme", value: "light" }, { id: "lang", value: "en" }], {
324
+ ...system,
325
+ onConflict: { keys: ["id"], action: "merge" }
326
+ });
327
+ assert.deepEqual(await knex("settings").orderBy("id").select("id", "value", "note"), [
328
+ { id: "lang", value: "en", note: null },
329
+ { id: "theme", value: "light", note: "kept" }
330
+ ]);
331
+ await assert.rejects(
332
+ resources.settings.createMany([{ id: "theme", value: "x" }], { ...system, onConflict: { keys: ["id", "value"], action: "merge" } }),
333
+ /onConflict takes rows without an id/u
334
+ );
335
+ // On a workspace table the conflict keys must hold the owner, so json-rest-api refuses the id alone.
336
+ await assert.rejects(notes.createMany([{ id: 1, title: "a" }], { context: WORKSPACE_A, onConflict: { keys: ["id"], action: "merge" } }));
337
+ assert.equal((await knex("notes").count({ count: "*" }).first()).count, 0);
338
+ });
339
+
340
+ test("an upsert finds rows by its conflict keys alone, never by an id, and stays in the caller's workspace", async (t) => {
341
+ const { notes, knex } = await createFixture(t);
342
+ await knex.schema.alterTable("notes", (table) => table.unique(["workspace_id", "title"]));
343
+ const upsert = { keys: ["workspaceId", "title"], action: "merge", merge: ["status"] };
344
+ await assert.rejects(
345
+ notes.createMany([{ id: 1, title: "a" }], { context: WORKSPACE_A, onConflict: { ...upsert, action: "ignore" } }),
346
+ /onConflict takes rows without an id/u
347
+ );
348
+ await assert.rejects(
349
+ notes.createMany([{ title: "a" }, { title: "a", status: "done" }], { context: WORKSPACE_A, onConflict: upsert }),
350
+ (error) => error.code === "REST_API_VALIDATION"
351
+ );
352
+ await notes.createMany([{ title: "a" }], { context: WORKSPACE_B });
353
+ await notes.createMany([{ title: "a" }], { context: WORKSPACE_A });
354
+ await notes.createMany([{ title: "a", status: "done" }], { context: WORKSPACE_A, onConflict: upsert });
355
+ assert.deepEqual(
356
+ (await knex("notes").orderBy("workspace_id").select("workspace_id", "status")).map(({ workspace_id: workspaceId, status }) => [workspaceId, status]),
357
+ [[1, "done"], [2, "open"]]
358
+ );
359
+ });
360
+
361
+ test("an upsert merge with no merge list changes the rows' fields but never their keys, owner or lock version", async (t) => {
362
+ const slots = defineTableResource({
363
+ name: "slots",
364
+ tableName: "slots",
365
+ ownership: "workspace",
366
+ lockVersionField: "lockVersion",
367
+ schema: {
368
+ id: { type: "id", primary: true },
369
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
370
+ day: { type: "string", required: true, search: true },
371
+ status: { type: "string", required: true },
372
+ lockVersion: { type: "integer", defaultTo: 0, storage: { column: "lock_version" } },
373
+ createdAt: { type: "dateTime", storage: { column: "created_at", writeSerializer: "datetime-utc" } },
374
+ updatedAt: { type: "dateTime", storage: { column: "updated_at", writeSerializer: "datetime-utc" } }
375
+ }
376
+ });
377
+ const { knex, resources } = await createFixture(t, { owns: [noteResource(), slots] });
378
+ await knex.schema.createTable("slots", (table) => {
379
+ table.increments("id");
380
+ table.integer("workspace_id").nullable();
381
+ table.string("day").notNullable();
382
+ table.string("status").notNullable();
383
+ table.integer("lock_version").notNullable().defaultTo(0);
384
+ table.string("created_at").notNullable();
385
+ table.string("updated_at").notNullable();
386
+ table.unique(["workspace_id", "day"]);
387
+ });
388
+ const upsert = { context: WORKSPACE_A, onConflict: { keys: ["workspaceId", "day"], action: "merge" } };
389
+ await resources.slots.createMany([{ day: "mon", status: "pencilled" }], { context: WORKSPACE_A });
390
+ const before = await knex("slots").first("created_at", "lock_version");
391
+ // The rows give their owner and lock version too, as a record read back and sent again does.
392
+ await resources.slots.createMany([
393
+ { workspaceId: 1, day: "mon", status: "booked", lockVersion: 7 },
394
+ { workspaceId: 1, day: "tue", status: "pencilled", lockVersion: 0 }
395
+ ], upsert);
396
+ // The merged row's version moves on by one, as on any write; the row's own 7 is never taken.
397
+ assert.deepEqual(await knex("slots").orderBy("day").select("workspace_id", "day", "status", "lock_version", "created_at"), [
398
+ { workspace_id: 1, day: "mon", status: "booked", lock_version: before.lock_version + 1, created_at: before.created_at },
399
+ { workspace_id: 1, day: "tue", status: "pencilled", lock_version: 0, created_at: (await knex("slots").where({ day: "tue" }).first()).created_at }
400
+ ]);
401
+ });
402
+
403
+ test("lockOrCreate() runs work on the locked record, creating it first when missing, with the work or not at all", async (t) => {
404
+ const counters = defineTableResource({
405
+ name: "counters",
406
+ tableName: "counters",
407
+ ownership: "public",
408
+ schema: { id: { type: "string", primary: true }, value: { type: "integer", required: true } }
409
+ });
410
+ const { knex, resources, systemContext } = await createFixture(t, {
411
+ owns: [noteResource(), { normalizeId: normalizeOpaqueId, resource: counters }]
412
+ });
413
+ await knex.schema.createTable("counters", (table) => {
414
+ table.string("id").primary();
415
+ table.integer("value").notNullable();
416
+ });
417
+ const options = { context: systemContext };
418
+ const bump = (record, txOptions) => resources.counters.patch(record.id, { value: record.value + 1 }, txOptions);
419
+ assert.deepEqual(await resources.counters.lockOrCreate({ id: "visits", value: 0 }, options, bump), { id: "visits", value: 1 });
420
+ assert.deepEqual(await resources.counters.lockOrCreate({ id: "visits", value: 0 }, options, bump), { id: "visits", value: 2 });
421
+ // Work that fails leaves no new record behind, and runs once.
422
+ let runs = 0;
423
+ await assert.rejects(resources.counters.lockOrCreate({ id: "failed", value: 0 }, options, () => {
424
+ runs += 1;
425
+ throw new Error("provider refused");
426
+ }), /provider refused/u);
427
+ assert.equal(runs, 1);
428
+ assert.equal(await knex("counters").where({ id: "failed" }).first(), undefined);
429
+ // In the caller's transaction it locks and creates there, and rolls back with it.
430
+ await assert.rejects(resources.counters.withTransaction(options, async (txOptions) => {
431
+ await resources.counters.lockOrCreate({ id: "inner", value: 5 }, txOptions, bump);
432
+ throw new Error("caller failed");
433
+ }), /caller failed/u);
434
+ assert.equal(await knex("counters").where({ id: "inner" }).first(), undefined);
435
+ await assert.rejects(resources.counters.lockOrCreate({ value: 0 }, options, bump), /takes \(row, options, work\)/u);
436
+ });
437
+
438
+ test("a table that stores createdAt and updatedAt has every write stamp them; a request never sets its own", async (t) => {
439
+ const stamped = defineTableResource({
440
+ name: "stamped",
441
+ tableName: "stamped",
442
+ ownership: "public",
443
+ schema: {
444
+ id: { type: "string", primary: true },
445
+ title: { type: "string", required: true },
446
+ createdAt: { type: "dateTime", storage: { column: "created_at", writeSerializer: "datetime-utc" } },
447
+ updatedAt: { type: "dateTime", storage: { column: "updated_at", writeSerializer: "datetime-utc" } }
448
+ }
449
+ });
450
+ const { knex, resources, systemContext } = await createFixture(t, {
451
+ owns: [noteResource(), { normalizeId: normalizeOpaqueId, resource: stamped }]
452
+ });
453
+ await knex.schema.createTable("stamped", (table) => {
454
+ table.string("id").primary();
455
+ table.string("title").notNullable();
456
+ table.string("created_at").notNullable();
457
+ table.string("updated_at").notNullable();
458
+ });
459
+ const options = { context: systemContext };
460
+ const at = (iso) => t.mock.timers.setTime(Date.parse(iso));
461
+ const stamps = async (id) => knex("stamped").where({ id }).first("created_at", "updated_at");
462
+ t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-11-08T01:00:00.000Z") });
463
+ await resources.stamped.create({ id: "a", title: "a" }, options);
464
+ await resources.stamped.createMany([{ id: "b", title: "b" }, { id: "c", title: "c" }], options);
465
+ assert.deepEqual(await stamps("b"), { created_at: "2026-11-08 01:00:00.000", updated_at: "2026-11-08 01:00:00.000" });
466
+
467
+ at("2026-11-08T02:00:00.000Z");
468
+ await resources.stamped.patch("a", { title: "a2" }, options);
469
+ assert.deepEqual(await stamps("a"), { created_at: "2026-11-08 01:00:00.000", updated_at: "2026-11-08 02:00:00.000" });
470
+ at("2026-11-08T03:00:00.000Z");
471
+ await resources.stamped.patchMany([{ id: "b", title: "b2" }], options);
472
+ await resources.stamped.patchWhere({ id: "c" }, { title: "c2" }, options);
473
+ assert.equal((await stamps("b")).updated_at, "2026-11-08 03:00:00.000");
474
+ assert.equal((await stamps("c")).updated_at, "2026-11-08 03:00:00.000");
475
+ // An upsert merge moves updatedAt on and keeps the stored createdAt; a new row gets both.
476
+ at("2026-11-08T04:00:00.000Z");
477
+ await resources.stamped.createMany([{ id: "a", title: "a3" }, { id: "d", title: "d" }], { ...options, onConflict: { keys: ["id"], action: "merge" } });
478
+ assert.deepEqual(await stamps("a"), { created_at: "2026-11-08 01:00:00.000", updated_at: "2026-11-08 04:00:00.000" });
479
+ assert.deepEqual(await stamps("d"), { created_at: "2026-11-08 04:00:00.000", updated_at: "2026-11-08 04:00:00.000" });
480
+ // A system context's write keeps the timestamps it sets, and a patch that changes nothing stamps nothing.
481
+ await resources.stamped.patch("a", { updatedAt: "2020-01-01T00:00:00.000Z" }, options);
482
+ await resources.stamped.create({ id: "e", title: "e", createdAt: "2020-01-01T00:00:00.000Z" }, options);
483
+ at("2026-11-08T05:00:00.000Z");
484
+ await resources.stamped.patch("a", {}, options);
485
+ assert.equal((await stamps("a")).updated_at, "2020-01-01 00:00:00.000");
486
+ assert.deepEqual(await stamps("e"), { created_at: "2020-01-01 00:00:00.000", updated_at: "2026-11-08 04:00:00.000" });
487
+ // A call without a context is a request's, even on a public table: server code cannot pass a forged body through.
488
+ await resources.stamped.create({ id: "g", title: "g", createdAt: "2020-01-01T00:00:00.000Z" }, {});
489
+ assert.equal((await stamps("g")).created_at, "2026-11-08 05:00:00.000");
490
+ // A request's timestamps are replaced by the handle's: a patch that sends updatedAt gets the server's time and
491
+ // leaves createdAt as stored, a create that sends createdAt gets the server's, and an upsert merge keeps createdAt.
492
+ const request = { context: {} };
493
+ await resources.stamped.patch("a", { title: "a4", createdAt: "2019-01-01T00:00:00.000Z", updatedAt: "2019-01-01T00:00:00.000Z" }, request);
494
+ assert.deepEqual(await stamps("a"), { created_at: "2026-11-08 01:00:00.000", updated_at: "2026-11-08 05:00:00.000" });
495
+ await resources.stamped.create({ id: "f", title: "f", createdAt: "2019-01-01T00:00:00.000Z" }, request);
496
+ assert.deepEqual(await stamps("f"), { created_at: "2026-11-08 05:00:00.000", updated_at: "2026-11-08 05:00:00.000" });
497
+ await resources.stamped.createMany([{ id: "a", title: "a5", createdAt: "2019-01-01T00:00:00.000Z" }], {
498
+ ...request, onConflict: { keys: ["id"], action: "merge", merge: ["title", "createdAt"] }
499
+ });
500
+ assert.equal((await stamps("a")).created_at, "2026-11-08 01:00:00.000");
501
+ });
502
+
503
+ test("the handle stamps a timestamp to its field's temporalPrecision: whole seconds for 0, milliseconds for 3", async (t) => {
504
+ const precise = defineTableResource({
505
+ name: "precise",
506
+ tableName: "precise",
507
+ ownership: "public",
508
+ schema: {
509
+ id: { type: "string", primary: true },
510
+ title: { type: "string", required: true },
511
+ createdAt: { type: "dateTime", temporalPrecision: 3, storage: { column: "created_at", writeSerializer: "datetime-utc" } },
512
+ updatedAt: { type: "dateTime", temporalPrecision: 0, storage: { column: "updated_at", writeSerializer: "datetime-utc" } }
513
+ }
514
+ });
515
+ const { knex, resources } = await createFixture(t, {
516
+ owns: [noteResource(), { normalizeId: normalizeOpaqueId, resource: precise }]
517
+ });
518
+ await knex.schema.createTable("precise", (table) => {
519
+ table.string("id").primary();
520
+ table.string("title").notNullable();
521
+ table.string("created_at").notNullable();
522
+ table.string("updated_at").notNullable();
523
+ });
524
+ const options = { context: {} };
525
+ const stamps = async () => knex("precise").where({ id: "a" }).first("created_at", "updated_at");
526
+ t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-11-08T01:00:00.789Z") });
527
+ await resources.precise.create({ id: "a", title: "a" }, options);
528
+ assert.deepEqual(await stamps(), { created_at: "2026-11-08 01:00:00.789", updated_at: "2026-11-08 01:00:00.000" });
529
+
530
+ t.mock.timers.setTime(Date.parse("2026-11-08T02:00:00.456Z"));
531
+ await resources.precise.patch("a", { title: "a2" }, options);
532
+ assert.deepEqual(await stamps(), { created_at: "2026-11-08 01:00:00.789", updated_at: "2026-11-08 02:00:00.000" });
533
+ });
534
+
535
+ test("patch() with lockVersion patches the version it read and refuses a stale one; onlyIf patches only matching rows", async (t) => {
536
+ const { notes } = await createFixture(t);
537
+ const note = await notes.create({ title: "draft" }, { context: WORKSPACE_A });
538
+ assert.equal(note.lockVersion, 0);
539
+ const saved = await notes.patch(note.id, { title: "v1" }, { context: WORKSPACE_A, lockVersion: 0 });
540
+ assert.equal(saved.lockVersion, 1);
541
+ await assert.rejects(notes.patch(note.id, { title: "late" }, { context: WORKSPACE_A, lockVersion: 0 }), (error) =>
542
+ error instanceof StaleRecordError && error.status === 409 && error.code === "crud.document.lockVersionConflict");
543
+ assert.equal(await notes.patch(note.id, { title: "x" }, { context: WORKSPACE_A, onlyIf: { status: "done" } }), null);
544
+ assert.equal((await notes.patch(note.id, { status: "done" }, { context: WORKSPACE_A, onlyIf: { status: "open" } })).status, "done");
545
+ await assert.rejects(notes.patch(note.id, { title: "x" }, { context: WORKSPACE_B, lockVersion: 2 }), notFound);
546
+ await assert.rejects(notes.patch(note.id, { title: "x" }, { context: WORKSPACE_B, onlyIf: { status: "done" } }), notFound);
547
+ assert.equal(await notes.patch(note.id, { title: "x" }, { context: WORKSPACE_B, lockVersion: 2, missing: "skip" }), null);
548
+ });
549
+
550
+ test("advancing a lock version with no field changed is an update: it stamps updatedAt, one row or many in one statement", async (t) => {
551
+ const versioned = defineTableResource({
552
+ name: "versioned",
553
+ tableName: "versioned",
554
+ ownership: "public",
555
+ lockVersionField: "lockVersion",
556
+ schema: {
557
+ id: { type: "string", primary: true },
558
+ title: { type: "string", required: true },
559
+ lockVersion: { type: "integer", defaultTo: 0, storage: { column: "lock_version" } },
560
+ updatedAt: { type: "dateTime", storage: { column: "updated_at", writeSerializer: "datetime-utc" } }
561
+ }
562
+ });
563
+ const { knex, resources } = await createFixture(t, {
564
+ owns: [noteResource(), { normalizeId: normalizeOpaqueId, resource: versioned }]
565
+ });
566
+ await knex.schema.createTable("versioned", (table) => {
567
+ table.string("id").primary();
568
+ table.string("title").notNullable();
569
+ table.integer("lock_version").notNullable().defaultTo(0);
570
+ table.string("updated_at").notNullable();
571
+ });
572
+ const options = { context: {} };
573
+ const rows = async () => (await knex("versioned").orderBy("id").select("id", "lock_version", "updated_at"))
574
+ .map(({ id, lock_version: version, updated_at: stamp }) => [id, version, stamp]);
575
+ t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-11-08T01:00:00.000Z") });
576
+ await resources.versioned.createMany([{ id: "a", title: "a" }, { id: "b", title: "b" }, { id: "c", title: "c" }], options);
577
+
578
+ t.mock.timers.setTime(Date.parse("2026-11-08T02:00:00.000Z"));
579
+ await resources.versioned.patch("a", {}, { ...options, lockVersion: 0 });
580
+ const statements = [];
581
+ const count = (query) => statements.push(query.sql);
582
+ knex.on("query", count);
583
+ await resources.versioned.patchMany([{ id: "b" }, { id: "c" }], options);
584
+ knex.off("query", count);
585
+ assert.deepEqual(await rows(), [
586
+ ["a", 1, "2026-11-08 02:00:00.000"],
587
+ ["b", 1, "2026-11-08 02:00:00.000"],
588
+ ["c", 1, "2026-11-08 02:00:00.000"]
589
+ ]);
590
+ assert.equal(statements.filter((sql) => /^update/iu.test(sql)).length, 1, "many rows advance in one UPDATE");
591
+ // A patch that changes nothing, and names no lock version, still writes nothing.
592
+ t.mock.timers.setTime(Date.parse("2026-11-08T03:00:00.000Z"));
593
+ await resources.versioned.patch("a", {}, options);
594
+ assert.deepEqual((await rows())[0], ["a", 1, "2026-11-08 02:00:00.000"]);
595
+ });
596
+
597
+ test("afterWrite runs once per write, in the write's transaction and scope", async (t) => {
598
+ const events = [];
599
+ const resource = noteResource();
600
+ const { notes, knex } = await createFixture(t, {
601
+ owns: [{
602
+ resource,
603
+ hooks: {
604
+ afterWrite: async ({ operation, ids, options }) => {
605
+ events.push({ operation, ids, scoped: options.context === WORKSPACE_A, inTransaction: Boolean(options.trx) });
606
+ if (operation === "delete") {
607
+ throw new Error("keep it");
608
+ }
609
+ }
610
+ }
611
+ }]
612
+ });
613
+ const note = await notes.create({ title: "a" }, { context: WORKSPACE_A, returning: "none" });
614
+ assert.equal(note, undefined);
615
+ const [id] = events[0].ids;
616
+ await notes.createMany([{ title: "b" }, { title: "c" }], { context: WORKSPACE_A });
617
+ await assert.rejects(notes.delete(id, { context: WORKSPACE_A }), /keep it/u);
618
+ assert.equal(await knex("notes").count({ count: "*" }).first().then(({ count }) => Number(count)), 3);
619
+ assert.deepEqual(events.map(({ operation, ids, scoped, inTransaction }) => [operation, ids.length, scoped, inTransaction]), [
620
+ ["create", 1, true, true],
621
+ ["createMany", 2, true, true],
622
+ ["delete", 1, true, true]
623
+ ]);
624
+ });
625
+
626
+ test("withTransaction() commits together and rolls back together", async (t) => {
627
+ const { notes, knex } = await createFixture(t);
628
+ await assert.rejects(notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
629
+ await notes.create({ title: "a" }, txOptions);
630
+ await notes.createMany([{ title: "b" }], txOptions);
631
+ throw new Error("undo");
632
+ }), /undo/u);
633
+ assert.equal((await knex("notes").count({ count: "*" }).first()).count, 0);
634
+ await notes.withTransaction({ context: WORKSPACE_A }, (txOptions) => notes.create({ title: "kept" }, txOptions));
635
+ assert.deepEqual(await knex("notes").pluck("title"), ["kept"]);
636
+ });
637
+
638
+ test("withTransaction() rethrows its work's own error, and takes its settings in options", async (t) => {
639
+ const { notes } = await createFixture(t);
640
+ const failure = Object.freeze(new Error("refused by the work"));
641
+ await assert.rejects(
642
+ notes.withTransaction({ context: WORKSPACE_A }, () => {
643
+ throw failure;
644
+ }),
645
+ (error) => error === failure
646
+ );
647
+ await assert.rejects(
648
+ notes.withTransaction({ context: WORKSPACE_A, retry: false }, async (txOptions) => {
649
+ assert.equal(Object.hasOwn(txOptions, "retry"), false);
650
+ await notes.create({ title: "undone" }, txOptions);
651
+ throw failure;
652
+ }),
653
+ (error) => error === failure
654
+ );
655
+ assert.equal((await notes.queryAll({}, { context: WORKSPACE_A })).length, 0);
656
+ // The work's own error says it rolled back, keeping its status and code and its serialized form, so a retry of a
657
+ // lost insert race sees the 409 the work made from the duplicate as one.
658
+ const duplicate = Object.assign(new Error("UNIQUE constraint failed: notes.title"), { code: "SQLITE_CONSTRAINT_UNIQUE" });
659
+ const taken = await notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
660
+ await notes.create({ title: "undone" }, txOptions);
661
+ throw new AppError(409, "That title is taken.", { code: "notes.title.taken", cause: duplicate });
662
+ }).catch((error) => error);
663
+ assert.deepEqual([taken.status, taken.code, taken.transactionOutcome], [409, "notes.title.taken", "rolledBack"]);
664
+ assert.equal(Object.keys(taken).includes("transactionOutcome"), false);
665
+ assert.equal(isLostDuplicateInsertRace(taken, { context: WORKSPACE_A }), true);
666
+ assert.equal((await notes.queryAll({}, { context: WORKSPACE_A })).length, 0);
667
+ // A failed write inside the work reports the outcome after the rollback, not the "pending" it saw.
668
+ await assert.rejects(
669
+ notes.withTransaction({ context: WORKSPACE_A }, (txOptions) => notes.patch("999", { title: "missing" }, txOptions)),
670
+ (error) => notFound(error) && error.transactionOutcome === "rolledBack"
671
+ );
672
+ await assert.rejects(notes.withTransaction({ context: WORKSPACE_A }, () => null, { retry: false }), /settings ride in options/u);
673
+ // outermost work owns its transaction, so a call inside one is refused.
674
+ await notes.withTransaction({ context: WORKSPACE_A, outermost: true }, async (txOptions) => {
675
+ await assert.rejects(notes.withTransaction({ ...txOptions, outermost: true }, () => null), /cannot run inside the caller's transaction/u);
676
+ assert.equal(await notes.withTransaction(txOptions, () => "joined"), "joined");
677
+ });
678
+ });
679
+
680
+ test("findOrCreate() answers the record with the id, creating it only when it is missing", async (t) => {
681
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
682
+ t.after(() => knex.destroy());
683
+ await knex.schema.createTable("counters", (table) => {
684
+ table.string("counter_key").primary();
685
+ table.integer("value").notNullable();
686
+ });
687
+ const runtime = await createTestResourceRuntime({ knex });
688
+ // A key the feature computes is an opaque string id.
689
+ const { counters } = await runtime.own("counters.feature", [{ normalizeId: normalizeOpaqueId, resource: defineTableResource({
690
+ name: "counters",
691
+ tableName: "counters",
692
+ ownership: "public",
693
+ idProperty: "counter_key",
694
+ schema: {
695
+ id: { type: "string", primary: true, required: true, storage: { column: "counter_key" } },
696
+ value: { type: "integer", required: true }
697
+ }
698
+ }) }]);
699
+ const options = { context: runtime.declareSystemScope("counters.feature", "tests")("keyed counters") };
700
+ assert.deepEqual(await counters.findOrCreate({ id: "visits", value: 0 }, options), { id: "visits", value: 0 });
701
+ await knex("counters").where({ counter_key: "visits" }).update({ value: 7 });
702
+ assert.deepEqual(await counters.findOrCreate({ id: "visits", value: 0 }, options), { id: "visits", value: 7 });
703
+ assert.deepEqual(await knex("counters").select("counter_key", "value"), [{ counter_key: "visits", value: 7 }]);
704
+ });
705
+
706
+ test("an id filter with an empty list matches no row", async (t) => {
707
+ const { notes } = await createFixture(t);
708
+ await notes.create({ title: "kept" }, { context: WORKSPACE_A });
709
+ assert.deepEqual((await notes.query({ filters: { id: [] }, page: { size: 5 } }, { context: WORKSPACE_A })).records, []);
710
+ assert.deepEqual(await notes.queryAll({ filters: { id: [] } }, { context: WORKSPACE_A }), []);
711
+ });
712
+
713
+ test("findOrCreate() of an id another workspace's row holds answers a missing record, never null or that row", async (t) => {
714
+ const { notes } = await createFixture(t);
715
+ const created = await notes.create({ title: "theirs" }, { context: WORKSPACE_B });
716
+ const missing = await notes.get(987654, {}, { context: WORKSPACE_A }).then(() => null, (error) => error);
717
+ const taken = await notes.findOrCreate({ id: created.id, title: "mine" }, { context: WORKSPACE_A }).then(() => null, (error) => error);
718
+ assert.equal(taken?.code, missing?.code);
719
+ assert.equal(taken?.subtype, missing?.subtype);
720
+ assert.doesNotMatch(String(taken?.message), /theirs/u);
721
+ assert.equal((await notes.get(created.id, {}, { context: WORKSPACE_B })).title, "theirs");
722
+ });
723
+
724
+ test("a handle offers one way to each write: no patchWhereIds, deleteMany, toRecords or inTransaction", async (t) => {
725
+ const { notes } = await createFixture(t);
726
+ for (const method of ["patchWhereIds", "deleteMany", "toRecords", "inTransaction"]) {
727
+ assert.equal(notes[method], undefined, method);
728
+ }
729
+ });
730
+
731
+ test("locks need a transaction and take only the caller's rows", async (t) => {
732
+ const { notes } = await createFixture(t);
733
+ const [a, b] = await notes.createMany([{ title: "a" }, { title: "b", status: "queued" }], { context: WORKSPACE_A, returning: "records" });
734
+ const [other] = await notes.createMany([{ title: "other", status: "queued" }], { context: WORKSPACE_B, returning: "records" });
735
+ await assert.rejects(notes.lock(a.id, { context: WORKSPACE_A }), RestApiLockOutsideTransactionError);
736
+ await notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
737
+ assert.equal((await notes.lock(a.id, txOptions)).title, "a");
738
+ assert.equal(await notes.lock(other.id, { ...txOptions, missing: "skip" }), null);
739
+ assert.deepEqual((await notes.lockMany([a.id, other.id], { ...txOptions, missing: "skip" })).map(({ title }) => title), ["a"]);
740
+ assert.deepEqual((await notes.lockWhere({ status: "queued" }, txOptions)).map(({ title }) => title), ["b"]);
741
+ assert.deepEqual((await notes.claim({ filters: { status: "queued" }, take: 5 }, txOptions)).map(({ id }) => String(id)), [String(b.id)]);
742
+ await assert.rejects(notes.claim({ status: "queued" }, { limit: 5 }, txOptions), /takes \(\{ filters, sort, take \}, options\)/u);
743
+ });
744
+ await assert.rejects(notes.withTransaction({ context: WORKSPACE_A }, (txOptions) => notes.lock(other.id, txOptions)), notFound);
745
+ });
746
+
747
+ test("scopedQuery() answers a read-only builder over the caller's rows that orWhere cannot widen", async (t) => {
748
+ const { notes } = await createFixture(t);
749
+ await notes.createMany([{ title: "a" }, { title: "b" }], { context: WORKSPACE_A });
750
+ await notes.createMany([{ title: "other" }], { context: WORKSPACE_B });
751
+ const { query } = await notes.scopedQuery({ alias: "n" }, { context: WORKSPACE_A });
752
+ // The scope is inside the derived table "n", so an orWhere on the outer query cannot reach another workspace's row.
753
+ const rows = await query.clone()
754
+ .where(notes.column("title", "n"), "a")
755
+ .orWhere(notes.column("title", "n"), "other")
756
+ .select(notes.column("title", "n"))
757
+ .orderBy(notes.column("id", "n"));
758
+ assert.deepEqual(rows.map(({ title }) => title), ["a"]);
759
+ const counted = await query.clone().select(notes.raw("count(*) as total")).first();
760
+ assert.equal(Number(counted.total), 2);
761
+ assert.throws(() => query.clone().update({ title: "x" }), RestApiScopedQueryReadOnlyError);
762
+ assert.throws(() => notes.raw("count(?)"), /needs the bindings/u);
763
+ });
764
+
765
+ test("raw() is a value only: it is no promise, running it any other way throws, and nothing runs", async (t) => {
766
+ const { notes, knex } = await createFixture(t);
767
+ await notes.create({ title: "keep" }, { context: WORKSPACE_A });
768
+ await notes.create({ title: "theirs" }, { context: WORKSPACE_B });
769
+ const statement = notes.raw("DELETE FROM notes");
770
+ // Its then is undefined, so awaiting it answers the value itself, unrun, and nothing mistakes it for a promise.
771
+ assert.equal(statement.then, undefined);
772
+ assert.equal(await statement, statement);
773
+ for (const method of ["catch", "finally", "asCallback", "stream", "pipe"]) {
774
+ assert.throws(() => statement[method](() => {}), /notes\.raw\(\) is a value inside a scoped query or a write; it never runs as a statement of its own/u, method);
775
+ }
776
+ assert.equal((await knex("notes")).length, 2);
777
+
778
+ const { query } = await notes.scopedQuery({}, { context: WORKSPACE_A });
779
+ const rows = await query.select({ shout: notes.raw("UPPER(??)", [notes.column("title")]) });
780
+ assert.deepEqual(rows.map(({ shout }) => shout), ["KEEP"]);
781
+ await notes.patchWhere({ title: "keep" }, { status: notes.raw("?", ["done"]) }, { context: WORKSPACE_A });
782
+ assert.deepEqual((await knex("notes").orderBy("id")).map(({ status }) => status), ["done", "open"]);
783
+ });
784
+
785
+ test("an undefined filter value is refused by every filtered call, so it never widens to every row", async (t) => {
786
+ const { notes, knex } = await createFixture(t);
787
+ await notes.createMany([{ title: "a", status: "paused" }, { title: "b", status: "paused" }], { context: WORKSPACE_A });
788
+ const context = WORKSPACE_A;
789
+ const refused = /filters\.status is undefined: pass its value, or leave the filter out/u;
790
+ await assert.rejects(notes.query({ filters: { status: undefined }, page: { size: 5 } }, { context }), refused);
791
+ await assert.rejects(notes.queryAll({ filters: { status: undefined } }, { context }), refused);
792
+ await assert.rejects(notes.aggregate({ measures: { total: { count: "id" } }, filters: { status: undefined } }, { context }), refused);
793
+ await assert.rejects(notes.scopedQuery({ filters: { status: undefined } }, { context }), refused);
794
+ await assert.rejects(notes.patchWhere({ status: undefined }, { title: "every" }, { context }), refused);
795
+ await assert.rejects(notes.patchWhere({ title: "a", status: undefined }, { title: "every" }, { context }), refused);
796
+ await assert.rejects(notes.deleteWhere({ status: undefined }, { context }), refused);
797
+ await notes.withTransaction({ context }, async (txOptions) => {
798
+ await assert.rejects(notes.lockWhere({ status: undefined }, txOptions), refused);
799
+ await assert.rejects(notes.claim({ filters: { status: undefined }, take: 5 }, txOptions), refused);
800
+ });
801
+ assert.deepEqual((await knex("notes").orderBy("id")).map(({ title, status }) => `${title}:${status}`), ["a:paused", "b:paused"]);
802
+ });
803
+
804
+ test("aggregate() totals the caller's rows", async (t) => {
805
+ const { notes } = await createFixture(t);
806
+ await notes.createMany([{ title: "a" }, { title: "b", status: "done" }, { title: "c", status: "done" }], { context: WORKSPACE_A });
807
+ await notes.createMany([{ title: "other", status: "done" }], { context: WORKSPACE_B });
808
+ const rows = await notes.aggregate({ groupBy: ["status"], measures: { count: { count: "*" } }, sort: ["status"] }, { context: WORKSPACE_A });
809
+ assert.deepEqual(rows, [{ status: "done", count: 2 }, { status: "open", count: 1 }]);
810
+ });
811
+
812
+ test("aggregate() counts a table's ids, distinct or not, as its rows, on a schema that declares no id field", async (t) => {
813
+ const { visits, options } = await createVisitFixture(t);
814
+ const [totals] = await visits.aggregate({
815
+ measures: { visits: { count: "id" }, distinct: { countDistinct: "id" }, seen: { count: "id", where: { stage: "seen" } } }
816
+ }, options);
817
+ assert.deepEqual(totals, { visits: 2, distinct: 2, seen: 0 });
818
+ });
819
+
820
+ test("workspace_or_shared reads a workspace's rows and the shared ones, and writes only its own", async (t) => {
821
+ const { notes, knex } = await createFixture(t, { owns: [noteResource({ ownership: "workspace_or_shared" })] });
822
+ await knex("notes").insert([{ workspace_id: null, title: "shared" }, { workspace_id: 2, title: "theirs" }]);
823
+ await notes.create({ title: "mine" }, { context: WORKSPACE_A });
824
+ const { records } = await notes.query({ page: { size: 10 } }, { context: WORKSPACE_A });
825
+ assert.deepEqual(records.map(({ title }) => title).sort(), ["mine", "shared"]);
826
+ const shared = records.find(({ title }) => title === "shared");
827
+ await assert.rejects(notes.patch(shared.id, { title: "taken" }, { context: WORKSPACE_A }), /only be changed in a system context/u);
828
+ assert.equal((await knex("notes").where({ id: shared.id }).first()).title, "shared");
829
+ });
830
+
831
+ test("a context naming no owner the table needs is refused, never read as the rows with no owner", async (t) => {
832
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
833
+ t.after(() => knex.destroy());
834
+ await knex.schema.createTable("notes", (table) => {
835
+ table.increments("id");
836
+ table.integer("workspace_id").nullable();
837
+ table.string("title").notNullable();
838
+ });
839
+ await knex.schema.createTable("bookmarks", (table) => {
840
+ table.increments("id");
841
+ table.integer("user_id").nullable();
842
+ table.string("title").notNullable();
843
+ });
844
+ await knex("notes").insert([{ workspace_id: null, title: "orphan" }, { workspace_id: 1, title: "mine" }]);
845
+ await knex("bookmarks").insert([{ user_id: null, title: "orphan" }, { user_id: 7, title: "mine" }]);
846
+ const workspaceField = { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } };
847
+ const userField = { type: "id", nullable: true, search: true, storage: { column: "user_id" } };
848
+ const runtime = await createTestResourceRuntime({ knex });
849
+ const { notes, bookmarks } = await runtime.own("notes.feature", [
850
+ defineTableResource({ name: "notes", tableName: "notes", ownership: "workspace", schema: { workspaceId: workspaceField, title: { type: "string", required: true } } }),
851
+ defineTableResource({ name: "bookmarks", tableName: "bookmarks", ownership: "user", schema: { userId: userField, title: { type: "string", required: true } } })
852
+ ]);
853
+ const system = runtime.declareSystemScope("notes.feature", "tests")("check scopes");
854
+ const userRoute = { visibilityContext: { visibility: "user", scopeOwnerId: "7", userId: "7" } };
855
+ const page = { page: { size: 10 } };
856
+
857
+ for (const [handle, context, owner] of [
858
+ [notes, {}, "workspace"],
859
+ [notes, userRoute, "workspace"],
860
+ [notes, system.forUser("7"), "workspace"],
861
+ [bookmarks, {}, "user"],
862
+ [bookmarks, system.forWorkspace("1"), "user"]
863
+ ]) {
864
+ const refused = new RegExp(`rows each belong to a ${owner}, and this context names no ${owner}`, "u");
865
+ await assert.rejects(handle.query(page, { context }), refused);
866
+ await assert.rejects(handle.queryAll({}, { context }), refused);
867
+ await assert.rejects(handle.create({ title: "x" }, { context }), refused);
868
+ await assert.rejects(handle.patchWhere({ title: "orphan" }, { title: "taken" }, { context }), refused);
869
+ }
870
+ assert.equal((await knex("notes").where({ title: "x" })).length, 0);
871
+ assert.deepEqual((await notes.queryAll({}, { context: WORKSPACE_A })).map(({ title }) => title), ["mine"]);
872
+ assert.deepEqual((await bookmarks.queryAll({}, { context: userRoute })).map(({ title }) => title), ["mine"]);
873
+ assert.equal((await notes.queryAll({}, { context: system })).length, 2);
874
+ });
875
+
876
+ test("workspace_or_shared without a workspace reads only the shared rows and writes none", async (t) => {
877
+ const { notes, knex } = await createFixture(t, { owns: [noteResource({ ownership: "workspace_or_shared" })] });
878
+ await knex("notes").insert([{ workspace_id: null, title: "shared" }, { workspace_id: 2, title: "theirs" }]);
879
+ const { records } = await notes.query({ page: { size: 10 } }, { context: {} });
880
+ assert.deepEqual(records.map(({ title }) => title), ["shared"]);
881
+ await assert.rejects(notes.create({ title: "stray" }, { context: {} }));
882
+ assert.equal((await knex("notes").where({ title: "stray" })).length, 0);
883
+ });
884
+
885
+ test("row policies read their own tables read-only, and contributions only narrow", async (t) => {
886
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
887
+ t.after(() => knex.destroy());
888
+ await knex.schema.createTable("rooms", (table) => {
889
+ table.increments("id");
890
+ table.string("name");
891
+ table.boolean("archived").defaultTo(false);
892
+ });
893
+ await knex.schema.createTable("room_members", (table) => {
894
+ table.increments("id");
895
+ table.integer("room_id");
896
+ table.integer("user_id");
897
+ });
898
+ await knex.schema.createTable("room_bans", (table) => {
899
+ table.increments("id");
900
+ table.integer("room_id");
901
+ });
902
+ await knex("rooms").insert([
903
+ { name: "general", archived: false },
904
+ { name: "secret", archived: false },
905
+ { name: "old", archived: true },
906
+ { name: "banned", archived: false }
907
+ ]);
908
+ await knex("room_members").insert([{ room_id: 1, user_id: 7 }, { room_id: 3, user_id: 7 }, { room_id: 4, user_id: 7 }]);
909
+ await knex("room_bans").insert([{ room_id: 4 }]);
910
+ const rooms = defineTableResource({
911
+ name: "rooms",
912
+ tableName: "rooms",
913
+ ownership: "public",
914
+ defaultSort: ["id"],
915
+ schema: { id: { type: "id", primary: true }, name: { type: "string" }, archived: { type: "boolean" } }
916
+ });
917
+ const roomMembers = defineTableResource({
918
+ name: "roomMembers",
919
+ tableName: "room_members",
920
+ ownership: "public",
921
+ schema: { id: { type: "id", primary: true }, roomId: { type: "id", storage: { column: "room_id" } }, userId: { type: "id", storage: { column: "user_id" } } }
922
+ });
923
+ const roomBans = defineTableResource({
924
+ name: "roomBans",
925
+ tableName: "room_bans",
926
+ ownership: "public",
927
+ schema: { id: { type: "id", primary: true }, roomId: { type: "id", storage: { column: "room_id" } } }
928
+ });
929
+ const runtime = await createTestResourceRuntime({ knex });
930
+ const owned = await runtime.own("rooms.feature", [
931
+ {
932
+ resource: rooms,
933
+ acceptsContributions: true,
934
+ rowPolicy: ({ query, column, context, ownTable }) => {
935
+ query.whereIn(column("id"), ownTable("roomMembers").select("room_id").where("user_id", context.userId));
936
+ return true;
937
+ }
938
+ },
939
+ roomMembers
940
+ ]);
941
+ const moderation = await runtime.own("moderation.feature", [roomBans]);
942
+ // A contribution that tries to widen with orWhere stays inside its own group.
943
+ moderation.contributeRowPolicy("rooms", ({ query, column, ownTable }) => {
944
+ query.whereNotIn(column("id"), ownTable("roomBans").select("room_id")).orWhere(column("archived"), false);
945
+ return true;
946
+ });
947
+ moderation.contributeRowPolicy("rooms", ({ query, column }) => {
948
+ query.where(column("archived"), false);
949
+ return true;
950
+ });
951
+ assert.throws(() => moderation.contributeRowPolicy("roomMembers", () => true), /does not accept contributions/u);
952
+ await assert.rejects(runtime.own("sneaky.feature", [{
953
+ resource: defineTableResource({ name: "peek", tableName: "peek", ownership: "public", schema: { id: { type: "id", primary: true } } }),
954
+ rowPolicy: ({ ownTable }) => Boolean(ownTable("roomMembers"))
955
+ }]).then((peek) => peek.peek.query({ page: { size: 5 } }, {})), /reads only its own feature's tables/u);
956
+
957
+ const { records } = await owned.rooms.query({ page: { size: 10 } }, { context: { userId: 7 } });
958
+ assert.deepEqual(records.map(({ name }) => name), ["general", "banned"]);
959
+ });
960
+
961
+ test("a row policy is told who the call runs for: a system context, a job it derived, a signed-in user or no one", async (t) => {
962
+ const seen = [];
963
+ const { notes, runtime } = await createFixture(t, {
964
+ owns: [{
965
+ resource: noteResource(),
966
+ rowPolicy: ({ query, column, caller, ...rest }) => {
967
+ seen.push(caller);
968
+ // caller is the one way to tell who the call runs for: json-rest-api's own system flag is not handed on.
969
+ assert.equal(Object.hasOwn(rest, "system"), false);
970
+ // A job reads its workspace's notes; a request reads only the notes its user may see, and no one reads none.
971
+ if (caller.kind === "anonymous") return false;
972
+ if (caller.kind === "user") query.where(column("title"), "shared");
973
+ return true;
974
+ }
975
+ }]
976
+ });
977
+ const systemContext = runtime.declareSystemScope("digest.feature", "nightly digests")("send the digest");
978
+ await notes.createMany([{ title: "shared" }, { title: "private" }], { context: WORKSPACE_A });
979
+ const titles = async (context) => (await notes.queryAll({}, { context })).map(({ title }) => title).sort();
980
+ seen.length = 0;
981
+ assert.deepEqual(await titles(systemContext), ["private", "shared"]);
982
+ assert.deepEqual(await titles(systemContext.forWorkspace("1")), ["private", "shared"]);
983
+ assert.deepEqual(await titles({ ...WORKSPACE_A, actor: { id: "7" } }), ["shared"]);
984
+ assert.deepEqual(await titles(WORKSPACE_A), []);
985
+ assert.deepEqual(seen, [
986
+ { kind: "system", reason: "digest.feature: send the digest" },
987
+ { kind: "job", reason: "digest.feature: send the digest" },
988
+ { kind: "user", userId: "7" },
989
+ { kind: "anonymous" }
990
+ ]);
991
+
992
+ // A job done for one person carries who it is for, for audit fields and the policy's caller.actorId. It reaches the
993
+ // workspace's rows as before, not that person's.
994
+ const forAda = systemContext.forWorkspace("1", { actorId: 9 });
995
+ assert.equal(resolveAuditActorId(forAda), "9");
996
+ assert.equal(resolveAuditActorId(systemContext.forWorkspace("1")), null);
997
+ seen.length = 0;
998
+ assert.deepEqual(await titles(forAda), ["private", "shared"]);
999
+ assert.deepEqual(await titles(systemContext.forWorkspaceUser("1", "4", { actorId: "9" })), ["private", "shared"]);
1000
+ assert.deepEqual(seen, [
1001
+ { kind: "job", reason: "digest.feature: send the digest", actorId: "9" },
1002
+ { kind: "job", reason: "digest.feature: send the digest", actorId: "9" }
1003
+ ]);
1004
+ assert.throws(() => systemContext.forUser("4", { actor: "9" }), /forUser takes \{ actorId \} as its last argument/u);
1005
+ assert.throws(() => systemContext.forWorkspace("1", { actorId: " " }), /forWorkspace needs the id of the user it acts for/u);
1006
+ });
1007
+
1008
+ test("an event a job publishes carries the scope of the workspace or user it works on, as a request there would", async (t) => {
1009
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1010
+ t.after(() => knex.destroy());
1011
+ const runtime = await createTestResourceRuntime({ knex });
1012
+ const system = runtime.declareSystemScope("journal.feature", "announce the entries people asked for")("announce an entry");
1013
+ const published = [];
1014
+ const actions = createActionCatalogue({ events: { publish: async (event) => published.push(event) } });
1015
+ actions.register({
1016
+ contributorId: "journal",
1017
+ domain: "journal",
1018
+ actions: [{
1019
+ id: "journal.announce",
1020
+ kind: "command",
1021
+ channels: ["automation"],
1022
+ surfaces: ["app"],
1023
+ permission: { require: "none" },
1024
+ input: emptyInputValidator,
1025
+ execute: () => ({ id: "4" }),
1026
+ events: [createEntityChangedActionEvent({ source: "journal", entity: "entry", operation: "created", entityId: ({ result }) => result.id })]
1027
+ }]
1028
+ });
1029
+ for (const context of [system.forWorkspace("1"), system.forUser("9"), system.forWorkspaceUser("1", "9"), system.forWorkspace("1", { actorId: "9" })]) {
1030
+ await actions.execute({ actionId: "journal.announce", input: {}, context: { ...context, channel: "automation", surface: "app" } });
1031
+ }
1032
+ assert.deepEqual(published.map(({ scope }) => scope), [
1033
+ { kind: "workspace", id: "1" },
1034
+ { kind: "user", id: "9" },
1035
+ { kind: "workspace_user", id: "1", scopeId: "1", userId: "9" },
1036
+ // The person the work acts for is no scope: the event is the workspace's.
1037
+ { kind: "workspace", id: "1" }
1038
+ ]);
1039
+ });
1040
+
1041
+ test("an action a system context or a job runs reads as that system context or job, and is still signed in as no one", async (t) => {
1042
+ const seen = [];
1043
+ const { notes, runtime } = await createFixture(t, {
1044
+ owns: [{
1045
+ resource: noteResource(),
1046
+ rowPolicy: ({ caller }) => {
1047
+ seen.push(caller);
1048
+ return caller.kind !== "anonymous";
1049
+ }
1050
+ }]
1051
+ });
1052
+ const systemContext = runtime.declareSystemScope("digest.feature", "nightly digests")("send the digest");
1053
+ await notes.createMany([{ title: "a" }, { title: "b" }], { context: WORKSPACE_A });
1054
+ seen.length = 0;
1055
+ const actions = createActionCatalogue();
1056
+ const action = (id, require, execute) => ({
1057
+ id,
1058
+ kind: "command",
1059
+ channels: ["automation"],
1060
+ surfaces: ["app"],
1061
+ permission: { require },
1062
+ input: emptyInputValidator,
1063
+ execute
1064
+ });
1065
+ actions.register({
1066
+ contributorId: "digest",
1067
+ domain: "digest",
1068
+ actions: [
1069
+ action("digest.count", "none", async (input, context) => ({
1070
+ count: (await notes.queryAll({}, { context })).length,
1071
+ actorId: resolveActorId(context)
1072
+ })),
1073
+ action("digest.send", "authenticated", () => ({ sent: true }))
1074
+ ]
1075
+ });
1076
+ const run = (actionId, context) => actions.execute({ actionId, input: {}, context: { ...context, channel: "automation", surface: "app" } });
1077
+
1078
+ assert.deepEqual(await run("digest.count", systemContext), { count: 2, actorId: null });
1079
+ assert.deepEqual(await run("digest.count", systemContext.forWorkspace("1")), { count: 2, actorId: null });
1080
+ assert.deepEqual(await run("digest.count", systemContext.forWorkspace("2", { actorId: "9" })), { count: 0, actorId: null });
1081
+ assert.deepEqual(seen, [
1082
+ { kind: "system", reason: "digest.feature: send the digest" },
1083
+ { kind: "job", reason: "digest.feature: send the digest" },
1084
+ { kind: "job", reason: "digest.feature: send the digest", actorId: "9" }
1085
+ ]);
1086
+ // A request context with no one signed in is still anonymous, and the policy refuses it.
1087
+ assert.deepEqual(await run("digest.count", WORKSPACE_A), { count: 0, actorId: null });
1088
+ assert.deepEqual(seen.at(-1), { kind: "anonymous" });
1089
+ // The person a job acts for is no one signed in: an authenticated action refuses every one of them.
1090
+ for (const context of [systemContext, systemContext.forWorkspace("1"), systemContext.forWorkspace("1", { actorId: "9" })]) {
1091
+ await assert.rejects(run("digest.send", context), (error) => error?.statusCode === 401 && error?.code === "ACTION_AUTHENTICATION_REQUIRED");
1092
+ }
1093
+ });
1094
+
1095
+ test("a report joins another owner's scopedQuery as a subquery: one statement, each owner's scope and row policy kept", async (t) => {
1096
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1097
+ t.after(() => knex.destroy());
1098
+ await knex.schema.createTable("tasks", (table) => {
1099
+ table.increments("id");
1100
+ table.integer("workspace_id");
1101
+ table.integer("project_id");
1102
+ });
1103
+ await knex.schema.createTable("invoices", (table) => {
1104
+ table.increments("id");
1105
+ table.integer("workspace_id");
1106
+ table.integer("project_id");
1107
+ table.integer("amount");
1108
+ table.string("status");
1109
+ });
1110
+ await knex("tasks").insert([
1111
+ { workspace_id: 1, project_id: 1 }, { workspace_id: 1, project_id: 1 }, { workspace_id: 1, project_id: 2 }, { workspace_id: 2, project_id: 1 }
1112
+ ]);
1113
+ await knex("invoices").insert([
1114
+ { workspace_id: 1, project_id: 1, amount: 100, status: "sent" },
1115
+ { workspace_id: 1, project_id: 1, amount: 50, status: "void" },
1116
+ { workspace_id: 1, project_id: 2, amount: 30, status: "sent" },
1117
+ { workspace_id: 2, project_id: 1, amount: 999, status: "sent" }
1118
+ ]);
1119
+ const owned = (name, fields) => defineTableResource({
1120
+ name,
1121
+ tableName: name,
1122
+ ownership: "workspace",
1123
+ schema: {
1124
+ id: { type: "id", primary: true },
1125
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
1126
+ projectId: { type: "integer", storage: { column: "project_id" } },
1127
+ ...fields
1128
+ }
1129
+ });
1130
+ const runtime = await createTestResourceRuntime({ knex });
1131
+ const { tasks } = await runtime.own("tasks.feature", [owned("tasks", {})]);
1132
+ const { invoices } = await runtime.own("billing.feature", [{
1133
+ resource: owned("invoices", { amount: { type: "integer" }, status: { type: "string" } }),
1134
+ // Billing never shows a void invoice.
1135
+ rowPolicy: ({ query, column }) => {
1136
+ query.whereNot(column("status"), "void");
1137
+ return true;
1138
+ }
1139
+ }]);
1140
+ // Billing's owner API: its scoped invoices as a join source, with their column names. It answers the builder wrapped,
1141
+ // since a builder is thenable and an async function would run it.
1142
+ const billing = {
1143
+ async invoiceSource(alias, options) {
1144
+ const { query } = await invoices.scopedQuery({ alias }, options);
1145
+ return { query, column: (field) => invoices.column(field, alias) };
1146
+ }
1147
+ };
1148
+
1149
+ // Knex: report - open tasks and billed totals per project, joined on billing's scoped invoices
1150
+ async function projectSummary(options) {
1151
+ const source = await billing.invoiceSource("i", options);
1152
+ const billed = source.query
1153
+ .select({ projectId: source.column("projectId") })
1154
+ .sum({ billed: source.column("amount") })
1155
+ .groupBy(source.column("projectId"))
1156
+ .as("b");
1157
+ const { query } = await tasks.scopedQuery({ alias: "t" }, options);
1158
+ const projectId = tasks.column("projectId", "t");
1159
+ const rows = await query
1160
+ .leftJoin(billed, "b.projectId", projectId)
1161
+ .select({ projectId })
1162
+ .count({ open: tasks.column("id", "t") })
1163
+ .max({ billed: "b.billed" })
1164
+ .groupBy(projectId)
1165
+ .orderBy(projectId);
1166
+ return rows.map((row) => ({ projectId: Number(row.projectId), open: Number(row.open), billed: Number(row.billed ?? 0) }));
1167
+ }
1168
+
1169
+ const { result, count } = await countQueries(knex, () => projectSummary({ context: WORKSPACE_A }));
1170
+ assert.deepEqual(result, [{ projectId: 1, open: 2, billed: 100 }, { projectId: 2, open: 1, billed: 30 }]);
1171
+ assert.equal(count, 1);
1172
+ assert.deepEqual(await projectSummary({ context: WORKSPACE_B }), [{ projectId: 1, open: 1, billed: 999 }]);
1173
+ });
1174
+
1175
+ test("a job done for one person records that person in audit fields and is never signed in as them", async (t) => {
1176
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1177
+ t.after(() => knex.destroy());
1178
+ await knex.schema.createTable("entries", (table) => {
1179
+ table.increments("id");
1180
+ table.integer("workspace_id");
1181
+ table.string("body").notNullable();
1182
+ table.string("created_by");
1183
+ });
1184
+ const entryResource = defineTableResource({
1185
+ name: "entries",
1186
+ tableName: "entries",
1187
+ ownership: "workspace",
1188
+ schema: {
1189
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
1190
+ body: { type: "string", required: true },
1191
+ createdBy: { type: "string", nullable: true, storage: { column: "created_by" } }
1192
+ }
1193
+ });
1194
+ const runtime = await createTestResourceRuntime({ knex });
1195
+ const { entries } = await runtime.own("journal.feature", [entryResource]);
1196
+ const systemContext = runtime.declareSystemScope("journal.feature", "write the entries people asked for");
1197
+ const forAda = systemContext("write the entry a person asked for").forWorkspace("1", { actorId: "9" });
1198
+ const written = [];
1199
+ const actions = createActionCatalogue();
1200
+ actions.register({
1201
+ contributorId: "journal",
1202
+ domain: "journal",
1203
+ actions: [{
1204
+ id: "journal.write",
1205
+ kind: "command",
1206
+ channels: ["automation"],
1207
+ surfaces: ["app"],
1208
+ permission: { require: "authenticated" },
1209
+ input: emptyInputValidator,
1210
+ execute: (input, context) => written.push(context)
1211
+ }]
1212
+ });
1213
+
1214
+ // An authenticated action and a service's requireAuth see no one signed in.
1215
+ await assert.rejects(
1216
+ actions.execute({ actionId: "journal.write", input: {}, context: { ...forAda, channel: "automation", surface: "app" } }),
1217
+ (error) => error?.statusCode === 401 && error?.code === "ACTION_AUTHENTICATION_REQUIRED"
1218
+ );
1219
+ assert.deepEqual(written, []);
1220
+ assert.throws(() => requireAuth({ context: forAda }, { require: "authenticated" }), (error) => error?.statusCode === 401);
1221
+ assert.equal(resolveActorId(forAda), null);
1222
+ // Its audit field still records whom the entry was written for.
1223
+ await entries.create({ body: "Asked for", createdBy: resolveAuditActorId(forAda) }, { context: forAda, returning: "none" });
1224
+ assert.deepEqual(await knex("entries").first("workspace_id", "body", "created_by"), { workspace_id: 1, body: "Asked for", created_by: "9" });
1225
+ });
1226
+
1227
+ test("a context that names no owner a linked table needs is refused there too, never shown the rows whose owner is NULL", async (t) => {
1228
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1229
+ t.after(() => knex.destroy());
1230
+ await knex.schema.createTable("profiles", (table) => {
1231
+ table.increments("id");
1232
+ table.integer("user_id");
1233
+ table.string("secret");
1234
+ });
1235
+ await knex.schema.createTable("cards", (table) => {
1236
+ table.increments("id");
1237
+ table.integer("workspace_id");
1238
+ table.string("title");
1239
+ table.integer("profile_id");
1240
+ });
1241
+ await knex.schema.createTable("tips", (table) => {
1242
+ table.increments("id");
1243
+ table.integer("workspace_id");
1244
+ table.string("text");
1245
+ });
1246
+ // Profile 1 has no owner; profile 2 is user 5's.
1247
+ await knex("profiles").insert([{ user_id: null, secret: "orphan" }, { user_id: 5, secret: "fives" }]);
1248
+ await knex("cards").insert([{ workspace_id: 1, title: "a", profile_id: 1 }, { workspace_id: 1, title: "b", profile_id: 2 }]);
1249
+ await knex("tips").insert([{ workspace_id: null, text: "shared" }, { workspace_id: 1, text: "ones" }, { workspace_id: 2, text: "twos" }]);
1250
+ const profiles = defineTableResource({
1251
+ name: "profiles",
1252
+ tableName: "profiles",
1253
+ ownership: "user",
1254
+ schema: { userId: { type: "id", nullable: true, storage: { column: "user_id" } }, secret: { type: "string", nullable: true } }
1255
+ });
1256
+ const cards = defineTableResource({
1257
+ name: "cards",
1258
+ tableName: "cards",
1259
+ ownership: "workspace",
1260
+ schema: {
1261
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
1262
+ title: { type: "string" },
1263
+ profileId: { type: "id", nullable: true, belongsTo: "profiles", as: "profile", storage: { column: "profile_id" } }
1264
+ }
1265
+ });
1266
+ const tips = defineTableResource({
1267
+ name: "tips",
1268
+ tableName: "tips",
1269
+ ownership: "workspace_or_shared",
1270
+ defaultSort: ["id"],
1271
+ schema: { workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } }, text: { type: "string" } }
1272
+ });
1273
+ const runtime = await createTestResourceRuntime({ knex });
1274
+ const handles = await runtime.own("cards.feature", [profiles, cards, tips]);
1275
+ // A workspace request names no user, so the user-owned profiles refuse it however it reaches them.
1276
+ const options = { context: { workspace: { id: "1" } } };
1277
+ // An include's refusal arrives wrapped in the include's own errors.
1278
+ const noUser = (error) => {
1279
+ for (let cause = error; cause; cause = cause.cause) {
1280
+ if (cause.code === "REST_API_AUTOFILTER_CONTEXT") return true;
1281
+ }
1282
+ return false;
1283
+ };
1284
+ await assert.rejects(handles.profiles.query({ page: { size: 10 } }, options));
1285
+ await assert.rejects(handles.cards.query({ include: ["profile"], page: { size: 10 } }, options), noUser);
1286
+ await assert.rejects(handles.cards.get("1", { include: ["profile"] }, options), noUser);
1287
+ await assert.rejects(handles.cards.create({ title: "linked", profileId: "1" }, options), noUser);
1288
+ assert.equal(await knex("cards").where({ title: "linked" }).first(), undefined);
1289
+ // The cards themselves still read when the read leaves the link out.
1290
+ assert.deepEqual((await handles.cards.queryAll({ fields: ["title"] }, options)).map(({ title }) => title), ["a", "b"]);
1291
+ // A shared table still reads its shared rows for a context with no workspace, and the workspace's rows with one.
1292
+ assert.deepEqual((await handles.tips.queryAll({}, { context: { actor: { id: "5" } } })).map(({ text }) => text), ["shared"]);
1293
+ assert.deepEqual((await handles.tips.queryAll({}, options)).map(({ text }) => text), ["shared", "ones"]);
1294
+ });
1295
+
1296
+ test("the owner's row policy cannot widen past ownership: its orWhere stays inside its own group", async (t) => {
1297
+ const { notes, knex } = await createFixture(t, {
1298
+ owns: [{
1299
+ resource: noteResource(),
1300
+ rowPolicy: ({ query, column }) => {
1301
+ query.where(column("status"), "open").orWhere(column("title"), "shared");
1302
+ return true;
1303
+ }
1304
+ }]
1305
+ });
1306
+ await knex("notes").insert([
1307
+ { workspace_id: 1, title: "mine", status: "open" },
1308
+ { workspace_id: 1, title: "closed", status: "closed" },
1309
+ { workspace_id: 2, title: "theirs", status: "open" },
1310
+ { workspace_id: 2, title: "shared", status: "closed" }
1311
+ ]);
1312
+ const { records } = await notes.query({ page: { size: 10 } }, { context: WORKSPACE_A });
1313
+ assert.deepEqual(records.map(({ title }) => title), ["mine"]);
1314
+ assert.deepEqual((await notes.query({ page: { size: 10 } }, { context: WORKSPACE_B })).records.map(({ title }) => title), ["theirs", "shared"]);
1315
+ const theirs = await knex("notes").where({ title: "shared" }).first();
1316
+ await assert.rejects(notes.get(theirs.id, {}, { context: WORKSPACE_A }), notFound);
1317
+ assert.equal(await notes.patch(theirs.id, { title: "taken" }, { context: WORKSPACE_A, missing: "skip" }), null);
1318
+ assert.equal((await knex("notes").where({ id: theirs.id }).first()).title, "shared");
1319
+ });
1320
+
1321
+ function deadlockVictim() {
1322
+ return Object.assign(new Error("Deadlock found when trying to get lock; try restarting transaction"), {
1323
+ code: "ER_LOCK_DEADLOCK",
1324
+ errno: 1213
1325
+ });
1326
+ }
1327
+
1328
+ test("a deadlock victim runs again whole when it owns its transaction, and never inside the caller's", async (t) => {
1329
+ let deadlocks = 0;
1330
+ let hookRuns = 0;
1331
+ const resource = noteResource();
1332
+ const { notes, knex } = await createFixture(t, {
1333
+ owns: [{
1334
+ resource,
1335
+ hooks: {
1336
+ afterWrite() {
1337
+ hookRuns += 1;
1338
+ if (deadlocks > 0) {
1339
+ deadlocks -= 1;
1340
+ throw deadlockVictim();
1341
+ }
1342
+ }
1343
+ }
1344
+ }]
1345
+ });
1346
+ deadlocks = 1;
1347
+ await notes.create({ title: "once" }, { context: WORKSPACE_A });
1348
+ assert.deepEqual(await knex("notes").pluck("title"), ["once"]);
1349
+
1350
+ deadlocks = 1;
1351
+ let attempts = 0;
1352
+ await notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
1353
+ attempts += 1;
1354
+ await notes.create({ title: `attempt ${attempts}` }, txOptions);
1355
+ });
1356
+ assert.equal(attempts, 2);
1357
+ assert.deepEqual(await knex("notes").orderBy("id").pluck("title"), ["once", "attempt 2"]);
1358
+
1359
+ deadlocks = 1;
1360
+ hookRuns = 0;
1361
+ await assert.rejects(
1362
+ notes.withTransaction({ context: WORKSPACE_A, retry: false }, (txOptions) => notes.create({ title: "inner" }, txOptions)),
1363
+ (error) => error.code === "ER_LOCK_DEADLOCK"
1364
+ );
1365
+ assert.equal(hookRuns, 1);
1366
+ assert.deepEqual(await knex("notes").orderBy("id").pluck("title"), ["once", "attempt 2"]);
1367
+ });
1368
+
1369
+ test("every call refuses a transaction no handle opened, and a missing record fails the transaction it was written in", async (t) => {
1370
+ const { notes, knex } = await createFixture(t);
1371
+ // Reads and writes alike, before anything runs, with the way to a handle's transaction.
1372
+ const refused = /notes: options\.trx is not a transaction a handle opened\. .*withTransaction\(options, work\)/u;
1373
+ await knex.transaction(async (trx) => {
1374
+ const options = { context: WORKSPACE_A, trx };
1375
+ await assert.rejects(notes.create({ title: "raw" }, options), refused);
1376
+ await assert.rejects(notes.get("1", {}, options), refused);
1377
+ await assert.rejects(notes.query({ page: { size: 5 } }, options), refused);
1378
+ await assert.rejects(notes.queryAll({}, options), refused);
1379
+ await assert.rejects(notes.withTransaction(options, () => "joined"), refused);
1380
+ });
1381
+ assert.deepEqual(await knex("notes"), []);
1382
+
1383
+ // json-rest-api fails a managed transaction once a write in it fails, so a missing record's not-found error fails the
1384
+ // transaction it was written in even when the work catches it; missing: "skip" leaves the row alone instead.
1385
+ await assert.rejects(notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
1386
+ await notes.create({ title: "kept?" }, txOptions);
1387
+ await assert.rejects(notes.patch("999", { title: "missing" }, txOptions), (error) => error.code === "REST_API_RESOURCE" && error.transactionOutcome === "pending");
1388
+ return "committed?";
1389
+ }), (error) => error.code === "REST_API_RESOURCE" && error.transactionOutcome === "rolledBack");
1390
+ assert.deepEqual(await knex("notes"), []);
1391
+ await assert.rejects(notes.patch("999", { title: "missing" }, { context: WORKSPACE_A }), notFound);
1392
+ });
1393
+
1394
+ test("a lockVersion patch moves the version on by one, also with no attributes, and rolls back with the caller", async (t) => {
1395
+ const { notes, knex } = await createFixture(t);
1396
+ const note = await notes.create({ title: "draft" }, { context: WORKSPACE_A });
1397
+ assert.equal((await notes.patch(note.id, {}, { context: WORKSPACE_A, lockVersion: 0 })).lockVersion, 1);
1398
+ assert.equal((await notes.patch(note.id, {}, { context: WORKSPACE_A })).lockVersion, 1);
1399
+ await assert.rejects(notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
1400
+ assert.equal((await notes.patch(note.id, { title: "renamed" }, { ...txOptions, lockVersion: 1 })).lockVersion, 2);
1401
+ throw new Error("Abandon the rename");
1402
+ }), /Abandon the rename/u);
1403
+ assert.deepEqual(await knex("notes").where({ id: note.id }).first("title", "lock_version"), { title: "draft", lock_version: 1 });
1404
+ // A condition named with no value (a version read under the wrong field name) is refused, never run without it.
1405
+ await assert.rejects(notes.patch(note.id, {}, { context: WORKSPACE_A, lockVersion: undefined }), /notes\.patch\(\) options\.lockVersion is undefined/u);
1406
+ await assert.rejects(notes.patch(note.id, { title: "x" }, { context: WORKSPACE_A, onlyIf: undefined }), /notes\.patch\(\) options\.onlyIf is undefined/u);
1407
+ await assert.rejects(notes.patchMany([{ id: note.id }], { context: WORKSPACE_A, onlyIf: undefined }), /notes\.patchMany\(\) options\.onlyIf is undefined/u);
1408
+ await assert.rejects(notes.patchWhere({ id: note.id }, { title: "x" }, { context: WORKSPACE_A, onlyIf: undefined }), /notes\.patchWhere\(\) options\.onlyIf is undefined/u);
1409
+ assert.deepEqual(await knex("notes").where({ id: note.id }).first("title", "lock_version"), { title: "draft", lock_version: 1 });
1410
+ await assert.rejects(notes.patch(note.id, {}, { context: WORKSPACE_A, lockVersion: -1 }), /must be a non-negative integer/u);
1411
+ await assert.rejects(notes.patch(note.id, {}, { context: WORKSPACE_A, lockVersion: "1" }), /must be a non-negative integer/u);
1412
+ await assert.rejects(
1413
+ notes.patch(note.id, { lockVersion: 9 }, { context: WORKSPACE_A, lockVersion: 1 }),
1414
+ /advances lockVersion itself, so the attributes must leave it out/u
1415
+ );
1416
+ });
1417
+
1418
+ test("a belongs-to field is written under its relationship, and naming both is refused", async (t) => {
1419
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1420
+ t.after(() => knex.destroy());
1421
+ await knex.schema.createTable("authors", (table) => {
1422
+ table.increments("id");
1423
+ table.string("name").notNullable();
1424
+ });
1425
+ await knex.schema.createTable("books", (table) => {
1426
+ table.increments("id");
1427
+ table.integer("author_id").nullable();
1428
+ table.string("title").notNullable();
1429
+ });
1430
+ await knex("authors").insert([{ name: "Octavia" }, { name: "Ursula" }]);
1431
+ const authors = defineTableResource({ name: "authors", tableName: "authors", ownership: "public", schema: { name: { type: "string", required: true } } });
1432
+ const books = defineTableResource({
1433
+ name: "books",
1434
+ tableName: "books",
1435
+ ownership: "public",
1436
+ schema: {
1437
+ authorId: { type: "id", nullable: true, belongsTo: "authors", as: "author", storage: { column: "author_id" } },
1438
+ title: { type: "string", required: true }
1439
+ }
1440
+ });
1441
+ const runtime = await createTestResourceRuntime({ knex });
1442
+ const { books: handle } = await runtime.own("books.feature", [authors, books]);
1443
+ const book = await handle.create({ title: "Kindred", authorId: "1" });
1444
+ assert.equal(String(book.authorId), "1");
1445
+ assert.equal(String((await handle.patch(book.id, { authorId: "2" })).authorId), "2");
1446
+ assert.equal((await handle.patch(book.id, { authorId: null })).authorId, null);
1447
+ await assert.rejects(handle.create({ title: "Dune", authorId: "1", author: "2" }), /Provide either "authorId" or "author", not both/u);
1448
+ });
1449
+
1450
+ // Visits each belong to a workspace's customer and to a vet; customers are workspace rows, vets public ones.
1451
+ async function createVisitFixture(t) {
1452
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1453
+ t.after(() => knex.destroy());
1454
+ await knex.schema.createTable("customers", (table) => {
1455
+ table.increments("id");
1456
+ table.integer("workspace_id");
1457
+ table.string("name");
1458
+ });
1459
+ await knex.schema.createTable("vets", (table) => {
1460
+ table.increments("id");
1461
+ table.string("name");
1462
+ });
1463
+ await knex.schema.createTable("visits", (table) => {
1464
+ table.increments("id");
1465
+ table.integer("workspace_id");
1466
+ table.integer("customer_id");
1467
+ table.integer("vet_id");
1468
+ table.string("stage").notNullable();
1469
+ table.string("internal_code");
1470
+ table.string("reviewer");
1471
+ table.integer("lock_version").notNullable().defaultTo(0);
1472
+ });
1473
+ await knex("customers").insert({ workspace_id: 1, name: "Ada" });
1474
+ await knex("vets").insert({ name: "Grace" });
1475
+ const customers = defineTableResource({
1476
+ name: "customers",
1477
+ tableName: "customers",
1478
+ ownership: "workspace",
1479
+ schema: { workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } }, name: { type: "string" } }
1480
+ });
1481
+ const vets = defineTableResource({ name: "vets", tableName: "vets", ownership: "public", schema: { name: { type: "string" } } });
1482
+ const visits = defineTableResource({
1483
+ name: "visits",
1484
+ tableName: "visits",
1485
+ ownership: "workspace",
1486
+ lockVersionField: "lockVersion",
1487
+ defaultExclude: ["internalCode"],
1488
+ schema: {
1489
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
1490
+ customerId: { type: "id", nullable: true, belongsTo: "customers", as: "customer", storage: { column: "customer_id" } },
1491
+ vetId: { type: "id", nullable: true, belongsTo: "vets", as: "vet", storage: { column: "vet_id" } },
1492
+ stage: { type: "string", required: true },
1493
+ internalCode: { type: "string", nullable: true, storage: { column: "internal_code" } },
1494
+ reviewer: { type: "string", nullable: true, hidden: true },
1495
+ lockVersion: { type: "integer", defaultTo: 0, storage: { column: "lock_version" } }
1496
+ }
1497
+ });
1498
+ const runtime = await createTestResourceRuntime({ knex });
1499
+ const handles = await runtime.own("visits.feature", [customers, vets, visits]);
1500
+ const options = { context: WORKSPACE_A };
1501
+ const created = await handles.visits.createMany([
1502
+ { customerId: "1", vetId: "1", stage: "new", internalCode: "k1", reviewer: "lee" },
1503
+ { customerId: "1", vetId: "1", stage: "new", internalCode: "k2" }
1504
+ ], { ...options, returning: "ids" });
1505
+ // The tables each SELECT reads, in order.
1506
+ const selects = async (work) => (await countQueries(knex, work)).queries
1507
+ .filter((sql) => /^select/iu.test(sql))
1508
+ .map((sql) => [...sql.matchAll(/from `(\w+)`/gu)].map(([, table]) => table)[0]);
1509
+ return { visits: handles.visits, options, ids: created, selects };
1510
+ }
1511
+
1512
+ test("a read whose list filter is an empty list answers nothing without a statement, as a read that matches no row does", async (t) => {
1513
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1514
+ t.after(() => knex.destroy());
1515
+ await knex.schema.createTable("tickets", (table) => {
1516
+ table.increments("id");
1517
+ table.integer("workspace_id");
1518
+ table.string("stage").notNullable();
1519
+ table.integer("points");
1520
+ });
1521
+ await knex("tickets").insert([{ workspace_id: 1, stage: "new", points: 3 }, { workspace_id: 1, stage: "done", points: 5 }]);
1522
+ const runtime = await createTestResourceRuntime({ knex });
1523
+ const { tickets } = await runtime.own("tickets.feature", [defineTableResource({
1524
+ name: "tickets",
1525
+ tableName: "tickets",
1526
+ ownership: "workspace",
1527
+ searchSchema: { stages: { type: "array", actualField: "stage", filterOperator: "in" } },
1528
+ schema: {
1529
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
1530
+ stage: { type: "string", required: true, search: true },
1531
+ points: { type: "integer", nullable: true }
1532
+ }
1533
+ })]);
1534
+ const options = { context: WORKSPACE_A };
1535
+ const measures = { tickets: { count: "id" }, points: { sum: "points" }, most: { max: "points" } };
1536
+ const reads = (filters) => [
1537
+ () => tickets.query({ filters, page: { size: 10 } }, options),
1538
+ () => tickets.query({ filters, page: { size: 10, number: 2 } }, options),
1539
+ () => tickets.queryAll({ filters }, options),
1540
+ () => tickets.aggregate({ measures, filters }, options),
1541
+ () => tickets.aggregate({ measures, groupBy: ["stage"], filters }, options)
1542
+ ];
1543
+ const none = reads({ id: ["999"] });
1544
+ for (const [index, read] of reads({ id: [] }).entries()) {
1545
+ const { result, count } = await countQueries(knex, read);
1546
+ assert.equal(count, 0);
1547
+ // The same answer the database gives a read that matches no row.
1548
+ assert.deepEqual(result, await none[index]());
1549
+ }
1550
+ for (const read of reads({ stages: [], stage: "new" })) {
1551
+ assert.equal((await countQueries(knex, read)).count, 0);
1552
+ }
1553
+ // The guards still run: a context that names no workspace is refused, and a list read still needs its page size.
1554
+ await assert.rejects(tickets.queryAll({ filters: { id: [] } }, { context: {} }), /this context names no workspace/u);
1555
+ await assert.rejects(tickets.query({ filters: { id: [] } }, options), (error) => error.name === "UnpagedQueryError");
1556
+ });
1557
+
1558
+ test("lock, lockMany, lockWhere and claim read only the fields they name, so a relation left out costs no visibility check", async (t) => {
1559
+ const { visits, options, ids, selects } = await createVisitFixture(t);
1560
+ let locked;
1561
+ // Every field: the locking read, then one visibility check per related type.
1562
+ assert.deepEqual(await selects(() => visits.withTransaction(options, (tx) => visits.lock(ids[0], tx))), ["visits", "customers", "vets"]);
1563
+ assert.deepEqual(await selects(() => visits.withTransaction(options, async (tx) => {
1564
+ locked = await visits.lock(ids[0], { ...tx, fields: ["stage", "lockVersion"] });
1565
+ })), ["visits"]);
1566
+ assert.deepEqual(locked, { id: ids[0], stage: "new", lockVersion: 0 });
1567
+ assert.deepEqual(await selects(() => visits.withTransaction(options, async (tx) => {
1568
+ locked = await visits.lockMany(ids, { ...tx, fields: ["stage", "customerId"] });
1569
+ })), ["visits", "customers"]);
1570
+ assert.deepEqual(locked, ids.map((id) => ({ id, stage: "new", customerId: "1" })));
1571
+ assert.deepEqual(await selects(() => visits.withTransaction(options, (tx) => visits.lockWhere({ id: ids }, tx))), ["visits", "customers", "vets"]);
1572
+ assert.deepEqual(await selects(() => visits.withTransaction(options, async (tx) => {
1573
+ locked = await visits.lockWhere({ id: ids }, { ...tx, fields: ["stage"] });
1574
+ })), ["visits"]);
1575
+ assert.deepEqual(locked, ids.map((id) => ({ id, stage: "new" })));
1576
+ assert.deepEqual(await selects(() => visits.withTransaction(options, (tx) => visits.claim({ filters: { id: ids } }, tx))), ["visits", "customers", "vets"]);
1577
+ assert.deepEqual(await selects(() => visits.withTransaction(options, async (tx) => {
1578
+ locked = await visits.claim({ filters: { id: ids }, take: 1 }, { ...tx, fields: ["stage", "vetId"] });
1579
+ })), ["visits", "vets"]);
1580
+ assert.deepEqual(locked, [{ id: ids[0], stage: "new", vetId: "1" }]);
1581
+ });
1582
+
1583
+ test("a patch that compares a lock version or onlyIf locks only the version, and compares onlyIf in SQL, hidden fields too", async (t) => {
1584
+ const { visits, options, ids, selects } = await createVisitFixture(t);
1585
+ // The patch's own locking read checks no relation: the customer it writes unchanged is checked by the write alone.
1586
+ const statements = await selects(() => visits.patch(ids[0], { stage: "seen", customerId: "1" }, { ...options, lockVersion: 0, returning: "none" }));
1587
+ assert.equal(statements[0], "visits");
1588
+ assert.equal(statements.filter((table) => table === "vets").length, 0);
1589
+ assert.deepEqual(await selects(() => visits.patch(ids[1], { stage: "seen" }, { ...options, lockVersion: 0, returning: "none" })).then((tables) => tables[0]), "visits");
1590
+ // onlyIf may compare a field reads hide by default.
1591
+ assert.equal((await visits.patch(ids[0], { stage: "done" }, { ...options, onlyIf: { internalCode: "k1" } })).stage, "done");
1592
+ assert.equal(await visits.patch(ids[0], { stage: "late" }, { ...options, onlyIf: { internalCode: "other" } }), null);
1593
+ // A hidden field, which no read answers, is compared by the UPDATE itself.
1594
+ assert.equal((await visits.patch(ids[0], { stage: "checked" }, { ...options, onlyIf: { reviewer: "lee" } })).stage, "checked");
1595
+ assert.equal(await visits.patch(ids[0], { stage: "late" }, { ...options, onlyIf: { reviewer: "sam" } }), null);
1596
+ assert.equal((await visits.patch(ids[1], { stage: "seen" }, { ...options, lockVersion: 1, onlyIf: { reviewer: null } })).stage, "seen");
1597
+ await assert.rejects(visits.patch("999", { stage: "late" }, { ...options, onlyIf: { reviewer: "lee" } }), notFound);
1598
+ });
1599
+
1600
+ test("a patch with onlyIf and no changes compares in SQL, so an id given as a number and a hidden field match", async (t) => {
1601
+ const { visits, options, ids } = await createVisitFixture(t);
1602
+ // A record answers customerId as text and never the hidden reviewer, yet both compare.
1603
+ for (const missing of ["throw", "skip"]) {
1604
+ const call = { ...options, missing };
1605
+ assert.equal((await visits.patch(ids[0], {}, { ...call, onlyIf: { customerId: 1 } }))?.id, ids[0], missing);
1606
+ assert.equal((await visits.patch(ids[0], {}, { ...call, onlyIf: { reviewer: "lee", customerId: "1" } }))?.id, ids[0], missing);
1607
+ assert.equal((await visits.patch(ids[1], {}, { ...call, onlyIf: { reviewer: null } }))?.id, ids[1], missing);
1608
+ assert.equal(await visits.patch(ids[0], {}, { ...call, onlyIf: { customerId: 2 } }), null, missing);
1609
+ assert.equal(await visits.patch(ids[0], {}, { ...call, onlyIf: { reviewer: "sam" } }), null, missing);
1610
+ }
1611
+ // With a lock version, an empty patch that matches advances it, and one that does not leaves it.
1612
+ assert.equal(await visits.patch(ids[0], {}, { ...options, lockVersion: 0, onlyIf: { reviewer: "sam" } }), null);
1613
+ assert.equal((await visits.patch(ids[0], {}, { ...options, lockVersion: 0, onlyIf: { reviewer: "lee" } })).lockVersion, 1);
1614
+ // onlyIf names stored fields, as an UPDATE's onlyIf does.
1615
+ await assert.rejects(visits.patch(ids[0], {}, { ...options, onlyIf: { nothing: 1 } }), /visits\.patch\(\) onlyIf\.nothing is not a stored field/u);
1616
+ await assert.rejects(visits.patch(ids[0], {}, { ...options, missing: "skip", onlyIf: { stage: ["new"] } }), /onlyIf\.stage must be a scalar/u);
1617
+ });
1618
+
1619
+ test("delete, deleteWhere, createMany, patchMany and patchWhere refuse a condition they cannot compare, and write nothing", async (t) => {
1620
+ const { knex, notes } = await createFixture(t);
1621
+ const options = { context: WORKSPACE_A };
1622
+ const note = await notes.create({ title: "draft" }, options);
1623
+ const refused = (method, keys) => new RegExp(`notes\\.${method}\\(\\) cannot compare ${keys}, so it refuses`, "u");
1624
+ await assert.rejects(notes.delete(note.id, { ...options, lockVersion: 0 }), refused("delete", "lockVersion"));
1625
+ await assert.rejects(notes.delete(note.id, { ...options, missing: "skip", onlyIf: { status: "open" } }), refused("delete", "onlyIf"));
1626
+ await assert.rejects(notes.deleteWhere({ id: note.id }, { ...options, lockVersion: 0, onlyIf: { status: "open" } }),
1627
+ refused("deleteWhere", "lockVersion or onlyIf"));
1628
+ await assert.rejects(notes.createMany([{ title: "copy" }], { ...options, onlyIf: { status: "open" } }), refused("createMany", "onlyIf"));
1629
+ await assert.rejects(notes.createMany([{ title: "copy" }], { ...options, lockVersion: 0 }), refused("createMany", "lockVersion"));
1630
+ await assert.rejects(notes.patchMany([{ id: note.id, title: "x" }], { ...options, lockVersion: 0 }), refused("patchMany", "lockVersion"));
1631
+ await assert.rejects(notes.patchWhere({ id: note.id }, { title: "x" }, { ...options, lockVersion: 0 }), refused("patchWhere", "lockVersion"));
1632
+ assert.deepEqual(await knex("notes").select("title", "lock_version"), [{ title: "draft", lock_version: 0 }]);
1633
+ // What the refusal suggests: patchMany's onlyIf on the lock version field changes only the rows still at that version.
1634
+ assert.deepEqual(await notes.patchMany([{ id: note.id, title: "x" }], { ...options, onlyIf: { lockVersion: 1 }, returning: "ids" }), []);
1635
+ assert.deepEqual(await notes.patchMany([{ id: note.id, title: "x" }], { ...options, onlyIf: { lockVersion: 0 }, returning: "ids" }), [note.id]);
1636
+ assert.deepEqual(await knex("notes").select("title", "lock_version"), [{ title: "x", lock_version: 1 }]);
1637
+ });
1638
+
1639
+ test("a read with one include or two reads each included type once, whatever the rows, and checks no included link again", async (t) => {
1640
+ const { visits, options, ids, selects } = await createVisitFixture(t);
1641
+ const one = await selects(() => visits.get(ids[0], { fields: ["stage", "customerId"], include: ["customer"] }, options));
1642
+ const two = await selects(() => visits.get(ids[0], { fields: ["stage", "customerId", "vetId"], include: ["customer", "vet"] }, options));
1643
+ const many = await selects(() => visits.query({ fields: ["stage", "customerId", "vetId"], include: ["customer", "vet"], page: { size: 10 } }, options));
1644
+ // The read, then each include's own scoped select. A link to an included target needs no visibility check: the
1645
+ // include's select already proved the reader may see it.
1646
+ assert.deepEqual(one, ["visits", "customers"]);
1647
+ assert.deepEqual(two, ["visits", "customers", "vets"]);
1648
+ // A page of rows reads the same tables as one row.
1649
+ assert.deepEqual(many, two);
1650
+ });
1651
+
1652
+ test("a record written back as it was read keeps a link to a target the writer cannot see", async (t) => {
1653
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1654
+ t.after(() => knex.destroy());
1655
+ await knex.schema.createTable("suppliers", (table) => {
1656
+ table.increments("id");
1657
+ table.string("name");
1658
+ table.boolean("archived").notNullable().defaultTo(false);
1659
+ });
1660
+ await knex.schema.createTable("items", (table) => {
1661
+ table.increments("id");
1662
+ table.string("name");
1663
+ table.integer("supplier_id");
1664
+ });
1665
+ await knex("suppliers").insert([{ name: "Open", archived: false }, { name: "Closed", archived: true }]);
1666
+ await knex("items").insert([{ name: "Kept", supplier_id: 2 }, { name: "Other", supplier_id: 2 }, { name: "Third", supplier_id: 2 }]);
1667
+ const suppliers = defineTableResource({
1668
+ name: "suppliers",
1669
+ tableName: "suppliers",
1670
+ ownership: "public",
1671
+ schema: { name: { type: "string" }, archived: { type: "boolean", defaultTo: false } }
1672
+ });
1673
+ const items = defineTableResource({
1674
+ name: "items",
1675
+ tableName: "items",
1676
+ ownership: "public",
1677
+ schema: {
1678
+ name: { type: "string" },
1679
+ supplierId: { type: "id", nullable: true, belongsTo: "suppliers", as: "supplier", storage: { column: "supplier_id" } }
1680
+ }
1681
+ });
1682
+ const runtime = await createTestResourceRuntime({ knex });
1683
+ // A request sees only open suppliers, so a link to an archived one reads as null.
1684
+ const handles = await runtime.own("items.feature", [items, {
1685
+ resource: suppliers,
1686
+ rowPolicy: ({ query, column, caller }) => {
1687
+ if (caller.kind !== "system") query.where(column("archived"), false);
1688
+ return true;
1689
+ }
1690
+ }]);
1691
+ const options = { context: { actor: { id: "7" } } };
1692
+ const supplierOf = async (id) => (await knex("items").where({ id }).first("supplier_id")).supplier_id;
1693
+ const read = await handles.items.get("1", {}, options);
1694
+ assert.equal(read.supplierId, null);
1695
+
1696
+ // A form sends back what it read: the null it was shown keeps the stored link, and the rest of the write applies.
1697
+ await handles.items.patch("1", { name: "Renamed", supplierId: read.supplierId }, { ...options, returning: "none" });
1698
+ assert.deepEqual(await knex("items").where({ id: 1 }).first("name", "supplier_id"), { name: "Renamed", supplier_id: 2 });
1699
+ await handles.items.patchMany([{ id: "2", name: "Also", supplierId: null }], options);
1700
+ assert.equal(await supplierOf(2), 2);
1701
+ // Linking a target the writer sees applies, and so does clearing a link to one.
1702
+ await handles.items.patch("3", { supplierId: "1" }, { ...options, returning: "none" });
1703
+ assert.equal(await supplierOf(3), 1);
1704
+ await handles.items.patch("3", { supplierId: null }, { ...options, returning: "none" });
1705
+ assert.equal(await supplierOf(3), null);
1706
+ });
1707
+
1708
+ test("own() refuses a cross-table filter whose target field is not indexed, whichever table is owned first", async (t) => {
1709
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1710
+ t.after(() => knex.destroy());
1711
+ await knex.schema.createTable("suppliers", (table) => {
1712
+ table.increments("id");
1713
+ table.string("name").index();
1714
+ });
1715
+ await knex.schema.createTable("items", (table) => {
1716
+ table.increments("id");
1717
+ table.string("name");
1718
+ table.integer("supplier_id");
1719
+ });
1720
+ await knex("suppliers").insert([{ name: "Open" }, { name: "Shut" }]);
1721
+ await knex("items").insert([{ name: "Kept", supplier_id: 1 }, { name: "Other", supplier_id: 2 }]);
1722
+ const suppliers = (indexed) => defineTableResource({
1723
+ name: "suppliers",
1724
+ tableName: "suppliers",
1725
+ ownership: "public",
1726
+ schema: { name: { type: "string", ...(indexed ? { indexed: true } : {}) } }
1727
+ });
1728
+ const items = defineTableResource({
1729
+ name: "items",
1730
+ tableName: "items",
1731
+ ownership: "public",
1732
+ searchSchema: { supplierName: { type: "string", actualField: "suppliers.name" } },
1733
+ schema: {
1734
+ name: { type: "string" },
1735
+ supplierId: { type: "id", nullable: true, belongsTo: "suppliers", as: "supplier", storage: { column: "supplier_id" } }
1736
+ }
1737
+ });
1738
+ const unindexed = /Table resource "items" filters "supplierName" on suppliers\.name, which "suppliers" does not declare indexed: true/u;
1739
+ // The target owned first, then the filtering table; and the other way round.
1740
+ const first = await createTestResourceRuntime({ knex });
1741
+ await first.own("suppliers.feature", [suppliers(false)]);
1742
+ await assert.rejects(first.own("items.feature", [items]), unindexed);
1743
+ const second = await createTestResourceRuntime({ knex });
1744
+ await second.own("items.feature", [items]);
1745
+ await assert.rejects(second.own("suppliers.feature", [suppliers(false)]), unindexed);
1746
+ // An indexed target is searched through the join.
1747
+ const runtime = await createTestResourceRuntime({ knex });
1748
+ const { items: handle } = await runtime.own("items.feature", [items, suppliers(true)]);
1749
+ assert.deepEqual((await handle.queryAll({ filters: { supplierName: "Open" } }, {})).map(({ name }) => name), ["Kept"]);
1750
+ });
1751
+
1752
+ test("a job finds its units by grouping on a hidden ownership field, which a request may not group by", async (t) => {
1753
+ const { notes, runtime } = await createFixture(t, {
1754
+ owns: [noteResource({
1755
+ schema: {
1756
+ id: { type: "id", primary: true },
1757
+ workspaceId: { type: "id", nullable: true, hidden: true, storage: { column: "workspace_id" } },
1758
+ title: { type: "string", required: true, search: true },
1759
+ status: { type: "string", defaultTo: "open", search: true },
1760
+ lockVersion: { type: "integer", defaultTo: 0, storage: { column: "lock_version" } }
1761
+ }
1762
+ })]
1763
+ });
1764
+ await notes.createMany([{ title: "a" }, { title: "b" }], { context: WORKSPACE_A });
1765
+ await notes.createMany([{ title: "c" }], { context: WORKSPACE_B });
1766
+ const systemContext = runtime.declareSystemScope("digest.feature", "nightly digests")("find the workspaces with notes");
1767
+ const units = await notes.aggregate({ groupBy: ["workspaceId"], measures: { notes: { count: "*" } }, filters: { status: "open" } }, { context: systemContext });
1768
+ assert.deepEqual(units.map(({ workspaceId, notes: count }) => [String(workspaceId), Number(count)]).sort(), [["1", 2], ["2", 1]]);
1769
+ await assert.rejects(
1770
+ notes.aggregate({ groupBy: ["workspaceId"], measures: { notes: { count: "*" } } }, { context: WORKSPACE_A }),
1771
+ /'workspaceId' is not an attribute of 'notes' that can be aggregated/u
1772
+ );
1773
+ });
1774
+
1775
+ test("a sparse read names a belongs-to field by its own key or its relationship, and leaves out the ones it does not name", async (t) => {
1776
+ const { visits, options, ids } = await createVisitFixture(t);
1777
+ const expected = { id: ids[0], customerId: "1" };
1778
+ assert.deepEqual(await visits.get(ids[0], { fields: ["customerId"] }, options), expected);
1779
+ assert.deepEqual(await visits.get(ids[0], { fields: ["customer"] }, options), expected);
1780
+ assert.deepEqual(await visits.get(ids[0], { fields: { visits: "customerId" } }, options), expected);
1781
+ assert.deepEqual(await visits.queryAll({ filters: { id: ids[0] }, fields: ["stage", "vetId"] }, options), [{ id: ids[0], stage: "new", vetId: "1" }]);
1782
+ });
1783
+
1784
+ test("a collection relation with hydrateOnList or hydrateOnView false loads its rows only when the read includes it", async (t) => {
1785
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1786
+ t.after(() => knex.destroy());
1787
+ await knex.schema.createTable("contacts", (table) => {
1788
+ table.increments("id");
1789
+ table.string("name").notNullable();
1790
+ });
1791
+ await knex.schema.createTable("pets", (table) => {
1792
+ table.increments("id");
1793
+ table.integer("contact_id");
1794
+ table.string("name").notNullable();
1795
+ });
1796
+ await knex("contacts").insert([{ name: "Ada" }, { name: "Grace" }]);
1797
+ await knex("pets").insert([{ contact_id: 1, name: "Rex" }, { contact_id: 2, name: "Tom" }]);
1798
+ const contacts = (flags) => defineTableResource({
1799
+ name: "contacts",
1800
+ tableName: "contacts",
1801
+ ownership: "public",
1802
+ schema: {
1803
+ name: { type: "string", required: true },
1804
+ pets: { type: "array", storage: { virtual: true }, relation: { kind: "collection", namespace: "pets", foreignKey: "contactId", ...flags } }
1805
+ }
1806
+ });
1807
+ const pets = defineTableResource({
1808
+ name: "pets",
1809
+ tableName: "pets",
1810
+ ownership: "public",
1811
+ schema: {
1812
+ contactId: { type: "id", nullable: true, belongsTo: "contacts", as: "contact", storage: { column: "contact_id" } },
1813
+ name: { type: "string", required: true }
1814
+ }
1815
+ });
1816
+ const reads = async (work) => (await countQueries(knex, work)).queries.filter((sql) => /^select/iu.test(sql)).length;
1817
+ const quietRuntime = await createTestResourceRuntime({ knex });
1818
+ const quiet = (await quietRuntime.own("contacts.feature", [contacts({ hydrateOnList: false, hydrateOnView: false }), pets])).contacts;
1819
+
1820
+ assert.equal(await reads(() => quiet.queryAll({}, {})), 1);
1821
+ assert.deepEqual(await quiet.queryAll({}, {}), [{ id: "1", name: "Ada" }, { id: "2", name: "Grace" }]);
1822
+ assert.deepEqual(await quiet.get("1", {}, {}), { id: "1", name: "Ada" });
1823
+ assert.equal(await reads(() => quiet.withTransaction({}, (tx) => quiet.lock("1", tx))), 1);
1824
+ // An include still loads it.
1825
+ assert.deepEqual((await quiet.get("1", { include: "pets" }, {})).pets.map(({ name }) => name), ["Rex"]);
1826
+
1827
+ const loudRuntime = await createTestResourceRuntime({ knex });
1828
+ const loud = (await loudRuntime.own("contacts.feature", [contacts({}), pets])).contacts;
1829
+ assert.equal(await reads(() => loud.queryAll({}, {})), 2, "without the flags a read loads the collection's linkage");
1830
+ assert.deepEqual((await loud.get("1", {}, {})).pets.map(({ id }) => id), ["1"]);
1831
+ });
1832
+
1833
+ test("an included record reads as a read of its own resource answers it, at every depth and in a collection", async (t) => {
1834
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1835
+ t.after(() => knex.destroy());
1836
+ await knex.schema.createTable("contacts", (table) => {
1837
+ table.increments("id");
1838
+ table.string("name").notNullable();
1839
+ });
1840
+ await knex.schema.createTable("pets", (table) => {
1841
+ table.increments("id");
1842
+ table.integer("contact_id");
1843
+ table.string("name").notNullable();
1844
+ });
1845
+ await knex.schema.createTable("bookings", (table) => {
1846
+ table.increments("id");
1847
+ table.integer("pet_id");
1848
+ table.string("day").notNullable();
1849
+ });
1850
+ await knex("contacts").insert({ name: "Ada" });
1851
+ await knex("pets").insert({ contact_id: 1, name: "Rex" });
1852
+ await knex("bookings").insert({ pet_id: 1, day: "monday" });
1853
+ const contacts = defineTableResource({
1854
+ name: "contacts",
1855
+ tableName: "contacts",
1856
+ ownership: "public",
1857
+ schema: {
1858
+ name: { type: "string", required: true },
1859
+ pets: { type: "array", storage: { virtual: true }, relation: { kind: "collection", namespace: "pets", foreignKey: "contactId" } }
1860
+ }
1861
+ });
1862
+ const pets = defineTableResource({
1863
+ name: "pets",
1864
+ tableName: "pets",
1865
+ ownership: "public",
1866
+ schema: {
1867
+ contactId: { type: "id", nullable: true, belongsTo: "contacts", as: "contact", storage: { column: "contact_id" } },
1868
+ name: { type: "string", required: true }
1869
+ }
1870
+ });
1871
+ const bookings = defineTableResource({
1872
+ name: "bookings",
1873
+ tableName: "bookings",
1874
+ ownership: "public",
1875
+ schema: {
1876
+ petId: { type: "id", nullable: true, belongsTo: "pets", as: "pet", storage: { column: "pet_id" } },
1877
+ day: { type: "string", required: true }
1878
+ }
1879
+ });
1880
+ const runtime = await createTestResourceRuntime({ knex });
1881
+ const handles = await runtime.own("bookings.feature", [contacts, pets, bookings]);
1882
+ const pet = await handles.pets.get("1", {}, {});
1883
+ assert.deepEqual(pet, { id: "1", name: "Rex", contactId: "1" });
1884
+
1885
+ const booking = await handles.bookings.get("1", { include: "pet" }, {});
1886
+ assert.deepEqual(booking, { id: "1", day: "monday", petId: "1", pet });
1887
+ const deeper = await handles.bookings.queryAll({ include: "pet.contact" }, {});
1888
+ // The contact's own collection is not included, so it is left out rather than answered empty.
1889
+ assert.deepEqual(deeper[0].pet, { ...pet, contact: { id: "1", name: "Ada" } });
1890
+ const contact = await handles.contacts.get("1", { include: "pets" }, {});
1891
+ assert.deepEqual(contact.pets, [pet]);
1892
+ });