@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,410 @@
1
+ import { RestApiResourceError, readOnlyQueryBuilder } from "json-rest-api";
2
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { isJobContext } from "./contexts.js";
4
+ import { namedFields } from "./handleRecords.js";
5
+
6
+ // A hidden field (`hidden: true`) is one json-rest-api never selects or answers, so no request, CRUD route or JSON:API
7
+ // document carries it. A handle's plain read still answers one when its `fields` names it, for the table's owner and
8
+ // for system work: the owner's own commands (a bearer token it checks) and a job that must learn each row's owner.
9
+
10
+ // The hidden field types a read answers: their stored values read as json-rest-api reads them without decoding.
11
+ const READABLE_TYPES = Object.freeze(new Set(["id", "string", "integer", "number", "boolean"]));
12
+ // The records each statement of the hidden fields' read covers.
13
+ const RECORDS_PER_READ = 1000;
14
+
15
+ /**
16
+ * The resource's hidden fields a read's `fields` names.
17
+ * @param {{ name: string, schema: Record<string, any> }} resource
18
+ * @param {unknown} fields
19
+ */
20
+ function namedHiddenFields(resource, fields) {
21
+ const named = namedFields(resource, fields);
22
+ return named ? [...named].filter((field) => resource.schema[field]?.hidden === true) : [];
23
+ }
24
+
25
+ /**
26
+ * A hidden field's stored value as a read answers it, as json-rest-api reads its type: an id as text, a whole
27
+ * number or a non-decimal number as a number (text past the safe integers), 0 and 1 as booleans.
28
+ * @param {unknown} value
29
+ * @param {{ type: string, precision?: number, scale?: number }} definition
30
+ */
31
+ function storedValue(value, { type, precision, scale }) {
32
+ if (value == null) {
33
+ return null;
34
+ }
35
+ if (type === "id") {
36
+ return String(value);
37
+ }
38
+ if (type === "boolean") {
39
+ return value === 1 || value === "1" ? true : value === 0 || value === "0" ? false : value;
40
+ }
41
+ const decimal = precision !== undefined && scale !== undefined;
42
+ if ((type === "integer" || type === "number") && typeof value === "string" && !decimal) {
43
+ const number = Number(value);
44
+ return value.trim() !== "" && Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) ? number : value;
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * A handle's reads of the hidden fields a plain read's `fields` names, which json-rest-api leaves out: one more
51
+ * statement per 1,000 records, on the table as scopedQuery() gives it (the caller's ownership and the row policy
52
+ * applied), in the call's transaction.
53
+ * @param {object} dependencies
54
+ * @param {{ name: string, schema: Record<string, any> }} dependencies.resource
55
+ * @param {(params: { filters: object }, options: object) => Promise<{ query: any }>} dependencies.scopedQuery
56
+ * @param {(field: string) => string} dependencies.column
57
+ */
58
+ function createHiddenFieldReads({ resource, scopedQuery, column }) {
59
+ /**
60
+ * The hidden fields a plain read names, checked before it runs: each is a stored id, string, number or boolean.
61
+ * @param {unknown} fields
62
+ * @param {string | undefined} format
63
+ */
64
+ function named(fields, format) {
65
+ if (format === "jsonapi") {
66
+ return [];
67
+ }
68
+ const hidden = namedHiddenFields(resource, fields);
69
+ for (const field of hidden) {
70
+ const definition = resource.schema[field];
71
+ if (!READABLE_TYPES.has(definition.type) || definition.virtual === true || definition.storage?.virtual === true) {
72
+ throw new TypeError(
73
+ `${resource.name}: fields names the hidden field "${field}", but a read answers a hidden field only when it ` +
74
+ "is a stored id, string, integer, number or boolean."
75
+ );
76
+ }
77
+ }
78
+ return hidden;
79
+ }
80
+
81
+ /**
82
+ * The records, each with the hidden fields added. A record whose row the added statement no longer sees (deleted
83
+ * since, outside a transaction) is left out.
84
+ * @param {Array<Record<string, any>>} records
85
+ * @param {string[]} hidden
86
+ * @param {{ context?: object | null, trx?: object | null }} options
87
+ */
88
+ async function add(records, hidden, options) {
89
+ if (hidden.length < 1 || records.length < 1) {
90
+ return records;
91
+ }
92
+ const rows = new Map();
93
+ const ids = records.map(({ id }) => id);
94
+ for (let start = 0; start < ids.length; start += RECORDS_PER_READ) {
95
+ const { query } = await scopedQuery({ filters: { id: ids.slice(start, start + RECORDS_PER_READ) } }, options);
96
+ const read = await query.select({ id: column("id"), ...Object.fromEntries(hidden.map((field) => [field, column(field)])) });
97
+ for (const row of read) {
98
+ rows.set(String(row.id), row);
99
+ }
100
+ }
101
+ return records.flatMap((record) => {
102
+ const row = rows.get(String(record.id));
103
+ return row
104
+ ? [{ ...record, ...Object.fromEntries(hidden.map((field) => [field, storedValue(row[field], resource.schema[field])])) }]
105
+ : [];
106
+ });
107
+ }
108
+
109
+ /**
110
+ * One record with the hidden fields added. A record deleted since it was read is missing: null with
111
+ * `missing: "skip"`, else json-rest-api's not-found error.
112
+ * @param {Record<string, any>} record
113
+ * @param {string[]} hidden
114
+ * @param {{ context?: object | null, trx?: object | null, missing?: "throw" | "skip" }} options
115
+ */
116
+ async function addOne(record, hidden, options) {
117
+ const [read] = await add([record], hidden, options);
118
+ if (read || options.missing === "skip") {
119
+ return read ?? null;
120
+ }
121
+ throw new RestApiResourceError("Resource not found", { subtype: "not_found", resourceType: resource.name, resourceId: record.id });
122
+ }
123
+
124
+ return Object.freeze({
125
+ named,
126
+ add,
127
+ addOne
128
+ });
129
+ }
130
+
131
+ // Every resource handle's scopedQuery, whose builder carries every column, and its withTransaction and lockOrCreate,
132
+ // which answer the caller's own work: withoutHiddenFields tells them by identity, under any name.
133
+ const HANDLE_SCOPED_QUERIES = new WeakSet();
134
+ const HANDLE_WORK_RUNNERS = new WeakSet();
135
+ // The names an owner's own wrappers of those runners keep.
136
+ const WORK_RUNNER_NAMES = Object.freeze(new Set(["withTransaction", "lockOrCreate"]));
137
+ // The builders a handle's joinSource() answers, which carry the table's visible columns only: the one query builder
138
+ // withoutHiddenFields hands another feature.
139
+ const JOIN_SOURCES = new WeakSet();
140
+
141
+ /**
142
+ * Records a new handle's scopedQuery, withTransaction and lockOrCreate for withoutHiddenFields.
143
+ * @param {{ scopedQuery: Function, withTransaction: Function, lockOrCreate: Function }} handle
144
+ */
145
+ function markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate }) {
146
+ HANDLE_SCOPED_QUERIES.add(scopedQuery);
147
+ HANDLE_WORK_RUNNERS.add(withTransaction);
148
+ HANDLE_WORK_RUNNERS.add(lockOrCreate);
149
+ }
150
+
151
+ /**
152
+ * A handle's joinSource(): the table as a join source for another owner's report, which joins it in its own
153
+ * statement. It answers `{ query, column }`: the rows scopedQuery() gives under `alias` (the caller's ownership, the
154
+ * row policy and `filters` applied) with the stored columns a read answers only, never a hidden one, and
155
+ * `column(field)` naming each under that alias. A CRUD feature's repository answers it to any feature, where it
156
+ * refuses scopedQuery(). The builder comes wrapped, since a builder is thenable.
157
+ * @param {object} dependencies
158
+ * @param {{ knex: { instance: any } }} dependencies.api
159
+ * @param {{ name: string, schema: Record<string, any> }} dependencies.resource
160
+ * @param {() => { vars: { schemaInfo: { schemaStructure: Record<string, any> } } }} dependencies.jsonRest
161
+ * @param {(params: { alias: string, filters?: object }, options: object) => Promise<{ query: any }>} dependencies.scopedQuery
162
+ * @param {(field: string, alias?: string) => string} dependencies.column
163
+ */
164
+ function createJoinSource({ api, resource, jsonRest, scopedQuery, column }) {
165
+ const { name } = resource;
166
+ /**
167
+ * @param {{ alias: string, filters?: object }} params
168
+ * @param {{ context?: object | null, trx?: object | null }} [options]
169
+ * @returns {Promise<Readonly<{ query: any, column: (field: string) => string }>>}
170
+ */
171
+ return async function joinSource({ alias, filters } = {}, options = {}) {
172
+ if (typeof alias !== "string" || !alias) {
173
+ throw new TypeError(`${name}.joinSource() needs the alias the report joins the ${name} under.`);
174
+ }
175
+ const { query } = await scopedQuery({ alias, ...(filters === undefined ? {} : { filters }) }, options);
176
+ const { schemaStructure } = jsonRest().vars.schemaInfo;
177
+ const visible = Object.keys(schemaStructure).filter((field) => {
178
+ const definition = schemaStructure[field];
179
+ return field !== "id" && definition.hidden !== true && definition.virtual !== true && !definition.belongsToPolymorphic;
180
+ });
181
+ // The scoped rows' visible columns, as a derived table under the alias: nothing a caller adds reaches the others.
182
+ const source = readOnlyQueryBuilder(
183
+ (options.trx || api.knex.instance).from(query.select(["id", ...visible].map((field) => column(field, alias))).as(alias)),
184
+ { resourceType: name }
185
+ );
186
+ JOIN_SOURCES.add(source);
187
+ return Object.freeze({
188
+ query: source,
189
+ column(field) {
190
+ if (resource.schema[field]?.hidden === true) {
191
+ throw new TypeError(`${name}.joinSource() carries no hidden field, so it has no column for "${field}".`);
192
+ }
193
+ return column(field, alias);
194
+ }
195
+ });
196
+ };
197
+ }
198
+
199
+ /**
200
+ * The field names a call's `fields` names: a list of names, names separated by commas, or a sparse fieldset (each
201
+ * resource type's names, separated by commas).
202
+ * @param {unknown} fields
203
+ * @returns {string[]}
204
+ */
205
+ function fieldNames(fields) {
206
+ if (typeof fields === "string") {
207
+ return fields.split(",").map((field) => field.trim());
208
+ }
209
+ if (Array.isArray(fields)) {
210
+ return fields.filter((field) => typeof field === "string");
211
+ }
212
+ return isRecord(fields)
213
+ ? Object.values(fields).flatMap((names) => (typeof names === "string" || Array.isArray(names) ? fieldNames(names) : []))
214
+ : [];
215
+ }
216
+
217
+ /**
218
+ * Whether a function answered a Knex value (a raw expression, a column's builder, a joinSource()'s query builder)
219
+ * rather than rows: a value for the caller's own query, which it builds on and never awaits.
220
+ * @param {unknown} value
221
+ */
222
+ function isKnexValue(value) {
223
+ // readable-check: allow method-probe - data: Knex raw expressions and query builders compile through toSQL()
224
+ return isRecord(value) && typeof value.toSQL === "function";
225
+ }
226
+
227
+ /**
228
+ * A Knex value another feature may build on, as it is: a raw expression or a column's reference, or a joinSource()'s
229
+ * builder. Any other query builder (a scopedQuery()'s, or one the owner built) can select every column of its table,
230
+ * the hidden ones too, so it is refused.
231
+ * @param {any} value A Knex value (see isKnexValue).
232
+ * @param {string} label
233
+ */
234
+ function withoutFullBuilder(value, label) {
235
+ // readable-check: allow method-probe - data: a Knex query builder, unlike a raw expression, takes where()
236
+ if (typeof value.where === "function" && !JOIN_SOURCES.has(value)) {
237
+ throw new TypeError(
238
+ `${label} answers a query builder, which can select every column, the hidden fields too, which only the table's ` +
239
+ "owner, or a system context, reads: answer the table's joinSource({ alias }, options), which carries its visible " +
240
+ "columns only."
241
+ );
242
+ }
243
+ return value;
244
+ }
245
+
246
+ /** @param {unknown} value */
247
+ function isPlainRecord(value) {
248
+ if (!isRecord(value)) {
249
+ return false;
250
+ }
251
+ const prototype = Object.getPrototypeOf(value);
252
+ return prototype === Object.prototype || prototype === null;
253
+ }
254
+
255
+ /**
256
+ * The value without the hidden fields, wherever a plain record in it, or in its arrays and maps, carries one: a copy
257
+ * of what changed (frozen when the original was), the value itself when nothing did. A query builder in it that is no
258
+ * joinSource()'s is refused (see withoutFullBuilder); raw expressions, dates and other instances are left as they are.
259
+ * @param {unknown} value
260
+ * @param {Set<string>} hidden
261
+ * @param {string} label
262
+ * @param {WeakMap<object, unknown>} [seen]
263
+ */
264
+ function withoutHiddenValues(value, hidden, label, seen = new WeakMap()) {
265
+ if (!value || typeof value !== "object") {
266
+ return value;
267
+ }
268
+ if (isKnexValue(value)) {
269
+ return withoutFullBuilder(value, label);
270
+ }
271
+ if (seen.has(value)) {
272
+ return seen.get(value);
273
+ }
274
+ seen.set(value, value);
275
+ let changed = false;
276
+ const strip = (entry) => {
277
+ const next = withoutHiddenValues(entry, hidden, label, seen);
278
+ changed ||= next !== entry;
279
+ return next;
280
+ };
281
+ let copy;
282
+ if (Array.isArray(value)) {
283
+ copy = value.map(strip);
284
+ } else if (value instanceof Map) {
285
+ copy = new Map([...value].map(([key, entry]) => [key, strip(entry)]));
286
+ } else if (isPlainRecord(value)) {
287
+ copy = Object.create(Object.getPrototypeOf(value));
288
+ for (const [key, entry] of Object.entries(value)) {
289
+ if (hidden.has(key)) {
290
+ changed = true;
291
+ } else {
292
+ copy[key] = strip(entry);
293
+ }
294
+ }
295
+ } else {
296
+ return value;
297
+ }
298
+ const result = !changed ? value : Object.isFrozen(value) && !(value instanceof Map) ? Object.freeze(copy) : copy;
299
+ seen.set(value, result);
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * The repository an owner gives other features (a CRUD feature's `resourceApi.repository`): every function it holds,
305
+ * its sub-repositories' included, answers without the hidden fields of the tables the owner owns. A call whose
306
+ * `fields` (in any record it takes) names one of those hidden fields is refused before it runs, whatever the method,
307
+ * so a read the owner adds in decorateRepository that forwards the caller's `fields` fails loudly rather than answer
308
+ * the field as undefined; so is any scopedQuery(), and an answer holding a query builder other than a joinSource()'s
309
+ * (either can select every column, hidden ones too). A call in a system context or one it derived, in any record it
310
+ * takes, runs as it is. The owner's own code reads with the repository itself. withTransaction and lockOrCreate answer
311
+ * the caller's own work as it is, and a function answering a Knex value (raw(), a joinSource()'s builder) answers it
312
+ * as it is, unawaited. A repository whose tables have no hidden fields stays as it is. Hidden fields are told by
313
+ * name, so a field one owned table hides and another shows is refused here, when the feature's repository is built.
314
+ * @template {object} T
315
+ * @param {T} repository
316
+ * @param {{ name: string, schema: Record<string, any> } | ReadonlyArray<{ name: string, schema: Record<string, any> }>} resources
317
+ * The resource the repository serves, or it first and the other tables its owner owns.
318
+ * @returns {T}
319
+ */
320
+ function withoutHiddenFields(repository, resources) {
321
+ const owned = [resources].flat();
322
+ const hidden = new Set(owned.flatMap(({ schema }) => Object.keys(schema).filter((field) => schema[field]?.hidden === true)));
323
+ if (!isRecord(repository) || hidden.size < 1) {
324
+ return repository;
325
+ }
326
+ for (const { name, schema } of owned) {
327
+ const shown = Object.keys(schema).find((field) => hidden.has(field) && schema[field]?.hidden !== true);
328
+ if (shown) {
329
+ const hiders = owned.filter((other) => other.schema[shown]?.hidden === true).map((other) => other.name);
330
+ throw new TypeError(
331
+ `${name}.${shown} is a visible field, but ${hiders.join(", ")}, which the same feature owns, hides a field of that ` +
332
+ `name; its repository strips hidden fields by name from every answer, so ${name}.${shown} would never reach ` +
333
+ "another feature: hide it in both tables, or rename one of them."
334
+ );
335
+ }
336
+ }
337
+ return guardedRepository(repository, owned[0], hidden, new WeakSet());
338
+ }
339
+
340
+ /**
341
+ * @param {object} repository
342
+ * @param {{ name: string, schema: Record<string, any> } | null} resource The resource it serves, which names its methods
343
+ * in errors and whose scopedQuery it refuses under that name too; none for a sub-repository.
344
+ * @param {Set<string>} hidden
345
+ * @param {WeakSet<object>} guarded
346
+ */
347
+ function guardedRepository(repository, resource, hidden, guarded) {
348
+ guarded.add(repository);
349
+ const descriptors = Object.getOwnPropertyDescriptors(repository);
350
+ for (const [key, descriptor] of Object.entries(descriptors)) {
351
+ const { value } = descriptor;
352
+ // readable-check: allow method-probe - data: every function a repository holds is guarded, whatever its name
353
+ if (typeof value === "function") {
354
+ descriptors[key] = { ...descriptor, value: guardedFunction(repository, key, value, resource, hidden) };
355
+ } else if (isPlainRecord(value) && !guarded.has(value)) {
356
+ descriptors[key] = { ...descriptor, value: guardedRepository(value, null, hidden, guarded) };
357
+ }
358
+ }
359
+ return Object.freeze(Object.create(Object.getPrototypeOf(repository), descriptors));
360
+ }
361
+
362
+ /**
363
+ * @param {object} repository
364
+ * @param {string} key
365
+ * @param {Function} call
366
+ * @param {{ name: string, schema: Record<string, any> } | null} resource
367
+ * @param {Set<string>} hidden
368
+ */
369
+ function guardedFunction(repository, key, call, resource, hidden) {
370
+ const label = `${resource?.name ?? "repository"}.${key}()`;
371
+ const builder = HANDLE_SCOPED_QUERIES.has(call) || (resource !== null && key === "scopedQuery");
372
+ const work = HANDLE_WORK_RUNNERS.has(call) || WORK_RUNNER_NAMES.has(key);
373
+ return function callWithoutHiddenFields(...args) {
374
+ // A system context, or one it derived, in whichever record the method takes its options.
375
+ if (args.some((arg) => isRecord(arg) && isJobContext(arg.context))) {
376
+ return call.apply(repository, args);
377
+ }
378
+ if (builder) {
379
+ return Promise.reject(new TypeError(
380
+ `${label} answers every column, its hidden fields too, which only the table's owner, or a system context, ` +
381
+ "reads: read the rows through the repository's reads, or join its joinSource({ alias }, options), which " +
382
+ "carries the visible columns only."
383
+ ));
384
+ }
385
+ const named = [...new Set(args.flatMap((arg) => (isRecord(arg) ? fieldNames(arg.fields) : [])))]
386
+ .filter((field) => hidden.has(field));
387
+ if (named.length > 0) {
388
+ return Promise.reject(new TypeError(
389
+ `${label} fields names the hidden field ${named.map((field) => `"${field}"`).join(", ")}, ` +
390
+ "which only the table's owner, or a system context, reads."
391
+ ));
392
+ }
393
+ const answer = call.apply(repository, args);
394
+ // A runner's answer is the caller's own work: it passes as it is.
395
+ if (work) {
396
+ return answer;
397
+ }
398
+ // A Knex value (raw(), a joinSource()'s builder) carries no rows: it passes as it is, unawaited, so a builder
399
+ // never runs here.
400
+ if (isKnexValue(answer)) {
401
+ return withoutFullBuilder(answer, label);
402
+ }
403
+ // readable-check: allow method-probe - data: a repository's method may answer at once or with a promise
404
+ return typeof answer?.then === "function"
405
+ ? answer.then((value) => withoutHiddenValues(value, hidden, label))
406
+ : withoutHiddenValues(answer, hidden, label);
407
+ };
408
+ }
409
+
410
+ export { createHiddenFieldReads, createJoinSource, markHandleFunctions, withoutHiddenFields };
@@ -0,0 +1,135 @@
1
+ import { missingOf, requireDefinedFilters } from "./handleGuards.js";
2
+ import { handleRecords, sparseFieldset, textList } from "./handleRecords.js";
3
+
4
+ // The row lock a locking read takes. "update" (the default) is FOR UPDATE. "noKeyUpdate" is PostgreSQL's FOR NO KEY
5
+ // UPDATE, which still keeps every other lock and write of the row out but lets other transactions insert rows that
6
+ // reference it (a foreign key's check takes FOR KEY SHARE); MySQL, MariaDB and SQLite have no weaker exclusive lock and
7
+ // lock as for "update".
8
+ const LOCK_MODES = Object.freeze(["update", "noKeyUpdate"]);
9
+
10
+ /**
11
+ * json-rest-api's lock mode for a locking read: none when the call names none, so json-rest-api takes its default.
12
+ * Another value is a TypeError naming the handle method, before anything runs.
13
+ * @param {string} name The resource's name.
14
+ * @param {string} caller The handle method, for the message.
15
+ * @param {unknown} lockMode
16
+ */
17
+ function lockModeOf(name, caller, lockMode) {
18
+ if (lockMode === undefined) {
19
+ return {};
20
+ }
21
+ if (!LOCK_MODES.includes(/** @type {string} */ (lockMode))) {
22
+ throw new TypeError(`${name}.${caller}() lockMode is "update" (the default) or "noKeyUpdate". Received: ${JSON.stringify(lockMode)}`);
23
+ }
24
+ return { lockMode };
25
+ }
26
+
27
+ /**
28
+ * A handle's locking reads: lock, lockMany, lockWhere and claim. Each needs the
29
+ * caller's transaction (`options.trx`), locks only rows the caller may see, and
30
+ * reads the fields `options.fields` names, or every field a one-record read
31
+ * answers, so a belongs-to field left out costs no check that its related
32
+ * record is visible.
33
+ * @param {object} dependencies
34
+ * @param {string} dependencies.name The resource's name.
35
+ * @param {{ name: string, schema: Record<string, any> }} dependencies.resource
36
+ * @param {() => Record<string, (params: object, context: object) => Promise<{ data: unknown[] }>>} dependencies.jsonRest
37
+ * The table's json-rest-api resource.
38
+ * @param {(params: { fields?: unknown }, kind: "view") => unknown} dependencies.readFields The fields a read names.
39
+ * @param {(options: object) => object} dependencies.contextOf The call's json-rest-api context, after its scope checks.
40
+ * @param {ReturnType<typeof import("./handleHidden.js").createHiddenFieldReads>} dependencies.hiddenFields The hidden
41
+ * fields `options.fields` names, read after the lock.
42
+ */
43
+ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hiddenFields }) {
44
+ // A locking read (json-rest-api's lockByIds, lockWhere or claim) of the fields options.fields names, as records, in the
45
+ // lock mode options.lockMode names. `caller` is the handle method, for the messages.
46
+ async function lockRead(caller, method, read, options) {
47
+ const mode = lockModeOf(name, caller, options.lockMode);
48
+ const hidden = hiddenFields.named(options.fields, "plain");
49
+ const fields = readFields({ fields: options.fields }, "view");
50
+ const { data } = await jsonRest()[method]({
51
+ ...read,
52
+ ...mode,
53
+ transaction: options.trx,
54
+ format: "plain",
55
+ ...(fields ? { queryParams: { fields: sparseFieldset(resource, fields) } } : {})
56
+ }, contextOf(options));
57
+ return hiddenFields.add(handleRecords(resource, data, { fields }), hidden, options);
58
+ }
59
+
60
+ /**
61
+ * Locks the visible rows among the ids, in id order, and answers them; hidden or missing ids throw, or are
62
+ * left out with `missing: "skip"`. MySQL locks every listed id, a hidden one too: take ids from scoped reads.
63
+ * `fields` names the fields the lock reads, so a belongs-to field left out costs no check that its related
64
+ * record is visible; without it every field is read. `lockMode: "noKeyUpdate"` takes PostgreSQL's FOR NO KEY
65
+ * UPDATE, so other transactions may still insert rows that reference the locked ones (see LOCK_MODES).
66
+ * @param {unknown[]} ids
67
+ * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
68
+ */
69
+ async function lockMany(ids, options) {
70
+ return lockRead("lockMany", "lockByIds", { ids, ...(options.missing ? { missing: options.missing } : {}) }, options);
71
+ }
72
+
73
+ // lockMany for lock(): its messages name lock().
74
+ function lockOne(id, options) {
75
+ return lockRead("lock", "lockByIds", { ids: [id], ...(options.missing ? { missing: options.missing } : {}) }, options);
76
+ }
77
+
78
+ /**
79
+ * Locks the record's row until the transaction ends and answers it as the
80
+ * lock saw it. Needs options.trx. A missing or hidden record throws
81
+ * json-rest-api's not-found error, or answers null with `missing: "skip"`.
82
+ * `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
83
+ * @param {unknown} id
84
+ * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
85
+ */
86
+ async function lock(id, options) {
87
+ missingOf(name, "lock", options.missing);
88
+ const [record] = await lockOne(id, options);
89
+ return record ?? null;
90
+ }
91
+
92
+ /**
93
+ * Locks the caller's rows the filters select until the transaction ends, in id
94
+ * order, and answers them; it waits for transactions holding them, so a row that
95
+ * starts to match meanwhile is locked too. At most 1,000 rows (RestApiRowLimitError
96
+ * past that). It does not lock rows inserted later: unique numbers take a unique
97
+ * index, or a counter row taken with lock(). On MySQL it needs an index starting
98
+ * with the ownership columns (no owners: with a filtered column), or it refuses.
99
+ * `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
100
+ * @param {object} filters
101
+ * @param {{ context?: object | null, trx: object, fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
102
+ */
103
+ async function lockWhere(filters, options) {
104
+ requireDefinedFilters(name, "lockWhere", filters);
105
+ return lockRead("lockWhere", "lockWhere", { filters }, options);
106
+ }
107
+
108
+ /**
109
+ * Takes up to `take` (default 1) of the rows the filters select that no
110
+ * other transaction holds, in `sort` order, locked until the transaction ends.
111
+ * `fields` reads only the fields the work needs, and `lockMode` picks the lock (see lockMany).
112
+ * @param {{ filters: object, sort?: string | string[], take?: number }} params
113
+ * @param {{ context?: object | null, trx: object, fields?: string[], lockMode?: "update" | "noKeyUpdate" }} options
114
+ */
115
+ async function claim({ filters, sort, take } = {}, options, ...rest) {
116
+ if (rest.length > 0) {
117
+ throw new TypeError(`${name}.claim() takes ({ filters, sort, take }, options).`);
118
+ }
119
+ requireDefinedFilters(name, "claim", filters);
120
+ return lockRead("claim", "claim", {
121
+ filters,
122
+ ...(take === undefined ? {} : { limit: take }),
123
+ ...(sort === undefined ? {} : { sort: textList(sort) })
124
+ }, options);
125
+ }
126
+
127
+ return Object.freeze({
128
+ lock,
129
+ lockMany,
130
+ lockWhere,
131
+ claim
132
+ });
133
+ }
134
+
135
+ export { createLockReads, lockModeOf };