@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,474 @@
1
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { RECORD_TIMESTAMPS, storedTimestamps } from "@jskit-ai/resource-core/shared/tableResource";
3
+ import { isJobContext } from "./contexts.js";
4
+ import { requireDefinedFilters } from "./handleGuards.js";
5
+ import { resolveJsonRestCollectionRelationships } from "./jsonRestValues.js";
6
+
7
+ // What a handle takes and answers, in json-rest-api's terms: read parameters,
8
+ // write input with belongs-to fields under their relationships, and plain
9
+ // records with each belongs-to field as the related id.
10
+
11
+ const READ_PARAM_KEYS = Object.freeze(["filters", "sort", "include", "fields", "page"]);
12
+
13
+ /** @param {unknown} value */
14
+ function textList(value) {
15
+ return Array.isArray(value) ? value : [value];
16
+ }
17
+
18
+ /**
19
+ * A sparse fieldset as json-rest-api takes it: a list of the resource's own fields, or `{ [type]: "a,b" }`. json-rest-api
20
+ * names a belongs-to field by its relationship (`as`), while a handle's records answer it under the field's own key,
21
+ * so the resource's own fields may be named either way; the field's key would otherwise select no relationship and
22
+ * answer null.
23
+ * @param {{ name: string, schema: Record<string, any> }} resource
24
+ * @param {string[] | Record<string, string>} fields
25
+ */
26
+ function sparseFieldset(resource, fields) {
27
+ const own = (names) => [...new Set(names.map((field) => {
28
+ const definition = resource.schema[field];
29
+ return definition?.belongsTo && definition.as ? definition.as : field;
30
+ }))].join(",");
31
+ if (Array.isArray(fields)) {
32
+ return { [resource.name]: own(fields) };
33
+ }
34
+ if (!isRecord(fields) || typeof fields[resource.name] !== "string") {
35
+ return fields;
36
+ }
37
+ return { ...fields, [resource.name]: own(fields[resource.name].split(",").map((field) => field.trim()).filter(Boolean)) };
38
+ }
39
+
40
+ /**
41
+ * The collection relations a kind of read loads only when it includes them: those whose `hydrateOnList` (lists) or
42
+ * `hydrateOnView` (one record) is false.
43
+ * @param {{ schema: Record<string, any> }} resource
44
+ * @param {"hydrateOnList" | "hydrateOnView"} flag
45
+ */
46
+ function quietCollections(resource, flag) {
47
+ return Object.entries(resource.schema).flatMap(([fieldName, definition]) => {
48
+ const relation = definition?.relation;
49
+ return relation?.kind === "collection" && relation[flag] === false
50
+ ? [{ fieldName, relationshipName: relation.as || definition.as || fieldName }]
51
+ : [];
52
+ });
53
+ }
54
+
55
+ /**
56
+ * The fields a read names: the caller's own, or, when it names none and leaves out one of its quiet collections (see
57
+ * quietCollections), every field json-rest-api would answer but those, so their rows are not read.
58
+ * @param {{ fields?: unknown, include?: unknown }} params
59
+ * @param {Array<{ fieldName: string, relationshipName: string }>} quiet
60
+ * @param {() => { outputFields: Record<string, any>, outputRelationships: Record<string, any>, idProperty?: string }} schemaInfo
61
+ * json-rest-api's compiled schema of the resource.
62
+ */
63
+ function requestedFields(params, quiet, schemaInfo) {
64
+ const included = includedRelationships(params.include);
65
+ const skipped = quiet.filter(({ relationshipName }) => !included.has(relationshipName));
66
+ if (params.fields !== undefined || skipped.length < 1) {
67
+ return params.fields;
68
+ }
69
+ const left = new Set(skipped.flatMap(({ fieldName, relationshipName }) => [fieldName, relationshipName]));
70
+ const { outputFields, outputRelationships, idProperty } = schemaInfo();
71
+ return [
72
+ ...Object.entries(outputFields)
73
+ .filter(([field, definition]) => field !== "id" && field !== idProperty && !definition?.hidden && !definition?.normallyHidden)
74
+ .map(([field]) => field),
75
+ ...Object.keys(outputRelationships)
76
+ ].filter((field) => !left.has(field));
77
+ }
78
+
79
+ /**
80
+ * An aggregate's measures as json-rest-api takes them. A record's id is no schema field there, so a count of it, or
81
+ * of its distinct values, which are every row's, is the count of rows (`"*"`).
82
+ * @param {unknown} measures
83
+ */
84
+ function aggregateMeasures(measures) {
85
+ if (!isRecord(measures)) {
86
+ return measures;
87
+ }
88
+ return Object.fromEntries(Object.entries(measures).map(([measureName, spec]) => {
89
+ if (!isRecord(spec) || (spec.count !== "id" && spec.countDistinct !== "id")) {
90
+ return [measureName, spec];
91
+ }
92
+ const { count: _count, countDistinct: _countDistinct, ...rest } = spec;
93
+ return [measureName, { ...rest, count: "*" }];
94
+ }));
95
+ }
96
+
97
+ /**
98
+ * What aggregate() answers over no rows: no groups, or, without groupBy, one row of totals (each count 0, every other
99
+ * measure null), as json-rest-api answers it.
100
+ * @param {Record<string, Record<string, unknown>>} measures
101
+ * @param {unknown[] | undefined} groupBy
102
+ */
103
+ function noRowsAggregate(measures, groupBy) {
104
+ if (Array.isArray(groupBy) && groupBy.length > 0) {
105
+ return Object.freeze([]);
106
+ }
107
+ const totals = Object.entries(measures || {}).map(([measureName, spec]) => [
108
+ measureName,
109
+ isRecord(spec) && (Object.hasOwn(spec, "count") || Object.hasOwn(spec, "countDistinct")) ? 0 : null
110
+ ]);
111
+ return Object.freeze([Object.freeze(Object.fromEntries(totals))]);
112
+ }
113
+
114
+ /**
115
+ * The read parameters of a handle call as json-rest-api's queryParams.
116
+ * @param {{ name: string, schema: Record<string, any> }} resource
117
+ * @param {Record<string, any>} params
118
+ * @param {{ page: boolean, caller: string }} allowed
119
+ */
120
+ function readQueryParams(resource, params, { page, caller }) {
121
+ const { name } = resource;
122
+ if (!isRecord(params)) {
123
+ throw new TypeError(`${name}.${caller}() params must be an object.`);
124
+ }
125
+ for (const key of Object.keys(params)) {
126
+ if (!READ_PARAM_KEYS.includes(key) || (key === "page" && !page)) {
127
+ throw new TypeError(`${name}.${caller}() does not take ${key}; it takes ${READ_PARAM_KEYS.filter((entry) => page || entry !== "page").join(", ")}.`);
128
+ }
129
+ }
130
+ const queryParams = {};
131
+ if (params.filters !== undefined) {
132
+ queryParams.filters = requireDefinedFilters(name, caller, params.filters);
133
+ }
134
+ if (params.sort !== undefined) {
135
+ queryParams.sort = textList(params.sort);
136
+ }
137
+ if (params.include !== undefined) {
138
+ queryParams.include = textList(params.include);
139
+ }
140
+ if (params.fields !== undefined) {
141
+ queryParams.fields = sparseFieldset(resource, params.fields);
142
+ }
143
+ if (page && params.page !== undefined) {
144
+ const { after, number } = params.page;
145
+ if (number !== undefined && (after || !Number.isSafeInteger(number) || number < 1)) {
146
+ throw new TypeError(`${name}.${caller}() takes page.number as a whole number from 1, and not with page.after.`);
147
+ }
148
+ // A CRUD list may name only its cursor: json-rest-api then serves its default page size.
149
+ queryParams.page = {
150
+ ...(params.page.size == null ? {} : { size: Number(params.page.size) }),
151
+ ...(after ? { after } : {}),
152
+ ...(number === undefined ? {} : { number })
153
+ };
154
+ }
155
+ return queryParams;
156
+ }
157
+
158
+ /**
159
+ * The id in a single write's answer: a record or `{ type, id }`, wrapped in
160
+ * `data` for JSON:API documents.
161
+ * @param {unknown} result
162
+ * @param {string} format
163
+ */
164
+ function idOf(result, format) {
165
+ const resource = format === "jsonapi" && isRecord(result) ? result.data : result;
166
+ return isRecord(resource) ? resource.id : undefined;
167
+ }
168
+
169
+ /**
170
+ * Whether a write may set its own timestamps: only in a system context, or one it derived (an import, a migration, a
171
+ * seed). Every other call is a request's, a call without a context too, so its timestamps are the handle's.
172
+ * @param {unknown} context
173
+ */
174
+ function setsOwnTimestamps(context) {
175
+ return isJobContext(context);
176
+ }
177
+
178
+ // A date-time's text around its fraction of a second: what comes before, the digits, and the zone after them.
179
+ const SECOND_FRACTION = /^(.+T\d{2}:\d{2}:\d{2})\.(\d+)(.*)$/u;
180
+
181
+ /**
182
+ * A timestamp as the field stores it: its fraction of a second cut (never rounded) to the field's temporalPrecision,
183
+ * none for 0, the precision every write of the field is validated against. The handle's own stamps come from
184
+ * `Date#toISOString()`, with milliseconds, and so do many a job's own (setsOwnTimestamps). Anything else, a date-time
185
+ * with no fraction or a value that is no date-time, is left as it is, for validation to judge.
186
+ * @param {{ temporalPrecision?: number }} field The field's schema definition.
187
+ * @param {unknown} value
188
+ */
189
+ function atPrecision(field, value) {
190
+ const precision = field.temporalPrecision;
191
+ const parts = typeof value === "string" && precision !== undefined ? SECOND_FRACTION.exec(value) : null;
192
+ if (!parts || parts[2].length <= precision) {
193
+ return value;
194
+ }
195
+ return `${parts[1]}${precision === 0 ? "" : `.${parts[2].slice(0, precision)}`}${parts[3]}`;
196
+ }
197
+
198
+ /**
199
+ * A write's attributes with the table's timestamps (see storedTimestamps). The handle's stamps replace any the
200
+ * caller sends, unless it may set its own (setsOwnTimestamps), so a request never sets them; a caller's own are cut to
201
+ * the field's precision as the handle's are (atPrecision). A create stamps
202
+ * createdAt and updatedAt, a change of a record updatedAt, and an empty change nothing, except on a table with a lock
203
+ * version: there an empty change advances the version, which is an update like any other, so it stamps updatedAt and
204
+ * the two never disagree about whether the record changed.
205
+ * @param {{ schema: Record<string, any>, lockVersionField?: string }} resource
206
+ * @param {Record<string, unknown>} attributes
207
+ * @param {"create" | "update"} stamp
208
+ * @param {unknown} context The call's context.
209
+ */
210
+ function stampedAttributes(resource, attributes, stamp, context) {
211
+ const kept = storedTimestamps(resource.schema);
212
+ if (kept.length < 1 || (stamp === "update" && Object.keys(attributes).length < 1 && !resource.lockVersionField)) {
213
+ return { ...attributes };
214
+ }
215
+ const callerSets = setsOwnTimestamps(context);
216
+ const data = Object.fromEntries(Object.entries(attributes)
217
+ .filter(([field]) => callerSets || !kept.includes(field))
218
+ .map(([field, value]) => [field, kept.includes(field) ? atPrecision(resource.schema[field], value) : value]));
219
+ const now = new Date().toISOString();
220
+ for (const field of storedTimestamps(resource.schema, stamp === "create" ? RECORD_TIMESTAMPS : ["updatedAt"])) {
221
+ if (!Object.hasOwn(data, field)) {
222
+ data[field] = atPrecision(resource.schema[field], now);
223
+ }
224
+ }
225
+ return data;
226
+ }
227
+
228
+ /**
229
+ * An upsert's onConflict with the timestamps its merge changes: updatedAt moves on, and a createdAt the handle stamped
230
+ * is the new record's only, so a merge then names its fields (those the rows give, by their field names), less those
231
+ * a merge never changes: the conflict keys, the ownership fields and the lock version.
232
+ * @param {{ schema: Record<string, any>, lockVersionField?: string }} resource
233
+ * @param {Array<Record<string, unknown>>} rows The caller's rows, before stamping.
234
+ * @param {{ action?: string, keys?: string[], merge?: string[] }} onConflict
235
+ * @param {unknown} context The call's context: only a caller that sets its own timestamps merges its createdAt.
236
+ * @param {string[]} ownershipFields The table's ownership fields, json-rest-api's `schemaInfo.ownershipFields`.
237
+ */
238
+ function upsertConflict(resource, rows, onConflict, context, ownershipFields) {
239
+ if (onConflict.action !== "merge") {
240
+ return onConflict;
241
+ }
242
+ const updated = storedTimestamps(resource.schema, ["updatedAt"]);
243
+ const callerSets = setsOwnTimestamps(context);
244
+ const mergeOf = (fields, mergesCreated) => [...new Set([...fields, ...updated])]
245
+ .filter((field) => field !== "id" && (mergesCreated || field !== "createdAt"));
246
+ if (onConflict.merge !== undefined) {
247
+ return { ...onConflict, merge: mergeOf(onConflict.merge, callerSets) };
248
+ }
249
+ const stampsCreated = storedTimestamps(resource.schema, ["createdAt"]).length > 0 &&
250
+ (!callerSets || rows.some((row) => isRecord(row) && !Object.hasOwn(row, "createdAt")));
251
+ if (!stampsCreated) {
252
+ return onConflict;
253
+ }
254
+ const fieldOf = Object.fromEntries(Object.entries(resource.schema).map(([field, definition]) => [definition?.as || field, field]));
255
+ const kept = new Set([...(onConflict.keys ?? []), ...ownershipFields, resource.lockVersionField].filter(Boolean));
256
+ const merge = mergeOf(rows.flatMap((row) => Object.keys(row)).map((key) => fieldOf[key] || key), false);
257
+ return { ...onConflict, merge: merge.filter((field) => !kept.has(field)) };
258
+ }
259
+
260
+ /**
261
+ * Record attributes as json-rest-api's input, stamped (see stampedAttributes): a belongs-to field written under its
262
+ * key goes to its relationship.
263
+ * @param {{ name: string, schema: Record<string, any> }} resource
264
+ * @param {unknown} attributes
265
+ * @param {"create" | "update"} stamp
266
+ * @param {unknown} context The call's context.
267
+ */
268
+ function inputData(resource, attributes, stamp, context) {
269
+ if (!isRecord(attributes)) {
270
+ throw new TypeError(`${resource.name} writes take attributes as an object.`);
271
+ }
272
+ const data = stampedAttributes(resource, attributes, stamp, context);
273
+ for (const [fieldName, definition] of Object.entries(resource.schema)) {
274
+ if (!definition?.belongsTo || !Object.hasOwn(data, fieldName)) {
275
+ continue;
276
+ }
277
+ const relationshipName = definition.as || fieldName;
278
+ if (relationshipName === fieldName) {
279
+ continue;
280
+ }
281
+ if (Object.hasOwn(data, relationshipName)) {
282
+ throw new TypeError(`Provide either "${fieldName}" or "${relationshipName}", not both.`);
283
+ }
284
+ if (data[fieldName] !== undefined) {
285
+ data[relationshipName] = data[fieldName];
286
+ }
287
+ delete data[fieldName];
288
+ }
289
+ return data;
290
+ }
291
+
292
+ /**
293
+ * The relationships a read includes, each with the include paths below it: `"pet.contact,vet"` includes pet, with
294
+ * `["contact"]` below it, and vet.
295
+ * @param {unknown} include
296
+ * @returns {Map<string, string[]>}
297
+ */
298
+ function includedRelationships(include) {
299
+ const included = new Map();
300
+ if (include === undefined) {
301
+ return included;
302
+ }
303
+ for (const path of textList(include).flatMap((entry) => String(entry).split(",")).map((entry) => entry.trim()).filter(Boolean)) {
304
+ const [head, ...rest] = path.split(".");
305
+ included.set(head, [...(included.get(head) ?? []), ...(rest.length > 0 ? [rest.join(".")] : [])]);
306
+ }
307
+ return included;
308
+ }
309
+
310
+ /**
311
+ * The resource's own fields a sparse read names (by relationship for a belongs-to field), or null when it reads all.
312
+ * @param {{ name: string, schema: Record<string, any> }} resource
313
+ * @param {unknown} fields
314
+ */
315
+ function namedFields(resource, fields) {
316
+ const own = fields === undefined ? undefined : sparseFieldset(resource, fields)?.[resource.name];
317
+ return typeof own === "string" ? new Set(own.split(",")) : null;
318
+ }
319
+
320
+ /**
321
+ * A json-rest-api plain record as a handle's record: each belongs-to field
322
+ * carries the related id under its own name (null when unset), and the related
323
+ * record stays nested under its relationship only when the read included it.
324
+ * A sparse read leaves out a belongs-to field it did not name, so an unread
325
+ * relation is never answered as null. An included record, or each record of an
326
+ * included collection, is shaped the same way by its own resource, so it reads
327
+ * as a read of that resource would answer it.
328
+ * @param {{ schema: Record<string, any> }} resource
329
+ * @param {unknown} record
330
+ * @param {Map<string, string[]>} included
331
+ * @param {Set<string> | null} named
332
+ * @param {(name: string) => ({ name: string, schema: Record<string, any> } | null)} [resourceNamed] The application's table
333
+ * resources by name, which shape included records; a read that includes passes it.
334
+ */
335
+ function handleRecord(resource, record, included, named, resourceNamed) {
336
+ if (!isRecord(record)) {
337
+ return record;
338
+ }
339
+ const shaped = { ...record };
340
+ // Included records keep their place, shaped by their own resource. json-rest-api reads a collection's linkage for
341
+ // the records a read asks for, not for those it includes, so an included record leaves out a collection it does
342
+ // not include itself rather than answer it as empty.
343
+ const nested = (type, value, belowPaths) => {
344
+ const related = resourceNamed(type);
345
+ const below = includedRelationships(belowPaths);
346
+ const shape = (entry) => {
347
+ if (!related || !isRecord(entry)) {
348
+ return entry;
349
+ }
350
+ const shapedEntry = handleRecord(related, entry, below, null, resourceNamed);
351
+ for (const name of Object.keys(resolveJsonRestCollectionRelationships(related)).filter((key) => !below.has(key))) {
352
+ delete shapedEntry[name];
353
+ }
354
+ return shapedEntry;
355
+ };
356
+ return Array.isArray(value) ? value.map(shape) : shape(value);
357
+ };
358
+ for (const [relationshipName, { target }] of Object.entries(resolveJsonRestCollectionRelationships(resource))) {
359
+ if (included.has(relationshipName) && Object.hasOwn(record, relationshipName)) {
360
+ shaped[relationshipName] = nested(target, record[relationshipName], included.get(relationshipName));
361
+ }
362
+ }
363
+ for (const [fieldName, definition] of Object.entries(resource.schema)) {
364
+ if (!definition?.belongsTo) {
365
+ continue;
366
+ }
367
+ const relationshipName = definition.as || fieldName;
368
+ if (named && !named.has(relationshipName) && !included.has(relationshipName)) {
369
+ delete shaped[fieldName];
370
+ delete shaped[relationshipName];
371
+ continue;
372
+ }
373
+ const related = record[relationshipName];
374
+ if (included.has(relationshipName)) {
375
+ shaped[relationshipName] = nested(definition.belongsTo, related, included.get(relationshipName));
376
+ }
377
+ if (relationshipName === fieldName && included.has(relationshipName)) {
378
+ continue;
379
+ }
380
+ shaped[fieldName] = related?.id == null ? null : String(related.id);
381
+ if (relationshipName !== fieldName && !included.has(relationshipName)) {
382
+ delete shaped[relationshipName];
383
+ }
384
+ }
385
+ return shaped;
386
+ }
387
+
388
+ /**
389
+ * Plain records as a handle answers them.
390
+ * @param {{ name: string, schema: Record<string, any> }} resource
391
+ * @param {unknown[]} records
392
+ * @param {{ include?: unknown, fields?: unknown }} [read] What the read included and the fields it named.
393
+ * @param {(name: string) => ({ name: string, schema: Record<string, any> } | null)} [resourceNamed] Shapes included records.
394
+ */
395
+ function handleRecords(resource, records, { include, fields } = {}, resourceNamed) {
396
+ const included = includedRelationships(include);
397
+ const named = namedFields(resource, fields);
398
+ return records.map((record) => handleRecord(resource, record, included, named, resourceNamed));
399
+ }
400
+
401
+ /**
402
+ * One single-record answer: a plain record as a handle answers it, or a
403
+ * JSON:API document left as it is.
404
+ * @param {{ name: string, schema: Record<string, any> }} resource
405
+ * @param {unknown} result
406
+ * @param {string} format
407
+ * @param {{ include?: unknown, fields?: unknown }} [read] What the read included and the fields it named.
408
+ * @param {(name: string) => ({ name: string, schema: Record<string, any> } | null)} [resourceNamed] Shapes included records.
409
+ */
410
+ function handleResult(resource, result, format, { include, fields } = {}, resourceNamed) {
411
+ return format === "jsonapi"
412
+ ? result
413
+ : handleRecord(resource, result, includedRelationships(include), namedFields(resource, fields), resourceNamed);
414
+ }
415
+
416
+ // A single write's `returning`, as json-rest-api names it.
417
+ const SINGLE_RETURNING = Object.freeze({ record: "full", id: "minimal", none: "none" });
418
+ // A many-row write's `returning`, as json-rest-api names it.
419
+ const MANY_RETURNING = Object.freeze({ none: "none", ids: "minimal", records: "full", count: "count" });
420
+
421
+ /**
422
+ * @param {Record<string, string>} modes
423
+ * @param {unknown} value
424
+ * @param {string} fallback
425
+ * @param {string} label
426
+ */
427
+ function pickReturning(modes, value, fallback, label) {
428
+ const key = value === undefined ? fallback : value;
429
+ if (typeof key !== "string" || !Object.hasOwn(modes, key)) {
430
+ throw new TypeError(`${label} returning must be one of ${Object.keys(modes).join(", ")}. Received: ${JSON.stringify(value)}`);
431
+ }
432
+ return key;
433
+ }
434
+
435
+ // The answer of a many-row write: nothing, the ids, or the records.
436
+ function manyResult(resource, result, returning) {
437
+ if (returning === "none") {
438
+ return undefined;
439
+ }
440
+ const rows = result?.data || [];
441
+ if (returning === "count") {
442
+ return Number(result?.meta?.count ?? rows.length);
443
+ }
444
+ return returning === "ids" ? rows.map((row) => String(row.id)) : handleRecords(resource, rows);
445
+ }
446
+
447
+ // What patch() answers for the row it wrote or read: the record (default), its id, or nothing.
448
+ function patchAnswer(id, record, returning) {
449
+ if (returning === "none") {
450
+ return undefined;
451
+ }
452
+ return returning === "id" ? id : record;
453
+ }
454
+
455
+ export {
456
+ aggregateMeasures,
457
+ MANY_RETURNING,
458
+ SINGLE_RETURNING,
459
+ handleRecords,
460
+ handleResult,
461
+ idOf,
462
+ inputData,
463
+ manyResult,
464
+ namedFields,
465
+ noRowsAggregate,
466
+ patchAnswer,
467
+ upsertConflict,
468
+ pickReturning,
469
+ quietCollections,
470
+ readQueryParams,
471
+ requestedFields,
472
+ sparseFieldset,
473
+ textList
474
+ };
@@ -0,0 +1,18 @@
1
+ export { ResourceRuntimeProvider } from "./ResourceRuntimeProvider.js";
2
+ export { DEFAULT_REQUEST_ROW_LIMIT, DEFAULT_SERVER_PAGE_SIZE } from "./resourceRuntime.js";
3
+ export { buildJsonRestQueryParams, resolveUserScopeValue, resolveWorkspaceScopeValue } from "./resourceHost.js";
4
+ export { checkForeignKeys } from "./foreignKeyCheck.js";
5
+ export { createStandaloneResources } from "./standaloneResources.js";
6
+ export { isSystemContext } from "./contexts.js";
7
+ export { withoutHiddenFields } from "./handleHidden.js";
8
+ export { passedOn } from "./handleGuards.js";
9
+ export {
10
+ DuplicateOwnerError,
11
+ MissingScopeError,
12
+ PageSizeError,
13
+ RowLimitError,
14
+ StaleRecordError,
15
+ SystemContextRequiredError,
16
+ UnpagedQueryError,
17
+ UnsupportedDatabaseError
18
+ } from "./errors.js";
@@ -0,0 +1,49 @@
1
+ import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { resolveCrudResourceScopeName } from "@jskit-ai/resource-crud-core/shared/crudLookup";
3
+
4
+ // Whether a value is a plain object: a json-rest-api payload, resource
5
+ // definition or option bag, not an array, class instance or null.
6
+ function isPlainJsonRestObject(value) {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
8
+ return false;
9
+ }
10
+
11
+ const prototype = Object.getPrototypeOf(value);
12
+ return prototype === Object.prototype || prototype === null;
13
+ }
14
+
15
+ // The json-rest-api hasMany relationships a table resource's collection fields declare (`relation.kind:
16
+ // "collection"`), by relationship name: the host registers them, and a handle shapes the records they include.
17
+ function resolveJsonRestCollectionRelationships(resource) {
18
+ const resourceSchema = normalizeObject(resource?.schema);
19
+ const relationships = {};
20
+
21
+ for (const [fieldName, fieldDefinition] of Object.entries(resourceSchema)) {
22
+ const normalizedFieldDefinition = normalizeObject(fieldDefinition);
23
+ const relation = normalizeObject(normalizedFieldDefinition.relation);
24
+ if (normalizeText(relation.kind).toLowerCase() !== "collection") {
25
+ continue;
26
+ }
27
+
28
+ const relationshipName = normalizeText(relation.as || normalizedFieldDefinition.as, {
29
+ fallback: fieldName
30
+ });
31
+ const target = resolveCrudResourceScopeName(
32
+ relation.target || relation.targetResource || relation.namespace || relation.apiPath
33
+ );
34
+ const foreignKey = normalizeText(relation.foreignKey);
35
+ if (!relationshipName || !target || !foreignKey) {
36
+ continue;
37
+ }
38
+
39
+ relationships[relationshipName] = {
40
+ type: "hasMany",
41
+ target,
42
+ foreignKey
43
+ };
44
+ }
45
+
46
+ return relationships;
47
+ }
48
+
49
+ export { isPlainJsonRestObject, resolveJsonRestCollectionRelationships };
@@ -0,0 +1,92 @@
1
+ import { isDeadlockError, isDuplicateEntryError, isLostDuplicateInsertRace } from "@jskit-ai/database-runtime/shared";
2
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { passedOn } from "./handleGuards.js";
4
+ import { lockModeOf } from "./handleLocks.js";
5
+
6
+ // First uses of one id at once can collide on its insert before any work runs (on MySQL and MariaDB, first uses of
7
+ // neighbouring ids too, through their gap locks): each collision runs the loser again after a short random wait.
8
+ const CREATE_RACE_ATTEMPTS = 4;
9
+ const CREATE_IF_MISSING = Object.freeze({ keys: ["id"], action: "ignore" });
10
+
11
+ /**
12
+ * A handle's lockOrCreate(row, options, work): runs work on the record with `row.id`, locked until the transaction
13
+ * ends, creating it from `row` first when it is missing, in the transaction with the work, so work that fails leaves
14
+ * no record behind. The table has no owners (the insert is an upsert keyed on the id). In the caller's transaction
15
+ * (`options.trx`) it runs work there once. Otherwise it opens a transaction of its own: two first uses of one id at
16
+ * once can collide on the insert before either work runs (a deadlock, or a duplicate key), and the loser runs again,
17
+ * up to four runs in all, and finds the winner's record. Work that has started never runs again, so it may call a
18
+ * provider. The id is one the server computes or generates, never one a request supplies: when a row policy hides the
19
+ * record with that id, the insert is skipped and the lock answers not found, which still tells the caller it exists.
20
+ * @template T
21
+ * @param {Pick<import("./types.js").ResourceHandle, "name" | "withTransaction" | "lock" | "createMany">} handle
22
+ * @param {{ id: unknown } & Record<string, unknown>} row
23
+ * @param {{ context?: object | null, trx?: object | null, lockMode?: "update" | "noKeyUpdate" }} options `lockMode` is the
24
+ * record's lock, as lock() takes it.
25
+ * @param {(record: Record<string, any>, txOptions: Readonly<Record<string, any>>) => Promise<T> | T} work
26
+ * @returns {Promise<T>}
27
+ */
28
+ async function lockOrCreate(handle, row, options, work) {
29
+ if (!isRecord(row) || row.id == null) {
30
+ throw new TypeError(`${handle.name}.lockOrCreate() takes (row, options, work), the row to create holding its id.`);
31
+ }
32
+ // The lock mode the caller names, for both locks of the record, refused before any transaction opens.
33
+ const mode = lockModeOf(handle.name, "lockOrCreate", options.lockMode);
34
+ const locked = async (txOptions) => {
35
+ const existing = await handle.lock(row.id, { ...txOptions, ...mode, missing: "skip" });
36
+ if (existing) {
37
+ return existing;
38
+ }
39
+ await handle.createMany([row], { ...txOptions, onConflict: CREATE_IF_MISSING, returning: "none" });
40
+ return handle.lock(row.id, { ...txOptions, ...mode });
41
+ };
42
+ if (options.trx) {
43
+ const txOptions = passedOn(options);
44
+ return work(await locked(txOptions), txOptions);
45
+ }
46
+ for (let attempt = 1; ; attempt += 1) {
47
+ let started = false;
48
+ try {
49
+ return await handle.withTransaction({ ...options, retry: false }, async (txOptions) => {
50
+ const record = await locked(txOptions);
51
+ started = true;
52
+ return work(record, txOptions);
53
+ });
54
+ } catch (error) {
55
+ if (started || attempt >= CREATE_RACE_ATTEMPTS || !(isDeadlockError(error) || isDuplicateEntryError(error))) {
56
+ throw error;
57
+ }
58
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 20 * 2 ** attempt));
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The record with `attributes.id`, created from `attributes` when it is
65
+ * missing. Outside a transaction, of two concurrent creations one wins and
66
+ * the other answers the winner's record; inside one, a lost race fails the
67
+ * transaction as any failed write does. An id another scope's row holds
68
+ * answers a missing record, as get() of it does, never null or that row: the
69
+ * caller learns only that the id is not its to have. That still tells it the
70
+ * id is taken, so the id is one the server computes or generates, never one
71
+ * a request supplies.
72
+ * @param {{ get: Function, create: Function }} handle
73
+ * @param {{ id: unknown } & Record<string, unknown>} attributes
74
+ * @param {{ context?: object | null, trx?: object | null }} options
75
+ */
76
+ async function findOrCreateRecord({ get, create }, attributes, options) {
77
+ const lookup = { ...options, missing: "skip" };
78
+ const existing = await get(attributes.id, {}, lookup);
79
+ if (existing) {
80
+ return existing;
81
+ }
82
+ try {
83
+ return await create(attributes, options);
84
+ } catch (error) {
85
+ if (!isLostDuplicateInsertRace(error, options)) {
86
+ throw error;
87
+ }
88
+ return get(attributes.id, {}, { ...options, missing: "throw" });
89
+ }
90
+ }
91
+
92
+ export { findOrCreateRecord, lockOrCreate };
@@ -0,0 +1,50 @@
1
+ // The ways a Knex raw expression runs as a statement of its own. A raw expression the runtime hands out is a value for
2
+ // a scoped query, a write or a query field's select, so each of these throws: a raw expression run on its own would run
3
+ // its SQL with no ownership scope at all.
4
+ const STATEMENT_METHODS = Object.freeze(new Set(["catch", "finally", "asCallback", "stream", "pipe"]));
5
+
6
+ /**
7
+ * A Knex raw expression that works only as a value: in a select, a where or a write. It is no promise: its `then` is
8
+ * undefined, so awaiting it answers the value itself and runs nothing, and code that tells a promise by its `then` (a
9
+ * repository wrapper awaiting what a method answers) never mistakes it for one.
10
+ * @param {string} label The call that made it, such as "notes.raw()".
11
+ * @param {object} expression
12
+ */
13
+ function valueOnly(label, expression) {
14
+ return new Proxy(expression, {
15
+ get(target, property, receiver) {
16
+ if (property === "then") {
17
+ return undefined;
18
+ }
19
+ if (STATEMENT_METHODS.has(property)) {
20
+ return () => {
21
+ throw new TypeError(`${label} is a value inside a scoped query or a write; it never runs as a statement of its own.`);
22
+ };
23
+ }
24
+ return Reflect.get(target, property, receiver);
25
+ }
26
+ });
27
+ }
28
+
29
+ /**
30
+ * One SQL expression as a value (see valueOnly): a string, with its values (and its identifiers, as `??` with
31
+ * column()) as an array of bindings. Lint (jskit/sql-literal) keeps the string one literal in the source.
32
+ * @param {string} label The call, such as "notes.raw()", that errors name.
33
+ * @param {{ raw: (sql: string, bindings: unknown[]) => object }} knex The Knex client or transaction that builds it.
34
+ * @param {unknown[]} args The call's arguments: the SQL, then its bindings.
35
+ */
36
+ function valueOnlyRaw(label, knex, args) {
37
+ const [sql, bindings, ...rest] = args;
38
+ if (typeof sql !== "string" || !sql.trim()) {
39
+ throw new TypeError(`${label} takes one SQL expression as a string.`);
40
+ }
41
+ if (rest.length > 0 || (bindings !== undefined && !Array.isArray(bindings))) {
42
+ throw new TypeError(`${label} takes (sql, bindings): one SQL expression, then its bindings as an array.`);
43
+ }
44
+ if (/\?/u.test(sql) && !Array.isArray(bindings)) {
45
+ throw new TypeError(`${label} needs the bindings of its placeholders.`);
46
+ }
47
+ return valueOnly(label, knex.raw(sql, bindings || []));
48
+ }
49
+
50
+ export { valueOnlyRaw };