@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/locales/en.json +16 -0
  3. package/package.json +68 -4
  4. package/src/server/ResourceRuntimeProvider.js +30 -0
  5. package/src/server/changeHorizon.js +26 -0
  6. package/src/server/contexts.js +192 -0
  7. package/src/server/errors.js +137 -0
  8. package/src/server/foreignKeyCheck.js +58 -0
  9. package/src/server/handleGuards.js +233 -0
  10. package/src/server/handleHidden.js +410 -0
  11. package/src/server/handleLocks.js +135 -0
  12. package/src/server/handleRecords.js +474 -0
  13. package/src/server/index.js +18 -0
  14. package/src/server/jsonRestValues.js +49 -0
  15. package/src/server/lockOrCreate.js +92 -0
  16. package/src/server/rawExpression.js +50 -0
  17. package/src/server/resourceHandle.js +737 -0
  18. package/src/server/resourceHost.js +687 -0
  19. package/src/server/resourceRuntime.js +300 -0
  20. package/src/server/standaloneResources.js +49 -0
  21. package/src/server/types.js +49 -0
  22. package/src/test/expectFlatQueries.js +117 -0
  23. package/src/test/expectScoped.js +111 -0
  24. package/src/test/index.js +123 -0
  25. package/test/callSettings.test.js +100 -0
  26. package/test/changes.postgres.test.js +141 -0
  27. package/test/entrypoints.boundary.test.js +895 -0
  28. package/test/foreignKeys.test.js +94 -0
  29. package/test/hiddenFields.test.js +244 -0
  30. package/test/i18nCatalog.test.js +18 -0
  31. package/test/locks.postgres.test.js +345 -0
  32. package/test/lostInsertRace.test.js +65 -0
  33. package/test/manyRowWrites.mysql.test.js +154 -0
  34. package/test/queryFields.test.js +69 -0
  35. package/test/requestCallOptions.test.js +155 -0
  36. package/test/resourceRuntime.test.js +2098 -0
  37. package/test/storedIntegers.mysql.test.js +4 -0
  38. package/test/storedIntegers.postgres.test.js +5 -0
  39. package/test/support/storedIntegerCases.js +152 -0
  40. package/test/testHelpers.test.js +290 -0
  41. package/test/workspaceScopeValue.test.js +49 -0
  42. package/test/writeValues.mysql.test.js +111 -0
  43. package/README.md +0 -3
@@ -0,0 +1,300 @@
1
+ import { readOnlyQueryBuilder } from "json-rest-api";
2
+ import { toDatabaseDateTimeUtc } from "@jskit-ai/database-runtime/shared";
3
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { isTableResource } from "@jskit-ai/resource-core/shared/tableResource";
5
+ import { callerOf, createSystemContext } from "./contexts.js";
6
+ import { DuplicateOwnerError } from "./errors.js";
7
+ import { createResourceHandle } from "./resourceHandle.js";
8
+ import { resourceScopeOptions } from "./resourceHost.js";
9
+
10
+ const DEFAULT_REQUEST_ROW_LIMIT = 10000;
11
+ const DEFAULT_SERVER_PAGE_SIZE = 1000;
12
+ const ENTRY_KEYS = Object.freeze(["resource", "rowPolicy", "hooks", "acceptsContributions", "normalizeId", "queryFields", "searchSchema"]);
13
+ // Every table resource's datetime-utc fields are written as UTC database date-times.
14
+ const WRITE_SERIALIZERS = Object.freeze({ "datetime-utc": toDatabaseDateTimeUtc });
15
+
16
+ /**
17
+ * One `owns` entry as `{ resource, ...serverOnlyExtras }`.
18
+ * @param {unknown} entry
19
+ * @param {string} featureId
20
+ */
21
+ function ownedEntry(entry, featureId) {
22
+ const normalized = isTableResource(entry) ? { resource: entry } : entry;
23
+ if (!isRecord(normalized) || !isTableResource(normalized.resource)) {
24
+ throw new TypeError(`Feature "${featureId}" owns entries must be table resources or { resource, ...extras } with a table resource.`);
25
+ }
26
+ for (const key of Object.keys(normalized)) {
27
+ if (!ENTRY_KEYS.includes(key)) {
28
+ throw new TypeError(`Feature "${featureId}" owns entry for "${normalized.resource.name}" does not take "${key}"; it takes ${ENTRY_KEYS.join(", ")}.`);
29
+ }
30
+ }
31
+ const hooks = normalized.hooks === undefined ? {} : normalized.hooks;
32
+ if (!isRecord(hooks) || Object.keys(hooks).some((key) => key !== "afterWrite")) {
33
+ throw new TypeError(`Feature "${featureId}" hooks for "${normalized.resource.name}" take only afterWrite.`);
34
+ }
35
+ // readable-check: allow method-probe - input validation: a row policy is the owner's optional function
36
+ if (normalized.rowPolicy !== undefined && typeof normalized.rowPolicy !== "function") {
37
+ throw new TypeError(`Feature "${featureId}" rowPolicy for "${normalized.resource.name}" must be a function.`);
38
+ }
39
+ return Object.freeze({
40
+ ...normalized,
41
+ hooks: Object.freeze({ ...hooks })
42
+ });
43
+ }
44
+
45
+ /**
46
+ * The filters of a resource's search that read another table: each `actualField` or `oneOf` path
47
+ * "resource.field" (or "a.b.field", whose last resource holds the field), as { filter, target, field }.
48
+ * @param {Record<string, any>} scopeOptions The resource's json-rest-api scope options.
49
+ */
50
+ function crossTableFilters(scopeOptions) {
51
+ const declared = [
52
+ ...Object.entries(scopeOptions.searchSchema || {}),
53
+ ...Object.entries(scopeOptions.schema || {}).filter(([, field]) => isRecord(field?.search)).map(([name, field]) => [name, field.search])
54
+ ];
55
+ return declared.flatMap(([filter, entry]) => [entry?.actualField, ...(Array.isArray(entry?.oneOf) ? entry.oneOf : [])]
56
+ .filter((path) => typeof path === "string" && path.includes("."))
57
+ .map((path) => {
58
+ const segments = path.split(".");
59
+ return { filter, target: segments.at(-2), field: segments.at(-1) };
60
+ }));
61
+ }
62
+
63
+ /**
64
+ * Applies one row policy, the owner's or a contribution, inside its own
65
+ * parenthesised group, so whatever it writes (an orWhere among it) only narrows
66
+ * the rows ownership and the other policies left.
67
+ * The policy writes to a recorder; its calls replay inside `query.where(...)`.
68
+ * @param {Function} policy
69
+ * @param {Record<string, any>} args
70
+ */
71
+ async function applyGrouped(policy, args) {
72
+ const calls = [];
73
+ const recorder = new Proxy({}, {
74
+ get(_target, method) {
75
+ if (method === "then") {
76
+ return undefined;
77
+ }
78
+ return (...callArgs) => {
79
+ calls.push([method, callArgs]);
80
+ return recorder;
81
+ };
82
+ }
83
+ });
84
+ const result = await policy({ ...args, query: recorder });
85
+ if (result === true && calls.length > 0) {
86
+ args.query.where(function contributedPredicates() {
87
+ for (const [method, callArgs] of calls) {
88
+ this[method](...callArgs);
89
+ }
90
+ });
91
+ }
92
+ return result;
93
+ }
94
+
95
+ /**
96
+ * The resource runtime behind `runtime.resources`: features own their tables
97
+ * through it (`own`), and a feature that declares a system scope gets its
98
+ * system context factory from it (`declareSystemScope`).
99
+ * @param {object} dependencies
100
+ * @param {import("./types.js").RuntimeJsonRestApi} dependencies.api The host from createResourceHost.
101
+ * @param {number} [dependencies.requestRowLimit] Rows queryAll() may read in a request context.
102
+ * @param {number} [dependencies.serverPageSize] Page size of queryAll()'s reads.
103
+ */
104
+ function createResourceRuntime({ api, requestRowLimit = DEFAULT_REQUEST_ROW_LIMIT, serverPageSize = DEFAULT_SERVER_PAGE_SIZE }) {
105
+ const settings = Object.freeze({ requestRowLimit, serverPageSize });
106
+ /** @type {Map<string, { featureId: string, resource: object, acceptsContributions: boolean, contributions: Function[] }>} */
107
+ const ownedByName = new Map();
108
+ /** @type {Map<string, string>} */
109
+ const ownerByTable = new Map();
110
+ // Cross-table filters whose target table no feature owns yet: they are checked when its owner owns it.
111
+ const pendingCrossTableFilters = [];
112
+ /** @type {Map<string, string>} The system scope each feature declared. */
113
+ const scopeByFeature = new Map();
114
+
115
+ function claim(featureId, resource) {
116
+ const tableOwner = ownerByTable.get(resource.tableName);
117
+ if (tableOwner) {
118
+ throw new DuplicateOwnerError(`Feature "${featureId}" owns table "${resource.tableName}", which feature "${tableOwner}" already owns. One table has one owner.`);
119
+ }
120
+ if (ownedByName.has(resource.name) || api.resources[resource.name]) {
121
+ throw new DuplicateOwnerError(`Feature "${featureId}" owns resource "${resource.name}", a name another feature already registered.`);
122
+ }
123
+ }
124
+
125
+ // An owned table resource by name, or null: a handle shapes included records with it.
126
+ function resourceNamed(name) {
127
+ return ownedByName.get(name)?.resource ?? null;
128
+ }
129
+
130
+ // A SELECT-only builder over one of the feature's own tables, for a row policy's subqueries.
131
+ function ownTableReader(featureId, db) {
132
+ return function ownTable(name) {
133
+ const owned = ownedByName.get(name);
134
+ if (!owned || owned.featureId !== featureId) {
135
+ throw new TypeError(`ownTable("${name}"): a row policy reads only its own feature's tables, which are ${[...ownedByName].filter(([, entry]) => entry.featureId === featureId).map(([ownedName]) => ownedName).join(", ")}.`);
136
+ }
137
+ return readOnlyQueryBuilder(db(owned.resource.tableName), { resourceType: name });
138
+ };
139
+ }
140
+
141
+ // The owner's policy, then every contribution, each in its own group ANDed with ownership: a policy only narrows.
142
+ // Each gets who the call runs for as `caller` (see callerOf), its one way to tell: json-rest-api's own `system` flag
143
+ // is left out.
144
+ function composedRowPolicy(featureId, entry, contributions) {
145
+ if (!entry.rowPolicy && !entry.acceptsContributions) {
146
+ return undefined;
147
+ }
148
+ return async function ownedRowPolicy(jsonRestArgs) {
149
+ const args = { ...jsonRestArgs };
150
+ delete args.system;
151
+ const caller = callerOf(args.context);
152
+ if (entry.rowPolicy) {
153
+ const result = await applyGrouped(entry.rowPolicy, { ...args, caller, ownTable: ownTableReader(featureId, args.db) });
154
+ if (result !== true) {
155
+ return result;
156
+ }
157
+ }
158
+ for (const contribution of contributions) {
159
+ const result = await applyGrouped(contribution.policy, { ...args, caller, ownTable: ownTableReader(contribution.featureId, args.db) });
160
+ if (result !== true) {
161
+ return result;
162
+ }
163
+ }
164
+ return true;
165
+ };
166
+ }
167
+
168
+ function contributeRowPolicy(featureId, name, policy) {
169
+ const owned = ownedByName.get(name);
170
+ if (!owned) {
171
+ throw new TypeError(`Feature "${featureId}" contributes a row policy to "${name}", which no feature owns.`);
172
+ }
173
+ if (!owned.acceptsContributions) {
174
+ throw new TypeError(`Feature "${featureId}" contributes a row policy to "${name}", whose owner "${owned.featureId}" does not accept contributions.`);
175
+ }
176
+ if (typeof policy !== "function") {
177
+ throw new TypeError(`Feature "${featureId}" row policy for "${name}" must be a function.`);
178
+ }
179
+ owned.contributions.push(Object.freeze({ featureId, policy }));
180
+ }
181
+
182
+ /**
183
+ * Refuses, at startup, a cross-table filter on a field json-rest-api cannot search: one its target resource does
184
+ * not declare `indexed: true`, which json-rest-api otherwise refuses only at the first query. A filter whose
185
+ * target no feature owns yet waits for it.
186
+ * @param {ReadonlyArray<{ resource: Record<string, any> }>} entries The resources being owned now.
187
+ * @param {ReadonlyArray<Record<string, any>>} scopes Their json-rest-api scope options, in the same order.
188
+ */
189
+ function checkCrossTableFilters(entries, scopes) {
190
+ const owning = new Map(entries.map(({ resource }) => [resource.name, resource]));
191
+ const targetNamed = (name) => owning.get(name) || ownedByName.get(name)?.resource;
192
+ const filters = [
193
+ ...pendingCrossTableFilters.splice(0),
194
+ ...entries.flatMap(({ resource }, index) => crossTableFilters(scopes[index]).map((filter) => ({ ...filter, source: resource.name })))
195
+ ];
196
+ for (const filter of filters) {
197
+ const target = targetNamed(filter.target);
198
+ if (!target) {
199
+ pendingCrossTableFilters.push(filter);
200
+ continue;
201
+ }
202
+ if (target.schema?.[filter.field]?.indexed !== true) {
203
+ throw new TypeError(
204
+ `Table resource "${filter.source}" filters "${filter.filter}" on ${filter.target}.${filter.field}, which ` +
205
+ `"${filter.target}" does not declare indexed: true. A cross-table filter reads another table by that field: ` +
206
+ `give the field indexed: true and its column a database index, or filter on a field of "${filter.source}".`
207
+ );
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Registers the feature as the one owner of each resource and answers their
214
+ * handles, by resource name. The answer's contributeRowPolicy(name, policy)
215
+ * narrows another feature's resource whose owner accepts contributions.
216
+ * @param {string} featureId
217
+ * @param {ReadonlyArray<object>} owns
218
+ */
219
+ async function own(featureId, owns) {
220
+ const entries = owns.map((entry) => ownedEntry(entry, featureId));
221
+ const names = new Set();
222
+ for (const { resource } of entries) {
223
+ if (names.has(resource.name)) {
224
+ throw new DuplicateOwnerError(`Feature "${featureId}" lists resource "${resource.name}" twice.`);
225
+ }
226
+ names.add(resource.name);
227
+ claim(featureId, resource);
228
+ }
229
+ const contributions = entries.map(() => []);
230
+ const scopes = entries.map((entry, index) => resourceScopeOptions(entry.resource, {
231
+ writeSerializers: WRITE_SERIALIZERS,
232
+ normalizeId: entry.normalizeId,
233
+ rowPolicy: composedRowPolicy(featureId, entry, contributions[index]),
234
+ searchSchema: entry.searchSchema,
235
+ queryFields: entry.queryFields
236
+ }));
237
+ checkCrossTableFilters(entries, scopes);
238
+ const handles = {};
239
+ for (const [index, entry] of entries.entries()) {
240
+ const { resource } = entry;
241
+ ownedByName.set(resource.name, {
242
+ featureId,
243
+ resource,
244
+ acceptsContributions: entry.acceptsContributions === true,
245
+ contributions: contributions[index]
246
+ });
247
+ ownerByTable.set(resource.tableName, featureId);
248
+ await api.addResource(resource.name, scopes[index]);
249
+ handles[resource.name] = createResourceHandle({ api, resource, hooks: entry.hooks, settings, resourceNamed });
250
+ }
251
+ Object.defineProperty(handles, "contributeRowPolicy", {
252
+ value: (name, policy) => contributeRowPolicy(featureId, name, policy),
253
+ enumerable: false
254
+ });
255
+ return Object.freeze(handles);
256
+ }
257
+
258
+ /** Every owned table, with its resource name and owning feature. */
259
+ function ownedTables() {
260
+ return [...ownedByName].map(([name, { featureId, resource }]) => Object.freeze({ name, tableName: resource.tableName, featureId }));
261
+ }
262
+
263
+ /**
264
+ * Records that the feature works across every workspace and user, and answers its system context factory, the only
265
+ * way to a system context: each call names its reason, so every place that skips the ownership scope says why. A
266
+ * feature declares one scope; declaring it again answers another factory for it.
267
+ * @param {string} featureId
268
+ * @param {string} systemScope Why the feature works across every workspace and user.
269
+ */
270
+ function declareSystemScope(featureId, systemScope) {
271
+ const scope = typeof systemScope === "string" ? systemScope.trim() : "";
272
+ if (!scope) {
273
+ throw new TypeError(`Feature "${featureId}" systemScope must say why it works across every workspace and user.`);
274
+ }
275
+ if (scopeByFeature.has(featureId) && scopeByFeature.get(featureId) !== scope) {
276
+ throw new TypeError(`Feature "${featureId}" declares one system scope, and it already declared "${scopeByFeature.get(featureId)}".`);
277
+ }
278
+ scopeByFeature.set(featureId, scope);
279
+ return function systemContext(reason) {
280
+ if (typeof reason !== "string" || !reason.trim()) {
281
+ throw new TypeError(`Feature "${featureId}" systemContext(reason) needs the reason this work reads or writes every workspace's rows.`);
282
+ }
283
+ return createSystemContext({ feature: featureId, systemScope: scope, reason: reason.trim() });
284
+ };
285
+ }
286
+
287
+ /** Every feature that declared a system scope, with the scope. */
288
+ function systemScopes() {
289
+ return [...scopeByFeature].map(([featureId, systemScope]) => Object.freeze({ featureId, systemScope }));
290
+ }
291
+
292
+ return Object.freeze({
293
+ own,
294
+ ownedTables,
295
+ declareSystemScope,
296
+ systemScopes
297
+ });
298
+ }
299
+
300
+ export { DEFAULT_REQUEST_ROW_LIMIT, DEFAULT_SERVER_PAGE_SIZE, createResourceRuntime };
@@ -0,0 +1,49 @@
1
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
2
+ import { DuplicateOwnerError } from "./errors.js";
3
+ import { createResourceHost } from "./resourceHost.js";
4
+ import { createResourceRuntime } from "./resourceRuntime.js";
5
+
6
+ // The resource runtime a JSKIT server built on each Knex client, so standalone resources never own one of its tables.
7
+ const serverRuntimes = new WeakMap();
8
+
9
+ /**
10
+ * Records the resource runtime a JSKIT server built on its Knex client.
11
+ * @param {object} knex
12
+ * @param {import("./types.js").ResourceRuntime} runtime
13
+ */
14
+ // readable-check: allow module-registry - safety invariant: each table has one owner; standalone resources run outside any container, so they find the server's runtime by the Knex client both use, held weakly
15
+ function registerServerRuntime(knex, runtime) {
16
+ serverRuntimes.set(knex, runtime);
17
+ }
18
+
19
+ /**
20
+ * Tables owned outside a JSKIT server, for code such as a command-line script: a resource runtime of its own on the
21
+ * application's Knex, with the handles of the tables it owns and, when it declares a system scope, its system
22
+ * context factory. A JSKIT application owns its tables through a feature instead, so each table has one owner: on
23
+ * the Knex client of a JSKIT server, a table one of its features owns is refused here. (A second Knex client on the
24
+ * same database is not recognised; code inside a server uses its features' handles.)
25
+ * @param {object} options
26
+ * @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} options.knex The application's Knex client.
27
+ * @param {string} options.feature The owner's name, as a feature's id.
28
+ * @param {ReadonlyArray<object>} options.owns The table resources it owns, as a feature's `owns`.
29
+ * @param {string} [options.systemScope] Why it works across every workspace and user.
30
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} [options.logger]
31
+ */
32
+ async function createStandaloneResources({ knex, feature, owns, systemScope, logger = silentLogger }) {
33
+ const runtime = createResourceRuntime({ api: await createResourceHost({ knex, logger }) });
34
+ const resources = await runtime.own(feature, owns);
35
+ const served = new Map((serverRuntimes.get(knex)?.ownedTables() ?? []).map(({ tableName, featureId }) => [tableName, featureId]));
36
+ const taken = runtime.ownedTables().find(({ tableName }) => served.has(tableName));
37
+ if (taken) {
38
+ throw new DuplicateOwnerError(
39
+ `Standalone resources "${feature}" own table "${taken.tableName}", which feature "${served.get(taken.tableName)}" of ` +
40
+ "this server already owns. One table has one owner: inside a server, use that feature's handles."
41
+ );
42
+ }
43
+ return Object.freeze({
44
+ resources,
45
+ ...(systemScope === undefined ? {} : { systemContext: runtime.declareSystemScope(feature, systemScope) })
46
+ });
47
+ }
48
+
49
+ export { createStandaloneResources, registerServerRuntime };
@@ -0,0 +1,49 @@
1
+ // The types of the resource runtime: `runtime.resources`, its handles and contexts.
2
+
3
+ /**
4
+ * The json-rest-api host createResourceHost builds. Its Knex plugin adds
5
+ * `knex.helpers.getStorageAdapter(scopeName)`: a registered resource's table
6
+ * query, id column and field columns.
7
+ * @typedef {Awaited<ReturnType<typeof import("./resourceHost.js").createResourceHost>> & {
8
+ * knex: { instance: import("knex").Knex, helpers: { getStorageAdapter(scopeName: string): NonNullable<import("json-rest-api").HookContext["storageAdapter"]> } }
9
+ * }} RuntimeJsonRestApi
10
+ */
11
+
12
+ /**
13
+ * One of `api.resources`: json-rest-api's resource and its methods. query()
14
+ * and delete() take any params, because the transitional readers pass params
15
+ * json-rest-api does not declare.
16
+ * @typedef {import("json-rest-api").RuntimeResource
17
+ * & Omit<import("json-rest-api").ResourceCoreMethods, "query" | "delete">
18
+ * & {
19
+ * query(params?: object, context?: object): Promise<any>,
20
+ * delete(params: object, context?: object): Promise<void>
21
+ * }} JsonRestResource
22
+ */
23
+
24
+ /**
25
+ * The `runtime.resources` capability.
26
+ * @typedef {ReturnType<typeof import("./resourceRuntime.js").createResourceRuntime>} ResourceRuntime
27
+ */
28
+
29
+ /**
30
+ * One owned table's handle: its reads, writes, locks and read-only escape hatch,
31
+ * each scoped by the call's `options.context`.
32
+ * @typedef {ReturnType<typeof import("./resourceHandle.js").createResourceHandle>} ResourceHandle
33
+ */
34
+
35
+ /**
36
+ * The options every handle call takes last: the caller's context (an action's,
37
+ * or a system context) and the transaction to join.
38
+ * @typedef {{ context?: object | null, trx?: object | null }} ResourceCallOptions
39
+ */
40
+
41
+ /**
42
+ * A context for work across every workspace and user, from `systemContext(reason)`.
43
+ * `forWorkspace(id)`, `forUser(id)` and `forWorkspaceUser(workspaceId, userId)` derive the
44
+ * context of one workspace, one user, or one user in one workspace; each takes `{ actorId }` last for work done
45
+ * for one person, whom audit fields then record (`resolveAuditActorId`); it signs no one in.
46
+ * @typedef {ReturnType<typeof import("./contexts.js").createSystemContext>} SystemContext
47
+ */
48
+
49
+ export {};
@@ -0,0 +1,117 @@
1
+ import { AssertionError } from "node:assert";
2
+ import diagnosticsChannel from "node:diagnostics_channel";
3
+ import {
4
+ countQueries,
5
+ countRuntimeQueries,
6
+ requireTestDatabase
7
+ } from "@jskit-ai/database-runtime/server/testDatabases";
8
+ import { MANY_ROW_CHANNEL } from "../server/resourceHandle.js";
9
+
10
+ const REPEATED_STATEMENTS_SHOWN = 5;
11
+ // A quoted SQL string, written as a string so that source scanners that skip quoted text keep their place.
12
+ const SQL_STRING_LITERAL = new RegExp("'(?:[^']|'')*'", "gu");
13
+
14
+ /** A statement with its values taken out, so the same statement for another row reads the same. */
15
+ function normalizedStatement(sql) {
16
+ return sql
17
+ .replace(SQL_STRING_LITERAL, "?")
18
+ .replace(/\$\d+/gu, "?")
19
+ .replace(/\b\d+(?:\.\d+)?\b/gu, "?")
20
+ .replace(/\(\s*\?(?:\s*,\s*\?)+\s*\)/gu, "(?, ...)");
21
+ }
22
+
23
+ function mostRepeated(queries) {
24
+ const counts = new Map();
25
+ for (const query of queries) {
26
+ const statement = normalizedStatement(query);
27
+ counts.set(statement, (counts.get(statement) || 0) + 1);
28
+ }
29
+ return [...counts].sort((left, right) => right[1] - left[1]).slice(0, REPEATED_STATEMENTS_SHOWN);
30
+ }
31
+
32
+ function requireSizes(sizes) {
33
+ const valid = Array.isArray(sizes) && sizes.length >= 2 &&
34
+ sizes.every((size, index) => Number.isInteger(size) && size > 0 && (index === 0 || size > sizes[index - 1]));
35
+ if (!valid) {
36
+ throw new TypeError("expectFlatQueries sizes must be at least two growing whole numbers of rows, such as [10, 200].");
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Counts the statements `work` sends, and the extra statements the many-row
42
+ * calls among them report for their chunks after the first.
43
+ * @param {import("knex").Knex | import("knex").Knex.Transaction | null} knex
44
+ * @param {() => unknown} work
45
+ */
46
+ async function measure(knex, work) {
47
+ const calls = [];
48
+ const listen = (message) => {
49
+ calls.push(message);
50
+ };
51
+ diagnosticsChannel.subscribe(MANY_ROW_CHANNEL, listen);
52
+ try {
53
+ const { count, queries } = knex ? await countQueries(knex, work) : await countRuntimeQueries(work);
54
+ const chunkStatements = calls.reduce((total, { rows, chunkSize }) => total + Math.max(0, Math.ceil(rows / chunkSize) - 1), 0);
55
+ return { count, chunkStatements, queries };
56
+ } finally {
57
+ diagnosticsChannel.unsubscribe(MANY_ROW_CHANNEL, listen);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Fails when a path's statements grow with its rows. For each size, smallest first, it seeds that many rows with
63
+ * `seed(size)`, then runs `run(seeded, size)` and counts the statements sent meanwhile: through `knex` when given (its
64
+ * transactions' included), else through the runtime of a server the test booted, as countRuntimeQueries() does. The
65
+ * extra statements of a many-row write's later chunks, which its handle reports, are allowed, so no number is
66
+ * hand-picked. A flat path then sends as many statements at every size; a path that reads or writes row by row sends
67
+ * more at each, and one that sends none at every size was counted on the wrong channel and fails too. On failure the message gives every count and the statements repeated most at the largest size. Its seed
68
+ * keeps the sizes apart (a workspace or user of their own, or a fresh database).
69
+ * @template S
70
+ * @param {object} options
71
+ * @param {number[]} [options.sizes] Growing row counts; 10 and 200 when absent.
72
+ * @param {(size: number) => S | Promise<S>} options.seed
73
+ * @param {(seeded: S, size: number) => unknown} options.run
74
+ * @param {import("knex").Knex | import("knex").Knex.Transaction | null} [options.knex]
75
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.environment The test's environment, such as `process.env`.
76
+ * @returns {Promise<Array<{ size: number, count: number, chunkStatements: number }>>} The count at each size, and how
77
+ * many of its statements were later chunks of many-row writes.
78
+ */
79
+ async function expectFlatQueries({ sizes = [10, 200], seed, run, knex = null, environment }) {
80
+ requireTestDatabase(environment);
81
+ requireSizes(sizes);
82
+ // readable-check: allow method-probe - input validation: seed and run are the caller's own functions
83
+ if (typeof seed !== "function" || typeof run !== "function") {
84
+ throw new TypeError("expectFlatQueries requires seed(size) and run(seeded, size) functions.");
85
+ }
86
+ const measured = [];
87
+ for (const size of sizes) {
88
+ const seeded = await seed(size);
89
+ measured.push({ size, ...await measure(knex, () => run(seeded, size)) });
90
+ }
91
+ const counts = measured.map(({ size, count, chunkStatements }) => ({ size, count, chunkStatements }));
92
+ const [smallest] = measured;
93
+ const largest = measured.at(-1);
94
+ // A data path that sends nothing at every size was counted on the wrong channel, so it would pass unmeasured.
95
+ if (measured.every(({ count }) => count === 0)) {
96
+ throw new AssertionError({
97
+ message: "The path sent no statement at any size, so nothing was measured: pass the test's knex when its features " +
98
+ "run on it (startTestFeatures, createTestResourceRuntime), or the path never reached the database."
99
+ });
100
+ }
101
+ const growing = ({ count, chunkStatements }) => count - chunkStatements > smallest.count - smallest.chunkStatements;
102
+ if (measured.some(growing)) {
103
+ const described = counts.map(({ size, count, chunkStatements }) =>
104
+ `${count} at ${size} rows${chunkStatements > 0 ? ` (${chunkStatements} of them later chunks)` : ""}`);
105
+ const repeated = mostRepeated(largest.queries).map(([statement, times]) => ` ${times} x ${statement}`);
106
+ throw new AssertionError({
107
+ message: [
108
+ `The statements grow with the rows: ${described.join(", ")}.`,
109
+ `Repeated most at ${largest.size} rows:`,
110
+ ...repeated
111
+ ].join("\n")
112
+ });
113
+ }
114
+ return counts;
115
+ }
116
+
117
+ export { expectFlatQueries };
@@ -0,0 +1,111 @@
1
+ import { AssertionError, deepStrictEqual } from "node:assert";
2
+ import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
3
+
4
+ // The other owner's rows are read back by id, this many ids per read.
5
+ const IDS_PER_READ = 1000;
6
+
7
+ // The ids each handle's table holds, read through the system context in one statement per table however many rows
8
+ // it has (queryAll would refuse a table past its row limit).
9
+ async function idsOf(handles, systemContext) {
10
+ const ids = new Map();
11
+ for (const handle of handles) {
12
+ const { query } = await handle.scopedQuery({}, { context: systemContext });
13
+ ids.set(handle, new Set((await query.pluck(handle.column("id"))).map(String)));
14
+ }
15
+ return ids;
16
+ }
17
+
18
+ // Every column of the handle's table, as its scoped query names the table.
19
+ function everyColumn(handle) {
20
+ const id = handle.column("id");
21
+ return `${id.slice(0, id.length - handle.column("id", "").length - 1)}.*`;
22
+ }
23
+
24
+ // The rows with these ids of each handle's table, read through the system context, by `${resource}:${id}`. They are
25
+ // the stored rows, not records, so a hidden or defaultExclude field, or a column outside the schema, is compared too.
26
+ async function rowsOf(idsByHandle, systemContext) {
27
+ const rows = new Map();
28
+ for (const [handle, ids] of idsByHandle) {
29
+ const list = [...ids];
30
+ for (let start = 0; start < list.length; start += IDS_PER_READ) {
31
+ const { query } = await handle.scopedQuery({ filters: { id: list.slice(start, start + IDS_PER_READ) } }, { context: systemContext });
32
+ for (const { __id: id, ...row } of await query.select({ __id: handle.column("id") }, everyColumn(handle))) {
33
+ rows.set(`${handle.name}:${id}`, row);
34
+ }
35
+ }
36
+ }
37
+ return rows;
38
+ }
39
+
40
+ /**
41
+ * Fails when a path reaches another owner's rows. It seeds owner A and runs the
42
+ * path as A; then it seeds owner B and runs the path as A again. It fails when
43
+ * the path removed or changed a row seeded for B, and when its second answer
44
+ * differs from its first, so it catches a report that sums, counts or joins B's
45
+ * rows, not only one that returns them. B's rows are read by id through the
46
+ * system context, from the handles of the tables the path touches, as stored:
47
+ * every column, hidden and defaultExclude fields included. The path
48
+ * runs twice, so it answers the same for the same rows: a write answers what
49
+ * repeats, such as the ids it changed or each refusal, not a fresh id or time.
50
+ * @template T
51
+ * @param {object} options
52
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.environment The test's environment, such as `process.env`.
53
+ * @param {ReadonlyArray<import("../server/types.js").ResourceHandle>} options.handles
54
+ * @param {object} options.systemContext A system context, for reading every owner's rows.
55
+ * @param {[T, T]} options.owners What seed and run receive for owners A and B, such as `{ context }` of each.
56
+ * @param {(owner: T) => unknown} options.seed Seeds one owner's rows.
57
+ * @param {(owner: T) => unknown} options.run The path, run as owner A.
58
+ * @returns {Promise<unknown>} The path's answer.
59
+ */
60
+ async function expectScoped({ environment, handles, systemContext, owners, seed, run }) {
61
+ requireTestDatabase(environment);
62
+ if (!Array.isArray(handles) || handles.length < 1) {
63
+ throw new TypeError("expectScoped needs the handles of the tables the path touches.");
64
+ }
65
+ if (!Array.isArray(owners) || owners.length !== 2) {
66
+ throw new TypeError("expectScoped needs two owners, [ownerA, ownerB].");
67
+ }
68
+ // readable-check: allow method-probe - input validation: seed and run are the caller's own functions
69
+ if (typeof seed !== "function" || typeof run !== "function") {
70
+ throw new TypeError("expectScoped requires seed(owner) and run(owner) functions.");
71
+ }
72
+ const [ownerA, ownerB] = owners;
73
+ await seed(ownerA);
74
+ const alone = await run(ownerA);
75
+
76
+ const before = await idsOf(handles, systemContext);
77
+ await seed(ownerB);
78
+ const added = new Map();
79
+ for (const [handle, ids] of await idsOf(handles, systemContext)) {
80
+ added.set(handle, new Set([...ids].filter((id) => !before.get(handle).has(id))));
81
+ }
82
+ const otherRows = await rowsOf(added, systemContext);
83
+ if (otherRows.size < 1) {
84
+ throw new TypeError("expectScoped: seeding owner B added no rows to the handles' tables, so there is nothing to protect.");
85
+ }
86
+ const result = await run(ownerA);
87
+
88
+ const after = await rowsOf(added, systemContext);
89
+ for (const [key, row] of otherRows) {
90
+ if (!after.has(key)) {
91
+ throw new AssertionError({ message: `The path removed ${key}, a row of the other owner.` });
92
+ }
93
+ try {
94
+ deepStrictEqual(after.get(key), row);
95
+ } catch {
96
+ throw new AssertionError({ message: `The path changed ${key}, a row of the other owner.`, actual: after.get(key), expected: row });
97
+ }
98
+ }
99
+ try {
100
+ deepStrictEqual(result, alone);
101
+ } catch {
102
+ throw new AssertionError({
103
+ message: "The path answered differently once the other owner's rows were seeded, so it reads them.",
104
+ actual: result,
105
+ expected: alone
106
+ });
107
+ }
108
+ return result;
109
+ }
110
+
111
+ export { expectScoped };