@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.
@@ -1,6 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import knexLib from "knex";
4
3
  import { RestApiLockOutsideTransactionError, RestApiScopedQueryReadOnlyError } from "json-rest-api";
5
4
  import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
6
5
  import {
@@ -9,7 +8,9 @@ import {
9
8
  PageSizeError,
10
9
  RowLimitError,
11
10
  StaleRecordError,
12
- UnpagedQueryError
11
+ SystemContextRequiredError,
12
+ UnpagedQueryError,
13
+ UnsupportedDatabaseError
13
14
  } from "../src/server/index.js";
14
15
  import { createActionCatalogue, createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
15
16
  import { emptyInputValidator, resolveActorId, resolveAuditActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
@@ -18,7 +19,10 @@ import { normalizeOpaqueId } from "@jskit-ai/kernel/shared/support/normalize";
18
19
  import { countQueries } from "@jskit-ai/database-runtime/server/testDatabases";
19
20
  import { isLostDuplicateInsertRace } from "@jskit-ai/database-runtime/shared";
20
21
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
21
- import { createTestResourceRuntime } from "../src/test/index.js";
22
+ import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
23
+
24
+ // SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
25
+ const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
22
26
 
23
27
  const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
24
28
  const WORKSPACE_B = Object.freeze({ workspace: { id: "2" } });
@@ -45,8 +49,7 @@ function noteResource(extra = {}) {
45
49
  }
46
50
 
47
51
  async function createFixture(t, { requestRowLimit, owns = [noteResource()] } = {}) {
48
- const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
49
- t.after(() => knex.destroy());
52
+ const knex = await testKnex(t);
50
53
  await knex.schema.createTable("notes", (table) => {
51
54
  table.increments("id");
52
55
  table.integer("workspace_id").nullable();
@@ -76,6 +79,68 @@ test("a call without a context is refused before anything runs", async (t) => {
76
79
  await assert.rejects(notes.query({ page: { size: 5 } }), MissingScopeError);
77
80
  });
78
81
 
82
+ test("a system table answers only a system context: a request's, a derived job's or none is refused before any statement", async (t) => {
83
+ const knex = await testKnex(t);
84
+ await knex.schema.createTable("leases", (table) => {
85
+ table.increments("id");
86
+ table.string("holder").notNullable();
87
+ });
88
+ const leaseResource = defineTableResource({
89
+ name: "leases",
90
+ tableName: "leases",
91
+ ownership: "system",
92
+ defaultSort: ["id"],
93
+ schema: { id: { type: "id", primary: true }, holder: { type: "string", required: true, search: true } }
94
+ });
95
+ const runtime = await createTestResourceRuntime({ knex });
96
+ const { leases } = await runtime.own("leases.feature", [leaseResource]);
97
+ const systemContext = runtime.declareSystemScope("leases.feature", "keep every server's leases")("take a lease");
98
+ const system = { context: systemContext };
99
+
100
+ // The owner's background work reads and writes every row, the many-row writes and the locks included.
101
+ const [first] = await leases.createMany([{ holder: "a" }, { holder: "b" }], { ...system, returning: "ids" });
102
+ assert.deepEqual((await leases.queryAll({}, system)).map(({ holder }) => holder), ["a", "b"]);
103
+ assert.equal(await leases.patchWhere({ holder: "b" }, { holder: "c" }, { ...system, returning: "count" }), 1);
104
+ assert.deepEqual(await leases.withTransaction(system, async (txOptions) => (await leases.claim({ filters: { holder: "a" } }, txOptions)).map(({ id }) => id)), [first]);
105
+
106
+ const statements = [];
107
+ knex.on("query", ({ sql }) => statements.push(sql));
108
+ const refused = [
109
+ { context: WORKSPACE_A },
110
+ { context: { visibilityContext: { userId: "7" } } },
111
+ { context: systemContext.forWorkspace("1") },
112
+ { context: systemContext.forUser("7", { actorId: "7" }) },
113
+ {}
114
+ ];
115
+ for (const options of refused) {
116
+ const calls = [
117
+ () => leases.create({ holder: "x" }, { ...options, returning: "none" }),
118
+ () => leases.createMany([{ holder: "x" }], options),
119
+ () => leases.query({ page: { size: 5 } }, options),
120
+ () => leases.queryAll({}, options),
121
+ () => leases.get(first, {}, options),
122
+ () => leases.aggregate({ measures: { leases: { count: "*" } } }, options),
123
+ () => leases.patch(first, { holder: "x" }, options),
124
+ () => leases.patchWhere({ holder: "a" }, { holder: "x" }, options),
125
+ () => leases.delete(first, options),
126
+ () => leases.deleteWhere({ holder: "a" }, options),
127
+ () => leases.scopedQuery({}, options),
128
+ () => leases.findOrCreate({ id: "9", holder: "x" }, options)
129
+ ];
130
+ for (const call of calls) {
131
+ await assert.rejects(call, (error) => error instanceof SystemContextRequiredError &&
132
+ error.code === "RESOURCE_SYSTEM_CONTEXT_REQUIRED" && /ownership "system"/u.test(error.message));
133
+ }
134
+ }
135
+ assert.deepEqual(statements, [], "a refused call sends nothing");
136
+ // A lock in the owner's transaction with another context is refused too.
137
+ await leases.withTransaction(system, async (txOptions) => {
138
+ await assert.rejects(leases.lock(first, { ...txOptions, context: WORKSPACE_A }), SystemContextRequiredError);
139
+ await assert.rejects(leases.claim({ filters: { holder: "a" } }, { ...txOptions, context: WORKSPACE_A }), SystemContextRequiredError);
140
+ });
141
+ assert.deepEqual((await knex("leases").orderBy("id")).map(({ holder }) => holder), ["a", "c"]);
142
+ });
143
+
79
144
  test("reads and single writes stay in the context's workspace", async (t) => {
80
145
  const { notes, knex } = await createFixture(t);
81
146
  const mine = await notes.create({ title: "mine" }, { context: WORKSPACE_A });
@@ -181,8 +246,7 @@ test("a system context comes only from a declared system scope, one per feature,
181
246
  });
182
247
 
183
248
  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());
249
+ const knex = await testKnex(t);
186
250
  await knex.schema.createTable("drafts", (table) => {
187
251
  table.increments("id");
188
252
  table.integer("workspace_id").notNullable();
@@ -334,7 +398,7 @@ test("an upsert on a table with no owners may key on the id alone; an owned tabl
334
398
  );
335
399
  // On a workspace table the conflict keys must hold the owner, so json-rest-api refuses the id alone.
336
400
  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);
401
+ assert.equal(Number((await knex("notes").count({ count: "*" }).first()).count), 0);
338
402
  });
339
403
 
340
404
  test("an upsert finds rows by its conflict keys alone, never by an id, and stays in the caller's workspace", async (t) => {
@@ -418,6 +482,18 @@ test("lockOrCreate() runs work on the locked record, creating it first when miss
418
482
  const bump = (record, txOptions) => resources.counters.patch(record.id, { value: record.value + 1 }, txOptions);
419
483
  assert.deepEqual(await resources.counters.lockOrCreate({ id: "visits", value: 0 }, options, bump), { id: "visits", value: 1 });
420
484
  assert.deepEqual(await resources.counters.lockOrCreate({ id: "visits", value: 0 }, options, bump), { id: "visits", value: 2 });
485
+ // lockMode picks the record's lock, as lock() takes it; another is refused before any transaction opens.
486
+ assert.deepEqual(await resources.counters.lockOrCreate({ id: "visits", value: 0 }, { ...options, lockMode: "noKeyUpdate" }, bump), { id: "visits", value: 3 });
487
+ assert.deepEqual(await resources.counters.lockOrCreate({ id: "fresh", value: 0 }, { ...options, lockMode: "noKeyUpdate" }, bump), { id: "fresh", value: 1 });
488
+ const statements = [];
489
+ const counted = ({ sql }) => statements.push(sql);
490
+ knex.on("query", counted);
491
+ await assert.rejects(resources.counters.lockOrCreate({ id: "moded", value: 0 }, { ...options, lockMode: "share" }, bump), {
492
+ name: "TypeError",
493
+ message: 'counters.lockOrCreate() lockMode is "update" (the default) or "noKeyUpdate". Received: "share"'
494
+ });
495
+ knex.off("query", counted);
496
+ assert.deepEqual(statements, []);
421
497
  // Work that fails leaves no new record behind, and runs once.
422
498
  let runs = 0;
423
499
  await assert.rejects(resources.counters.lockOrCreate({ id: "failed", value: 0 }, options, () => {
@@ -630,7 +706,7 @@ test("withTransaction() commits together and rolls back together", async (t) =>
630
706
  await notes.createMany([{ title: "b" }], txOptions);
631
707
  throw new Error("undo");
632
708
  }), /undo/u);
633
- assert.equal((await knex("notes").count({ count: "*" }).first()).count, 0);
709
+ assert.equal(Number((await knex("notes").count({ count: "*" }).first()).count), 0);
634
710
  await notes.withTransaction({ context: WORKSPACE_A }, (txOptions) => notes.create({ title: "kept" }, txOptions));
635
711
  assert.deepEqual(await knex("notes").pluck("title"), ["kept"]);
636
712
  });
@@ -678,8 +754,7 @@ test("withTransaction() rethrows its work's own error, and takes its settings in
678
754
  });
679
755
 
680
756
  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());
757
+ const knex = await testKnex(t);
683
758
  await knex.schema.createTable("counters", (table) => {
684
759
  table.string("counter_key").primary();
685
760
  table.integer("value").notNullable();
@@ -744,6 +819,68 @@ test("locks need a transaction and take only the caller's rows", async (t) => {
744
819
  await assert.rejects(notes.withTransaction({ context: WORKSPACE_A }, (txOptions) => notes.lock(other.id, txOptions)), notFound);
745
820
  });
746
821
 
822
+ test("every lock and patchWhere take lockMode \"update\" or \"noKeyUpdate\" and reach the same rows; another is refused before any statement", async (t) => {
823
+ const { knex, notes } = await createFixture(t);
824
+ const [a] = await notes.createMany([{ title: "a", status: "queued" }], { context: WORKSPACE_A, returning: "ids" });
825
+ const statements = [];
826
+ const counted = ({ sql }) => statements.push(sql);
827
+ await notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
828
+ for (const lockMode of ["update", "noKeyUpdate"]) {
829
+ const options = { ...txOptions, lockMode };
830
+ assert.equal((await notes.lock(a, options)).title, "a", lockMode);
831
+ assert.deepEqual((await notes.lockMany([a], options)).map(({ title }) => title), ["a"], lockMode);
832
+ assert.deepEqual((await notes.lockWhere({ status: "queued" }, options)).map(({ title }) => title), ["a"], lockMode);
833
+ assert.deepEqual((await notes.claim({ filters: { status: "queued" } }, options)).map(({ title }) => title), ["a"], lockMode);
834
+ assert.equal(await notes.patchWhere({ status: "queued" }, { title: "a" }, { ...options, returning: "count" }), 1, lockMode);
835
+ }
836
+ knex.on("query", counted);
837
+ const options = { ...txOptions, lockMode: "share" };
838
+ const refused = (method) => ({ name: "TypeError", message: `notes.${method}() lockMode is "update" (the default) or "noKeyUpdate". Received: "share"` });
839
+ await assert.rejects(notes.lock(a, options), refused("lock"));
840
+ await assert.rejects(notes.lockMany([a], options), refused("lockMany"));
841
+ await assert.rejects(notes.lockWhere({ status: "queued" }, options), refused("lockWhere"));
842
+ await assert.rejects(notes.claim({ filters: { status: "queued" } }, options), refused("claim"));
843
+ await assert.rejects(notes.patchWhere({ status: "queued" }, { title: "b" }, options), refused("patchWhere"));
844
+ knex.off("query", counted);
845
+ // Nothing reached the database, so the transaction goes on (on PostgreSQL a failed statement would end it).
846
+ assert.equal((await notes.lock(a, txOptions)).title, "a");
847
+ });
848
+ assert.deepEqual(statements, []);
849
+ });
850
+
851
+ test("changeHorizon() answers PostgreSQL's snapshot horizon as decimal text; elsewhere it throws before any statement", async (t) => {
852
+ const { knex, notes } = await createFixture(t);
853
+ await assert.rejects(notes.changeHorizon({}), MissingScopeError);
854
+ if (knex.client.config.client === "pg") {
855
+ assert.match(await notes.changeHorizon({ context: WORKSPACE_A }), /^\d{1,20}$/u);
856
+ return;
857
+ }
858
+ const statements = [];
859
+ const counted = ({ sql }) => statements.push(sql);
860
+ knex.on("query", counted);
861
+ await assert.rejects(notes.changeHorizon({ context: WORKSPACE_A }), (error) => error instanceof UnsupportedDatabaseError &&
862
+ error.code === "RESOURCE_DATABASE_UNSUPPORTED" &&
863
+ error.message === "notes.changeHorizon() reads PostgreSQL's snapshot horizon (pg_snapshot_xmin), which MySQL, MariaDB and SQLite do not have.");
864
+ knex.off("query", counted);
865
+ assert.deepEqual(statements, []);
866
+ });
867
+
868
+ test("an xid8 field, PostgreSQL's transaction id, is refused where the table is on another database", async (t) => {
869
+ const knex = await testKnex(t);
870
+ if (knex.client.config.client === "pg") {
871
+ t.skip("PostgreSQL has xid8: changes.postgres.test.js reads it");
872
+ return;
873
+ }
874
+ const stamped = defineTableResource({
875
+ name: "stamped",
876
+ tableName: "stamped",
877
+ ownership: "public",
878
+ schema: { id: { type: "id", primary: true }, changed: { type: "xid8", nullable: true } }
879
+ });
880
+ const runtime = await createTestResourceRuntime({ knex });
881
+ await assert.rejects(runtime.own("stamped.feature", [stamped]), /Field 'changed' in resource 'stamped' has type 'xid8', PostgreSQL's transaction id, which only Knex storage on PostgreSQL has/u);
882
+ });
883
+
747
884
  test("scopedQuery() answers a read-only builder over the caller's rows that orWhere cannot widen", async (t) => {
748
885
  const { notes } = await createFixture(t);
749
886
  await notes.createMany([{ title: "a" }, { title: "b" }], { context: WORKSPACE_A });
@@ -829,8 +966,7 @@ test("workspace_or_shared reads a workspace's rows and the shared ones, and writ
829
966
  });
830
967
 
831
968
  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());
969
+ const knex = await testKnex(t);
834
970
  await knex.schema.createTable("notes", (table) => {
835
971
  table.increments("id");
836
972
  table.integer("workspace_id").nullable();
@@ -882,9 +1018,49 @@ test("workspace_or_shared without a workspace reads only the shared rows and wri
882
1018
  assert.equal((await knex("notes").where({ title: "stray" })).length, 0);
883
1019
  });
884
1020
 
1021
+ test("a shared row is written in the owner's system context, naming its empty owner, also inside a request's transaction", async (t) => {
1022
+ const { notes, knex, systemContext } = await createFixture(t, { owns: [noteResource({ ownership: "workspace_or_shared" })] });
1023
+ const forbidden = (error) => error.code === "REST_API_RESOURCE" && error.subtype === "forbidden";
1024
+ // A job derived for a user is no system context: with no workspace to stamp, it may not create a shared row.
1025
+ await assert.rejects(notes.create({ title: "stray" }, { context: systemContext.forUser("7") }), forbidden);
1026
+ await assert.rejects(notes.create({ title: "stray", workspaceId: null }, { context: systemContext.forUser("7") }), forbidden);
1027
+ // A workspace request stamps its own workspace and refuses another owner, the empty one included.
1028
+ await assert.rejects(notes.create({ title: "stray", workspaceId: null }, { context: WORKSPACE_A }));
1029
+ assert.equal((await knex("notes").where({ title: "stray" })).length, 0);
1030
+ // The system context names the owner: an empty one is the shared row, and leaving it out is refused.
1031
+ await assert.rejects(notes.create({ title: "stray" }, { context: systemContext }), /needs explicit ownership values/u);
1032
+
1033
+ const shared = await notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
1034
+ await notes.create({ title: "mine" }, { ...txOptions, returning: "none" });
1035
+ return notes.create({ title: "house rules", workspaceId: null }, { ...txOptions, context: systemContext });
1036
+ });
1037
+ assert.equal((await knex("notes").where({ id: shared.id }).first()).workspace_id, null);
1038
+ for (const [context, titles] of [[WORKSPACE_A, ["house rules", "mine"]], [WORKSPACE_B, ["house rules"]]]) {
1039
+ assert.deepEqual((await notes.queryAll({}, { context })).map(({ title }) => title).sort(), titles);
1040
+ }
1041
+ // A request or a job may not change or delete it, and its many-row writes leave shared rows out.
1042
+ await assert.rejects(notes.patch(shared.id, { title: "taken" }, { context: WORKSPACE_A }), /only be changed in a system context/u);
1043
+ await assert.rejects(notes.delete(shared.id, { context: systemContext.forWorkspace("1") }), /only be changed in a system context/u);
1044
+ assert.deepEqual(await notes.patchWhere({ id: [shared.id] }, { title: "taken" }, { context: WORKSPACE_A, returning: "ids" }), []);
1045
+
1046
+ // The owner changes it in its system context once the request has read it in its own (it reaches the row), with
1047
+ // the empty owner among the filters: a compare-and-set that never reaches a workspace's row, whatever id it names.
1048
+ const { id: mine } = await notes.withTransaction({ context: WORKSPACE_A }, async (txOptions) => {
1049
+ const reached = await notes.get(shared.id, {}, txOptions);
1050
+ const changed = await notes.patchWhere({ id: [reached.id], workspaceId: null }, { title: "house rules, revised" }, {
1051
+ ...txOptions, context: systemContext, returning: "ids"
1052
+ });
1053
+ assert.deepEqual(changed.map(String), [String(shared.id)]);
1054
+ return (await notes.queryAll({ filters: { title: "mine" } }, txOptions))[0];
1055
+ });
1056
+ assert.deepEqual(await notes.patchWhere({ id: [mine], workspaceId: null }, { title: "taken" }, { context: systemContext, returning: "ids" }), []);
1057
+ assert.equal((await knex("notes").where({ id: mine }).first()).title, "mine");
1058
+ assert.deepEqual((await notes.deleteWhere({ id: [shared.id], workspaceId: null }, { context: systemContext, returning: "ids" })).map(String), [String(shared.id)]);
1059
+ assert.equal(await knex("notes").where({ id: shared.id }).first(), undefined);
1060
+ });
1061
+
885
1062
  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());
1063
+ const knex = await testKnex(t);
888
1064
  await knex.schema.createTable("rooms", (table) => {
889
1065
  table.increments("id");
890
1066
  table.string("name");
@@ -1005,9 +1181,56 @@ test("a row policy is told who the call runs for: a system context, a job it der
1005
1181
  assert.throws(() => systemContext.forWorkspace("1", { actorId: " " }), /forWorkspace needs the id of the user it acts for/u);
1006
1182
  });
1007
1183
 
1184
+ test("a job's actorId never moves its scope; a row policy that keys on the person holds a job done for them to their rows", async (t) => {
1185
+ const drafts = defineTableResource({
1186
+ name: "drafts",
1187
+ tableName: "drafts",
1188
+ ownership: "workspace",
1189
+ defaultSort: ["id"],
1190
+ schema: {
1191
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
1192
+ authorId: { type: "id", search: true, storage: { column: "author_id" } },
1193
+ title: { type: "string", required: true }
1194
+ }
1195
+ });
1196
+ const knex = await testKnex(t);
1197
+ await knex.schema.createTable("drafts", (table) => {
1198
+ table.increments("id");
1199
+ table.integer("workspace_id");
1200
+ table.integer("author_id");
1201
+ table.string("title");
1202
+ });
1203
+ await knex("drafts").insert([
1204
+ { workspace_id: 1, author_id: 9, title: "first author's" },
1205
+ { workspace_id: 1, author_id: 8, title: "second author's" },
1206
+ { workspace_id: 2, author_id: 9, title: "first author's elsewhere" }
1207
+ ]);
1208
+ const runtime = await createTestResourceRuntime({ knex });
1209
+ const handles = await runtime.own("drafts.feature", [{
1210
+ resource: drafts,
1211
+ // A person sees their own drafts, whether they ask or a job asks for them; a job for no one sees its whole scope.
1212
+ rowPolicy: ({ query, caller, column, value }) => {
1213
+ if (caller.kind === "anonymous") return false;
1214
+ const personId = caller.kind === "user" ? caller.userId : caller.actorId;
1215
+ if (personId) query.where(column("authorId"), value("authorId", personId));
1216
+ return true;
1217
+ }
1218
+ }]);
1219
+ const jobs = runtime.declareSystemScope("drafts.feature", "publish drafts people scheduled")("publish a scheduled draft");
1220
+ const titles = async (context) => (await handles.drafts.queryAll({}, { context })).map(({ title }) => title);
1221
+
1222
+ assert.deepEqual(await titles(jobs.forWorkspace("1")), ["first author's", "second author's"]);
1223
+ assert.deepEqual(await titles(jobs.forWorkspace("1", { actorId: "9" })), ["first author's"]);
1224
+ assert.deepEqual(await titles({ ...WORKSPACE_A, actor: { id: "9" } }), ["first author's"]);
1225
+ // The person's rows in another workspace stay out: the scope comes from the workspace the job was derived for.
1226
+ assert.deepEqual(await titles(jobs.forWorkspace("2", { actorId: "8" })), []);
1227
+ // The policy holds the job's writes too: another person's draft is missing to it.
1228
+ await assert.rejects(handles.drafts.patch("2", { title: "taken" }, { context: jobs.forWorkspace("1", { actorId: "9" }) }), notFound);
1229
+ assert.equal((await knex("drafts").where({ id: 2 }).first()).title, "second author's");
1230
+ });
1231
+
1008
1232
  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());
1233
+ const knex = await testKnex(t);
1011
1234
  const runtime = await createTestResourceRuntime({ knex });
1012
1235
  const system = runtime.declareSystemScope("journal.feature", "announce the entries people asked for")("announce an entry");
1013
1236
  const published = [];
@@ -1092,9 +1315,8 @@ test("an action a system context or a job runs reads as that system context or j
1092
1315
  }
1093
1316
  });
1094
1317
 
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());
1318
+ test("a report joins another owner's joinSource() as a subquery: one statement, each owner's scope and row policy kept", async (t) => {
1319
+ const knex = await testKnex(t);
1098
1320
  await knex.schema.createTable("tasks", (table) => {
1099
1321
  table.increments("id");
1100
1322
  table.integer("workspace_id");
@@ -1137,18 +1359,9 @@ test("a report joins another owner's scopedQuery as a subquery: one statement, e
1137
1359
  return true;
1138
1360
  }
1139
1361
  }]);
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
1362
  // Knex: report - open tasks and billed totals per project, joined on billing's scoped invoices
1150
1363
  async function projectSummary(options) {
1151
- const source = await billing.invoiceSource("i", options);
1364
+ const source = await invoices.joinSource({ alias: "i" }, options);
1152
1365
  const billed = source.query
1153
1366
  .select({ projectId: source.column("projectId") })
1154
1367
  .sum({ billed: source.column("amount") })
@@ -1173,8 +1386,7 @@ test("a report joins another owner's scopedQuery as a subquery: one statement, e
1173
1386
  });
1174
1387
 
1175
1388
  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());
1389
+ const knex = await testKnex(t);
1178
1390
  await knex.schema.createTable("entries", (table) => {
1179
1391
  table.increments("id");
1180
1392
  table.integer("workspace_id");
@@ -1225,8 +1437,7 @@ test("a job done for one person records that person in audit fields and is never
1225
1437
  });
1226
1438
 
1227
1439
  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());
1440
+ const knex = await testKnex(t);
1230
1441
  await knex.schema.createTable("profiles", (table) => {
1231
1442
  table.increments("id");
1232
1443
  table.integer("user_id");
@@ -1416,8 +1627,7 @@ test("a lockVersion patch moves the version on by one, also with no attributes,
1416
1627
  });
1417
1628
 
1418
1629
  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());
1630
+ const knex = await testKnex(t);
1421
1631
  await knex.schema.createTable("authors", (table) => {
1422
1632
  table.increments("id");
1423
1633
  table.string("name").notNullable();
@@ -1449,8 +1659,7 @@ test("a belongs-to field is written under its relationship, and naming both is r
1449
1659
 
1450
1660
  // Visits each belong to a workspace's customer and to a vet; customers are workspace rows, vets public ones.
1451
1661
  async function createVisitFixture(t) {
1452
- const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
1453
- t.after(() => knex.destroy());
1662
+ const knex = await testKnex(t);
1454
1663
  await knex.schema.createTable("customers", (table) => {
1455
1664
  table.increments("id");
1456
1665
  table.integer("workspace_id");
@@ -1505,13 +1714,12 @@ async function createVisitFixture(t) {
1505
1714
  // The tables each SELECT reads, in order.
1506
1715
  const selects = async (work) => (await countQueries(knex, work)).queries
1507
1716
  .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 };
1717
+ .map((sql) => [...sql.matchAll(/from [`"](\w+)[`"]/gu)].map(([, table]) => table)[0]);
1718
+ return { visits: handles.visits, options, ids: created, selects, postgres: knex.client.config.client === "pg" };
1510
1719
  }
1511
1720
 
1512
1721
  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());
1722
+ const knex = await testKnex(t);
1515
1723
  await knex.schema.createTable("tickets", (table) => {
1516
1724
  table.increments("id");
1517
1725
  table.integer("workspace_id");
@@ -1556,8 +1764,10 @@ test("a read whose list filter is an empty list answers nothing without a statem
1556
1764
  });
1557
1765
 
1558
1766
  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);
1767
+ const { visits, options, ids, selects, postgres } = await createVisitFixture(t);
1560
1768
  let locked;
1769
+ // PostgreSQL's lockWhere reads once more under the locks it took, for rows that started to match while it waited.
1770
+ const lockWhereReads = postgres ? ["visits", "visits"] : ["visits"];
1561
1771
  // Every field: the locking read, then one visibility check per related type.
1562
1772
  assert.deepEqual(await selects(() => visits.withTransaction(options, (tx) => visits.lock(ids[0], tx))), ["visits", "customers", "vets"]);
1563
1773
  assert.deepEqual(await selects(() => visits.withTransaction(options, async (tx) => {
@@ -1568,10 +1778,10 @@ test("lock, lockMany, lockWhere and claim read only the fields they name, so a r
1568
1778
  locked = await visits.lockMany(ids, { ...tx, fields: ["stage", "customerId"] });
1569
1779
  })), ["visits", "customers"]);
1570
1780
  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"]);
1781
+ assert.deepEqual(await selects(() => visits.withTransaction(options, (tx) => visits.lockWhere({ id: ids }, tx))), [...lockWhereReads, "customers", "vets"]);
1572
1782
  assert.deepEqual(await selects(() => visits.withTransaction(options, async (tx) => {
1573
1783
  locked = await visits.lockWhere({ id: ids }, { ...tx, fields: ["stage"] });
1574
- })), ["visits"]);
1784
+ })), lockWhereReads);
1575
1785
  assert.deepEqual(locked, ids.map((id) => ({ id, stage: "new" })));
1576
1786
  assert.deepEqual(await selects(() => visits.withTransaction(options, (tx) => visits.claim({ filters: { id: ids } }, tx))), ["visits", "customers", "vets"]);
1577
1787
  assert.deepEqual(await selects(() => visits.withTransaction(options, async (tx) => {
@@ -1650,8 +1860,7 @@ test("a read with one include or two reads each included type once, whatever the
1650
1860
  });
1651
1861
 
1652
1862
  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());
1863
+ const knex = await testKnex(t);
1655
1864
  await knex.schema.createTable("suppliers", (table) => {
1656
1865
  table.increments("id");
1657
1866
  table.string("name");
@@ -1706,8 +1915,7 @@ test("a record written back as it was read keeps a link to a target the writer c
1706
1915
  });
1707
1916
 
1708
1917
  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());
1918
+ const knex = await testKnex(t);
1711
1919
  await knex.schema.createTable("suppliers", (table) => {
1712
1920
  table.increments("id");
1713
1921
  table.string("name").index();
@@ -1782,8 +1990,7 @@ test("a sparse read names a belongs-to field by its own key or its relationship,
1782
1990
  });
1783
1991
 
1784
1992
  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());
1993
+ const knex = await testKnex(t);
1787
1994
  await knex.schema.createTable("contacts", (table) => {
1788
1995
  table.increments("id");
1789
1996
  table.string("name").notNullable();
@@ -1831,8 +2038,7 @@ test("a collection relation with hydrateOnList or hydrateOnView false loads its
1831
2038
  });
1832
2039
 
1833
2040
  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());
2041
+ const knex = await testKnex(t);
1836
2042
  await knex.schema.createTable("contacts", (table) => {
1837
2043
  table.increments("id");
1838
2044
  table.string("name").notNullable();
@@ -0,0 +1,4 @@
1
+ import { storedIntegerCases } from "./support/storedIntegerCases.js";
2
+
3
+ // Non-id BIGINT columns read through a handle on MariaDB/MySQL.
4
+ storedIntegerCases("mysql");
@@ -0,0 +1,5 @@
1
+ import { storedIntegerCases } from "./support/storedIntegerCases.js";
2
+
3
+ // Non-id BIGINT columns read through a handle on PostgreSQL: run with TEST_DB_NAME (and the usual DB_HOST, DB_PORT,
4
+ // DB_USER and DB_PASSWORD) naming a PostgreSQL database, and DB_CLIENT unset or pg.
5
+ storedIntegerCases("postgres");