@jskit-ai/resource-runtime 0.2.12 → 0.2.13

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,345 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
4
+ import { retryLostInsertRace } from "@jskit-ai/database-runtime/shared";
5
+ import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
6
+
7
+ // The row locks, the many-row writes and the upserts on PostgreSQL, with real concurrent transactions: what an
8
+ // application on PostgreSQL relies on when two requests or two servers reach the same rows at once. Each test works in
9
+ // a schema of its own in TEST_DB_NAME (createPackageTestKnex); the other data tests of this package run here too on a
10
+ // DB_CLIENT=pg run, and the MySQL and MariaDB races are in manyRowWrites.mysql.test.js.
11
+
12
+ const skip = process.env.DB_CLIENT === "pg" ? false : "a PostgreSQL test: it runs on a DB_CLIENT=pg run, with TEST_DB_NAME";
13
+ const WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: Object.freeze({ id: "1" }) }) });
14
+ const OTHER_WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: Object.freeze({ id: "2" }) }) });
15
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ const settle = (work) => work().then((value) => ({ value }), (error) => ({ error }));
17
+
18
+ const slotResource = defineTableResource({
19
+ name: "slots",
20
+ tableName: "slots",
21
+ ownership: "workspace",
22
+ defaultSort: ["id"],
23
+ schema: {
24
+ id: { type: "id", primary: true },
25
+ workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
26
+ code: { type: "string", nullable: true, search: true },
27
+ status: { type: "string", defaultTo: "open", search: true },
28
+ takenBy: { type: "string", nullable: true, storage: { column: "taken_by" } }
29
+ }
30
+ });
31
+
32
+ // A table with no owners keyed by an id the server computes, as lockOrCreate and findOrCreate take.
33
+ const balanceResource = defineTableResource({
34
+ name: "balances",
35
+ tableName: "balances",
36
+ ownership: "public",
37
+ schema: {
38
+ id: { type: "string", primary: true },
39
+ amount: { type: "integer", defaultTo: 0 }
40
+ }
41
+ });
42
+
43
+ async function database(t) {
44
+ const knex = await createPackageTestKnex(t, { environment: process.env });
45
+ await knex.schema.createTable("slots", (table) => {
46
+ table.increments("id");
47
+ table.integer("workspace_id").nullable();
48
+ table.string("code").nullable();
49
+ table.string("status").notNullable().defaultTo("open");
50
+ table.string("taken_by").nullable();
51
+ table.unique(["workspace_id", "code"]);
52
+ });
53
+ await knex.schema.createTable("balances", (table) => {
54
+ table.string("id").primary();
55
+ table.integer("amount").notNullable().defaultTo(0);
56
+ });
57
+ const runtime = await createTestResourceRuntime({ knex });
58
+ const { slots, balances } = await runtime.own("slots.feature", [slotResource, balanceResource]);
59
+ return { knex, slots, balances };
60
+ }
61
+
62
+ /**
63
+ * Waits until `count` sessions wait, directly or behind another waiter, for the session `pid` (pg_blocking_pids).
64
+ * @param {import("knex").Knex} knex
65
+ * @param {number} pid
66
+ * @param {number} count
67
+ */
68
+ async function waitingBehind(knex, pid, count) {
69
+ for (let waited = 0; waited < 10_000; waited += 25) {
70
+ const { rows: [{ waiting }] } = await knex.raw(
71
+ "WITH RECURSIVE blocked(pid) AS (" +
72
+ "SELECT a.pid FROM pg_stat_activity a WHERE ? = ANY(pg_blocking_pids(a.pid)) " +
73
+ "UNION SELECT a.pid FROM pg_stat_activity a JOIN blocked b ON b.pid = ANY(pg_blocking_pids(a.pid))" +
74
+ ") SELECT count(*)::int AS waiting FROM blocked",
75
+ [pid]
76
+ );
77
+ if (waiting >= count) return;
78
+ await pause(25);
79
+ }
80
+ assert.fail(`${count} sessions did not wait for session ${pid} within 10 s`);
81
+ }
82
+
83
+ /**
84
+ * Runs `hold(trx)` in a transaction of its own, then the writers, until all of them wait behind it; then ends it
85
+ * (commits, or rolls back with `rollback`) and answers each writer's outcome.
86
+ */
87
+ async function raceBehind(knex, hold, writers, { rollback = false } = {}) {
88
+ const holder = await knex.transaction();
89
+ try {
90
+ const { rows: [{ pid }] } = await holder.raw("SELECT pg_backend_pid() AS pid");
91
+ await hold(holder);
92
+ const running = writers.map((write) => settle(write));
93
+ await waitingBehind(knex, pid, writers.length);
94
+ await (rollback ? holder.rollback() : holder.commit());
95
+ return await Promise.all(running);
96
+ } finally {
97
+ if (!holder.isCompleted()) await holder.rollback();
98
+ }
99
+ }
100
+
101
+ const noErrors = (outcomes) => assert.deepEqual(outcomes.filter(({ error }) => error).map(({ error }) => error.message), []);
102
+
103
+ test("of two concurrent patchWhere calls whose filters both matched one row, exactly one writes it", { skip }, async (t) => {
104
+ const { knex, slots } = await database(t);
105
+ const [id] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
106
+ const take = (taker) => () => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: taker }, { ...WORKSPACE, returning: "ids" });
107
+ const outcomes = await raceBehind(knex, (holder) => holder("slots").where({ id }).forUpdate().select("id"), [take("first"), take("second")]);
108
+ noErrors(outcomes);
109
+ assert.deepEqual(outcomes.map(({ value }) => value.length).sort(), [0, 1], "exactly one writer takes the row");
110
+ const row = await knex("slots").where({ id }).first("status", "taken_by");
111
+ assert.deepEqual([row.status, row.taken_by], ["taken", outcomes[0].value.length === 1 ? "first" : "second"]);
112
+ });
113
+
114
+ test("a deleteWhere whose filter matched a row a concurrent patchWhere changed leaves it in place", { skip }, async (t) => {
115
+ const { knex, slots } = await database(t);
116
+ const [id] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
117
+ const outcomes = await raceBehind(knex, (holder) => holder("slots").where({ id }).forUpdate().select("id"), [
118
+ () => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: "first" }, { ...WORKSPACE, returning: "ids" }),
119
+ () => slots.deleteWhere({ id, status: "open" }, { ...WORKSPACE, returning: "ids" })
120
+ ]);
121
+ noErrors(outcomes);
122
+ const [patched, deleted] = outcomes.map(({ value }) => value.length);
123
+ const row = await knex("slots").where({ id }).first("status");
124
+ assert.deepEqual([patched + deleted, row ? row.status : "deleted"], [1, patched === 1 ? "taken" : "deleted"]);
125
+ });
126
+
127
+ test("a patchWhere that waited for a row changed meanwhile writes only the rows that still match", { skip }, async (t) => {
128
+ const { knex, slots } = await database(t);
129
+ const ids = await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" });
130
+ const [outcome] = await raceBehind(
131
+ knex,
132
+ (holder) => holder("slots").where({ id: ids[1] }).update({ status: "taken", taken_by: "holder" }),
133
+ [() => slots.patchWhere({ status: "open" }, { status: "closed" }, { ...WORKSPACE, returning: "ids" })]
134
+ );
135
+ noErrors([outcome]);
136
+ assert.deepEqual(outcome.value.map(String).sort(), [ids[0], ids[2]].map(String).sort());
137
+ assert.deepEqual((await knex("slots").orderBy("id").pluck("status")), ["closed", "taken", "closed"]);
138
+ });
139
+
140
+ test("two workers claiming at once take different rows, and skip a row another transaction holds", { skip }, async (t) => {
141
+ const { knex, slots } = await database(t);
142
+ const ids = (await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
143
+ await slots.createMany([{ status: "open" }], { ...OTHER_WORKSPACE, returning: "none" });
144
+ const holder = await knex.transaction();
145
+ t.after(() => (holder.isCompleted() ? undefined : holder.rollback()));
146
+ await holder("slots").where({ id: ids[0] }).forUpdate().select("id");
147
+
148
+ const first = Promise.withResolvers();
149
+ const release = Promise.withResolvers();
150
+ const claimNext = (take) => (txOptions) => slots.claim({ filters: { status: "open" }, sort: ["id"], take }, txOptions);
151
+ const worker = slots.withTransaction(WORKSPACE, async (txOptions) => {
152
+ const claimed = await claimNext(1)(txOptions);
153
+ first.resolve(claimed.map(({ id }) => String(id)));
154
+ await release.promise;
155
+ await slots.patchWhere({ id: claimed.map(({ id }) => id) }, { status: "taken", takenBy: "first" }, txOptions);
156
+ return claimed;
157
+ });
158
+ assert.deepEqual(await first.promise, [ids[1]], "the first worker skips the row the holder locked");
159
+ // While the first worker holds its row, the second takes the rest, never the held ones nor another workspace's.
160
+ const second = await slots.withTransaction(WORKSPACE, async (txOptions) => (await claimNext(10)(txOptions)).map(({ id }) => String(id)));
161
+ assert.deepEqual(second, [ids[2], ids[3]]);
162
+ release.resolve();
163
+ await worker;
164
+ await holder.rollback();
165
+ assert.deepEqual((await knex("slots").where({ workspace_id: 1 }).orderBy("id").pluck("status")), ["open", "taken", "open", "open"]);
166
+ });
167
+
168
+ test("a claim leaves a row whose committed change stopped it matching while the claim waited, and takes the next", { skip }, async (t) => {
169
+ const { knex, slots } = await database(t);
170
+ const ids = (await slots.createMany([{ status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
171
+ // A claim never waits (SKIP LOCKED): the held row is skipped, and once its holder commits a change, it no longer matches.
172
+ const holder = await knex.transaction();
173
+ t.after(() => (holder.isCompleted() ? undefined : holder.rollback()));
174
+ await holder("slots").where({ id: ids[0] }).update({ status: "taken" });
175
+ const during = await slots.withTransaction(WORKSPACE, async (txOptions) => (await slots.claim({ filters: { status: "open" } }, txOptions)).map(({ id }) => String(id)));
176
+ await holder.commit();
177
+ const after = await slots.withTransaction(WORKSPACE, async (txOptions) => (await slots.claim({ filters: { status: "open" }, take: 5 }, txOptions)).map(({ id }) => String(id)));
178
+ assert.deepEqual([during, after], [[ids[1]], [ids[1]]]);
179
+ });
180
+
181
+ test("lockMany waits for another transaction's lock, then reads the rows as it committed them, in id order", { skip }, async (t) => {
182
+ const { knex, slots } = await database(t);
183
+ const ids = (await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
184
+ const [theirs] = (await slots.createMany([{ status: "open" }], { ...OTHER_WORKSPACE, returning: "ids" })).map(String);
185
+ const [outcome] = await raceBehind(
186
+ knex,
187
+ (holder) => holder("slots").where({ id: ids[1] }).update({ status: "taken", taken_by: "holder" }),
188
+ [() => slots.withTransaction(WORKSPACE, (txOptions) => slots.lockMany([ids[2], theirs, ids[1], ids[0]], { ...txOptions, missing: "skip" }))]
189
+ );
190
+ noErrors([outcome]);
191
+ assert.deepEqual(outcome.value.map(({ id, status }) => [String(id), status]), [[ids[0], "open"], [ids[1], "taken"], [ids[2], "open"]]);
192
+ });
193
+
194
+ test("a lockWhere that waited also locks a row that started to match while it waited", { skip }, async (t) => {
195
+ const { knex, slots } = await database(t);
196
+ const ids = (await slots.createMany([{ status: "open" }, { status: "taken" }, { status: "taken" }], { ...WORKSPACE, returning: "ids" })).map(String);
197
+ const [outcome] = await raceBehind(
198
+ knex,
199
+ (holder) => holder("slots").whereIn("id", [ids[0], ids[1]]).update({ status: "open" }),
200
+ [() => slots.withTransaction(WORKSPACE, (txOptions) => slots.lockWhere({ status: "open" }, txOptions))]
201
+ );
202
+ noErrors([outcome]);
203
+ assert.deepEqual(outcome.value.map(({ id }) => String(id)), [ids[0], ids[1]]);
204
+ });
205
+
206
+ test("two upserts of one key behind an uncommitted insert of it both succeed, and the key has one row", { skip }, async (t) => {
207
+ const { knex, slots } = await database(t);
208
+ const upsert = (action, takenBy) => () => slots.createMany(
209
+ [{ code: "a", status: "open", takenBy }],
210
+ { ...WORKSPACE, onConflict: { keys: ["workspaceId", "code"], action }, returning: "ids" }
211
+ );
212
+ for (const [action, expected] of [["ignore", ["holder"]], ["merge", ["first", "second"]]]) {
213
+ await knex("slots").del();
214
+ const outcomes = await raceBehind(
215
+ knex,
216
+ (holder) => holder("slots").insert({ workspace_id: 1, code: "a", status: "open", taken_by: "holder" }),
217
+ [upsert(action, "first"), upsert(action, "second")],
218
+ { rollback: action === "merge" }
219
+ );
220
+ noErrors(outcomes);
221
+ const rows = await knex("slots").where({ workspace_id: 1, code: "a" }).select("taken_by");
222
+ assert.equal(rows.length, 1, `${action}: one row for the key`);
223
+ assert.ok(expected.includes(rows[0].taken_by), `${action}: kept ${rows[0].taken_by}`);
224
+ }
225
+ // The same key in another workspace is that workspace's own row.
226
+ await slots.createMany([{ code: "a", status: "open" }], { ...OTHER_WORKSPACE, onConflict: { keys: ["workspaceId", "code"], action: "ignore" } });
227
+ assert.equal((await knex("slots").where({ code: "a" })).length, 2);
228
+ });
229
+
230
+ test("two transactions that lock two rows in opposite orders deadlock, and the victim runs again and commits", { skip }, async (t) => {
231
+ const { slots } = await database(t);
232
+ const [a, b] = (await slots.createMany([{ status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
233
+ const locked = { first: Promise.withResolvers(), second: Promise.withResolvers() };
234
+ let runs = 0;
235
+ const work = (mine, theirs, name, other) => slots.withTransaction(WORKSPACE, async (txOptions) => {
236
+ runs += 1;
237
+ await slots.lock(mine, txOptions);
238
+ locked[name].resolve();
239
+ await locked[other].promise;
240
+ await slots.lock(theirs, txOptions);
241
+ await slots.patch(mine, { takenBy: name }, { ...txOptions, returning: "none" });
242
+ });
243
+ noErrors(await Promise.all([settle(() => work(a, b, "first", "second")), settle(() => work(b, a, "second", "first"))]));
244
+ assert.equal(runs, 3, "the deadlock victim's work ran once more");
245
+ assert.deepEqual((await slots.queryAll({ filters: { id: [a, b] } }, WORKSPACE)).map(({ takenBy }) => takenBy), ["first", "second"]);
246
+ });
247
+
248
+ test("a transaction whose insert lost a unique race runs once more and finds the winner's row", { skip }, async (t) => {
249
+ const { knex, slots } = await database(t);
250
+ let runs = 0;
251
+ const record = (takenBy) => () => retryLostInsertRace(WORKSPACE, () => slots.withTransaction(WORKSPACE, async (txOptions) => {
252
+ runs += 1;
253
+ const [found] = await slots.queryAll({ filters: { code: "k" } }, txOptions);
254
+ return found ?? slots.create({ code: "k", status: "taken", takenBy }, txOptions);
255
+ }));
256
+ const outcomes = await raceBehind(
257
+ knex,
258
+ (holder) => holder("slots").insert({ workspace_id: 1, code: "k", status: "open", taken_by: "holder" }),
259
+ [record("first"), record("second")],
260
+ { rollback: true }
261
+ );
262
+ noErrors(outcomes);
263
+ const [first, second] = outcomes.map(({ value }) => value);
264
+ assert.equal(String(first.id), String(second.id), "both answer the one row");
265
+ assert.equal(runs, 3, "the loser ran once more");
266
+ assert.equal((await knex("slots").where({ code: "k" })).length, 1);
267
+ });
268
+
269
+ test("findOrCreate of one id at once, behind an uncommitted insert of it, answers one record to both", { skip }, async (t) => {
270
+ const { knex, balances } = await database(t);
271
+ const outcomes = await raceBehind(
272
+ knex,
273
+ (holder) => holder("balances").insert({ id: "acct-1", amount: 7 }),
274
+ [() => balances.findOrCreate({ id: "acct-1", amount: 1 }, WORKSPACE), () => balances.findOrCreate({ id: "acct-1", amount: 2 }, WORKSPACE)],
275
+ { rollback: true }
276
+ );
277
+ noErrors(outcomes);
278
+ const [first, second] = outcomes.map(({ value }) => value);
279
+ assert.deepEqual([first.id, second.id, first.amount], ["acct-1", "acct-1", second.amount]);
280
+ assert.deepEqual(await knex("balances").pluck("id"), ["acct-1"]);
281
+ });
282
+
283
+ test("first uses of one id at once through lockOrCreate create one record and run every work once, one at a time", { skip }, async (t) => {
284
+ const { balances } = await database(t);
285
+ let works = 0;
286
+ let inside = 0;
287
+ let overlapped = false;
288
+ const add = () => balances.lockOrCreate({ id: "acct-2", amount: 0 }, WORKSPACE, async (record, txOptions) => {
289
+ works += 1;
290
+ inside += 1;
291
+ overlapped ||= inside > 1;
292
+ await pause(20);
293
+ await balances.patch(record.id, { amount: record.amount + 1 }, { ...txOptions, returning: "none" });
294
+ inside -= 1;
295
+ });
296
+ noErrors(await Promise.all(Array.from({ length: 4 }, () => settle(add))));
297
+ assert.equal(works, 4);
298
+ assert.equal(overlapped, false, "the lock serializes the works");
299
+ assert.equal((await balances.get("acct-2", {}, WORKSPACE)).amount, 4);
300
+ });
301
+
302
+ test("a lock or patchWhere with lockMode noKeyUpdate lets another transaction add a row that references the locked one; the default lock does not", { skip }, async (t) => {
303
+ const { knex, slots, balances } = await database(t);
304
+ await knex.schema.createTable("entries", (table) => {
305
+ table.increments("id");
306
+ table.integer("slot_id").nullable().references("id").inTable("slots");
307
+ table.string("balance_id").nullable().references("id").inTable("balances");
308
+ });
309
+ const [slot] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
310
+ await balances.createMany([{ id: "acct-3", amount: 0 }], { ...WORKSPACE, returning: "none" });
311
+ // Another transaction adds a row that references the locked one. Its foreign key check takes FOR KEY SHARE on the
312
+ // locked row, which FOR NO KEY UPDATE lets through and FOR UPDATE does not; it gives up after 500 ms (55P03).
313
+ const addEntry = async (entry) => {
314
+ const { error } = await settle(() => knex.transaction(async (trx) => {
315
+ await trx.raw("SET LOCAL lock_timeout = '500ms'");
316
+ await trx("entries").insert(entry);
317
+ }));
318
+ return error ? error.code : "added";
319
+ };
320
+ const lockers = {
321
+ lock: (txOptions) => slots.lock(slot, txOptions),
322
+ lockMany: (txOptions) => slots.lockMany([slot], txOptions),
323
+ lockWhere: (txOptions) => slots.lockWhere({ status: "open" }, txOptions),
324
+ claim: (txOptions) => slots.claim({ filters: { status: "open" } }, txOptions),
325
+ patchWhere: (txOptions) => slots.patchWhere({ status: "open" }, { takenBy: "worker" }, txOptions)
326
+ };
327
+ const outcomes = {};
328
+ for (const [name, locker] of Object.entries(lockers)) {
329
+ for (const mode of ["noKeyUpdate", "update"]) {
330
+ outcomes[`${name} ${mode}`] = await slots.withTransaction(WORKSPACE, async (txOptions) => {
331
+ await locker(mode === "update" ? txOptions : { ...txOptions, lockMode: mode });
332
+ return addEntry({ slot_id: slot });
333
+ });
334
+ }
335
+ }
336
+ for (const mode of ["noKeyUpdate", "update"]) {
337
+ const options = mode === "update" ? WORKSPACE : { ...WORKSPACE, lockMode: mode };
338
+ outcomes[`lockOrCreate ${mode}`] = await balances.lockOrCreate({ id: "acct-3", amount: 0 }, options, () => addEntry({ balance_id: "acct-3" }));
339
+ }
340
+ assert.deepEqual(outcomes, Object.fromEntries([...Object.keys(lockers), "lockOrCreate"].flatMap((name) => [
341
+ [`${name} noKeyUpdate`, "added"],
342
+ [`${name} update`, "55P03"]
343
+ ])));
344
+ assert.equal(Number((await knex("entries").count({ count: "*" }).first()).count), 6);
345
+ });
@@ -1,16 +1,17 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import knexLib from "knex";
4
3
  import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
4
  import { isDuplicateEntryError, retryLostInsertRace } from "@jskit-ai/database-runtime/shared";
6
- import { createTestResourceRuntime } from "../src/test/index.js";
5
+ import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
6
+
7
+ // SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
8
+ const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
7
9
 
8
10
  const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
9
11
 
10
12
  // Requests keyed by a request key, unique in each workspace: a repeated request finds its first answer by the key.
11
13
  async function createRequestFixture(t) {
12
- const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
13
- t.after(() => knex.destroy());
14
+ const knex = await testKnex(t);
14
15
  await knex.schema.createTable("requests", (table) => {
15
16
  table.increments("id");
16
17
  table.integer("workspace_id").notNullable();
@@ -3,6 +3,7 @@ import test from "node:test";
3
3
  import knexLib from "knex";
4
4
  import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
5
  import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
6
+ import { UnsupportedDatabaseError } from "../src/server/index.js";
6
7
  import { createTestResourceRuntime } from "../src/test/index.js";
7
8
 
8
9
  // patchWhere and deleteWhere on MySQL or MariaDB, under two concurrent writers: each checks its filters on the row
@@ -143,3 +144,11 @@ test("a deleteWhere whose filter matched a row a concurrent patchWhere changed l
143
144
  // Whichever went first wins; the other finds the row changed (or gone) and leaves it.
144
145
  assert.deepEqual([patched + deleted, row ? row.status : "deleted"], [1, patched === 1 ? "taken" : "deleted"]);
145
146
  });
147
+
148
+ test("changeHorizon() throws on MySQL and MariaDB, which stamp no row with its transaction, before any statement", async (t) => {
149
+ const { knex, slots } = await ownDatabase(t, "horizon");
150
+ const statements = [];
151
+ knex.on("query", ({ sql }) => statements.push(sql));
152
+ await assert.rejects(slots.changeHorizon({ context: WORKSPACE }), UnsupportedDatabaseError);
153
+ assert.deepEqual(statements, []);
154
+ });
@@ -1,14 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import knexLib from "knex";
4
3
  import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
- import { createTestResourceRuntime } from "../src/test/index.js";
4
+ import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
5
+
6
+ // SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
7
+ const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
6
8
 
7
9
  const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
8
10
 
9
11
  test("a query field's select gets a value-only raw, column() and the read's filters, never Knex or its context", async (t) => {
10
- const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
11
- t.after(() => knex.destroy());
12
+ const knex = await testKnex(t);
12
13
  await knex.schema.createTable("receivals", (table) => {
13
14
  table.increments("id");
14
15
  table.integer("workspace_id");
@@ -0,0 +1,155 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
4
+ import { emptyInputValidator, resolveActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
5
+ import { defineFeature } from "@jskit-ai/kernel/server/features";
6
+ import { HttpProvider } from "@jskit-ai/kernel/server/http";
7
+ import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
8
+ import { createPackageTestKnex, startTestFeatures } from "../src/test/index.js";
9
+
10
+ // A route that reads its own tables without an action takes request.callOptions(): the call options the same request's
11
+ // action gets, from the same context contributors, so each table is scoped as the action would scope it.
12
+
13
+ const VISIBILITIES = Object.freeze(["user", "workspace", "workspace_user"]);
14
+ const userField = { type: "id", nullable: true, storage: { column: "user_id" } };
15
+ const workspaceField = { type: "id", nullable: true, storage: { column: "workspace_id" } };
16
+ const tables = Object.freeze([
17
+ defineTableResource({ name: "bookmarks", tableName: "bookmarks", ownership: "user", schema: { userId: userField, title: { type: "string" } } }),
18
+ defineTableResource({ name: "notes", tableName: "notes", ownership: "workspace", schema: { workspaceId: workspaceField, title: { type: "string" } } }),
19
+ defineTableResource({
20
+ name: "pins",
21
+ tableName: "pins",
22
+ ownership: "workspace_user",
23
+ schema: { workspaceId: workspaceField, userId: userField, title: { type: "string" } }
24
+ })
25
+ ]);
26
+
27
+ // What auth-core and workspaces-core give every action of a request: the user and workspace the auth plugin resolved,
28
+ // and the route's visibility scope.
29
+ const RequestAuthProvider = defineProvider({
30
+ id: "test.request-auth",
31
+ requires: { actions: "runtime.actions", http: "runtime.http" },
32
+ setup({ actions, http }) {
33
+ actions.registerContextContributor({
34
+ id: "test.request-auth",
35
+ contribute: ({ context }) => {
36
+ const request = context.requestMeta?.request;
37
+ return { ...(request?.user ? { actor: request.user } : {}), ...(request?.workspace ? { workspace: request.workspace } : {}) };
38
+ }
39
+ });
40
+ http.registerVisibilityResolver({
41
+ id: "test.visibility",
42
+ resolve: ({ visibility, context }) => {
43
+ const userId = resolveActorId(context);
44
+ const scopeOwnerId = context.workspace?.id;
45
+ if (visibility === "user") return userId ? { userId, requiresActorScope: true } : {};
46
+ if (visibility === "workspace") return scopeOwnerId ? { scopeKind: "workspace", scopeOwnerId } : {};
47
+ if (visibility === "workspace_user") {
48
+ return { scopeKind: "workspace_user", requiresActorScope: true, ...(scopeOwnerId ? { scopeOwnerId } : {}), ...(userId ? { userId } : {}) };
49
+ }
50
+ return {};
51
+ }
52
+ });
53
+ return {};
54
+ }
55
+ });
56
+
57
+ // Each table's titles the call options reach, or the owner they name none of.
58
+ async function readEveryTable(resources, options) {
59
+ const reads = {};
60
+ for (const { name } of tables) {
61
+ try {
62
+ reads[name] = (await resources[name].queryAll({}, options)).map(({ title }) => title).sort();
63
+ } catch (error) {
64
+ if (error?.code !== "RESOURCE_MISSING_SCOPE") throw error;
65
+ reads[name] = "refused";
66
+ }
67
+ }
68
+ return reads;
69
+ }
70
+
71
+ const ReadsFeature = defineFeature({
72
+ id: "reads",
73
+ domain: "reads",
74
+ owns: tables,
75
+ requires: { http: "runtime.http" },
76
+ provides: { reads: "test.reads" },
77
+ setup({ http, resources }) {
78
+ const reads = (options) => readEveryTable(resources, options);
79
+ for (const visibility of VISIBILITIES) {
80
+ http.router.get(`/action/${visibility}`, { surface: "app", visibility }, async (request, reply) => {
81
+ reply.send(await request.executeAction({ actionId: `reads.${visibility}` }));
82
+ });
83
+ http.router.get(`/hand/${visibility}`, { surface: "app", visibility }, async (request, reply) => {
84
+ const options = await request.callOptions();
85
+ reply.send({ reads: await reads(options), visibilityContext: options.context.visibilityContext });
86
+ });
87
+ }
88
+ return { reads };
89
+ },
90
+ actions: ({ reads }) => VISIBILITIES.map((visibility) => ({
91
+ id: `reads.${visibility}`,
92
+ kind: "query",
93
+ channels: ["api"],
94
+ surfaces: ["app"],
95
+ permission: { require: "none" },
96
+ input: emptyInputValidator,
97
+ execute: async (_input, context) => ({ reads: await reads({ context }), visibilityContext: context.visibilityContext })
98
+ }))
99
+ });
100
+
101
+ function fastifyStub() {
102
+ return {
103
+ routes: [],
104
+ log: { error() {} },
105
+ route(value) { this.routes.push(value); },
106
+ addHook() {},
107
+ setErrorHandler() {},
108
+ hasContentTypeParser: () => true,
109
+ getDefaultJsonParser: () => (_request, body, done) => done(null, body),
110
+ addContentTypeParser() {}
111
+ };
112
+ }
113
+
114
+ test("a hand route's callOptions() scope user, workspace and workspace_user tables as the same request's action", async (t) => {
115
+ // SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
116
+ const knex = await createPackageTestKnex(t, { environment: process.env });
117
+ for (const [table, columns] of [["bookmarks", ["user_id"]], ["notes", ["workspace_id"]], ["pins", ["workspace_id", "user_id"]]]) {
118
+ await knex.schema.createTable(table, (builder) => {
119
+ builder.increments("id");
120
+ for (const column of columns) builder.integer(column);
121
+ builder.string("title");
122
+ });
123
+ }
124
+ await knex("bookmarks").insert([{ user_id: 7, title: "7's" }, { user_id: 8, title: "8's" }]);
125
+ await knex("notes").insert([{ workspace_id: 1, title: "in 1" }, { workspace_id: 2, title: "in 2" }]);
126
+ await knex("pins").insert([{ workspace_id: 1, user_id: 7, title: "7's in 1" }, { workspace_id: 1, user_id: 8, title: "8's in 1" }, { workspace_id: 2, user_id: 7, title: "7's in 2" }]);
127
+ const fastify = fastifyStub();
128
+ const { stop } = await startTestFeatures({ knex, features: [HttpProvider, RequestAuthProvider, ReadsFeature], inputs: { "runtime.fastify": fastify } });
129
+ t.after(stop);
130
+
131
+ async function call(path, requestValues) {
132
+ const route = fastify.routes.find(({ url }) => url === path);
133
+ const reply = { statusCode: 200, headers: {}, code() { return this; }, header() { return this; }, hasHeader: () => false, send(value) { this.payload = value; return this; } };
134
+ await route.handler({ params: {}, routeOptions: { config: route.config }, ...requestValues }, reply);
135
+ return reply.payload;
136
+ }
137
+
138
+ const member = { user: { id: "7" }, workspace: { id: "1" } };
139
+ const expected = {
140
+ user: [{ user: member.user }, { bookmarks: ["7's"], notes: "refused", pins: "refused" }],
141
+ workspace: [member, { bookmarks: "refused", notes: ["in 1"], pins: "refused" }],
142
+ workspace_user: [member, { bookmarks: ["7's"], notes: ["in 1"], pins: ["7's in 1"] }]
143
+ };
144
+ for (const visibility of VISIBILITIES) {
145
+ const [request, reads] = expected[visibility];
146
+ const action = await call(`/action/${visibility}`, request);
147
+ assert.deepEqual(await call(`/hand/${visibility}`, request), action, `${visibility} route`);
148
+ assert.deepEqual(action.reads, reads, `${visibility} route`);
149
+ // A request with no one signed in gets no user scope, exactly as its action does.
150
+ const anonymous = await call(`/hand/${visibility}`, {});
151
+ assert.deepEqual(anonymous, await call(`/action/${visibility}`, {}), `${visibility} route, signed out`);
152
+ assert.equal(anonymous.reads.bookmarks, "refused");
153
+ assert.equal(anonymous.visibilityContext.userId ?? null, null);
154
+ }
155
+ });