@jskit-ai/resource-runtime 0.2.12 → 0.2.14
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 +13 -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 +141 -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 +78 -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,18 +1,19 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import knexLib from "knex";
|
|
4
3
|
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
4
|
import { countQueries } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
6
5
|
import { withoutHiddenFields } from "../src/server/index.js";
|
|
7
|
-
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
6
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
7
|
+
|
|
8
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
9
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
8
10
|
|
|
9
11
|
const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
|
|
10
12
|
|
|
11
13
|
// Cards with a hidden owner, a hidden bearer token, a hidden flag, a hidden count and a hidden time: two in workspace
|
|
12
14
|
// 1 and one in workspace 2.
|
|
13
15
|
async function createCardFixture(t) {
|
|
14
|
-
const knex =
|
|
15
|
-
t.after(() => knex.destroy());
|
|
16
|
+
const knex = await testKnex(t);
|
|
16
17
|
await knex.schema.createTable("cards", (table) => {
|
|
17
18
|
table.increments("id");
|
|
18
19
|
table.integer("workspace_id").notNullable();
|
|
@@ -46,8 +47,8 @@ async function createCardFixture(t) {
|
|
|
46
47
|
// The tables each SELECT reads, in order.
|
|
47
48
|
const selects = async (work) => (await countQueries(knex, work)).queries
|
|
48
49
|
.filter((sql) => /^select/iu.test(sql))
|
|
49
|
-
.map((sql) => [...sql.matchAll(/from `(\w+)
|
|
50
|
-
return { cards, resource, system, selects, options: { context: WORKSPACE_A } };
|
|
50
|
+
.map((sql) => [...sql.matchAll(/from [`"](\w+)[`"]/gu)].map(([, table]) => table)[0]);
|
|
51
|
+
return { knex, cards, resource, system, selects, options: { context: WORKSPACE_A } };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
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) => {
|
|
@@ -113,43 +114,61 @@ test("withoutHiddenFields refuses another feature's read naming a hidden field,
|
|
|
113
114
|
assert.equal(withoutHiddenFields(cards, plain), cards);
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
test("withoutHiddenFields refuses another feature's scopedQuery,
|
|
117
|
-
const { cards, resource, system, options } = await createCardFixture(t);
|
|
118
|
-
//
|
|
117
|
+
test("withoutHiddenFields refuses another feature's scopedQuery, and any builder a method answers but a joinSource()'s, which carries no hidden column but the ownership fields", async (t) => {
|
|
118
|
+
const { knex, cards, resource, system, options } = await createCardFixture(t);
|
|
119
|
+
// Methods an owner's decorateRepository adds with the handle it receives, answering its scoped rows as scopedQuery()
|
|
120
|
+
// gives them, every column with them: wrapped, and at once.
|
|
119
121
|
async function cardSource(alias, callOptions) {
|
|
120
122
|
const { query } = await cards.scopedQuery({ alias }, callOptions);
|
|
121
123
|
return { query, column: (field) => cards.column(field, alias) };
|
|
122
124
|
}
|
|
123
|
-
const
|
|
125
|
+
const everyColumn = () => knex("cards");
|
|
126
|
+
const exposed = withoutHiddenFields(Object.freeze({ ...cards, cardSource, everyColumn }), resource);
|
|
124
127
|
|
|
125
128
|
// Another feature's own builder would select the token: refused before it reads anything.
|
|
126
129
|
await assert.rejects(
|
|
127
130
|
(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
|
|
131
|
+
/cards\.scopedQuery\(\) answers every column, its hidden fields too, which only the table's owner, or a system context, reads: read the rows through the repository's reads, or join its joinSource\(\{ alias \}, options\)/u
|
|
129
132
|
);
|
|
130
133
|
await assert.rejects(exposed.withTransaction(options, (tx) => exposed.scopedQuery({ alias: "c" }, tx)), /cards\.scopedQuery\(\)/u);
|
|
134
|
+
await assert.rejects(exposed.cardSource("c", options),
|
|
135
|
+
/cards\.cardSource\(\) answers a query builder, which can select every column, the hidden fields too, which only the table's owner, or a system context, reads: answer the table's joinSource\(\{ alias \}, options\)/u);
|
|
136
|
+
assert.throws(() => exposed.everyColumn(), /cards\.everyColumn\(\) answers a query builder/u);
|
|
131
137
|
|
|
132
|
-
// A system context, and one it derived, still builds on
|
|
138
|
+
// A system context, and one it derived, still builds on them.
|
|
133
139
|
const { query: everyWorkspace } = await exposed.scopedQuery({}, { context: system });
|
|
134
|
-
assert.deepEqual((await everyWorkspace.count({ cards: exposed.column("id") }))[0].cards, 3);
|
|
140
|
+
assert.deepEqual(Number((await everyWorkspace.count({ cards: exposed.column("id") }))[0].cards), 3);
|
|
135
141
|
const { query: workspaceTwo } = await exposed.scopedQuery({}, { context: system.forWorkspace("2") });
|
|
136
142
|
assert.deepEqual(await workspaceTwo.select({ title: exposed.column("title") }), [{ title: "Kit" }]);
|
|
143
|
+
const everyCard = await exposed.cardSource("c", { context: system });
|
|
144
|
+
assert.deepEqual(await everyCard.query.select({ token: everyCard.column("publicToken") }).orderBy(everyCard.column("id")),
|
|
145
|
+
[{ token: "t1" }, { token: "t2" }, { token: "t3" }]);
|
|
137
146
|
|
|
138
|
-
// The cross-owner report pattern: the
|
|
139
|
-
|
|
147
|
+
// The cross-owner report pattern: the table's joinSource() answers the caller's scoped rows, which the report
|
|
148
|
+
// selects from, with the columns a read answers and the ownership fields, and no other.
|
|
149
|
+
const source = await exposed.joinSource({ alias: "c" }, options);
|
|
140
150
|
const rows = await source.query.select({ title: source.column("title") }).orderBy(source.column("id"));
|
|
141
151
|
assert.deepEqual(rows, [{ title: "Rex" }, { title: "Bo" }]);
|
|
152
|
+
const { query: rex } = await exposed.joinSource({ alias: "c", filters: { title: "Rex" } }, options);
|
|
153
|
+
assert.deepEqual((await rex).map((row) => Object.keys(row)), [["id", "workspace_id", "title"]]);
|
|
154
|
+
assert.throws(() => source.column("publicToken"),
|
|
155
|
+
/cards\.joinSource\(\) carries no hidden field but its ownership fields, so it has no column for "publicToken"/u);
|
|
156
|
+
// The hidden workspaceId is a join key the source carries: a system report counts each workspace's cards on it.
|
|
157
|
+
const perWorkspace = await exposed.joinSource({ alias: "c" }, { context: system });
|
|
158
|
+
const workspaceId = perWorkspace.column("workspaceId");
|
|
159
|
+
const counts = await perWorkspace.query.select({ workspaceId }).count({ cards: perWorkspace.column("id") })
|
|
160
|
+
.groupBy(workspaceId).orderBy(workspaceId);
|
|
161
|
+
assert.deepEqual(counts.map((row) => [Number(row.workspaceId), Number(row.cards)]), [[1, 2], [2, 1]]);
|
|
162
|
+
const again = await exposed.joinSource({ alias: "c" }, options);
|
|
163
|
+
await assert.rejects(async () => again.query.select(cards.column("publicToken", "c")), /public_token/u);
|
|
164
|
+
await assert.rejects(exposed.joinSource({}, options), /cards\.joinSource\(\) needs the alias the report joins the cards under/u);
|
|
142
165
|
});
|
|
143
166
|
|
|
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) => {
|
|
167
|
+
test("withoutHiddenFields answers raw(), column(), joinSource() 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
168
|
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
169
|
// An owner's method answering a Knex value at once: an expression over its own column.
|
|
151
170
|
const lowerTitle = (alias) => cards.raw("LOWER(??)", [cards.column("title", alias)]);
|
|
152
|
-
const exposed = withoutHiddenFields(Object.freeze({ ...cards,
|
|
171
|
+
const exposed = withoutHiddenFields(Object.freeze({ ...cards, lowerTitle }), resource);
|
|
153
172
|
|
|
154
173
|
assert.equal(exposed.column("title", "c"), cards.column("title", "c"));
|
|
155
174
|
const shout = exposed.raw("UPPER(??)", [exposed.column("title", "c")]);
|
|
@@ -157,24 +176,25 @@ test("withoutHiddenFields answers raw(), column() and an owner's Knex values as
|
|
|
157
176
|
assert.equal(typeof exposed.lowerTitle("i").toSQL, "function");
|
|
158
177
|
|
|
159
178
|
// In the owner's scoped rows, as a select.
|
|
160
|
-
const source = await exposed.
|
|
179
|
+
const source = await exposed.joinSource({ alias: "c" }, options);
|
|
161
180
|
assert.deepEqual(await source.query.select({ shout }).orderBy(source.column("id")), [{ shout: "REX" }, { shout: "BO" }]);
|
|
162
181
|
|
|
163
182
|
// In a subquery: the cards of workspace 1 whose lower-cased title is "bo".
|
|
164
|
-
const outer = await exposed.
|
|
165
|
-
const inner = await exposed.
|
|
183
|
+
const outer = await exposed.joinSource({ alias: "c" }, options);
|
|
184
|
+
const inner = await exposed.joinSource({ alias: "i" }, options);
|
|
166
185
|
const matching = inner.query.select(inner.column("id")).where(exposed.lowerTitle("i"), exposed.raw("?", ["bo"]));
|
|
167
186
|
assert.deepEqual(await outer.query.whereIn(outer.column("id"), matching).select({ title: outer.column("title") }), [{ title: "Bo" }]);
|
|
168
187
|
});
|
|
169
188
|
|
|
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) => {
|
|
189
|
+
test("withoutHiddenFields refuses a hidden field named through any method, and strips the hidden fields from every answer of a read the owner adds, unless it runs in a system context", async (t) => {
|
|
171
190
|
const { cards, resource, system, options } = await createCardFixture(t);
|
|
172
191
|
// 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.
|
|
192
|
+
// taking its options first, one answering a map, one in a sub-repository and one answering at once.
|
|
174
193
|
const owner = Object.freeze({
|
|
175
194
|
...cards,
|
|
176
195
|
queryAllIncludingArchived: (params, callOptions) => cards.queryAll(params, callOptions),
|
|
177
196
|
everyCard: cards.queryAll,
|
|
197
|
+
cardsTitled: (callOptions, { title }) => cards.queryAll({ filters: { title }, fields: ["title", "publicToken"] }, callOptions),
|
|
178
198
|
async cardsByTitle(callOptions) {
|
|
179
199
|
const records = await cards.queryAll({ fields: ["title", "publicToken"] }, callOptions);
|
|
180
200
|
return new Map(records.map((record) => [record.title, record]));
|
|
@@ -184,10 +204,15 @@ test("withoutHiddenFields strips the hidden fields from every answer of a read t
|
|
|
184
204
|
});
|
|
185
205
|
const exposed = withoutHiddenFields(owner, resource);
|
|
186
206
|
|
|
187
|
-
// Another feature's calls:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
207
|
+
// Another feature's calls: a hidden field its fields names is refused before the method runs, whatever the method's
|
|
208
|
+
// name, as a list or a sparse fieldset, so a method forwarding the caller's fields never answers it as undefined.
|
|
209
|
+
await assert.rejects(exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title", "publicToken", "workspaceId"] }, options),
|
|
210
|
+
/cards\.queryAllIncludingArchived\(\) fields names the hidden field "publicToken", "workspaceId", which only the table's owner, or a system context, reads/u);
|
|
211
|
+
await assert.rejects(exposed.everyCard({ filters: { title: "Bo" }, fields: { cards: "title, sent" } }, options),
|
|
212
|
+
/cards\.everyCard\(\) fields names the hidden field "sent"/u);
|
|
213
|
+
assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Rex" }, fields: ["title"] }, options), [{ id: "1", title: "Rex" }]);
|
|
214
|
+
// No hidden field the owner's own reads name leaves, whatever the method's name or depth.
|
|
215
|
+
assert.deepEqual(await exposed.cardsTitled(options, { title: "Rex" }), [{ id: "1", title: "Rex" }]);
|
|
191
216
|
assert.deepEqual([...(await exposed.cardsByTitle(options)).values()], [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
|
|
192
217
|
assert.deepEqual(await exposed.archive.list(options), [{ id: "1", title: "Rex" }, { id: "2", title: "Bo" }]);
|
|
193
218
|
// A method that answers at once still does, and withTransaction answers the caller's own work as it is.
|
|
@@ -200,4 +225,27 @@ test("withoutHiddenFields strips the hidden fields from every answer of a read t
|
|
|
200
225
|
assert.deepEqual(await exposed.queryAllIncludingArchived({ filters: { title: "Kit" }, fields: ["publicToken", "workspaceId"] }, { context: system }),
|
|
201
226
|
[{ id: "3", publicToken: "t3", workspaceId: "2" }]);
|
|
202
227
|
assert.deepEqual(await exposed.archive.list({ context: system.forWorkspace("2") }), [{ id: "3", title: "Kit", views: 1 }]);
|
|
228
|
+
// The system context counts in whichever record carries it, not only the last.
|
|
229
|
+
assert.deepEqual(await exposed.cardsTitled({ context: system.forWorkspace("2") }, { title: "Kit" }), [{ id: "3", title: "Kit", publicToken: "t3" }]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("withoutHiddenFields refuses a field one owned table hides and another shows, since it strips hidden fields by name", () => {
|
|
233
|
+
const cards = defineTableResource({
|
|
234
|
+
name: "cards",
|
|
235
|
+
tableName: "cards",
|
|
236
|
+
ownership: "public",
|
|
237
|
+
schema: { title: { type: "string" }, publicToken: { type: "string", hidden: true } }
|
|
238
|
+
});
|
|
239
|
+
const links = (hidden) => defineTableResource({
|
|
240
|
+
name: "links",
|
|
241
|
+
tableName: "links",
|
|
242
|
+
ownership: "public",
|
|
243
|
+
schema: { publicToken: { type: "string", ...(hidden ? { hidden } : {}) } }
|
|
244
|
+
});
|
|
245
|
+
const repository = Object.freeze({ get: async () => ({ id: "1", publicToken: "t1" }) });
|
|
246
|
+
assert.throws(() => withoutHiddenFields(repository, [cards, links(false)]),
|
|
247
|
+
/links\.publicToken is a visible field, but cards, which the same feature owns, hides a field of that name; its repository strips hidden fields by name from every answer/u);
|
|
248
|
+
assert.throws(() => withoutHiddenFields(repository, [links(false), cards]), /links\.publicToken is a visible field, but cards/u);
|
|
249
|
+
// Hidden in both, it is one hidden field.
|
|
250
|
+
assert.equal(typeof withoutHiddenFields(repository, [cards, links(true)]).get, "function");
|
|
203
251
|
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
4
|
+
import { retryLostInsertRace } from "@jskit-ai/database-runtime/shared";
|
|
5
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
6
|
+
|
|
7
|
+
// The row locks, the many-row writes and the upserts on PostgreSQL, with real concurrent transactions: what an
|
|
8
|
+
// application on PostgreSQL relies on when two requests or two servers reach the same rows at once. Each test works in
|
|
9
|
+
// a schema of its own in TEST_DB_NAME (createPackageTestKnex); the other data tests of this package run here too on a
|
|
10
|
+
// DB_CLIENT=pg run, and the MySQL and MariaDB races are in manyRowWrites.mysql.test.js.
|
|
11
|
+
|
|
12
|
+
const skip = process.env.DB_CLIENT === "pg" ? false : "a PostgreSQL test: it runs on a DB_CLIENT=pg run, with TEST_DB_NAME";
|
|
13
|
+
const WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: Object.freeze({ id: "1" }) }) });
|
|
14
|
+
const OTHER_WORKSPACE = Object.freeze({ context: Object.freeze({ workspace: Object.freeze({ id: "2" }) }) });
|
|
15
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
const settle = (work) => work().then((value) => ({ value }), (error) => ({ error }));
|
|
17
|
+
|
|
18
|
+
const slotResource = defineTableResource({
|
|
19
|
+
name: "slots",
|
|
20
|
+
tableName: "slots",
|
|
21
|
+
ownership: "workspace",
|
|
22
|
+
defaultSort: ["id"],
|
|
23
|
+
schema: {
|
|
24
|
+
id: { type: "id", primary: true },
|
|
25
|
+
workspaceId: { type: "id", nullable: true, search: true, storage: { column: "workspace_id" } },
|
|
26
|
+
code: { type: "string", nullable: true, search: true },
|
|
27
|
+
status: { type: "string", defaultTo: "open", search: true },
|
|
28
|
+
takenBy: { type: "string", nullable: true, storage: { column: "taken_by" } }
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// A table with no owners keyed by an id the server computes, as lockOrCreate and findOrCreate take.
|
|
33
|
+
const balanceResource = defineTableResource({
|
|
34
|
+
name: "balances",
|
|
35
|
+
tableName: "balances",
|
|
36
|
+
ownership: "public",
|
|
37
|
+
schema: {
|
|
38
|
+
id: { type: "string", primary: true },
|
|
39
|
+
amount: { type: "integer", defaultTo: 0 }
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function database(t) {
|
|
44
|
+
const knex = await createPackageTestKnex(t, { environment: process.env });
|
|
45
|
+
await knex.schema.createTable("slots", (table) => {
|
|
46
|
+
table.increments("id");
|
|
47
|
+
table.integer("workspace_id").nullable();
|
|
48
|
+
table.string("code").nullable();
|
|
49
|
+
table.string("status").notNullable().defaultTo("open");
|
|
50
|
+
table.string("taken_by").nullable();
|
|
51
|
+
table.unique(["workspace_id", "code"]);
|
|
52
|
+
});
|
|
53
|
+
await knex.schema.createTable("balances", (table) => {
|
|
54
|
+
table.string("id").primary();
|
|
55
|
+
table.integer("amount").notNullable().defaultTo(0);
|
|
56
|
+
});
|
|
57
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
58
|
+
const { slots, balances } = await runtime.own("slots.feature", [slotResource, balanceResource]);
|
|
59
|
+
return { knex, slots, balances };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Waits until `count` sessions wait, directly or behind another waiter, for the session `pid` (pg_blocking_pids).
|
|
64
|
+
* @param {import("knex").Knex} knex
|
|
65
|
+
* @param {number} pid
|
|
66
|
+
* @param {number} count
|
|
67
|
+
*/
|
|
68
|
+
async function waitingBehind(knex, pid, count) {
|
|
69
|
+
for (let waited = 0; waited < 10_000; waited += 25) {
|
|
70
|
+
const { rows: [{ waiting }] } = await knex.raw(
|
|
71
|
+
"WITH RECURSIVE blocked(pid) AS (" +
|
|
72
|
+
"SELECT a.pid FROM pg_stat_activity a WHERE ? = ANY(pg_blocking_pids(a.pid)) " +
|
|
73
|
+
"UNION SELECT a.pid FROM pg_stat_activity a JOIN blocked b ON b.pid = ANY(pg_blocking_pids(a.pid))" +
|
|
74
|
+
") SELECT count(*)::int AS waiting FROM blocked",
|
|
75
|
+
[pid]
|
|
76
|
+
);
|
|
77
|
+
if (waiting >= count) return;
|
|
78
|
+
await pause(25);
|
|
79
|
+
}
|
|
80
|
+
assert.fail(`${count} sessions did not wait for session ${pid} within 10 s`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Runs `hold(trx)` in a transaction of its own, then the writers, until all of them wait behind it; then ends it
|
|
85
|
+
* (commits, or rolls back with `rollback`) and answers each writer's outcome.
|
|
86
|
+
*/
|
|
87
|
+
async function raceBehind(knex, hold, writers, { rollback = false } = {}) {
|
|
88
|
+
const holder = await knex.transaction();
|
|
89
|
+
try {
|
|
90
|
+
const { rows: [{ pid }] } = await holder.raw("SELECT pg_backend_pid() AS pid");
|
|
91
|
+
await hold(holder);
|
|
92
|
+
const running = writers.map((write) => settle(write));
|
|
93
|
+
await waitingBehind(knex, pid, writers.length);
|
|
94
|
+
await (rollback ? holder.rollback() : holder.commit());
|
|
95
|
+
return await Promise.all(running);
|
|
96
|
+
} finally {
|
|
97
|
+
if (!holder.isCompleted()) await holder.rollback();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const noErrors = (outcomes) => assert.deepEqual(outcomes.filter(({ error }) => error).map(({ error }) => error.message), []);
|
|
102
|
+
|
|
103
|
+
test("of two concurrent patchWhere calls whose filters both matched one row, exactly one writes it", { skip }, async (t) => {
|
|
104
|
+
const { knex, slots } = await database(t);
|
|
105
|
+
const [id] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
106
|
+
const take = (taker) => () => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: taker }, { ...WORKSPACE, returning: "ids" });
|
|
107
|
+
const outcomes = await raceBehind(knex, (holder) => holder("slots").where({ id }).forUpdate().select("id"), [take("first"), take("second")]);
|
|
108
|
+
noErrors(outcomes);
|
|
109
|
+
assert.deepEqual(outcomes.map(({ value }) => value.length).sort(), [0, 1], "exactly one writer takes the row");
|
|
110
|
+
const row = await knex("slots").where({ id }).first("status", "taken_by");
|
|
111
|
+
assert.deepEqual([row.status, row.taken_by], ["taken", outcomes[0].value.length === 1 ? "first" : "second"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a deleteWhere whose filter matched a row a concurrent patchWhere changed leaves it in place", { skip }, async (t) => {
|
|
115
|
+
const { knex, slots } = await database(t);
|
|
116
|
+
const [id] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
117
|
+
const outcomes = await raceBehind(knex, (holder) => holder("slots").where({ id }).forUpdate().select("id"), [
|
|
118
|
+
() => slots.patchWhere({ id, status: "open" }, { status: "taken", takenBy: "first" }, { ...WORKSPACE, returning: "ids" }),
|
|
119
|
+
() => slots.deleteWhere({ id, status: "open" }, { ...WORKSPACE, returning: "ids" })
|
|
120
|
+
]);
|
|
121
|
+
noErrors(outcomes);
|
|
122
|
+
const [patched, deleted] = outcomes.map(({ value }) => value.length);
|
|
123
|
+
const row = await knex("slots").where({ id }).first("status");
|
|
124
|
+
assert.deepEqual([patched + deleted, row ? row.status : "deleted"], [1, patched === 1 ? "taken" : "deleted"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a patchWhere that waited for a row changed meanwhile writes only the rows that still match", { skip }, async (t) => {
|
|
128
|
+
const { knex, slots } = await database(t);
|
|
129
|
+
const ids = await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
130
|
+
const [outcome] = await raceBehind(
|
|
131
|
+
knex,
|
|
132
|
+
(holder) => holder("slots").where({ id: ids[1] }).update({ status: "taken", taken_by: "holder" }),
|
|
133
|
+
[() => slots.patchWhere({ status: "open" }, { status: "closed" }, { ...WORKSPACE, returning: "ids" })]
|
|
134
|
+
);
|
|
135
|
+
noErrors([outcome]);
|
|
136
|
+
assert.deepEqual(outcome.value.map(String).sort(), [ids[0], ids[2]].map(String).sort());
|
|
137
|
+
assert.deepEqual((await knex("slots").orderBy("id").pluck("status")), ["closed", "taken", "closed"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("two workers claiming at once take different rows, and skip a row another transaction holds", { skip }, async (t) => {
|
|
141
|
+
const { knex, slots } = await database(t);
|
|
142
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
143
|
+
await slots.createMany([{ status: "open" }], { ...OTHER_WORKSPACE, returning: "none" });
|
|
144
|
+
const holder = await knex.transaction();
|
|
145
|
+
t.after(() => (holder.isCompleted() ? undefined : holder.rollback()));
|
|
146
|
+
await holder("slots").where({ id: ids[0] }).forUpdate().select("id");
|
|
147
|
+
|
|
148
|
+
const first = Promise.withResolvers();
|
|
149
|
+
const release = Promise.withResolvers();
|
|
150
|
+
const claimNext = (take) => (txOptions) => slots.claim({ filters: { status: "open" }, sort: ["id"], take }, txOptions);
|
|
151
|
+
const worker = slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
152
|
+
const claimed = await claimNext(1)(txOptions);
|
|
153
|
+
first.resolve(claimed.map(({ id }) => String(id)));
|
|
154
|
+
await release.promise;
|
|
155
|
+
await slots.patchWhere({ id: claimed.map(({ id }) => id) }, { status: "taken", takenBy: "first" }, txOptions);
|
|
156
|
+
return claimed;
|
|
157
|
+
});
|
|
158
|
+
assert.deepEqual(await first.promise, [ids[1]], "the first worker skips the row the holder locked");
|
|
159
|
+
// While the first worker holds its row, the second takes the rest, never the held ones nor another workspace's.
|
|
160
|
+
const second = await slots.withTransaction(WORKSPACE, async (txOptions) => (await claimNext(10)(txOptions)).map(({ id }) => String(id)));
|
|
161
|
+
assert.deepEqual(second, [ids[2], ids[3]]);
|
|
162
|
+
release.resolve();
|
|
163
|
+
await worker;
|
|
164
|
+
await holder.rollback();
|
|
165
|
+
assert.deepEqual((await knex("slots").where({ workspace_id: 1 }).orderBy("id").pluck("status")), ["open", "taken", "open", "open"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a claim leaves a row whose committed change stopped it matching while the claim waited, and takes the next", { skip }, async (t) => {
|
|
169
|
+
const { knex, slots } = await database(t);
|
|
170
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
171
|
+
// A claim never waits (SKIP LOCKED): the held row is skipped, and once its holder commits a change, it no longer matches.
|
|
172
|
+
const holder = await knex.transaction();
|
|
173
|
+
t.after(() => (holder.isCompleted() ? undefined : holder.rollback()));
|
|
174
|
+
await holder("slots").where({ id: ids[0] }).update({ status: "taken" });
|
|
175
|
+
const during = await slots.withTransaction(WORKSPACE, async (txOptions) => (await slots.claim({ filters: { status: "open" } }, txOptions)).map(({ id }) => String(id)));
|
|
176
|
+
await holder.commit();
|
|
177
|
+
const after = await slots.withTransaction(WORKSPACE, async (txOptions) => (await slots.claim({ filters: { status: "open" }, take: 5 }, txOptions)).map(({ id }) => String(id)));
|
|
178
|
+
assert.deepEqual([during, after], [[ids[1]], [ids[1]]]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("lockMany waits for another transaction's lock, then reads the rows as it committed them, in id order", { skip }, async (t) => {
|
|
182
|
+
const { knex, slots } = await database(t);
|
|
183
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
184
|
+
const [theirs] = (await slots.createMany([{ status: "open" }], { ...OTHER_WORKSPACE, returning: "ids" })).map(String);
|
|
185
|
+
const [outcome] = await raceBehind(
|
|
186
|
+
knex,
|
|
187
|
+
(holder) => holder("slots").where({ id: ids[1] }).update({ status: "taken", taken_by: "holder" }),
|
|
188
|
+
[() => slots.withTransaction(WORKSPACE, (txOptions) => slots.lockMany([ids[2], theirs, ids[1], ids[0]], { ...txOptions, missing: "skip" }))]
|
|
189
|
+
);
|
|
190
|
+
noErrors([outcome]);
|
|
191
|
+
assert.deepEqual(outcome.value.map(({ id, status }) => [String(id), status]), [[ids[0], "open"], [ids[1], "taken"], [ids[2], "open"]]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a lockWhere that waited also locks a row that started to match while it waited", { skip }, async (t) => {
|
|
195
|
+
const { knex, slots } = await database(t);
|
|
196
|
+
const ids = (await slots.createMany([{ status: "open" }, { status: "taken" }, { status: "taken" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
197
|
+
const [outcome] = await raceBehind(
|
|
198
|
+
knex,
|
|
199
|
+
(holder) => holder("slots").whereIn("id", [ids[0], ids[1]]).update({ status: "open" }),
|
|
200
|
+
[() => slots.withTransaction(WORKSPACE, (txOptions) => slots.lockWhere({ status: "open" }, txOptions))]
|
|
201
|
+
);
|
|
202
|
+
noErrors([outcome]);
|
|
203
|
+
assert.deepEqual(outcome.value.map(({ id }) => String(id)), [ids[0], ids[1]]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("two upserts of one key behind an uncommitted insert of it both succeed, and the key has one row", { skip }, async (t) => {
|
|
207
|
+
const { knex, slots } = await database(t);
|
|
208
|
+
const upsert = (action, takenBy) => () => slots.createMany(
|
|
209
|
+
[{ code: "a", status: "open", takenBy }],
|
|
210
|
+
{ ...WORKSPACE, onConflict: { keys: ["workspaceId", "code"], action }, returning: "ids" }
|
|
211
|
+
);
|
|
212
|
+
for (const [action, expected] of [["ignore", ["holder"]], ["merge", ["first", "second"]]]) {
|
|
213
|
+
await knex("slots").del();
|
|
214
|
+
const outcomes = await raceBehind(
|
|
215
|
+
knex,
|
|
216
|
+
(holder) => holder("slots").insert({ workspace_id: 1, code: "a", status: "open", taken_by: "holder" }),
|
|
217
|
+
[upsert(action, "first"), upsert(action, "second")],
|
|
218
|
+
{ rollback: action === "merge" }
|
|
219
|
+
);
|
|
220
|
+
noErrors(outcomes);
|
|
221
|
+
const rows = await knex("slots").where({ workspace_id: 1, code: "a" }).select("taken_by");
|
|
222
|
+
assert.equal(rows.length, 1, `${action}: one row for the key`);
|
|
223
|
+
assert.ok(expected.includes(rows[0].taken_by), `${action}: kept ${rows[0].taken_by}`);
|
|
224
|
+
}
|
|
225
|
+
// The same key in another workspace is that workspace's own row.
|
|
226
|
+
await slots.createMany([{ code: "a", status: "open" }], { ...OTHER_WORKSPACE, onConflict: { keys: ["workspaceId", "code"], action: "ignore" } });
|
|
227
|
+
assert.equal((await knex("slots").where({ code: "a" })).length, 2);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("two transactions that lock two rows in opposite orders deadlock, and the victim runs again and commits", { skip }, async (t) => {
|
|
231
|
+
const { slots } = await database(t);
|
|
232
|
+
const [a, b] = (await slots.createMany([{ status: "open" }, { status: "open" }], { ...WORKSPACE, returning: "ids" })).map(String);
|
|
233
|
+
const locked = { first: Promise.withResolvers(), second: Promise.withResolvers() };
|
|
234
|
+
let runs = 0;
|
|
235
|
+
const work = (mine, theirs, name, other) => slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
236
|
+
runs += 1;
|
|
237
|
+
await slots.lock(mine, txOptions);
|
|
238
|
+
locked[name].resolve();
|
|
239
|
+
await locked[other].promise;
|
|
240
|
+
await slots.lock(theirs, txOptions);
|
|
241
|
+
await slots.patch(mine, { takenBy: name }, { ...txOptions, returning: "none" });
|
|
242
|
+
});
|
|
243
|
+
noErrors(await Promise.all([settle(() => work(a, b, "first", "second")), settle(() => work(b, a, "second", "first"))]));
|
|
244
|
+
assert.equal(runs, 3, "the deadlock victim's work ran once more");
|
|
245
|
+
assert.deepEqual((await slots.queryAll({ filters: { id: [a, b] } }, WORKSPACE)).map(({ takenBy }) => takenBy), ["first", "second"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a transaction whose insert lost a unique race runs once more and finds the winner's row", { skip }, async (t) => {
|
|
249
|
+
const { knex, slots } = await database(t);
|
|
250
|
+
let runs = 0;
|
|
251
|
+
const record = (takenBy) => () => retryLostInsertRace(WORKSPACE, () => slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
252
|
+
runs += 1;
|
|
253
|
+
const [found] = await slots.queryAll({ filters: { code: "k" } }, txOptions);
|
|
254
|
+
return found ?? slots.create({ code: "k", status: "taken", takenBy }, txOptions);
|
|
255
|
+
}));
|
|
256
|
+
const outcomes = await raceBehind(
|
|
257
|
+
knex,
|
|
258
|
+
(holder) => holder("slots").insert({ workspace_id: 1, code: "k", status: "open", taken_by: "holder" }),
|
|
259
|
+
[record("first"), record("second")],
|
|
260
|
+
{ rollback: true }
|
|
261
|
+
);
|
|
262
|
+
noErrors(outcomes);
|
|
263
|
+
const [first, second] = outcomes.map(({ value }) => value);
|
|
264
|
+
assert.equal(String(first.id), String(second.id), "both answer the one row");
|
|
265
|
+
assert.equal(runs, 3, "the loser ran once more");
|
|
266
|
+
assert.equal((await knex("slots").where({ code: "k" })).length, 1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("findOrCreate of one id at once, behind an uncommitted insert of it, answers one record to both", { skip }, async (t) => {
|
|
270
|
+
const { knex, balances } = await database(t);
|
|
271
|
+
const outcomes = await raceBehind(
|
|
272
|
+
knex,
|
|
273
|
+
(holder) => holder("balances").insert({ id: "acct-1", amount: 7 }),
|
|
274
|
+
[() => balances.findOrCreate({ id: "acct-1", amount: 1 }, WORKSPACE), () => balances.findOrCreate({ id: "acct-1", amount: 2 }, WORKSPACE)],
|
|
275
|
+
{ rollback: true }
|
|
276
|
+
);
|
|
277
|
+
noErrors(outcomes);
|
|
278
|
+
const [first, second] = outcomes.map(({ value }) => value);
|
|
279
|
+
assert.deepEqual([first.id, second.id, first.amount], ["acct-1", "acct-1", second.amount]);
|
|
280
|
+
assert.deepEqual(await knex("balances").pluck("id"), ["acct-1"]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("first uses of one id at once through lockOrCreate create one record and run every work once, one at a time", { skip }, async (t) => {
|
|
284
|
+
const { balances } = await database(t);
|
|
285
|
+
let works = 0;
|
|
286
|
+
let inside = 0;
|
|
287
|
+
let overlapped = false;
|
|
288
|
+
const add = () => balances.lockOrCreate({ id: "acct-2", amount: 0 }, WORKSPACE, async (record, txOptions) => {
|
|
289
|
+
works += 1;
|
|
290
|
+
inside += 1;
|
|
291
|
+
overlapped ||= inside > 1;
|
|
292
|
+
await pause(20);
|
|
293
|
+
await balances.patch(record.id, { amount: record.amount + 1 }, { ...txOptions, returning: "none" });
|
|
294
|
+
inside -= 1;
|
|
295
|
+
});
|
|
296
|
+
noErrors(await Promise.all(Array.from({ length: 4 }, () => settle(add))));
|
|
297
|
+
assert.equal(works, 4);
|
|
298
|
+
assert.equal(overlapped, false, "the lock serializes the works");
|
|
299
|
+
assert.equal((await balances.get("acct-2", {}, WORKSPACE)).amount, 4);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a lock or patchWhere with lockMode noKeyUpdate lets another transaction add a row that references the locked one; the default lock does not", { skip }, async (t) => {
|
|
303
|
+
const { knex, slots, balances } = await database(t);
|
|
304
|
+
await knex.schema.createTable("entries", (table) => {
|
|
305
|
+
table.increments("id");
|
|
306
|
+
table.integer("slot_id").nullable().references("id").inTable("slots");
|
|
307
|
+
table.string("balance_id").nullable().references("id").inTable("balances");
|
|
308
|
+
});
|
|
309
|
+
const [slot] = await slots.createMany([{ status: "open" }], { ...WORKSPACE, returning: "ids" });
|
|
310
|
+
await balances.createMany([{ id: "acct-3", amount: 0 }], { ...WORKSPACE, returning: "none" });
|
|
311
|
+
// Another transaction adds a row that references the locked one. Its foreign key check takes FOR KEY SHARE on the
|
|
312
|
+
// locked row, which FOR NO KEY UPDATE lets through and FOR UPDATE does not; it gives up after 500 ms (55P03).
|
|
313
|
+
const addEntry = async (entry) => {
|
|
314
|
+
const { error } = await settle(() => knex.transaction(async (trx) => {
|
|
315
|
+
await trx.raw("SET LOCAL lock_timeout = '500ms'");
|
|
316
|
+
await trx("entries").insert(entry);
|
|
317
|
+
}));
|
|
318
|
+
return error ? error.code : "added";
|
|
319
|
+
};
|
|
320
|
+
const lockers = {
|
|
321
|
+
lock: (txOptions) => slots.lock(slot, txOptions),
|
|
322
|
+
lockMany: (txOptions) => slots.lockMany([slot], txOptions),
|
|
323
|
+
lockWhere: (txOptions) => slots.lockWhere({ status: "open" }, txOptions),
|
|
324
|
+
claim: (txOptions) => slots.claim({ filters: { status: "open" } }, txOptions),
|
|
325
|
+
patchWhere: (txOptions) => slots.patchWhere({ status: "open" }, { takenBy: "worker" }, txOptions)
|
|
326
|
+
};
|
|
327
|
+
const outcomes = {};
|
|
328
|
+
for (const [name, locker] of Object.entries(lockers)) {
|
|
329
|
+
for (const mode of ["noKeyUpdate", "update"]) {
|
|
330
|
+
outcomes[`${name} ${mode}`] = await slots.withTransaction(WORKSPACE, async (txOptions) => {
|
|
331
|
+
await locker(mode === "update" ? txOptions : { ...txOptions, lockMode: mode });
|
|
332
|
+
return addEntry({ slot_id: slot });
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const mode of ["noKeyUpdate", "update"]) {
|
|
337
|
+
const options = mode === "update" ? WORKSPACE : { ...WORKSPACE, lockMode: mode };
|
|
338
|
+
outcomes[`lockOrCreate ${mode}`] = await balances.lockOrCreate({ id: "acct-3", amount: 0 }, options, () => addEntry({ balance_id: "acct-3" }));
|
|
339
|
+
}
|
|
340
|
+
assert.deepEqual(outcomes, Object.fromEntries([...Object.keys(lockers), "lockOrCreate"].flatMap((name) => [
|
|
341
|
+
[`${name} noKeyUpdate`, "added"],
|
|
342
|
+
[`${name} update`, "55P03"]
|
|
343
|
+
])));
|
|
344
|
+
assert.equal(Number((await knex("entries").count({ count: "*" }).first()).count), 6);
|
|
345
|
+
});
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import knexLib from "knex";
|
|
4
3
|
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
4
|
import { isDuplicateEntryError, retryLostInsertRace } from "@jskit-ai/database-runtime/shared";
|
|
6
|
-
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
5
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
6
|
+
|
|
7
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
8
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
7
9
|
|
|
8
10
|
const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
|
|
9
11
|
|
|
10
12
|
// Requests keyed by a request key, unique in each workspace: a repeated request finds its first answer by the key.
|
|
11
13
|
async function createRequestFixture(t) {
|
|
12
|
-
const knex =
|
|
13
|
-
t.after(() => knex.destroy());
|
|
14
|
+
const knex = await testKnex(t);
|
|
14
15
|
await knex.schema.createTable("requests", (table) => {
|
|
15
16
|
table.increments("id");
|
|
16
17
|
table.integer("workspace_id").notNullable();
|
|
@@ -3,6 +3,7 @@ import test from "node:test";
|
|
|
3
3
|
import knexLib from "knex";
|
|
4
4
|
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
5
|
import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
6
|
+
import { UnsupportedDatabaseError } from "../src/server/index.js";
|
|
6
7
|
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
7
8
|
|
|
8
9
|
// patchWhere and deleteWhere on MySQL or MariaDB, under two concurrent writers: each checks its filters on the row
|
|
@@ -143,3 +144,11 @@ test("a deleteWhere whose filter matched a row a concurrent patchWhere changed l
|
|
|
143
144
|
// Whichever went first wins; the other finds the row changed (or gone) and leaves it.
|
|
144
145
|
assert.deepEqual([patched + deleted, row ? row.status : "deleted"], [1, patched === 1 ? "taken" : "deleted"]);
|
|
145
146
|
});
|
|
147
|
+
|
|
148
|
+
test("changeHorizon() throws on MySQL and MariaDB, which stamp no row with its transaction, before any statement", async (t) => {
|
|
149
|
+
const { knex, slots } = await ownDatabase(t, "horizon");
|
|
150
|
+
const statements = [];
|
|
151
|
+
knex.on("query", ({ sql }) => statements.push(sql));
|
|
152
|
+
await assert.rejects(slots.changeHorizon({ context: WORKSPACE }), UnsupportedDatabaseError);
|
|
153
|
+
assert.deepEqual(statements, []);
|
|
154
|
+
});
|
package/test/queryFields.test.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import knexLib from "knex";
|
|
4
3
|
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
|
-
import { createTestResourceRuntime } from "../src/test/index.js";
|
|
4
|
+
import { createPackageTestKnex, createTestResourceRuntime } from "../src/test/index.js";
|
|
5
|
+
|
|
6
|
+
// SQLite in memory, or a PostgreSQL schema of its own on a DB_CLIENT=pg run (see createPackageTestKnex).
|
|
7
|
+
const testKnex = (t) => createPackageTestKnex(t, { environment: process.env });
|
|
6
8
|
|
|
7
9
|
const WORKSPACE_A = Object.freeze({ workspace: { id: "1" } });
|
|
8
10
|
|
|
9
11
|
test("a query field's select gets a value-only raw, column() and the read's filters, never Knex or its context", async (t) => {
|
|
10
|
-
const knex =
|
|
11
|
-
t.after(() => knex.destroy());
|
|
12
|
+
const knex = await testKnex(t);
|
|
12
13
|
await knex.schema.createTable("receivals", (table) => {
|
|
13
14
|
table.increments("id");
|
|
14
15
|
table.integer("workspace_id");
|