@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.
- package/CHANGELOG.md +12 -0
- package/package.json +7 -7
- package/src/server/changeHorizon.js +26 -0
- package/src/server/errors.js +44 -1
- package/src/server/foreignKeyCheck.js +58 -0
- package/src/server/handleGuards.js +9 -2
- package/src/server/handleHidden.js +137 -36
- package/src/server/handleLocks.js +47 -15
- package/src/server/index.js +4 -1
- package/src/server/lockOrCreate.js +7 -3
- package/src/server/resourceHandle.js +22 -9
- package/src/server/resourceHost.js +3 -2
- package/src/test/index.js +2 -1
- package/test/callSettings.test.js +5 -4
- package/test/changes.postgres.test.js +141 -0
- package/test/foreignKeys.test.js +94 -0
- package/test/hiddenFields.test.js +71 -30
- package/test/locks.postgres.test.js +345 -0
- package/test/lostInsertRace.test.js +5 -4
- package/test/manyRowWrites.mysql.test.js +9 -0
- package/test/queryFields.test.js +5 -4
- package/test/requestCallOptions.test.js +155 -0
- package/test/resourceRuntime.test.js +259 -53
- package/test/storedIntegers.mysql.test.js +4 -0
- package/test/storedIntegers.postgres.test.js +5 -0
- package/test/support/storedIntegerCases.js +152 -0
- package/test/testHelpers.test.js +8 -16
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
1096
|
-
const knex =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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+)
|
|
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 =
|
|
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))), [
|
|
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
|
-
})),
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,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");
|