@jskit-ai/resource-runtime 0.0.0-stage → 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 +82 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/changeHorizon.js +26 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +137 -0
- package/src/server/foreignKeyCheck.js +58 -0
- package/src/server/handleGuards.js +233 -0
- package/src/server/handleHidden.js +410 -0
- package/src/server/handleLocks.js +135 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +18 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +92 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +737 -0
- package/src/server/resourceHost.js +687 -0
- package/src/server/resourceRuntime.js +300 -0
- package/src/server/standaloneResources.js +49 -0
- package/src/server/types.js +49 -0
- package/src/test/expectFlatQueries.js +117 -0
- package/src/test/expectScoped.js +111 -0
- package/src/test/index.js +123 -0
- package/test/callSettings.test.js +100 -0
- package/test/changes.postgres.test.js +141 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/foreignKeys.test.js +94 -0
- package/test/hiddenFields.test.js +244 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/locks.postgres.test.js +345 -0
- package/test/lostInsertRace.test.js +65 -0
- package/test/manyRowWrites.mysql.test.js +154 -0
- package/test/queryFields.test.js +69 -0
- package/test/requestCallOptions.test.js +155 -0
- package/test/resourceRuntime.test.js +2098 -0
- 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 +290 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
4
|
+
import { retryLostInsertRace } from "@jskit-ai/database-runtime/shared";
|
|
5
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
6
|
+
|
|
7
|
+
// The row locks, the many-row writes and the upserts on PostgreSQL, with real concurrent transactions: what an
|
|
8
|
+
// application on PostgreSQL relies on when two requests or two servers reach the same rows at once. Each test works in
|
|
9
|
+
// a schema of its own in TEST_DB_NAME (createPackageTestKnex); the other data tests of this package run here too on a
|
|
10
|
+
// DB_CLIENT=pg run, and the MySQL and MariaDB races are in manyRowWrites.mysql.test.js.
|
|
11
|
+
|
|
12
|
+
const skip = process.env.DB_CLIENT === "pg" ? false : "a PostgreSQL test: it runs on a DB_CLIENT=pg run, with TEST_DB_NAME";
|
|
13
|
+
const WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: Object.freeze({ id: "1" }) }) });
|
|
14
|
+
const OTHER_WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: Object.freeze({ id: "2" }) }) });
|
|
15
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
const settle = (work) => work().then((value) => ({ value }), (error) => ({ error }));
|
|
17
|
+
|
|
18
|
+
const slotResource = defineTableResource({
|
|
19
|
+
name: "slots",
|
|
20
|
+
tableName: "slots",
|
|
21
|
+
ownership: "workspace",
|
|
22
|
+
defaultSort: ["id"],
|
|
23
|
+
schema: {
|
|
24
|
+
id: { type: "id", primary: true },
|
|
25
|
+
workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
|
|
26
|
+
code: { type: "string", nullable: true, search: true },
|
|
27
|
+
status: { type: "string", defaultTo: "open", search: true },
|
|
28
|
+
takenBy: { type: "string", nullable: true, storage: { column: "taken_by" } }
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// A table with no owners keyed by an id the server computes, as lockOrCreate and findOrCreate take.
|
|
33
|
+
const balanceResource = defineTableResource({
|
|
34
|
+
name: "balances",
|
|
35
|
+
tableName: "balances",
|
|
36
|
+
ownership: "public",
|
|
37
|
+
schema: {
|
|
38
|
+
id: { type: "string", primary: true },
|
|
39
|
+
amount: { type: "integer", defaultTo: 0 }
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function database(t) {
|
|
44
|
+
const knex = await createPackageTestKnex(t, { environment: process.env });
|
|
45
|
+
await knex.schema.createTable("slots", (table) => {
|
|
46
|
+
table.increments("id");
|
|
47
|
+
table.integer("workspace_id").nullable();
|
|
48
|
+
table.string("code").nullable();
|
|
49
|
+
table.string("status").notNullable().defaultTo("open");
|
|
50
|
+
table.string("taken_by").nullable();
|
|
51
|
+
table.unique(["workspace_id", "code"]);
|
|
52
|
+
});
|
|
53
|
+
await knex.schema.createTable("balances", (table) => {
|
|
54
|
+
table.string("id").primary();
|
|
55
|
+
table.integer("amount").notNullable().defaultTo(0);
|
|
56
|
+
});
|
|
57
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
58
|
+
const { slots, balances } = await runtime.own("slots.feature", [slotResource, balanceResource]);
|
|
59
|
+
return { knex, slots, balances };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Waits until `count` sessions wait, directly or behind another waiter, for the session `pid` (pg_blocking_pids).
|
|
64
|
+
* @param {import("knex").Knex} knex
|
|
65
|
+
* @param {number} pid
|
|
66
|
+
* @param {number} count
|
|
67
|
+
*/
|
|
68
|
+
async function waitingBehind(knex, pid, count) {
|
|
69
|
+
for (let waited = 0; waited < 10_000; waited += 25) {
|
|
70
|
+
const { rows: [{ waiting }] } = await knex.raw(
|
|
71
|
+
"WITH RECURSIVE blocked(pid) AS (" +
|
|
72
|
+
"SELECT a.pid FROM pg_stat_activity a WHERE ? = ANY(pg_blocking_pids(a.pid)) " +
|
|
73
|
+
"UNION SELECT a.pid FROM pg_stat_activity a JOIN blocked b ON b.pid = ANY(pg_blocking_pids(a.pid))" +
|
|
74
|
+
") SELECT count(*)::int AS waiting FROM blocked",
|
|
75
|
+
[pid]
|
|
76
|
+
);
|
|
77
|
+
if (waiting >= count) return;
|
|
78
|
+
await pause(25);
|
|
79
|
+
}
|
|
80
|
+
assert.fail(`${count} sessions did not wait for session ${pid} within 10 s`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Runs `hold(trx)` in a transaction of its own, then the writers, until all of them wait behind it; then ends it
|
|
85
|
+
* (commits, or rolls back with `rollback`) and answers each writer's outcome.
|
|
86
|
+
*/
|
|
87
|
+
async function raceBehind(knex, hold, writers, { rollback = false } = {}) {
|
|
88
|
+
const holder = await knex.transaction();
|
|
89
|
+
try {
|
|
90
|
+
const { rows: [{ pid }] } = await holder.raw("SELECT pg_backend_pid() AS pid");
|
|
91
|
+
await hold(holder);
|
|
92
|
+
const running = writers.map((write) => settle(write));
|
|
93
|
+
await waitingBehind(knex, pid, writers.length);
|
|
94
|
+
await (rollback ? holder.rollback() : holder.commit());
|
|
95
|
+
return await Promise.all(running);
|
|
96
|
+
} finally {
|
|
97
|
+
if (!holder.isCompleted()) await holder.rollback();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const noErrors = (outcomes) => assert.deepEqual(outcomes.filter(({ error }) => error).map(({ error }) => error.message), []);
|
|
102
|
+
|
|
103
|
+
test("of two concurrent patchWhere calls whose filters both matched one row, exactly one writes it", { skip }, async (t) => {
|
|
104
|
+
const { knex, slots } = await database(t);
|
|
105
|
+
const [id] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
106
|
+
const take = (taker) => () => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: taker }, { ...WORKSPACE, returning: "ids" });
|
|
107
|
+
const outcomes = await raceBehind(knex, (holder) => holder("slots").where({ id }).forUpdate().select("id"), [take("first"), take("second")]);
|
|
108
|
+
noErrors(outcomes);
|
|
109
|
+
assert.deepEqual(outcomes.map(({ value }) => value.length).sort(), [0, 1], "exactly one writer takes the row");
|
|
110
|
+
const row = await knex("slots").where({ id }).first("status", "taken_by");
|
|
111
|
+
assert.deepEqual([row.status, row.taken_by], ["taken", outcomes[0].value.length === 1 ? "first" : "second"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a deleteWhere whose filter matched a row a concurrent patchWhere changed leaves it in place", { skip }, async (t) => {
|
|
115
|
+
const { knex, slots } = await database(t);
|
|
116
|
+
const [id] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
117
|
+
const outcomes = await raceBehind(knex, (holder) => holder("slots").where({ id }).forUpdate().select("id"), [
|
|
118
|
+
() => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: "first" }, { ...WORKSPACE, returning: "ids" }),
|
|
119
|
+
() => slots.deleteWhere({ id, status: "open" }, { ...WORKSPACE, returning: "ids" })
|
|
120
|
+
]);
|
|
121
|
+
noErrors(outcomes);
|
|
122
|
+
const [patched, deleted] = outcomes.map(({ value }) => value.length);
|
|
123
|
+
const row = await knex("slots").where({ id }).first("status");
|
|
124
|
+
assert.deepEqual([patched + deleted, row ? row.status : "deleted"], [1, patched === 1 ? "taken" : "deleted"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a patchWhere that waited for a row changed meanwhile writes only the rows that still match", { skip }, async (t) => {
|
|
128
|
+
const { knex, slots } = await database(t);
|
|
129
|
+
const ids = await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
130
|
+
const [outcome] = await raceBehind(
|
|
131
|
+
knex,
|
|
132
|
+
(holder) => holder("slots").where({ id: ids[1] }).update({ status: "taken", taken_by: "holder" }),
|
|
133
|
+
[() => slots.patchWhere({ status: "open" }, { status: "closed" }, { ...WORKSPACE, returning: "ids" })]
|
|
134
|
+
);
|
|
135
|
+
noErrors([outcome]);
|
|
136
|
+
assert.deepEqual(outcome.value.map(String).sort(), [ids[0], ids[2]].map(String).sort());
|
|
137
|
+
assert.deepEqual((await knex("slots").orderBy("id").pluck("status")), ["closed", "taken", "closed"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("two workers claiming at once take different rows, and skip a row another transaction holds", { skip }, async (t) => {
|
|
141
|
+
const { knex, slots } = await database(t);
|
|
142
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
143
|
+
await slots.createMany([{ status: "open" }], { ...OTHER_WORKSPACE, returning: "none" });
|
|
144
|
+
const holder = await knex.transaction();
|
|
145
|
+
t.after(() => (holder.isCompleted() ? undefined : holder.rollback()));
|
|
146
|
+
await holder("slots").where({ id: ids[0] }).forUpdate().select("id");
|
|
147
|
+
|
|
148
|
+
const first = Promise.withResolvers();
|
|
149
|
+
const release = Promise.withResolvers();
|
|
150
|
+
const claimNext = (take) => (txOptions) => slots.claim({ filters: { status: "open" }, sort: ["id"], take }, txOptions);
|
|
151
|
+
const worker = slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
152
|
+
const claimed = await claimNext(1)(txOptions);
|
|
153
|
+
first.resolve(claimed.map(({ id }) => String(id)));
|
|
154
|
+
await release.promise;
|
|
155
|
+
await slots.patchWhere({ id: claimed.map(({ id }) => id) }, { status: "taken", takenBy: "first" }, txOptions);
|
|
156
|
+
return claimed;
|
|
157
|
+
});
|
|
158
|
+
assert.deepEqual(await first.promise, [ids[1]], "the first worker skips the row the holder locked");
|
|
159
|
+
// While the first worker holds its row, the second takes the rest, never the held ones nor another workspace's.
|
|
160
|
+
const second = await slots.withTransaction(WORKSPACE, async (txOptions) => (await claimNext(10)(txOptions)).map(({ id }) => String(id)));
|
|
161
|
+
assert.deepEqual(second, [ids[2], ids[3]]);
|
|
162
|
+
release.resolve();
|
|
163
|
+
await worker;
|
|
164
|
+
await holder.rollback();
|
|
165
|
+
assert.deepEqual((await knex("slots").where({ workspace_id: 1 }).orderBy("id").pluck("status")), ["open", "taken", "open", "open"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a claim leaves a row whose committed change stopped it matching while the claim waited, and takes the next", { skip }, async (t) => {
|
|
169
|
+
const { knex, slots } = await database(t);
|
|
170
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
171
|
+
// A claim never waits (SKIP LOCKED): the held row is skipped, and once its holder commits a change, it no longer matches.
|
|
172
|
+
const holder = await knex.transaction();
|
|
173
|
+
t.after(() => (holder.isCompleted() ? undefined : holder.rollback()));
|
|
174
|
+
await holder("slots").where({ id: ids[0] }).update({ status: "taken" });
|
|
175
|
+
const during = await slots.withTransaction(WORKSPACE, async (txOptions) => (await slots.claim({ filters: { status: "open" } }, txOptions)).map(({ id }) => String(id)));
|
|
176
|
+
await holder.commit();
|
|
177
|
+
const after = await slots.withTransaction(WORKSPACE, async (txOptions) => (await slots.claim({ filters: { status: "open" }, take: 5 }, txOptions)).map(({ id }) => String(id)));
|
|
178
|
+
assert.deepEqual([during, after], [[ids[1]], [ids[1]]]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("lockMany waits for another transaction's lock, then reads the rows as it committed them, in id order", { skip }, async (t) => {
|
|
182
|
+
const { knex, slots } = await database(t);
|
|
183
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
184
|
+
const [theirs] = (await slots.createMany([{ status: "open" }], { ...OTHER_WORKSPACE, returning: "ids" })).map(String);
|
|
185
|
+
const [outcome] = await raceBehind(
|
|
186
|
+
knex,
|
|
187
|
+
(holder) => holder("slots").where({ id: ids[1] }).update({ status: "taken", taken_by: "holder" }),
|
|
188
|
+
[() => slots.withTransaction(WORKSPACE, (txOptions) => slots.lockMany([ids[2], theirs, ids[1], ids[0]], { ...txOptions, missing: "skip" }))]
|
|
189
|
+
);
|
|
190
|
+
noErrors([outcome]);
|
|
191
|
+
assert.deepEqual(outcome.value.map(({ id, status }) => [String(id), status]), [[ids[0], "open"], [ids[1], "taken"], [ids[2], "open"]]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a lockWhere that waited also locks a row that started to match while it waited", { skip }, async (t) => {
|
|
195
|
+
const { knex, slots } = await database(t);
|
|
196
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "taken" }, { status: "taken" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
197
|
+
const [outcome] = await raceBehind(
|
|
198
|
+
knex,
|
|
199
|
+
(holder) => holder("slots").whereIn("id", [ids[0], ids[1]]).update({ status: "open" }),
|
|
200
|
+
[() => slots.withTransaction(WORKSPACE, (txOptions) => slots.lockWhere({ status: "open" }, txOptions))]
|
|
201
|
+
);
|
|
202
|
+
noErrors([outcome]);
|
|
203
|
+
assert.deepEqual(outcome.value.map(({ id }) => String(id)), [ids[0], ids[1]]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("two upserts of one key behind an uncommitted insert of it both succeed, and the key has one row", { skip }, async (t) => {
|
|
207
|
+
const { knex, slots } = await database(t);
|
|
208
|
+
const upsert = (action, takenBy) => () => slots.createMany(
|
|
209
|
+
[{ code: "a", status: "open", takenBy }],
|
|
210
|
+
{ ...WORKSPACE, onConflict: { keys: ["workspaceId", "code"], action }, returning: "ids" }
|
|
211
|
+
);
|
|
212
|
+
for (const [action, expected] of [["ignore", ["holder"]], ["merge", ["first", "second"]]]) {
|
|
213
|
+
await knex("slots").del();
|
|
214
|
+
const outcomes = await raceBehind(
|
|
215
|
+
knex,
|
|
216
|
+
(holder) => holder("slots").insert({ workspace_id: 1, code: "a", status: "open", taken_by: "holder" }),
|
|
217
|
+
[upsert(action, "first"), upsert(action, "second")],
|
|
218
|
+
{ rollback: action === "merge" }
|
|
219
|
+
);
|
|
220
|
+
noErrors(outcomes);
|
|
221
|
+
const rows = await knex("slots").where({ workspace_id: 1, code: "a" }).select("taken_by");
|
|
222
|
+
assert.equal(rows.length, 1, `${action}: one row for the key`);
|
|
223
|
+
assert.ok(expected.includes(rows[0].taken_by), `${action}: kept ${rows[0].taken_by}`);
|
|
224
|
+
}
|
|
225
|
+
// The same key in another workspace is that workspace's own row.
|
|
226
|
+
await slots.createMany([{ code: "a", status: "open" }], { ...OTHER_WORKSPACE, onConflict: { keys: ["workspaceId", "code"], action: "ignore" } });
|
|
227
|
+
assert.equal((await knex("slots").where({ code: "a" })).length, 2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("two transactions that lock two rows in opposite orders deadlock, and the victim runs again and commits", { skip }, async (t) => {
|
|
231
|
+
const { slots } = await database(t);
|
|
232
|
+
const [a, b] = (await slots.createMany([{ status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
233
|
+
const locked = { first: Promise.withResolvers(), second: Promise.withResolvers() };
|
|
234
|
+
let runs = 0;
|
|
235
|
+
const work = (mine, theirs, name, other) => slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
236
|
+
runs += 1;
|
|
237
|
+
await slots.lock(mine, txOptions);
|
|
238
|
+
locked[name].resolve();
|
|
239
|
+
await locked[other].promise;
|
|
240
|
+
await slots.lock(theirs, txOptions);
|
|
241
|
+
await slots.patch(mine, { takenBy: name }, { ...txOptions, returning: "none" });
|
|
242
|
+
});
|
|
243
|
+
noErrors(await Promise.all([settle(() => work(a, b, "first", "second")), settle(() => work(b, a, "second", "first"))]));
|
|
244
|
+
assert.equal(runs, 3, "the deadlock victim's work ran once more");
|
|
245
|
+
assert.deepEqual((await slots.queryAll({ filters: { id: [a, b] } }, WORKSPACE)).map(({ takenBy }) => takenBy), ["first", "second"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a transaction whose insert lost a unique race runs once more and finds the winner's row", { skip }, async (t) => {
|
|
249
|
+
const { knex, slots } = await database(t);
|
|
250
|
+
let runs = 0;
|
|
251
|
+
const record = (takenBy) => () => retryLostInsertRace(WORKSPACE, () => slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
252
|
+
runs += 1;
|
|
253
|
+
const [found] = await slots.queryAll({ filters: { code: "k" } }, txOptions);
|
|
254
|
+
return found ?? slots.create({ code: "k", status: "taken", takenBy }, txOptions);
|
|
255
|
+
}));
|
|
256
|
+
const outcomes = await raceBehind(
|
|
257
|
+
knex,
|
|
258
|
+
(holder) => holder("slots").insert({ workspace_id: 1, code: "k", status: "open", taken_by: "holder" }),
|
|
259
|
+
[record("first"), record("second")],
|
|
260
|
+
{ rollback: true }
|
|
261
|
+
);
|
|
262
|
+
noErrors(outcomes);
|
|
263
|
+
const [first, second] = outcomes.map(({ value }) => value);
|
|
264
|
+
assert.equal(String(first.id), String(second.id), "both answer the one row");
|
|
265
|
+
assert.equal(runs, 3, "the loser ran once more");
|
|
266
|
+
assert.equal((await knex("slots").where({ code: "k" })).length, 1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("findOrCreate of one id at once, behind an uncommitted insert of it, answers one record to both", { skip }, async (t) => {
|
|
270
|
+
const { knex, balances } = await database(t);
|
|
271
|
+
const outcomes = await raceBehind(
|
|
272
|
+
knex,
|
|
273
|
+
(holder) => holder("balances").insert({ id: "acct-1", amount: 7 }),
|
|
274
|
+
[() => balances.findOrCreate({ id: "acct-1", amount: 1 }, WORKSPACE), () => balances.findOrCreate({ id: "acct-1", amount: 2 }, WORKSPACE)],
|
|
275
|
+
{ rollback: true }
|
|
276
|
+
);
|
|
277
|
+
noErrors(outcomes);
|
|
278
|
+
const [first, second] = outcomes.map(({ value }) => value);
|
|
279
|
+
assert.deepEqual([first.id, second.id, first.amount], ["acct-1", "acct-1", second.amount]);
|
|
280
|
+
assert.deepEqual(await knex("balances").pluck("id"), ["acct-1"]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("first uses of one id at once through lockOrCreate create one record and run every work once, one at a time", { skip }, async (t) => {
|
|
284
|
+
const { balances } = await database(t);
|
|
285
|
+
let works = 0;
|
|
286
|
+
let inside = 0;
|
|
287
|
+
let overlapped = false;
|
|
288
|
+
const add = () => balances.lockOrCreate({ id: "acct-2", amount: 0 }, WORKSPACE, async (record, txOptions) => {
|
|
289
|
+
works += 1;
|
|
290
|
+
inside += 1;
|
|
291
|
+
overlapped ||= inside > 1;
|
|
292
|
+
await pause(20);
|
|
293
|
+
await balances.patch(record.id, { amount: record.amount + 1 }, { ...txOptions, returning: "none" });
|
|
294
|
+
inside -= 1;
|
|
295
|
+
});
|
|
296
|
+
noErrors(await Promise.all(Array.from({ length: 4 }, () => settle(add))));
|
|
297
|
+
assert.equal(works, 4);
|
|
298
|
+
assert.equal(overlapped, false, "the lock serializes the works");
|
|
299
|
+
assert.equal((await balances.get("acct-2", {}, WORKSPACE)).amount, 4);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a lock or patchWhere with lockMode noKeyUpdate lets another transaction add a row that references the locked one; the default lock does not", { skip }, async (t) => {
|
|
303
|
+
const { knex, slots, balances } = await database(t);
|
|
304
|
+
await knex.schema.createTable("entries", (table) => {
|
|
305
|
+
table.increments("id");
|
|
306
|
+
table.integer("slot_id").nullable().references("id").inTable("slots");
|
|
307
|
+
table.string("balance_id").nullable().references("id").inTable("balances");
|
|
308
|
+
});
|
|
309
|
+
const [slot] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
310
|
+
await balances.createMany([{ id: "acct-3", amount: 0 }], { ...WORKSPACE, returning: "none" });
|
|
311
|
+
// Another transaction adds a row that references the locked one. Its foreign key check takes FOR KEY SHARE on the
|
|
312
|
+
// locked row, which FOR NO KEY UPDATE lets through and FOR UPDATE does not; it gives up after 500 ms (55P03).
|
|
313
|
+
const addEntry = async (entry) => {
|
|
314
|
+
const { error } = await settle(() => knex.transaction(async (trx) => {
|
|
315
|
+
await trx.raw("SET LOCAL lock_timeout = '500ms'");
|
|
316
|
+
await trx("entries").insert(entry);
|
|
317
|
+
}));
|
|
318
|
+
return error ? error.code : "added";
|
|
319
|
+
};
|
|
320
|
+
const lockers = {
|
|
321
|
+
lock: (txOptions) => slots.lock(slot, txOptions),
|
|
322
|
+
lockMany: (txOptions) => slots.lockMany([slot], txOptions),
|
|
323
|
+
lockWhere: (txOptions) => slots.lockWhere({ status: "open" }, txOptions),
|
|
324
|
+
claim: (txOptions) => slots.claim({ filters: { status: "open" } }, txOptions),
|
|
325
|
+
patchWhere: (txOptions) => slots.patchWhere({ status: "open" }, { takenBy: "worker" }, txOptions)
|
|
326
|
+
};
|
|
327
|
+
const outcomes = {};
|
|
328
|
+
for (const [name, locker] of Object.entries(lockers)) {
|
|
329
|
+
for (const mode of ["noKeyUpdate", "update"]) {
|
|
330
|
+
outcomes[`${name} ${mode}`] = await slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
331
|
+
await locker(mode === "update" ? txOptions : { ...txOptions, lockMode: mode });
|
|
332
|
+
return addEntry({ slot_id: slot });
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const mode of ["noKeyUpdate", "update"]) {
|
|
337
|
+
const options = mode === "update" ? WORKSPACE : { ...WORKSPACE, lockMode: mode };
|
|
338
|
+
outcomes[`lockOrCreate ${mode}`] = await balances.lockOrCreate({ id: "acct-3", amount: 0 }, options, () => addEntry({ balance_id: "acct-3" }));
|
|
339
|
+
}
|
|
340
|
+
assert.deepEqual(outcomes, Object.fromEntries([...Object.keys(lockers), "lockOrCreate"].flatMap((name) => [
|
|
341
|
+
[`${name} noKeyUpdate`, "added"],
|
|
342
|
+
[`${name} update`, "55P03"]
|
|
343
|
+
])));
|
|
344
|
+
assert.equal(Number((await knex("entries").count({ count: "*" }).first()).count), 6);
|
|
345
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
4
|
+
import { isDuplicateEntryError, retryLostInsertRace } from "@jskit-ai/database-runtime/shared";
|
|
5
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
6
|
+
|
|
7
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
8
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
9
|
+
|
|
10
|
+
const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
|
|
11
|
+
|
|
12
|
+
// Requests keyed by a request key, unique in each workspace: a repeated request finds its first answer by the key.
|
|
13
|
+
async function createRequestFixture(t) {
|
|
14
|
+
const knex = await testKnex(t);
|
|
15
|
+
await knex.schema.createTable("requests", (table) => {
|
|
16
|
+
table.increments("id");
|
|
17
|
+
table.integer("workspace_id").notNullable();
|
|
18
|
+
table.string("request_key").notNullable();
|
|
19
|
+
table.integer("amount").notNullable();
|
|
20
|
+
table.unique(["workspace_id", "request_key"]);
|
|
21
|
+
});
|
|
22
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
23
|
+
const { requests } = await runtime.own("requests.feature", [defineTableResource({
|
|
24
|
+
name: "requests",
|
|
25
|
+
tableName: "requests",
|
|
26
|
+
ownership: "workspace",
|
|
27
|
+
schema: {
|
|
28
|
+
workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
|
|
29
|
+
requestKey: { type: "string", required: true, search: true, storage: { column: "request_key" } },
|
|
30
|
+
amount: { type: "integer", required: true }
|
|
31
|
+
}
|
|
32
|
+
})]);
|
|
33
|
+
return { knex, requests };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("retryLostInsertRace runs a transaction whose insert lost a unique race once more, and the rerun finds the winner", async (t) => {
|
|
37
|
+
const { knex, requests } = await createRequestFixture(t);
|
|
38
|
+
const options = { context: WORKSPACE_A };
|
|
39
|
+
// The winner commits after this request looked its key up: the first run reads no row, as a snapshot taken before
|
|
40
|
+
// that commit does, so its insert fails on the key and its transaction rolls back.
|
|
41
|
+
await requests.create({ requestKey: "k", amount: 5 }, options);
|
|
42
|
+
let runs = 0;
|
|
43
|
+
const answer = await retryLostInsertRace(options, () => requests.withTransaction(options, async (txOptions) => {
|
|
44
|
+
runs += 1;
|
|
45
|
+
const [existing] = runs === 1 ? [] : await requests.queryAll({ filters: { requestKey: "k" }, fields: ["amount"] }, txOptions);
|
|
46
|
+
if (existing) {
|
|
47
|
+
return existing;
|
|
48
|
+
}
|
|
49
|
+
await requests.create({ requestKey: "k", amount: 9 }, { ...txOptions, returning: "none" });
|
|
50
|
+
return null;
|
|
51
|
+
}));
|
|
52
|
+
assert.equal(runs, 2);
|
|
53
|
+
assert.equal(answer.amount, 5);
|
|
54
|
+
assert.deepEqual(await knex("requests").pluck("amount"), [5]);
|
|
55
|
+
|
|
56
|
+
// Inside the caller's transaction the failed insert fails that transaction: nothing runs again, and the caller
|
|
57
|
+
// gets the duplicate.
|
|
58
|
+
runs = 0;
|
|
59
|
+
await assert.rejects(requests.withTransaction(options, (outer) => retryLostInsertRace(outer, () => requests.withTransaction(outer, (txOptions) => {
|
|
60
|
+
runs += 1;
|
|
61
|
+
return requests.create({ requestKey: "k", amount: 1 }, { ...txOptions, returning: "none" });
|
|
62
|
+
}))), (error) => isDuplicateEntryError(error));
|
|
63
|
+
assert.equal(runs, 1);
|
|
64
|
+
assert.deepEqual(await knex("requests").pluck("amount"), [5]);
|
|
65
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { UnsupportedDatabaseError } from "../src/server/index.js";
|
|
7
|
+
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
8
|
+
|
|
9
|
+
// patchWhere and deleteWhere on MySQL or MariaDB, under two concurrent writers: each checks its filters on the row
|
|
10
|
+
// once it holds the row's lock, so of two writers whose filters both matched a row, the one that waited for the
|
|
11
|
+
// other's lock leaves the row out when it no longer matches (a compare-and-set on the filters).
|
|
12
|
+
|
|
13
|
+
const WORKSPACE = Object.freeze({ workspace: { id: "1" } });
|
|
14
|
+
const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
|
|
15
|
+
|
|
16
|
+
const slotResource = defineTableResource({
|
|
17
|
+
name: "slots",
|
|
18
|
+
tableName: "slots",
|
|
19
|
+
ownership: "workspace",
|
|
20
|
+
defaultSort: ["id"],
|
|
21
|
+
schema: {
|
|
22
|
+
id: { type: "id", primary: true },
|
|
23
|
+
workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
|
|
24
|
+
status: { type: "string", defaultTo: "open", search: true },
|
|
25
|
+
takenBy: { type: "string", nullable: true, storage: { column: "taken_by" } }
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A database of the test's own beside TEST_DB_NAME, with one slots table, dropped after the test. Every connection
|
|
31
|
+
* runs with MariaDB's innodb_snapshot_isolation set to `snapshotIsolation` (default off). A server without the
|
|
32
|
+
* setting (MySQL, older MariaDB) behaves as off, so asking it for on answers null.
|
|
33
|
+
*/
|
|
34
|
+
async function ownDatabase(context, suffix, { snapshotIsolation = false } = {}) {
|
|
35
|
+
const name = `${requireTestDatabase(process.env)}_${suffix}`;
|
|
36
|
+
const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
|
|
37
|
+
const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
|
|
38
|
+
const [variables] = await admin.raw("SHOW VARIABLES LIKE 'innodb_snapshot_isolation'");
|
|
39
|
+
if (snapshotIsolation && variables.length === 0) {
|
|
40
|
+
await admin.destroy();
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
44
|
+
await admin.raw("CREATE DATABASE ??", [name]);
|
|
45
|
+
const setting = variables.length === 0 ? null : `SET SESSION innodb_snapshot_isolation = ${snapshotIsolation ? "ON" : "OFF"}`;
|
|
46
|
+
const knex = knexLib({
|
|
47
|
+
client: "mysql2",
|
|
48
|
+
connection: { ...server, database: name, ...RUNTIME_MYSQL },
|
|
49
|
+
pool: { min: 0, max: 6, ...(setting ? { afterCreate: (connection, done) => connection.query(setting, (error) => done(error, connection)) } : {}) }
|
|
50
|
+
});
|
|
51
|
+
context.after(async () => {
|
|
52
|
+
await knex.destroy();
|
|
53
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
54
|
+
await admin.destroy();
|
|
55
|
+
});
|
|
56
|
+
await knex.schema.createTable("slots", (table) => {
|
|
57
|
+
table.increments("id");
|
|
58
|
+
table.integer("workspace_id").nullable();
|
|
59
|
+
table.string("status").notNullable().defaultTo("open");
|
|
60
|
+
table.string("taken_by").nullable();
|
|
61
|
+
});
|
|
62
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
63
|
+
const { slots } = await runtime.own("slots.feature", [slotResource]);
|
|
64
|
+
return { knex, slots };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Waits until `count` transactions on this test's database wait for a row lock. A server that keeps INNODB_TRX from
|
|
71
|
+
* the test user gets a fixed pause instead, long enough for both writers to reach the lock.
|
|
72
|
+
*/
|
|
73
|
+
async function bothWaiting(knex, count) {
|
|
74
|
+
for (let waited = 0; waited < 10_000; waited += 25) {
|
|
75
|
+
let waiting;
|
|
76
|
+
try {
|
|
77
|
+
const [[row]] = await knex.raw(
|
|
78
|
+
"SELECT COUNT(*) AS waiting FROM information_schema.INNODB_TRX t JOIN information_schema.PROCESSLIST p " +
|
|
79
|
+
"ON p.ID = t.trx_mysql_thread_id WHERE t.trx_state = 'LOCK WAIT' AND p.DB = DATABASE()"
|
|
80
|
+
);
|
|
81
|
+
waiting = Number(row.waiting);
|
|
82
|
+
} catch {
|
|
83
|
+
await pause(500);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (waiting >= count) return;
|
|
87
|
+
await pause(25);
|
|
88
|
+
}
|
|
89
|
+
assert.fail(`${count} writers did not reach the row's lock within 10 s`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Holds the row's lock while both writers start, until both wait on it, then lets them go one after the other. */
|
|
93
|
+
async function raceOnLockedRow(knex, id, writers) {
|
|
94
|
+
const holder = await knex.transaction();
|
|
95
|
+
try {
|
|
96
|
+
await holder("slots").where({ id }).forUpdate().select("id");
|
|
97
|
+
const running = writers.map((write) => write().then((value) => ({ value }), (error) => ({ error })));
|
|
98
|
+
await bothWaiting(knex, writers.length);
|
|
99
|
+
await holder.commit();
|
|
100
|
+
return await Promise.all(running);
|
|
101
|
+
} finally {
|
|
102
|
+
if (!holder.isCompleted()) await holder.rollback();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function takeTwice(t, suffix, options) {
|
|
107
|
+
const database = await ownDatabase(t, suffix, options);
|
|
108
|
+
if (!database) {
|
|
109
|
+
t.skip("the server has no innodb_snapshot_isolation setting");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const { knex, slots } = database;
|
|
113
|
+
const [id] = await slots.createMany([{ status: "open" }], { context: WORKSPACE, returning: "ids" });
|
|
114
|
+
const take = (taker) => () => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: taker }, { context: WORKSPACE, returning: "ids" });
|
|
115
|
+
const outcomes = await raceOnLockedRow(knex, id, [take("first"), take("second")]);
|
|
116
|
+
assert.deepEqual(outcomes.filter(({ error }) => error), [], "neither writer fails");
|
|
117
|
+
assert.deepEqual(outcomes.map(({ value }) => value.length).sort(), [0, 1], "exactly one writer takes the row");
|
|
118
|
+
const row = await knex("slots").where({ id }).first("status", "taken_by");
|
|
119
|
+
assert.equal(row.status, "taken");
|
|
120
|
+
assert.equal(row.taken_by, outcomes[0].value.length === 1 ? "first" : "second");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test("of two concurrent patchWhere calls whose filters both matched one row, exactly one writes it", async (t) => {
|
|
124
|
+
requireTestDatabase(process.env);
|
|
125
|
+
await takeTwice(t, "da_pw_race");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("with innodb_snapshot_isolation on, the waiting patchWhere fails as changed since its read and runs again, leaving the row out", async (t) => {
|
|
129
|
+
requireTestDatabase(process.env);
|
|
130
|
+
await takeTwice(t, "da_pw_snapshot", { snapshotIsolation: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a deleteWhere whose filter matched a row a concurrent patchWhere changed leaves it in place", async (t) => {
|
|
134
|
+
requireTestDatabase(process.env);
|
|
135
|
+
const { knex, slots } = await ownDatabase(t, "da_dw_race");
|
|
136
|
+
const [id] = await slots.createMany([{ status: "open" }], { context: WORKSPACE, returning: "ids" });
|
|
137
|
+
const outcomes = await raceOnLockedRow(knex, id, [
|
|
138
|
+
() => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: "first" }, { context: WORKSPACE, returning: "ids" }),
|
|
139
|
+
() => slots.deleteWhere({ id, status: "open" }, { context: WORKSPACE, returning: "ids" })
|
|
140
|
+
]);
|
|
141
|
+
assert.deepEqual(outcomes.filter(({ error }) => error), []);
|
|
142
|
+
const [patched, deleted] = outcomes.map(({ value }) => value.length);
|
|
143
|
+
const row = await knex("slots").where({ id }).first("status");
|
|
144
|
+
// Whichever went first wins; the other finds the row changed (or gone) and leaves it.
|
|
145
|
+
assert.deepEqual([patched + deleted, row ? row.status : "deleted"], [1, patched === 1 ? "taken" : "deleted"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("changeHorizon() throws on MySQL and MariaDB, which stamp no row with its transaction, before any statement", async (t) => {
|
|
149
|
+
const { knex, slots } = await ownDatabase(t, "horizon");
|
|
150
|
+
const statements = [];
|
|
151
|
+
knex.on("query", ({ sql }) => statements.push(sql));
|
|
152
|
+
await assert.rejects(slots.changeHorizon({ context: WORKSPACE }), UnsupportedDatabaseError);
|
|
153
|
+
assert.deepEqual(statements, []);
|
|
154
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
4
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
5
|
+
|
|
6
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
7
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
8
|
+
|
|
9
|
+
const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
|
|
10
|
+
|
|
11
|
+
test("a query field's select gets a value-only raw, column() and the read's filters, never Knex or its context", async (t) => {
|
|
12
|
+
const knex = await testKnex(t);
|
|
13
|
+
await knex.schema.createTable("receivals", (table) => {
|
|
14
|
+
table.increments("id");
|
|
15
|
+
table.integer("workspace_id");
|
|
16
|
+
table.string("status").notNullable();
|
|
17
|
+
table.integer("received_weight").notNullable();
|
|
18
|
+
table.integer("processed_weight");
|
|
19
|
+
});
|
|
20
|
+
const receivalResource = defineTableResource({
|
|
21
|
+
name: "receivals",
|
|
22
|
+
tableName: "receivals",
|
|
23
|
+
ownership: "workspace",
|
|
24
|
+
schema: {
|
|
25
|
+
workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
|
|
26
|
+
status: { type: "string", required: true, search: true },
|
|
27
|
+
receivedWeight: { type: "integer", required: true, storage: { column: "received_weight" } },
|
|
28
|
+
processedWeight: { type: "integer", nullable: true, storage: { column: "processed_weight" } },
|
|
29
|
+
remainingWeight: { type: "number", storage: { virtual: true } }
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
let tools;
|
|
33
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
34
|
+
const { receivals } = await runtime.own("receivals.feature", [{
|
|
35
|
+
resource: receivalResource,
|
|
36
|
+
queryFields: {
|
|
37
|
+
remainingWeight: {
|
|
38
|
+
type: "number",
|
|
39
|
+
select(given) {
|
|
40
|
+
tools = given;
|
|
41
|
+
return given.raw("?? - coalesce(??, 0)", [given.column("receivedWeight"), given.column("processedWeight")]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}]);
|
|
46
|
+
const options = { context: WORKSPACE_A };
|
|
47
|
+
await receivals.createMany([
|
|
48
|
+
{ status: "open", receivedWeight: 10, processedWeight: 4 },
|
|
49
|
+
{ status: "open", receivedWeight: 7 },
|
|
50
|
+
{ status: "closed", receivedWeight: 3, processedWeight: 3 }
|
|
51
|
+
], options);
|
|
52
|
+
|
|
53
|
+
const open = await receivals.queryAll({ filters: { status: "open" }, fields: ["remainingWeight"] }, options);
|
|
54
|
+
assert.deepEqual(open.map(({ remainingWeight }) => remainingWeight), [6, 7]);
|
|
55
|
+
assert.deepEqual(Object.keys(tools).sort(), ["column", "filters", "raw"]);
|
|
56
|
+
assert.equal(tools.filters.status, "open");
|
|
57
|
+
assert.equal(Object.isFrozen(tools.filters), true);
|
|
58
|
+
|
|
59
|
+
// raw takes one SQL string and its bindings as an array, and is a value only: awaited, it answers itself and runs
|
|
60
|
+
// nothing; run as a statement, it throws.
|
|
61
|
+
assert.throws(() => tools.raw(42), /Query field "remainingWeight" raw\(\) takes one SQL expression as a string/u);
|
|
62
|
+
assert.throws(() => tools.raw("?? + 1"), /needs the bindings of its placeholders/u);
|
|
63
|
+
assert.throws(() => tools.raw("? + 1", { value: 1 }), /takes \(sql, bindings\)/u);
|
|
64
|
+
assert.throws(() => tools.raw("? + 1", [1], "extra"), /takes \(sql, bindings\)/u);
|
|
65
|
+
const statement = tools.raw("DELETE FROM receivals");
|
|
66
|
+
assert.equal(await statement, statement);
|
|
67
|
+
assert.throws(() => statement.asCallback(() => {}), /is a value inside a scoped query or a write/u);
|
|
68
|
+
assert.equal((await knex("receivals")).length, 3);
|
|
69
|
+
});
|