@jskit-ai/resource-runtime 0.0.0-stage → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/changeHorizon.js +26 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +137 -0
- package/src/server/foreignKeyCheck.js +58 -0
- package/src/server/handleGuards.js +233 -0
- package/src/server/handleHidden.js +410 -0
- package/src/server/handleLocks.js +135 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +18 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +92 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +737 -0
- package/src/server/resourceHost.js +687 -0
- package/src/server/resourceRuntime.js +300 -0
- package/src/server/standaloneResources.js +49 -0
- package/src/server/types.js +49 -0
- package/src/test/expectFlatQueries.js +117 -0
- package/src/test/expectScoped.js +111 -0
- package/src/test/index.js +123 -0
- package/test/callSettings.test.js +100 -0
- package/test/changes.postgres.test.js +141 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/foreignKeys.test.js +94 -0
- package/test/hiddenFields.test.js +244 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/locks.postgres.test.js +345 -0
- package/test/lostInsertRace.test.js +65 -0
- package/test/manyRowWrites.mysql.test.js +154 -0
- package/test/queryFields.test.js +69 -0
- package/test/requestCallOptions.test.js +155 -0
- package/test/resourceRuntime.test.js +2098 -0
- package/test/storedIntegers.mysql.test.js +4 -0
- package/test/storedIntegers.postgres.test.js +5 -0
- package/test/support/storedIntegerCases.js +152 -0
- package/test/testHelpers.test.js +290 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
|
@@ -0,0 +1,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 };
|