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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
+ import { countQueries } from "@jskit-ai/database-runtime/server/testDatabases";
6
+ import { withoutHiddenFields } from "../src/server/index.js";
7
+ import { createTestResourceRuntime } from "../src/test/index.js";
8
+
9
+ const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
10
+
11
+ // Cards with a hidden owner, a hidden bearer token, a hidden flag, a hidden count and a hidden time: two in workspace
12
+ // 1 and one in workspace 2.
13
+ async function createCardFixture(t) {
14
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
15
+ t.after(() => knex.destroy());
16
+ await knex.schema.createTable("cards", (table) => {
17
+ table.increments("id");
18
+ table.integer("workspace_id").notNullable();
19
+ table.string("title").notNullable();
20
+ table.string("public_token");
21
+ table.boolean("sent");
22
+ table.integer("views");
23
+ table.dateTime("issued_at");
24
+ });
25
+ await knex("cards").insert([
26
+ { workspace_id: 1, title: "Rex", public_token: "t1", sent: 1, views: 3 },
27
+ { workspace_id: 1, title: "Bo", public_token: "t2", sent: 0, views: null },
28
+ { workspace_id: 2, title: "Kit", public_token: "t3", sent: 1, views: 1 }
29
+ ]);
30
+ const resource = defineTableResource({
31
+ name: "cards",
32
+ tableName: "cards",
33
+ ownership: "workspace",
34
+ schema: {
35
+ workspaceId: { type: "id", required: true, hidden: true, storage: { column: "workspace_id" } },
36
+ title: { type: "string", required: true, search: true },
37
+ publicToken: { type: "string", nullable: true, hidden: true, storage: { column: "public_token" } },
38
+ sent: { type: "boolean", nullable: true, hidden: true },
39
+ views: { type: "integer", nullable: true, hidden: true },
40
+ issuedAt: { type: "dateTime", nullable: true, hidden: true, storage: { column: "issued_at" } }
41
+ }
42
+ });
43
+ const runtime = await createTestResourceRuntime({ knex });
44
+ const { cards } = await runtime.own("cards.feature", [resource]);
45
+ const system = runtime.declareSystemScope("cards.feature", "send each workspace its cards")("send the cards");
46
+ // The tables each SELECT reads, in order.
47
+ const selects = async (work) => (await countQueries(knex, work)).queries
48
+ .filter((sql) => /^select/iu.test(sql))
49
+ .map((sql) => [...sql.matchAll(/from `(\w+)`/gu)].map(([, table]) => table)[0]);
50
+ return { cards, resource, system, selects, options: { context: WORKSPACE_A } };
51
+ }
52
+
53
+ test("a handle's plain read answers the hidden fields its fields names, in one more statement, and no read without them does", async (t) => {
54
+ const { cards, system, selects, options } = await createCardFixture(t);
55
+ let card;
56
+ const plainRead = await selects(async () => {
57
+ card = await cards.get("1", {}, options);
58
+ });
59
+ assert.deepEqual(card, { id: "1", title: "Rex" });
60
+ assert.deepEqual(await selects(async () => {
61
+ card = await cards.get("1", { fields: ["title", "publicToken", "workspaceId"] }, options);
62
+ }), [...plainRead, "cards"]);
63
+ assert.deepEqual(card, { id: "1", title: "Rex", publicToken: "t1", workspaceId: "1" });
64
+ assert.equal(await cards.get("3", { fields: ["publicToken"] }, { ...options, missing: "skip" }), null);
65
+
66
+ const { records } = await cards.query({ fields: ["title", "sent", "views"], page: { size: 10 } }, options);
67
+ assert.deepEqual(records, [{ id: "1", title: "Rex", sent: true, views: 3 }, { id: "2", title: "Bo", sent: false, views: null }]);
68
+ assert.deepEqual(await cards.queryAll({ filters: { title: "Bo" }, fields: ["publicToken"] }, options), [{ id: "2", publicToken: "t2" }]);
69
+
70
+ // A system context reads every workspace's rows, and learns each one's owner.
71
+ const owners = await cards.queryAll({ fields: ["workspaceId"] }, { context: system });
72
+ assert.deepEqual(owners, [{ id: "1", workspaceId: "1" }, { id: "2", workspaceId: "1" }, { id: "3", workspaceId: "2" }]);
73
+
74
+ // The locks read them after the locking read, in the same transaction.
75
+ await cards.withTransaction(options, async (tx) => {
76
+ assert.deepEqual(await cards.lock("1", { ...tx, fields: ["publicToken"] }), { id: "1", publicToken: "t1" });
77
+ assert.deepEqual(await cards.lockMany(["1", "2"], { ...tx, fields: ["sent"] }), [{ id: "1", sent: true }, { id: "2", sent: false }]);
78
+ assert.deepEqual(await cards.lockWhere({ title: "Rex" }, { ...tx, fields: ["views"] }), [{ id: "1", views: 3 }]);
79
+ assert.deepEqual(await cards.claim({ filters: { title: "Bo" } }, { ...tx, fields: ["title", "publicToken"] }),
80
+ [{ id: "2", title: "Bo", publicToken: "t2" }]);
81
+ });
82
+ });
83
+
84
+ test("a JSON:API read never answers a hidden field, and a hidden field a read cannot decode is refused before it runs", async (t) => {
85
+ const { cards, selects, options } = await createCardFixture(t);
86
+ const document = await cards.query({ fields: ["title", "publicToken"], page: { size: 10 } }, { ...options, format: "jsonapi" });
87
+ assert.deepEqual(document.data.map(({ attributes }) => attributes), [{ title: "Rex" }, { title: "Bo" }]);
88
+ const one = await cards.get("1", { fields: ["title", "publicToken"] }, { ...options, format: "jsonapi" });
89
+ assert.deepEqual(one.data.attributes, { title: "Rex" });
90
+
91
+ assert.deepEqual(await selects(() => assert.rejects(cards.queryAll({ fields: ["issuedAt"] }, options),
92
+ /cards: fields names the hidden field "issuedAt", but a read answers a hidden field only when it is a stored id/u)), []);
93
+ });
94
+
95
+ test("withoutHiddenFields refuses another feature's read naming a hidden field, unless it runs in a system context", async (t) => {
96
+ const { cards, resource, system, options } = await createCardFixture(t);
97
+ const exposed = withoutHiddenFields(cards, resource);
98
+ await assert.rejects(exposed.get("1", { fields: ["title", "publicToken"] }, options),
99
+ /cards\.get\(\) fields names the hidden field "publicToken", which only the table's owner, or a system context, reads/u);
100
+ await assert.rejects(exposed.queryAll({ fields: ["workspaceId", "sent"] }, options), /hidden field "workspaceId", "sent"/u);
101
+ await assert.rejects(exposed.withTransaction(options, (tx) => exposed.lockMany(["1"], { ...tx, fields: ["publicToken"] })),
102
+ /cards\.lockMany\(\) fields names the hidden field "publicToken"/u);
103
+ assert.deepEqual(await exposed.get("1", { fields: ["title"] }, options), { id: "1", title: "Rex" });
104
+ assert.deepEqual(await exposed.queryAll({ filters: { title: "Kit" }, fields: ["workspaceId"] }, { context: system }),
105
+ [{ id: "3", workspaceId: "2" }]);
106
+ assert.deepEqual(await exposed.queryAll({ filters: { title: "Kit" }, fields: ["publicToken"] }, { context: system.forWorkspace("2") }),
107
+ [{ id: "3", publicToken: "t3" }]);
108
+ // Everything else is the repository's own, and a resource without hidden fields keeps its repository.
109
+ assert.equal(exposed.column("title"), cards.column("title"));
110
+ assert.equal(exposed.name, "cards");
111
+ assert.equal(Object.isFrozen(exposed), true);
112
+ const plain = defineTableResource({ name: "notes", tableName: "notes", ownership: "public", schema: { body: { type: "string" } } });
113
+ assert.equal(withoutHiddenFields(cards, plain), cards);
114
+ });
115
+
116
+ test("withoutHiddenFields refuses another feature's scopedQuery, whose builder carries the hidden columns, and the owner's join source still joins", async (t) => {
117
+ const { cards, resource, system, options } = await createCardFixture(t);
118
+ // The owner's join source, a method its decorateRepository adds with the handle it receives.
119
+ async function cardSource(alias, callOptions) {
120
+ const { query } = await cards.scopedQuery({ alias }, callOptions);
121
+ return { query, column: (field) => cards.column(field, alias) };
122
+ }
123
+ const exposed = withoutHiddenFields(Object.freeze({ ...cards, cardSource }), resource);
124
+
125
+ // Another feature's own builder would select the token: refused before it reads anything.
126
+ await assert.rejects(
127
+ (async () => (await exposed.scopedQuery({}, options)).query.select(exposed.column("publicToken")))(),
128
+ /cards\.scopedQuery\(\) answers every column, its hidden fields too, which only the table's owner, or a system context, reads/u
129
+ );
130
+ await assert.rejects(exposed.withTransaction(options, (tx) => exposed.scopedQuery({ alias: "c" }, tx)), /cards\.scopedQuery\(\)/u);
131
+
132
+ // A system context, and one it derived, still builds on it.
133
+ const { query: everyWorkspace } = await exposed.scopedQuery({}, { context: system });
134
+ assert.deepEqual((await everyWorkspace.count({ cards: exposed.column("id") }))[0].cards, 3);
135
+ const { query: workspaceTwo } = await exposed.scopedQuery({}, { context: system.forWorkspace("2") });
136
+ assert.deepEqual(await workspaceTwo.select({ title: exposed.column("title") }), [{ title: "Kit" }]);
137
+
138
+ // The cross-owner report pattern: the owner's method answers its scoped rows, which the report selects from.
139
+ const source = await exposed.cardSource("c", options);
140
+ const rows = await source.query.select({ title: source.column("title") }).orderBy(source.column("id"));
141
+ assert.deepEqual(rows, [{ title: "Rex" }, { title: "Bo" }]);
142
+ });
143
+
144
+ test("withoutHiddenFields answers raw(), column() and an owner's Knex values as they are, so another feature builds on them in a scoped query and a subquery", async (t) => {
145
+ const { cards, resource, options } = await createCardFixture(t);
146
+ async function cardSource(alias, callOptions) {
147
+ const { query } = await cards.scopedQuery({ alias }, callOptions);
148
+ return { query, column: (field) => cards.column(field, alias) };
149
+ }
150
+ // An owner's method answering a Knex value at once: an expression over its own column.
151
+ const lowerTitle = (alias) => cards.raw("LOWER(??)", [cards.column("title", alias)]);
152
+ const exposed = withoutHiddenFields(Object.freeze({ ...cards, cardSource, lowerTitle }), resource);
153
+
154
+ assert.equal(exposed.column("title", "c"), cards.column("title", "c"));
155
+ const shout = exposed.raw("UPPER(??)", [exposed.column("title", "c")]);
156
+ assert.equal(shout.then, undefined);
157
+ assert.equal(typeof exposed.lowerTitle("i").toSQL, "function");
158
+
159
+ // In the owner's scoped rows, as a select.
160
+ const source = await exposed.cardSource("c", options);
161
+ assert.deepEqual(await source.query.select({ shout }).orderBy(source.column("id")), [{ shout: "REX" }, { shout: "BO" }]);
162
+
163
+ // In a subquery: the cards of workspace 1 whose lower-cased title is "bo".
164
+ const outer = await exposed.cardSource("c", options);
165
+ const inner = await exposed.cardSource("i", options);
166
+ const matching = inner.query.select(inner.column("id")).where(exposed.lowerTitle("i"), exposed.raw("?", ["bo"]));
167
+ assert.deepEqual(await outer.query.whereIn(outer.column("id"), matching).select({ title: outer.column("title") }), [{ title: "Bo" }]);
168
+ });
169
+
170
+ test("withoutHiddenFields strips the hidden fields from every answer of a read the owner adds, unless it runs in a system context", async (t) => {
171
+ const { cards, resource, system, options } = await createCardFixture(t);
172
+ // The owner's decorateRepository reads: one forwarding the caller's params, the handle's own under another name, one
173
+ // answering a map, one in a sub-repository and one answering at once.
174
+ const owner = Object.freeze({
175
+ ...cards,
176
+ queryAllIncludingArchived: (params, callOptions) => cards.queryAll(params, callOptions),
177
+ everyCard: cards.queryAll,
178
+ async cardsByTitle(callOptions) {
179
+ const records = await cards.queryAll({ fields: ["title", "publicToken"] }, callOptions);
180
+ return new Map(records.map((record) => [record.title, record]));
181
+ },
182
+ archive: Object.freeze({ list: (callOptions) => cards.queryAll({ fields: ["title", "views"] }, callOptions) }),
183
+ tokenLength: (card) => String(card.publicToken).length
184
+ });
185
+ const exposed = withoutHiddenFields(owner, resource);
186
+
187
+ // Another feature's calls: no hidden field leaves, whatever the method's name or depth.
188
+ assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title", "publicToken", "workspaceId"] }, options),
189
+ [{ id: "1", title: "Rex" }]);
190
+ assert.deepEqual(await exposed.everyCard({ filters: { title: "Bo" }, fields: ["publicToken"] }, options), [{ id: "2" }]);
191
+ assert.deepEqual([...(await exposed.cardsByTitle(options)).values()], [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
192
+ assert.deepEqual(await exposed.archive.list(options), [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
193
+ // A method that answers at once still does, and withTransaction answers the caller's own work as it is.
194
+ assert.equal(exposed.tokenLength({ publicToken: "abc" }), 3);
195
+ assert.deepEqual(await exposed.withTransaction(options, async () => ({ publicToken: "the caller's own" })), { publicToken: "the caller's own" });
196
+
197
+ // The owner, with its own repository, and a system context, or one it derived, still read them when named.
198
+ assert.deepEqual(await owner.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["publicToken"] }, options),
199
+ [{ id: "1", publicToken: "t1" }]);
200
+ assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Kit" }, fields: ["publicToken", "workspaceId"] }, { context: system }),
201
+ [{ id: "3", publicToken: "t3", workspaceId: "2" }]);
202
+ assert.deepEqual(await exposed.archive.list({ context: system.forWorkspace("2") }), [{ id: "3", title: "Kit", views: 1 }]);
203
+ });
@@ -0,0 +1,18 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import { evaluateI18nCatalogContract } from "../../../tooling/test-support/i18nCatalogContract.mjs";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
9
+
10
+ test("resource-runtime English catalog matches its source", () => {
11
+ const { problems } = evaluateI18nCatalogContract({
12
+ repoRoot: REPO_ROOT,
13
+ packageDir: path.join(REPO_ROOT, "packages", "resource-runtime"),
14
+ // json-rest-api throws these; their English is fixed by the library.
15
+ externalCodes: ["REST_API_VERSION_CONFLICT", "REST_API_PRECONDITION_FAILED"]
16
+ });
17
+ assert.deepEqual(problems, []);
18
+ });
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
+ import { isDuplicateEntryError, retryLostInsertRace } from "@jskit-ai/database-runtime/shared";
6
+ import { createTestResourceRuntime } from "../src/test/index.js";
7
+
8
+ const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
9
+
10
+ // Requests keyed by a request key, unique in each workspace: a repeated request finds its first answer by the key.
11
+ async function createRequestFixture(t) {
12
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
13
+ t.after(() => knex.destroy());
14
+ await knex.schema.createTable("requests", (table) => {
15
+ table.increments("id");
16
+ table.integer("workspace_id").notNullable();
17
+ table.string("request_key").notNullable();
18
+ table.integer("amount").notNullable();
19
+ table.unique(["workspace_id", "request_key"]);
20
+ });
21
+ const runtime = await createTestResourceRuntime({ knex });
22
+ const { requests } = await runtime.own("requests.feature", [defineTableResource({
23
+ name: "requests",
24
+ tableName: "requests",
25
+ ownership: "workspace",
26
+ schema: {
27
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
28
+ requestKey: { type: "string", required: true, search: true, storage: { column: "request_key" } },
29
+ amount: { type: "integer", required: true }
30
+ }
31
+ })]);
32
+ return { knex, requests };
33
+ }
34
+
35
+ test("retryLostInsertRace runs a transaction whose insert lost a unique race once more, and the rerun finds the winner", async (t) => {
36
+ const { knex, requests } = await createRequestFixture(t);
37
+ const options = { context: WORKSPACE_A };
38
+ // The winner commits after this request looked its key up: the first run reads no row, as a snapshot taken before
39
+ // that commit does, so its insert fails on the key and its transaction rolls back.
40
+ await requests.create({ requestKey: "k", amount: 5 }, options);
41
+ let runs = 0;
42
+ const answer = await retryLostInsertRace(options, () => requests.withTransaction(options, async (txOptions) => {
43
+ runs += 1;
44
+ const [existing] = runs === 1 ? [] : await requests.queryAll({ filters: { requestKey: "k" }, fields: ["amount"] }, txOptions);
45
+ if (existing) {
46
+ return existing;
47
+ }
48
+ await requests.create({ requestKey: "k", amount: 9 }, { ...txOptions, returning: "none" });
49
+ return null;
50
+ }));
51
+ assert.equal(runs, 2);
52
+ assert.equal(answer.amount, 5);
53
+ assert.deepEqual(await knex("requests").pluck("amount"), [5]);
54
+
55
+ // Inside the caller's transaction the failed insert fails that transaction: nothing runs again, and the caller
56
+ // gets the duplicate.
57
+ runs = 0;
58
+ await assert.rejects(requests.withTransaction(options, (outer) => retryLostInsertRace(outer, () => requests.withTransaction(outer, (txOptions) => {
59
+ runs += 1;
60
+ return requests.create({ requestKey: "k", amount: 1 }, { ...txOptions, returning: "none" });
61
+ }))), (error) => isDuplicateEntryError(error));
62
+ assert.equal(runs, 1);
63
+ assert.deepEqual(await knex("requests").pluck("amount"), [5]);
64
+ });
@@ -0,0 +1,145 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
+ import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
6
+ import { createTestResourceRuntime } from "../src/test/index.js";
7
+
8
+ // patchWhere and deleteWhere on MySQL or MariaDB, under two concurrent writers: each checks its filters on the row
9
+ // once it holds the row's lock, so of two writers whose filters both matched a row, the one that waited for the
10
+ // other's lock leaves the row out when it no longer matches (a compare-and-set on the filters).
11
+
12
+ const WORKSPACE = Object.freeze({ workspace: { id: "1" } });
13
+ const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
14
+
15
+ const slotResource = defineTableResource({
16
+ name: "slots",
17
+ tableName: "slots",
18
+ ownership: "workspace",
19
+ defaultSort: ["id"],
20
+ schema: {
21
+ id: { type: "id", primary: true },
22
+ workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
23
+ status: { type: "string", defaultTo: "open", search: true },
24
+ takenBy: { type: "string", nullable: true, storage: { column: "taken_by" } }
25
+ }
26
+ });
27
+
28
+ /**
29
+ * A database of the test's own beside TEST_DB_NAME, with one slots table, dropped after the test. Every connection
30
+ * runs with MariaDB's innodb_snapshot_isolation set to `snapshotIsolation` (default off). A server without the
31
+ * setting (MySQL, older MariaDB) behaves as off, so asking it for on answers null.
32
+ */
33
+ async function ownDatabase(context, suffix, { snapshotIsolation = false } = {}) {
34
+ const name = `${requireTestDatabase(process.env)}_${suffix}`;
35
+ const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
36
+ const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
37
+ const [variables] = await admin.raw("SHOW VARIABLES LIKE 'innodb_snapshot_isolation'");
38
+ if (snapshotIsolation && variables.length === 0) {
39
+ await admin.destroy();
40
+ return null;
41
+ }
42
+ await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
43
+ await admin.raw("CREATE DATABASE ??", [name]);
44
+ const setting = variables.length === 0 ? null : `SET SESSION innodb_snapshot_isolation = ${snapshotIsolation ? "ON" : "OFF"}`;
45
+ const knex = knexLib({
46
+ client: "mysql2",
47
+ connection: { ...server, database: name, ...RUNTIME_MYSQL },
48
+ pool: { min: 0, max: 6, ...(setting ? { afterCreate: (connection, done) => connection.query(setting, (error) => done(error, connection)) } : {}) }
49
+ });
50
+ context.after(async () => {
51
+ await knex.destroy();
52
+ await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
53
+ await admin.destroy();
54
+ });
55
+ await knex.schema.createTable("slots", (table) => {
56
+ table.increments("id");
57
+ table.integer("workspace_id").nullable();
58
+ table.string("status").notNullable().defaultTo("open");
59
+ table.string("taken_by").nullable();
60
+ });
61
+ const runtime = await createTestResourceRuntime({ knex });
62
+ const { slots } = await runtime.own("slots.feature", [slotResource]);
63
+ return { knex, slots };
64
+ }
65
+
66
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
67
+
68
+ /**
69
+ * Waits until `count` transactions on this test's database wait for a row lock. A server that keeps INNODB_TRX from
70
+ * the test user gets a fixed pause instead, long enough for both writers to reach the lock.
71
+ */
72
+ async function bothWaiting(knex, count) {
73
+ for (let waited = 0; waited < 10_000; waited += 25) {
74
+ let waiting;
75
+ try {
76
+ const [[row]] = await knex.raw(
77
+ "SELECT COUNT(*) AS waiting FROM information_schema.INNODB_TRX t JOIN information_schema.PROCESSLIST p " +
78
+ "ON p.ID = t.trx_mysql_thread_id WHERE t.trx_state = 'LOCK WAIT' AND p.DB = DATABASE()"
79
+ );
80
+ waiting = Number(row.waiting);
81
+ } catch {
82
+ await pause(500);
83
+ return;
84
+ }
85
+ if (waiting >= count) return;
86
+ await pause(25);
87
+ }
88
+ assert.fail(`${count} writers did not reach the row's lock within 10 s`);
89
+ }
90
+
91
+ /** Holds the row's lock while both writers start, until both wait on it, then lets them go one after the other. */
92
+ async function raceOnLockedRow(knex, id, writers) {
93
+ const holder = await knex.transaction();
94
+ try {
95
+ await holder("slots").where({ id }).forUpdate().select("id");
96
+ const running = writers.map((write) => write().then((value) => ({ value }), (error) => ({ error })));
97
+ await bothWaiting(knex, writers.length);
98
+ await holder.commit();
99
+ return await Promise.all(running);
100
+ } finally {
101
+ if (!holder.isCompleted()) await holder.rollback();
102
+ }
103
+ }
104
+
105
+ async function takeTwice(t, suffix, options) {
106
+ const database = await ownDatabase(t, suffix, options);
107
+ if (!database) {
108
+ t.skip("the server has no innodb_snapshot_isolation setting");
109
+ return;
110
+ }
111
+ const { knex, slots } = database;
112
+ const [id] = await slots.createMany([{ status: "open" }], { context: WORKSPACE, returning: "ids" });
113
+ const take = (taker) => () => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: taker }, { context: WORKSPACE, returning: "ids" });
114
+ const outcomes = await raceOnLockedRow(knex, id, [take("first"), take("second")]);
115
+ assert.deepEqual(outcomes.filter(({ error }) => error), [], "neither writer fails");
116
+ assert.deepEqual(outcomes.map(({ value }) => value.length).sort(), [0, 1], "exactly one writer takes the row");
117
+ const row = await knex("slots").where({ id }).first("status", "taken_by");
118
+ assert.equal(row.status, "taken");
119
+ assert.equal(row.taken_by, outcomes[0].value.length === 1 ? "first" : "second");
120
+ }
121
+
122
+ test("of two concurrent patchWhere calls whose filters both matched one row, exactly one writes it", async (t) => {
123
+ requireTestDatabase(process.env);
124
+ await takeTwice(t, "da_pw_race");
125
+ });
126
+
127
+ test("with innodb_snapshot_isolation on, the waiting patchWhere fails as changed since its read and runs again, leaving the row out", async (t) => {
128
+ requireTestDatabase(process.env);
129
+ await takeTwice(t, "da_pw_snapshot", { snapshotIsolation: true });
130
+ });
131
+
132
+ test("a deleteWhere whose filter matched a row a concurrent patchWhere changed leaves it in place", async (t) => {
133
+ requireTestDatabase(process.env);
134
+ const { knex, slots } = await ownDatabase(t, "da_dw_race");
135
+ const [id] = await slots.createMany([{ status: "open" }], { context: WORKSPACE, returning: "ids" });
136
+ const outcomes = await raceOnLockedRow(knex, id, [
137
+ () => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: "first" }, { context: WORKSPACE, returning: "ids" }),
138
+ () => slots.deleteWhere({ id, status: "open" }, { context: WORKSPACE, returning: "ids" })
139
+ ]);
140
+ assert.deepEqual(outcomes.filter(({ error }) => error), []);
141
+ const [patched, deleted] = outcomes.map(({ value }) => value.length);
142
+ const row = await knex("slots").where({ id }).first("status");
143
+ // Whichever went first wins; the other finds the row changed (or gone) and leaves it.
144
+ assert.deepEqual([patched + deleted, row ? row.status : "deleted"], [1, patched === 1 ? "taken" : "deleted"]);
145
+ });
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knexLib from "knex";
4
+ import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
+ import { createTestResourceRuntime } from "../src/test/index.js";
6
+
7
+ const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
8
+
9
+ 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
+ await knex.schema.createTable("receivals", (table) => {
13
+ table.increments("id");
14
+ table.integer("workspace_id");
15
+ table.string("status").notNullable();
16
+ table.integer("received_weight").notNullable();
17
+ table.integer("processed_weight");
18
+ });
19
+ const receivalResource = defineTableResource({
20
+ name: "receivals",
21
+ tableName: "receivals",
22
+ ownership: "workspace",
23
+ schema: {
24
+ workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
25
+ status: { type: "string", required: true, search: true },
26
+ receivedWeight: { type: "integer", required: true, storage: { column: "received_weight" } },
27
+ processedWeight: { type: "integer", nullable: true, storage: { column: "processed_weight" } },
28
+ remainingWeight: { type: "number", storage: { virtual: true } }
29
+ }
30
+ });
31
+ let tools;
32
+ const runtime = await createTestResourceRuntime({ knex });
33
+ const { receivals } = await runtime.own("receivals.feature", [{
34
+ resource: receivalResource,
35
+ queryFields: {
36
+ remainingWeight: {
37
+ type: "number",
38
+ select(given) {
39
+ tools = given;
40
+ return given.raw("?? - coalesce(??, 0)", [given.column("receivedWeight"), given.column("processedWeight")]);
41
+ }
42
+ }
43
+ }
44
+ }]);
45
+ const options = { context: WORKSPACE_A };
46
+ await receivals.createMany([
47
+ { status: "open", receivedWeight: 10, processedWeight: 4 },
48
+ { status: "open", receivedWeight: 7 },
49
+ { status: "closed", receivedWeight: 3, processedWeight: 3 }
50
+ ], options);
51
+
52
+ const open = await receivals.queryAll({ filters: { status: "open" }, fields: ["remainingWeight"] }, options);
53
+ assert.deepEqual(open.map(({ remainingWeight }) => remainingWeight), [6, 7]);
54
+ assert.deepEqual(Object.keys(tools).sort(), ["column", "filters", "raw"]);
55
+ assert.equal(tools.filters.status, "open");
56
+ assert.equal(Object.isFrozen(tools.filters), true);
57
+
58
+ // raw takes one SQL string and its bindings as an array, and is a value only: awaited, it answers itself and runs
59
+ // nothing; run as a statement, it throws.
60
+ assert.throws(() => tools.raw(42), /Query field "remainingWeight" raw\(\) takes one SQL expression as a string/u);
61
+ assert.throws(() => tools.raw("?? + 1"), /needs the bindings of its placeholders/u);
62
+ assert.throws(() => tools.raw("? + 1", { value: 1 }), /takes \(sql, bindings\)/u);
63
+ assert.throws(() => tools.raw("? + 1", [1], "extra"), /takes \(sql, bindings\)/u);
64
+ const statement = tools.raw("DELETE FROM receivals");
65
+ assert.equal(await statement, statement);
66
+ assert.throws(() => statement.asCallback(() => {}), /is a value inside a scoped query or a write/u);
67
+ assert.equal((await knex("receivals")).length, 3);
68
+ });