@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.
- package/CHANGELOG.md +70 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +94 -0
- package/src/server/handleGuards.js +226 -0
- package/src/server/handleHidden.js +309 -0
- package/src/server/handleLocks.js +103 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +15 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +88 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +724 -0
- package/src/server/resourceHost.js +686 -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 +122 -0
- package/test/callSettings.test.js +99 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/hiddenFields.test.js +203 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/lostInsertRace.test.js +64 -0
- package/test/manyRowWrites.mysql.test.js +145 -0
- package/test/queryFields.test.js +68 -0
- package/test/resourceRuntime.test.js +1892 -0
- package/test/testHelpers.test.js +298 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knexFactory from "knex";
|
|
4
|
+
import { defineTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
5
|
+
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
6
|
+
import { defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
7
|
+
import {
|
|
8
|
+
TestDatabaseMissingError,
|
|
9
|
+
createTestResourceRuntime,
|
|
10
|
+
expectEveryTableOwned,
|
|
11
|
+
expectFlatQueries,
|
|
12
|
+
expectScoped,
|
|
13
|
+
startTestFeatures
|
|
14
|
+
} from "../src/test/index.js";
|
|
15
|
+
|
|
16
|
+
const environment = Object.freeze({ TEST_DB_NAME: "catalogue_test" });
|
|
17
|
+
|
|
18
|
+
test("expectFlatQueries() passes a path that reads its rows at once and fails one that reads them row by row", async () => {
|
|
19
|
+
const knex = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
20
|
+
try {
|
|
21
|
+
await knex.schema.createTable("books", (table) => { table.increments("id"); table.integer("shelf"); table.string("title"); });
|
|
22
|
+
let shelf = 0;
|
|
23
|
+
// Each size gets a shelf of its own, so the sizes stay apart.
|
|
24
|
+
const seed = async (size) => {
|
|
25
|
+
shelf += 1;
|
|
26
|
+
await knex("books").insert(Array.from({ length: size }, (_, index) => ({ shelf, title: `Book ${index + 1}` })));
|
|
27
|
+
return shelf;
|
|
28
|
+
};
|
|
29
|
+
const atOnce = (seeded) => knex("books").where({ shelf: seeded }).orderBy("id").pluck("title");
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
await expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: atOnce }),
|
|
32
|
+
[{ size: 3, count: 1, chunkStatements: 0 }, { size: 30, count: 1, chunkStatements: 0 }]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const rowByRow = async (seeded) => {
|
|
36
|
+
const ids = await knex("books").where({ shelf: seeded }).pluck("id");
|
|
37
|
+
for (const id of ids) await knex("books").where({ id }).first("title");
|
|
38
|
+
};
|
|
39
|
+
await assert.rejects(expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: rowByRow }), (error) => {
|
|
40
|
+
assert.equal(error.name, "AssertionError");
|
|
41
|
+
assert.equal(error.message, [
|
|
42
|
+
"The statements grow with the rows: 4 at 3 rows, 31 at 30 rows.",
|
|
43
|
+
"Repeated most at 30 rows:",
|
|
44
|
+
" 30 x select `title` from `books` where `id` = ? limit ?",
|
|
45
|
+
" 1 x select `id` from `books` where `shelf` = ?"
|
|
46
|
+
].join("\n"));
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let seeded = false;
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
expectFlatQueries({ knex, environment: {}, seed: () => { seeded = true; }, run: atOnce }),
|
|
53
|
+
TestDatabaseMissingError
|
|
54
|
+
);
|
|
55
|
+
assert.equal(seeded, false);
|
|
56
|
+
for (const sizes of [[10], [200, 10], [10, 10], [0, 10], [10, 20.5]]) {
|
|
57
|
+
await assert.rejects(expectFlatQueries({ knex, environment, sizes, seed, run: atOnce }), /at least two growing whole numbers/u);
|
|
58
|
+
}
|
|
59
|
+
await assert.rejects(expectFlatQueries({ knex, environment, seed }), /requires seed\(size\) and run\(seeded, size\)/u);
|
|
60
|
+
// Counted on a channel the path does not use (another Knex client than its own), it measures nothing.
|
|
61
|
+
const elsewhere = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
62
|
+
try {
|
|
63
|
+
await assert.rejects(expectFlatQueries({ knex: elsewhere, environment, sizes: [3, 30], seed, run: atOnce }), /sent no statement at any size/u);
|
|
64
|
+
} finally {
|
|
65
|
+
await elsewhere.destroy();
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
await knex.destroy();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function createShelfFixture(t) {
|
|
73
|
+
const knex = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
74
|
+
t.after(() => knex.destroy());
|
|
75
|
+
await knex.schema.createTable("books", (table) => {
|
|
76
|
+
table.increments("id");
|
|
77
|
+
table.integer("workspace_id");
|
|
78
|
+
table.string("title");
|
|
79
|
+
});
|
|
80
|
+
const books = defineTableResource({
|
|
81
|
+
name: "books",
|
|
82
|
+
tableName: "books",
|
|
83
|
+
ownership: "workspace",
|
|
84
|
+
schema: {
|
|
85
|
+
id: { type: "id", primary: true },
|
|
86
|
+
workspaceId: { type: "id", search: true, storage: { column: "workspace_id" } },
|
|
87
|
+
title: { type: "string", search: true }
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
91
|
+
const { books: handle } = await runtime.own("books.feature", [books]);
|
|
92
|
+
const systemContext = runtime.declareSystemScope("books.feature", "tests")("check scope");
|
|
93
|
+
return { knex, runtime, books: handle, systemContext };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test("expectFlatQueries() allows the later chunks a many-row write reports, and nothing else", async (t) => {
|
|
97
|
+
const { knex, books } = await createShelfFixture(t);
|
|
98
|
+
let workspace = 0;
|
|
99
|
+
const seed = () => {
|
|
100
|
+
workspace += 1;
|
|
101
|
+
return { context: { workspace: { id: String(workspace) } } };
|
|
102
|
+
};
|
|
103
|
+
const createInChunks = (options, size) => books.createMany(
|
|
104
|
+
Array.from({ length: size }, (_, index) => ({ title: `Book ${index}` })),
|
|
105
|
+
{ ...options, chunkSize: 5 }
|
|
106
|
+
);
|
|
107
|
+
const counts = await expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: createInChunks });
|
|
108
|
+
assert.deepEqual(counts.map(({ size, chunkStatements }) => [size, chunkStatements]), [[3, 0], [30, 5]]);
|
|
109
|
+
|
|
110
|
+
const oneByOne = async (options, size) => {
|
|
111
|
+
for (let index = 0; index < size; index += 1) {
|
|
112
|
+
await books.create({ title: `Book ${index}` }, { ...options, returning: "none" });
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
await assert.rejects(expectFlatQueries({ knex, environment, sizes: [3, 30], seed, run: oneByOne }), /The statements grow with the rows/u);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("expectScoped() passes a path that keeps to its workspace and fails one that reaches another", async (t) => {
|
|
119
|
+
const { books, systemContext } = await createShelfFixture(t);
|
|
120
|
+
const owners = [{ context: { workspace: { id: "1" } } }, { context: { workspace: { id: "2" } } }];
|
|
121
|
+
const seed = (owner) => books.createMany([{ title: "a" }, { title: "b" }], owner);
|
|
122
|
+
const handles = [books];
|
|
123
|
+
|
|
124
|
+
const result = await expectScoped({
|
|
125
|
+
environment,
|
|
126
|
+
handles,
|
|
127
|
+
systemContext,
|
|
128
|
+
owners,
|
|
129
|
+
seed,
|
|
130
|
+
run: (owner) => books.queryAll({}, owner)
|
|
131
|
+
});
|
|
132
|
+
assert.equal(result.length, 2);
|
|
133
|
+
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
expectScoped({ environment, handles, systemContext, owners, seed, run: () => books.queryAll({}, { context: systemContext }) }),
|
|
136
|
+
/answered differently once the other owner's rows were seeded/u
|
|
137
|
+
);
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
expectScoped({ environment, handles, systemContext, owners, seed, run: () => books.patchWhere({}, { title: "every" }, { context: systemContext, all: true }) }),
|
|
140
|
+
/changed books:\d+, a row of the other owner/u
|
|
141
|
+
);
|
|
142
|
+
await assert.rejects(
|
|
143
|
+
expectScoped({ environment, handles, systemContext, owners, seed, run: () => books.deleteWhere({ title: "b" }, { context: systemContext }) }),
|
|
144
|
+
/removed books:\d+, a row of the other owner/u
|
|
145
|
+
);
|
|
146
|
+
await assert.rejects(expectScoped({ environment: {}, handles, systemContext, owners, seed, run: seed }), TestDatabaseMissingError);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("expectScoped() fails a report that joins another owner's table by name, though no row of it is in the answer", async (t) => {
|
|
150
|
+
const knex = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
151
|
+
t.after(() => knex.destroy());
|
|
152
|
+
await knex.schema.createTable("visits", (table) => { table.increments("id"); table.integer("workspace_id"); table.string("person"); });
|
|
153
|
+
await knex.schema.createTable("people", (table) => { table.increments("id"); table.integer("workspace_id"); table.string("name"); table.string("business"); });
|
|
154
|
+
const owned = (name, schema) => defineTableResource({
|
|
155
|
+
name,
|
|
156
|
+
tableName: name,
|
|
157
|
+
ownership: "workspace",
|
|
158
|
+
schema: { id: { type: "id", primary: true }, workspaceId: { type: "id", search: true, storage: { column: "workspace_id" } }, ...schema }
|
|
159
|
+
});
|
|
160
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
161
|
+
const { visits } = await runtime.own("visits.feature", [owned("visits", { person: { type: "string" } })]);
|
|
162
|
+
const { people } = await runtime.own("people.feature", [owned("people", { name: { type: "string" }, business: { type: "string" } })]);
|
|
163
|
+
const systemContext = runtime.declareSystemScope("visits.feature", "tests")("check scope");
|
|
164
|
+
const owners = [{ context: { workspace: { id: "1" } }, business: "business A" }, { context: { workspace: { id: "2" } }, business: "business B" }];
|
|
165
|
+
const seed = async ({ context, business }) => {
|
|
166
|
+
await visits.create({ person: "Pat" }, { context, returning: "none" });
|
|
167
|
+
await people.create({ name: "Pat", business }, { context, returning: "none" });
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Knex: report - each visit with where its person works, joining the people table by name: it reads every owner's people
|
|
171
|
+
async function visitLabels(options) {
|
|
172
|
+
const { query } = await visits.scopedQuery({ alias: "v" }, options);
|
|
173
|
+
const rows = await query.join("people", "people.name", visits.column("person", "v")).select("people.name", "people.business").orderBy("people.id");
|
|
174
|
+
return rows.map((row) => `${row.name} at ${row.business}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Each answer is text, so no row of the other owner's is in it by id; the second answer gained one.
|
|
178
|
+
await assert.rejects(
|
|
179
|
+
expectScoped({ environment, handles: [visits, people], systemContext, owners, seed, run: (owner) => visitLabels(owner) }),
|
|
180
|
+
(error) => {
|
|
181
|
+
assert.equal(error.name, "AssertionError");
|
|
182
|
+
assert.match(error.message, /answered differently once the other owner's rows were seeded/u);
|
|
183
|
+
assert.deepEqual(error.expected, ["Pat at business A"]);
|
|
184
|
+
assert.deepEqual(error.actual, ["Pat at business A", "Pat at business B"]);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("expectScoped() compares the other owner's stored rows, so a changed hidden column or one outside the schema fails", async (t) => {
|
|
191
|
+
const knex = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
192
|
+
t.after(() => knex.destroy());
|
|
193
|
+
await knex.schema.createTable("keys", (table) => {
|
|
194
|
+
table.increments("id");
|
|
195
|
+
table.integer("workspace_id");
|
|
196
|
+
table.string("label");
|
|
197
|
+
table.string("secret");
|
|
198
|
+
table.string("note");
|
|
199
|
+
});
|
|
200
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
201
|
+
const { keys } = await runtime.own("keys.feature", [defineTableResource({
|
|
202
|
+
name: "keys",
|
|
203
|
+
tableName: "keys",
|
|
204
|
+
ownership: "workspace",
|
|
205
|
+
schema: {
|
|
206
|
+
id: { type: "id", primary: true },
|
|
207
|
+
workspaceId: { type: "id", search: true, hidden: true, storage: { column: "workspace_id" } },
|
|
208
|
+
label: { type: "string", search: true },
|
|
209
|
+
secret: { type: "string", hidden: true }
|
|
210
|
+
}
|
|
211
|
+
})]);
|
|
212
|
+
const systemContext = runtime.declareSystemScope("keys.feature", "tests")("check scope");
|
|
213
|
+
const owners = [{ context: { workspace: { id: "1" } } }, { context: { workspace: { id: "2" } } }];
|
|
214
|
+
const seed = (owner) => keys.createMany([{ label: "a", secret: "s" }, { label: "b", secret: "s" }], { ...owner, returning: "none" });
|
|
215
|
+
const scoped = (run) => expectScoped({ environment, handles: [keys], systemContext, owners, seed, run });
|
|
216
|
+
|
|
217
|
+
const result = await scoped((owner) => keys.queryAll({}, owner));
|
|
218
|
+
assert.deepEqual(result.map(({ label }) => label).sort(), ["a", "b"]);
|
|
219
|
+
await assert.rejects(
|
|
220
|
+
scoped(() => keys.patchWhere({ label: "b" }, { secret: "rewritten" }, { context: systemContext, returning: "none" })),
|
|
221
|
+
/changed keys:\d+, a row of the other owner/u
|
|
222
|
+
);
|
|
223
|
+
// Knex: write - a column outside the table resource's schema, changed for every workspace
|
|
224
|
+
await assert.rejects(scoped(() => knex("keys").update({ note: "everyone's" })), /changed keys:\d+, a row of the other owner/u);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("expectScoped() reads back only the other owner's rows, so a table past queryAll's row limit is checked too", async (t) => {
|
|
228
|
+
const { knex, books, systemContext } = await createShelfFixture(t);
|
|
229
|
+
await knex.batchInsert("books", Array.from({ length: 10_001 }, (_, index) => ({ workspace_id: 3, title: `Old ${index}` })), 500);
|
|
230
|
+
const owners = [{ context: { workspace: { id: "1" } } }, { context: { workspace: { id: "2" } } }];
|
|
231
|
+
const result = await expectScoped({
|
|
232
|
+
environment,
|
|
233
|
+
handles: [books],
|
|
234
|
+
systemContext,
|
|
235
|
+
owners,
|
|
236
|
+
seed: (owner) => books.createMany([{ title: "a" }, { title: "b" }], owner),
|
|
237
|
+
run: (owner) => books.queryAll({}, owner)
|
|
238
|
+
});
|
|
239
|
+
assert.deepEqual(result.map(({ title }) => title).sort(), ["a", "b"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("expectEveryTableOwned() names each table no feature owns", async (t) => {
|
|
243
|
+
const { knex, runtime } = await createShelfFixture(t);
|
|
244
|
+
await expectEveryTableOwned({ knex, runtime, environment });
|
|
245
|
+
await knex.schema.createTable("loose_notes", (table) => table.increments("id"));
|
|
246
|
+
await assert.rejects(expectEveryTableOwned({ knex, runtime, environment }), /Tables no feature owns: loose_notes\./u);
|
|
247
|
+
await expectEveryTableOwned({ knex, runtime, infrastructureTables: ["loose_notes"], environment });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("startTestFeatures() starts features in the order their requires give, on tables it creates, and boots them", async (t) => {
|
|
251
|
+
const knex = knexFactory({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
252
|
+
t.after(() => knex.destroy());
|
|
253
|
+
const shelves = defineTableResource({
|
|
254
|
+
name: "shelves",
|
|
255
|
+
tableName: "shelves",
|
|
256
|
+
ownership: "workspace",
|
|
257
|
+
schema: { workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } }, label: { type: "string", required: true } }
|
|
258
|
+
});
|
|
259
|
+
const order = [];
|
|
260
|
+
const ShelvesFeature = defineFeature({
|
|
261
|
+
id: "shelves",
|
|
262
|
+
owns: [shelves],
|
|
263
|
+
systemScope: "count every workspace's shelves",
|
|
264
|
+
provides: { shelfRecords: "shelves.records" },
|
|
265
|
+
setup: ({ resources, systemContext }) => {
|
|
266
|
+
order.push("shelves");
|
|
267
|
+
return {
|
|
268
|
+
shelfRecords: Object.freeze({
|
|
269
|
+
add: (label, options) => resources.shelves.create({ label }, options),
|
|
270
|
+
countAll: () => resources.shelves.aggregate({ measures: { shelves: { count: "id" } } }, { context: systemContext("count every shelf") })
|
|
271
|
+
})
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
// Listed first, it still starts after the feature whose records it requires, and its boot runs after every setup.
|
|
276
|
+
const ReportsFeature = defineFeature({
|
|
277
|
+
id: "reports",
|
|
278
|
+
requires: { shelfRecords: "shelves.records" },
|
|
279
|
+
provides: { reports: "reports" },
|
|
280
|
+
setup: ({ shelfRecords }) => {
|
|
281
|
+
order.push("reports");
|
|
282
|
+
return { reports: Object.freeze({ shelfCount: async () => (await shelfRecords.countAll())[0].shelves }) };
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
const Sealer = defineProvider({ id: "test.sealer", setup: () => ({}), boot: () => { order.push("boot"); } });
|
|
286
|
+
const { capabilities, resources, stop } = await startTestFeatures({
|
|
287
|
+
knex,
|
|
288
|
+
features: [ReportsFeature, Sealer, ShelvesFeature],
|
|
289
|
+
requires: { reports: "reports", shelfRecords: "shelves.records" },
|
|
290
|
+
createTables: true
|
|
291
|
+
});
|
|
292
|
+
t.after(stop);
|
|
293
|
+
await capabilities.shelfRecords.add("fiction", { context: { workspace: { id: "1" } } });
|
|
294
|
+
await capabilities.shelfRecords.add("poetry", { context: { workspace: { id: "2" } } });
|
|
295
|
+
assert.equal(await capabilities.reports.shelfCount(), 2);
|
|
296
|
+
assert.deepEqual(order, ["shelves", "reports", "boot"]);
|
|
297
|
+
assert.deepEqual(resources.ownedTables(), [{ name: "shelves", tableName: "shelves", featureId: "shelves" }]);
|
|
298
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolveWorkspaceScopeValue } from "../src/server/resourceHost.js";
|
|
4
|
+
|
|
5
|
+
test("resolveWorkspaceScopeValue reads one workspace id from any of its sources", () => {
|
|
6
|
+
const all = {
|
|
7
|
+
workspace: { id: "1" },
|
|
8
|
+
requestMeta: {
|
|
9
|
+
resolvedWorkspaceContext: { workspace: { id: "1" } },
|
|
10
|
+
request: { workspace: { id: 1 } }
|
|
11
|
+
},
|
|
12
|
+
visibilityContext: { scopeOwnerId: "1" },
|
|
13
|
+
scopeValues: { workspaceId: 1 }
|
|
14
|
+
};
|
|
15
|
+
assert.equal(resolveWorkspaceScopeValue(all), "1");
|
|
16
|
+
assert.equal(resolveWorkspaceScopeValue({ workspace: { id: "2" } }), "2");
|
|
17
|
+
assert.equal(resolveWorkspaceScopeValue({ requestMeta: { resolvedWorkspaceContext: { workspace: { id: "3" } } } }), "3");
|
|
18
|
+
assert.equal(resolveWorkspaceScopeValue({ requestMeta: { request: { workspace: { id: "4" } } } }), "4");
|
|
19
|
+
assert.equal(resolveWorkspaceScopeValue({ scopeValues: { workspaceId: 5 } }), "5");
|
|
20
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "workspace_user", scopeKind: "workspace_user", scopeOwnerId: "6" } }), "6");
|
|
21
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "workspace", scopeOwnerId: "7" } }), "7");
|
|
22
|
+
assert.equal(resolveWorkspaceScopeValue({}), null);
|
|
23
|
+
assert.equal(resolveWorkspaceScopeValue(), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("resolveWorkspaceScopeValue ignores the owner of a visibility scope that is not a workspace's", () => {
|
|
27
|
+
const scopeValues = { workspaceId: "4" };
|
|
28
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "user", scopeOwnerId: "9" }, scopeValues }), "4");
|
|
29
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "public", scopeOwnerId: "9" } }), null);
|
|
30
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: { visibility: "workspace", scopeKind: "team", scopeOwnerId: "9" } }), null);
|
|
31
|
+
assert.equal(resolveWorkspaceScopeValue({ visibilityContext: null, scopeValues }), "4");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("resolveWorkspaceScopeValue refuses a context that names two workspaces", () => {
|
|
35
|
+
assert.throws(
|
|
36
|
+
() => resolveWorkspaceScopeValue({
|
|
37
|
+
workspace: { id: "1" },
|
|
38
|
+
requestMeta: { resolvedWorkspaceContext: { workspace: { id: "2" } } }
|
|
39
|
+
}),
|
|
40
|
+
{ message: "safety invariant: a context names two workspaces (1, 2); one execution acts on one workspace." }
|
|
41
|
+
);
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => resolveWorkspaceScopeValue({
|
|
44
|
+
visibilityContext: { visibility: "workspace", scopeOwnerId: "3" },
|
|
45
|
+
scopeValues: { workspaceId: "4" }
|
|
46
|
+
}),
|
|
47
|
+
{ message: "safety invariant: a context names two workspaces (3, 4); one execution acts on one workspace." }
|
|
48
|
+
);
|
|
49
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
// Values a handle writes and compares on MySQL or MariaDB: timestamps a job sets itself, with milliseconds, on a
|
|
9
|
+
// table that stores whole seconds, and a patch's onlyIf with no changes, compared in SQL.
|
|
10
|
+
|
|
11
|
+
const WORKSPACE = Object.freeze({ workspace: { id: "1" } });
|
|
12
|
+
const RUNTIME_MYSQL = Object.freeze({ supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" });
|
|
13
|
+
|
|
14
|
+
const eventResource = defineTableResource({
|
|
15
|
+
name: "events",
|
|
16
|
+
tableName: "events",
|
|
17
|
+
ownership: "workspace",
|
|
18
|
+
defaultSort: ["id"],
|
|
19
|
+
schema: {
|
|
20
|
+
id: { type: "id", primary: true },
|
|
21
|
+
workspaceId: { type: "id", nullable: true, storage: { column: "workspace_id" } },
|
|
22
|
+
ownerUserId: { type: "id", nullable: true, storage: { column: "owner_user_id" } },
|
|
23
|
+
title: { type: "string", required: true },
|
|
24
|
+
secret: { type: "string", nullable: true, hidden: true },
|
|
25
|
+
createdAt: { type: "dateTime", temporalPrecision: 0, readOnly: true, storage: { column: "created_at" } },
|
|
26
|
+
updatedAt: { type: "dateTime", temporalPrecision: 0, readOnly: true, storage: { column: "updated_at" } }
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** A database of the test's own beside TEST_DB_NAME, with one events table storing whole seconds, dropped after it. */
|
|
31
|
+
async function ownDatabase(context, suffix) {
|
|
32
|
+
const name = `${requireTestDatabase(process.env)}_${suffix}`;
|
|
33
|
+
const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
|
|
34
|
+
const admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
|
|
35
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
36
|
+
await admin.raw("CREATE DATABASE ??", [name]);
|
|
37
|
+
const knex = knexLib({ client: "mysql2", connection: { ...server, database: name, ...RUNTIME_MYSQL }, pool: { min: 0, max: 2 } });
|
|
38
|
+
context.after(async () => {
|
|
39
|
+
await knex.destroy();
|
|
40
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [name]);
|
|
41
|
+
await admin.destroy();
|
|
42
|
+
});
|
|
43
|
+
await knex.schema.createTable("events", (table) => {
|
|
44
|
+
table.increments("id");
|
|
45
|
+
table.integer("workspace_id").nullable();
|
|
46
|
+
table.integer("owner_user_id").nullable();
|
|
47
|
+
table.string("title").notNullable();
|
|
48
|
+
table.string("secret").nullable();
|
|
49
|
+
table.datetime("created_at", { precision: 0 }).notNullable();
|
|
50
|
+
table.datetime("updated_at", { precision: 0 }).notNullable();
|
|
51
|
+
});
|
|
52
|
+
const runtime = await createTestResourceRuntime({ knex });
|
|
53
|
+
const { events } = await runtime.own("events.feature", [eventResource]);
|
|
54
|
+
const job = runtime.declareSystemScope("events.feature", "import past events")("import the events");
|
|
55
|
+
return { knex, events, job };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The stored timestamps of a row, as UTC text.
|
|
59
|
+
async function storedTimes(knex, id) {
|
|
60
|
+
const row = await knex("events").where({ id }).first("created_at", "updated_at");
|
|
61
|
+
return [row.created_at.toISOString(), row.updated_at.toISOString()];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("a job's own timestamps with milliseconds are stored to the whole second on a temporalPrecision 0 table", async (t) => {
|
|
65
|
+
const { knex, events, job } = await ownDatabase(t, "job_stamps");
|
|
66
|
+
const options = { context: job };
|
|
67
|
+
// A job's Date#toISOString(): its milliseconds are cut, as the handle cuts its own stamps, never rounded up.
|
|
68
|
+
const created = await events.create(
|
|
69
|
+
{ workspaceId: "1", title: "imported", createdAt: "2026-01-02T03:04:05.956Z", updatedAt: "2026-01-02T03:04:06.250Z" },
|
|
70
|
+
options
|
|
71
|
+
);
|
|
72
|
+
assert.deepEqual(await storedTimes(knex, created.id), ["2026-01-02T03:04:05.000Z", "2026-01-02T03:04:06.000Z"]);
|
|
73
|
+
await events.patch(created.id, { title: "renamed", updatedAt: "2026-02-03T04:05:06.999Z" }, options);
|
|
74
|
+
assert.deepEqual(await storedTimes(knex, created.id), ["2026-01-02T03:04:05.000Z", "2026-02-03T04:05:06.000Z"]);
|
|
75
|
+
|
|
76
|
+
const ids = await events.createMany([
|
|
77
|
+
{ workspaceId: "1", title: "a", createdAt: "2026-03-04T05:06:07.123Z", updatedAt: "2026-03-04T05:06:07.123Z" },
|
|
78
|
+
{ workspaceId: "1", title: "b", createdAt: "2026-03-04T05:06:08.987Z", updatedAt: "2026-03-04T05:06:08.987Z" }
|
|
79
|
+
], { ...options, returning: "ids" });
|
|
80
|
+
assert.deepEqual(await storedTimes(knex, ids[1]), ["2026-03-04T05:06:08.000Z", "2026-03-04T05:06:08.000Z"]);
|
|
81
|
+
await events.patchMany([{ id: ids[0], updatedAt: "2026-04-05T06:07:08.765Z" }], options);
|
|
82
|
+
assert.deepEqual(await storedTimes(knex, ids[0]), ["2026-03-04T05:06:07.000Z", "2026-04-05T06:07:08.000Z"]);
|
|
83
|
+
await events.patchWhere({ id: ids[1] }, { updatedAt: "2026-05-06T07:08:09.5Z" }, options);
|
|
84
|
+
assert.deepEqual(await storedTimes(knex, ids[1]), ["2026-03-04T05:06:08.000Z", "2026-05-06T07:08:09.000Z"]);
|
|
85
|
+
|
|
86
|
+
// A request's timestamps are the handle's, whole seconds too.
|
|
87
|
+
const requested = await events.create({ workspaceId: "1", title: "asked" }, { context: WORKSPACE });
|
|
88
|
+
const [requestCreated] = await storedTimes(knex, requested.id);
|
|
89
|
+
assert.match(requestCreated, /\.000Z$/u);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a patch with onlyIf and no changes compares in SQL: an id given as a number or as text, and a hidden field, match", async (t) => {
|
|
93
|
+
const { events } = await ownDatabase(t, "only_if");
|
|
94
|
+
const options = { context: WORKSPACE };
|
|
95
|
+
const event = await events.create({ title: "talk", ownerUserId: "7", secret: "s1" }, options);
|
|
96
|
+
// A record never answers the hidden secret, so only SQL can compare it.
|
|
97
|
+
assert.equal(Object.hasOwn(event, "secret"), false);
|
|
98
|
+
for (const missing of ["throw", "skip"]) {
|
|
99
|
+
const call = { ...options, missing };
|
|
100
|
+
// The id compares whether the caller gives it as a number or as text, however the record answers it.
|
|
101
|
+
assert.equal((await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: 7 } }))?.id, event.id, missing);
|
|
102
|
+
assert.equal((await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: "7" } }))?.id, event.id, missing);
|
|
103
|
+
assert.equal((await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: "7", secret: "s1" } }))?.id, event.id, missing);
|
|
104
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { ownerUserId: 8 } }), null, missing);
|
|
105
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { secret: "other" } }), null, missing);
|
|
106
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { secret: null } }), null, missing);
|
|
107
|
+
assert.equal(await events.patch(event.id, {}, { ...call, onlyIf: { title: "talk" }, returning: "id" }), event.id, missing);
|
|
108
|
+
}
|
|
109
|
+
// Another workspace's caller does not see the record, whatever onlyIf holds.
|
|
110
|
+
assert.equal(await events.patch(event.id, {}, { context: { workspace: { id: "2" } }, missing: "skip", onlyIf: { ownerUserId: 7 } }), null);
|
|
111
|
+
});
|
package/README.md
DELETED