@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,737 @@
1
+ import diagnosticsChannel from "node:diagnostics_channel";
2
+ import { RestApiRowLimitError } from "json-rest-api";
3
+ import { retryDeadlocks } from "@jskit-ai/database-runtime/shared";
4
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
5
+ import { readChangeHorizon } from "./changeHorizon.js";
6
+ import { createJsonRestContext, isJobContext } from "./contexts.js";
7
+ import { createHiddenFieldReads, createJoinSource, markHandleFunctions } from "./handleHidden.js";
8
+ import { createLockReads, lockModeOf } from "./handleLocks.js";
9
+ import { findOrCreateRecord, lockOrCreate } from "./lockOrCreate.js";
10
+ import { valueOnlyRaw } from "./rawExpression.js";
11
+ import { PageSizeError, RowLimitError, StaleRecordError, UnpagedQueryError } from "./errors.js";
12
+ import {
13
+ formatOf, matchesNoRow, missingOf, passedOn, refuseConditions, requireConditionValues, requireDefinedFilters, requireOwners, requirePatchConditions,
14
+ requireReadParams, requireUpsertRows, whereSelection
15
+ } from "./handleGuards.js";
16
+ import {
17
+ aggregateMeasures,
18
+ MANY_RETURNING,
19
+ SINGLE_RETURNING,
20
+ handleRecords,
21
+ handleResult,
22
+ idOf,
23
+ inputData,
24
+ manyResult,
25
+ noRowsAggregate,
26
+ patchAnswer,
27
+ pickReturning,
28
+ quietCollections,
29
+ readQueryParams,
30
+ requestedFields,
31
+ textList,
32
+ upsertConflict
33
+ } from "./handleRecords.js";
34
+ import {
35
+ returnBadRequestWhenJsonRestFieldsetInvalid,
36
+ returnNullWhenJsonRestResourceMissing
37
+ } from "./resourceHost.js";
38
+
39
+ // Each many-row call publishes { resource, operation, rows, chunkSize } here while something
40
+ // subscribes, so a flat-query test can tell the statements chunking adds from statements that
41
+ // grow with the rows.
42
+ const MANY_ROW_CHANNEL = "jskit:resources:many-row";
43
+ const DEFAULT_CHUNK_SIZE = 500;
44
+ // Why a many-row patch refuses lockVersion: one version does not hold for many records.
45
+ const ONE_VERSION = "To change only the rows still at one version, give onlyIf the lock version field and that version; " +
46
+ "to compare each record's own version, patch() it with its lockVersion.";
47
+
48
+ /**
49
+ * Reports one many-row call while a flat-query test listens: its rows and chunk
50
+ * size tell the test how many statements chunking adds, so a call over more rows
51
+ * is not taken for N+1.
52
+ * @param {string} resource
53
+ * @param {string} operation
54
+ * @param {number} rows
55
+ * @param {number | undefined} chunkSize
56
+ */
57
+ function reportManyRow(resource, operation, rows, chunkSize) {
58
+ const channel = diagnosticsChannel.channel(MANY_ROW_CHANNEL);
59
+ if (channel.hasSubscribers) {
60
+ channel.publish(Object.freeze({
61
+ resource,
62
+ operation,
63
+ rows,
64
+ chunkSize: chunkSize || DEFAULT_CHUNK_SIZE
65
+ }));
66
+ }
67
+ }
68
+
69
+ // The transactions handles opened: json-rest-api's own, which every handle call inside the work joins.
70
+ const handleTransactions = new WeakSet();
71
+
72
+ // A call's options.trx, read or write, is one a handle's withTransaction opened, never a raw Knex transaction.
73
+ function requireHandleTransaction(name, options) {
74
+ if (options.trx && !handleTransactions.has(options.trx)) {
75
+ throw new TypeError(
76
+ `${name}: options.trx is not a transaction a handle opened. A raw Knex transaction or savepoint cannot carry ` +
77
+ "handle calls, reads included: run the work in withTransaction(options, work) and pass on the txOptions it gives work."
78
+ );
79
+ }
80
+ }
81
+
82
+ // Opens a transaction for work. When work throws, json-rest-api rolls the transaction back and the caller gets the
83
+ // error work threw, with its status and code, reporting the transaction's outcome ("rolledBack") as transactionOutcome
84
+ // (not enumerable, so its serialized form is unchanged; a frozen error cannot carry it): a caller that retries a lost
85
+ // race, such as one that turned a duplicate key into its own 409, sees that nothing was written. A failed write inside
86
+ // work (an error that reports a transactionOutcome, which said "pending") arrives as json-rest-api's RestApiWriteError,
87
+ // which reports the outcome after the rollback, as a failed commit does.
88
+ // Errors work threw that a transaction marked with its outcome: an enclosing transaction still passes them on as work's
89
+ // own, not as a failed write's.
90
+ const outcomeMarked = new WeakSet();
91
+
92
+ async function openTransaction(api, options, work) {
93
+ let own = null;
94
+ try {
95
+ return await api.transaction(async (trx) => {
96
+ handleTransactions.add(trx);
97
+ try {
98
+ return await work(Object.freeze({ ...passedOn(options), trx }));
99
+ } catch (error) {
100
+ own = { error };
101
+ throw error;
102
+ }
103
+ });
104
+ } catch (error) {
105
+ if (!own || error?.cause !== own.error || (own.error?.transactionOutcome !== undefined && !outcomeMarked.has(own.error))) {
106
+ throw error;
107
+ }
108
+ if (Object.isExtensible(own.error)) {
109
+ Object.defineProperty(own.error, "transactionOutcome", { value: error.transactionOutcome, configurable: true });
110
+ outcomeMarked.add(own.error);
111
+ }
112
+ throw own.error;
113
+ }
114
+ }
115
+
116
+ // A call's own format setting, for the handle's nested call that answers for it.
117
+ function formatSetting(options) {
118
+ return options.format === undefined ? {} : { format: options.format };
119
+ }
120
+
121
+ // Whether the call runs inside a transaction (`options.trx` is set): the handle's own reader, never handed out.
122
+ function inTransaction(options) {
123
+ return Boolean(options.trx);
124
+ }
125
+
126
+ // A write given no transaction owns one, so it is outermost and may run again.
127
+ function write(options, run) {
128
+ return inTransaction(options) ? run() : retryDeadlocks(run);
129
+ }
130
+
131
+ /**
132
+ * One owned table's handle: every read and write of the table, each applying
133
+ * the scope of the call's `options.context` (ownership and row policy) and
134
+ * joining `options.trx` when there is one. Handles answer records:
135
+ * `{ id, ...fields }`, relationships as ids and included records nested.
136
+ * `options.format: "jsonapi"` is the CRUD service's HTTP edge, which answers
137
+ * JSON:API documents instead.
138
+ * @param {object} dependencies
139
+ * @param {import("./types.js").RuntimeJsonRestApi} dependencies.api
140
+ * @param {import("@jskit-ai/resource-core/shared/tableResource").TableResource} dependencies.resource
141
+ * @param {{ afterWrite?: (event: { operation: string, ids: string[], options: object }) => unknown }} dependencies.hooks
142
+ * @param {{ requestRowLimit: number, serverPageSize: number }} dependencies.settings
143
+ * @param {(name: string) => (import("@jskit-ai/resource-core/shared/tableResource").TableResource | null)} dependencies.resourceNamed
144
+ * The application's table resources by name, which shape included records.
145
+ */
146
+ function createResourceHandle({ api, resource, hooks, settings, resourceNamed }) {
147
+ const name = resource.name;
148
+ const lockVersionField = resource.lockVersionField || "";
149
+ const quiet = { list: quietCollections(resource, "hydrateOnList"), view: quietCollections(resource, "hydrateOnView") };
150
+
151
+ function jsonRest() {
152
+ return api.resources[name];
153
+ }
154
+
155
+ /** @param {{ context?: object | null }} options */
156
+ function contextOf(options) {
157
+ requireHandleTransaction(name, options);
158
+ requireOwners(name, resource.ownership, options.context);
159
+ return createJsonRestContext(options.context);
160
+ }
161
+
162
+ // The fields a list or a one-record read names (see requestedFields).
163
+ const readFields = (params, kind) => requestedFields(params, quiet[kind], () => jsonRest().vars.schemaInfo);
164
+ // The hidden fields a plain read names, which json-rest-api leaves out (see handleHidden.js).
165
+ const hiddenFields = createHiddenFieldReads({ resource, scopedQuery, column });
166
+ // The table as a join source, with its visible columns only (see handleHidden.js).
167
+ const joinSource = createJoinSource({ api, resource, jsonRest, scopedQuery, column });
168
+ // lock, lockMany, lockWhere and claim (see handleLocks.js).
169
+ const { lock, lockMany, lockWhere, claim } = createLockReads({ name, resource, jsonRest, readFields, contextOf, hiddenFields });
170
+
171
+ /**
172
+ * Runs work in the caller's transaction (`options.trx`), or in one it opens,
173
+ * commits and, when the database aborts it as a deadlock victim, runs again
174
+ * whole, up to three more times (a lock wait timeout is not retried). `work` receives the frozen options
175
+ * it must pass to every call inside: the context and the transaction, and no setting of the caller's
176
+ * (`returning`, `missing`, `format`...), which was for the call it was named on. When work throws, the caller gets work's
177
+ * own error; a failed write inside it, or a failed commit, arrives as
178
+ * json-rest-api's RestApiWriteError with the transaction's outcome.
179
+ * Settings ride in options: `{ ...options, retry: false }` runs work once, for
180
+ * work that cannot be repeated (it calls a provider); `{ ...options,
181
+ * outermost: true }` refuses a call inside a transaction, for work that must
182
+ * commit before the caller goes on (mail sent after it, say).
183
+ * @template T
184
+ * @param {{ trx?: object | null, context?: object | null, retry?: boolean, outermost?: boolean }} options
185
+ * @param {(txOptions: Readonly<{ context?: object | null, trx: object }>) => Promise<T> | T} work
186
+ * @returns {Promise<T>}
187
+ */
188
+ async function withTransaction(options, work, ...rest) {
189
+ if (rest.length > 0) {
190
+ throw new TypeError(`${name}.withTransaction() takes (options, work); settings ride in options: withTransaction({ ...options, retry: false }, work).`);
191
+ }
192
+ const { retry = true, outermost = false, ...callOptions } = options;
193
+ requireHandleTransaction(name, callOptions);
194
+ if (inTransaction(callOptions)) {
195
+ if (outermost) {
196
+ throw new TypeError(`${name}.withTransaction() outermost work commits before it answers, so it cannot run inside the caller's transaction.`);
197
+ }
198
+ return work(passedOn(callOptions));
199
+ }
200
+ const run = () => openTransaction(api, callOptions, work);
201
+ return retry ? retryDeadlocks(run) : run();
202
+ }
203
+
204
+ // With an afterWrite hook, the write and the hook share one transaction, in the caller's scope.
205
+ async function hooked(operation, options, run, idsOf) {
206
+ if (!hooks.afterWrite) {
207
+ return write(options, () => run(options));
208
+ }
209
+ return withTransaction(options, async (txOptions) => {
210
+ const result = await run(txOptions);
211
+ await hooks.afterWrite({ operation, ids: idsOf(result), options: txOptions });
212
+ return result;
213
+ });
214
+ }
215
+
216
+ /** The largest page query() serves, and the page size of every-row reads. */
217
+ function pageLimits() {
218
+ return Object.freeze({
219
+ request: jsonRest().vars.queryMaxLimit,
220
+ server: settings.serverPageSize
221
+ });
222
+ }
223
+
224
+ /**
225
+ * One page of the records the filters match: `{ records, page: { size, hasMore, next } }`.
226
+ * `page.size` is required and at most pageLimits().request. A scrolling list
227
+ * passes the previous page's `next` as `page.after`; a numbered page passes
228
+ * `page.number` (from 1), answered as `page: { size, number, hasMore, next }`
229
+ * with `next` the following number. Either costs one statement, with no count.
230
+ * @param {{ filters?: object, sort?: string | string[], include?: string | string[], fields?: string[] | object, page?: { size: number, after?: string, number?: number } }} [params]
231
+ * @param {{ context?: object | null, trx?: object | null, format?: "jsonapi" }} [options]
232
+ */
233
+ async function query(params = {}, options = {}) {
234
+ const format = formatOf(name, options);
235
+ const read = { ...params, fields: readFields(params, "list") };
236
+ const queryParams = readQueryParams(resource, read, { page: true, caller: "query" });
237
+ const hidden = hiddenFields.named(params.fields, format);
238
+ const size = params.page?.size;
239
+ if (size == null && format !== "jsonapi") {
240
+ throw new UnpagedQueryError(name);
241
+ }
242
+ if (size != null && Number(size) > pageLimits().request) {
243
+ throw new PageSizeError(name, size, pageLimits().request);
244
+ }
245
+ const context = contextOf(options);
246
+ const payload = format !== "jsonapi" && matchesNoRow(params.filters, jsonRest().vars.schemaInfo)
247
+ ? { data: [] }
248
+ : await returnBadRequestWhenJsonRestFieldsetInvalid(() => jsonRest().query(
249
+ {
250
+ queryParams,
251
+ transaction: options.trx || undefined,
252
+ format
253
+ },
254
+ context
255
+ ));
256
+ if (format === "jsonapi") {
257
+ return payload;
258
+ }
259
+ const pagination = payload.meta?.pagination;
260
+ const hasMore = pagination?.hasMore === true;
261
+ const number = params.page?.number;
262
+ return Object.freeze({
263
+ records: await hiddenFields.add(handleRecords(resource, payload.data, read, resourceNamed), hidden, options),
264
+ page: Object.freeze(number === undefined
265
+ ? { size: Number(size), hasMore, next: hasMore ? pagination.cursor?.next ?? null : null }
266
+ : { size: Number(size), number, hasMore, next: hasMore ? number + 1 : null })
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Every record the filters match, read in pages of pageLimits().server rows in
272
+ * id order (or `sort`). In a request it throws past the request row limit;
273
+ * a system context, and the workspace and user contexts it derives, read
274
+ * without one.
275
+ * @param {{ filters?: object, sort?: string | string[], include?: string | string[], fields?: string[] | object }} [params]
276
+ * @param {{ context?: object | null, trx?: object | null }} [options]
277
+ */
278
+ async function queryAll(params = {}, options = {}) {
279
+ const context = contextOf(options);
280
+ const rowLimit = isJobContext(options.context) ? undefined : settings.requestRowLimit;
281
+ const read = { ...params, fields: readFields(params, "list") };
282
+ const queryParams = readQueryParams(resource, read, { page: false, caller: "queryAll" });
283
+ const hidden = hiddenFields.named(params.fields, "plain");
284
+ try {
285
+ const payload = matchesNoRow(params.filters, jsonRest().vars.schemaInfo)
286
+ ? { data: [] }
287
+ : await returnBadRequestWhenJsonRestFieldsetInvalid(() => jsonRest().queryAll(
288
+ {
289
+ queryParams,
290
+ pageSize: settings.serverPageSize,
291
+ ...(rowLimit === undefined ? {} : { rowLimit }),
292
+ transaction: options.trx || undefined,
293
+ format: "plain"
294
+ },
295
+ context
296
+ ));
297
+ return await hiddenFields.add(handleRecords(resource, payload.data, read, resourceNamed), hidden, options);
298
+ } catch (error) {
299
+ if (error instanceof RestApiRowLimitError && rowLimit !== undefined) {
300
+ throw new RowLimitError(name, rowLimit, error);
301
+ }
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * One record. A missing or hidden one throws json-rest-api's not-found error
308
+ * (404 at the HTTP edge), or answers null with `missing: "skip"`.
309
+ * @param {unknown} id
310
+ * @param {{ include?: string | string[], fields?: string[] | object }} [params]
311
+ * @param {{ context?: object | null, trx?: object | null, format?: "jsonapi", missing?: "throw" | "skip" }} [options]
312
+ */
313
+ async function get(id, params = {}, options = {}) {
314
+ requireReadParams(name, params);
315
+ const format = formatOf(name, options);
316
+ const named = { ...params, fields: readFields(params, "view") };
317
+ const hidden = hiddenFields.named(params.fields, format);
318
+ const read = () => returnBadRequestWhenJsonRestFieldsetInvalid(() => jsonRest().get(
319
+ {
320
+ id,
321
+ queryParams: readQueryParams(resource, named, { page: false, caller: "get" }),
322
+ transaction: options.trx || undefined,
323
+ format
324
+ },
325
+ contextOf(options)
326
+ ));
327
+ const result = missingOf(name, "get", options.missing) === "skip" ? await returnNullWhenJsonRestResourceMissing(read) : await read();
328
+ return result === null ? null : hiddenFields.addOne(handleResult(resource, result, format, named, resourceNamed), hidden, options);
329
+ }
330
+
331
+ /**
332
+ * Totals: `measures` ({ name: { count | countDistinct | sum | avg | min | max: field, where? } })
333
+ * over the filtered rows, grouped by `groupBy` fields or date buckets. Answers the rows.
334
+ * @param {{ measures: object, groupBy?: Array<string | object>, filters?: object, sort?: string[], page?: object }} spec
335
+ * @param {{ context?: object | null, trx?: object | null }} [options]
336
+ */
337
+ async function aggregate({ measures, groupBy, filters, sort, page }, options = {}) {
338
+ requireDefinedFilters(name, "aggregate", filters);
339
+ const context = contextOf(options);
340
+ if (matchesNoRow(filters, jsonRest().vars.schemaInfo)) {
341
+ return noRowsAggregate(measures, groupBy);
342
+ }
343
+ const result = await jsonRest().aggregate(
344
+ {
345
+ measures: aggregateMeasures(measures),
346
+ ...(groupBy === undefined ? {} : { groupBy }),
347
+ queryParams: {
348
+ ...(filters === undefined ? {} : { filters }),
349
+ ...(sort === undefined ? {} : { sort: textList(sort) }),
350
+ ...(page === undefined ? {} : { page })
351
+ },
352
+ transaction: options.trx || undefined
353
+ },
354
+ context
355
+ );
356
+ return result.data;
357
+ }
358
+
359
+ /**
360
+ * Creates one record, stamping its owner from the context. `options.returning`:
361
+ * "record" (default) reads it back, "id" answers its id, "none" nothing.
362
+ * @param {Record<string, unknown>} attributes
363
+ * @param {{ context?: object | null, trx?: object | null, returning?: "record" | "id" | "none", format?: "jsonapi" }} [options]
364
+ */
365
+ async function create(attributes, options = {}) {
366
+ const returning = pickReturning(SINGLE_RETURNING, options.returning, "record", `${name}.create()`);
367
+ const format = formatOf(name, options);
368
+ // An afterWrite hook needs the new id, so a create returning nothing still asks for it.
369
+ const askFor = hooks.afterWrite && returning === "none" ? "id" : returning;
370
+ const result = await hooked("create", options, (callOptions) => jsonRest().post(
371
+ {
372
+ data: inputData(resource, attributes, "create", options.context),
373
+ returning: SINGLE_RETURNING[askFor],
374
+ transaction: callOptions.trx || undefined,
375
+ format
376
+ },
377
+ contextOf(callOptions)
378
+ ), (created) => [String(idOf(created, format))]);
379
+ if (returning === "none") {
380
+ return undefined;
381
+ }
382
+ return returning === "id" ? idOf(result, format) : handleResult(resource, result, format);
383
+ }
384
+
385
+ /**
386
+ * The record with `attributes.id`, created when it is missing (findOrCreateRecord).
387
+ * @param {{ id: unknown } & Record<string, unknown>} attributes
388
+ * @param {{ context?: object | null, trx?: object | null }} [options]
389
+ */
390
+ function findOrCreate(attributes, options = {}) {
391
+ return findOrCreateRecord({ get, create }, attributes, passedOn(options));
392
+ }
393
+
394
+ // One row's UPDATE, where a missing or hidden row throws. An empty patch reads the record instead, unless it moves
395
+ // the lock version on: a lockVersion patch always writes.
396
+ async function patchRecord(id, attributes, options, returning, { advance = false } = {}) {
397
+ const format = formatOf(name, options);
398
+ if (Object.keys(attributes).length < 1 && !advance) {
399
+ return patchAnswer(id, await get(id, {}, { ...passedOn(options), ...formatSetting(options) }), returning);
400
+ }
401
+ const result = await hooked("patch", options, (callOptions) => jsonRest().patch(
402
+ {
403
+ id,
404
+ data: inputData(resource, attributes, "update", options.context),
405
+ returning: SINGLE_RETURNING[returning],
406
+ transaction: callOptions.trx || undefined,
407
+ format
408
+ },
409
+ contextOf(callOptions)
410
+ ), () => [String(id)]);
411
+ return patchAnswer(id, returning === "record" ? handleResult(resource, result, format) : undefined, returning);
412
+ }
413
+
414
+ // patch() with missing: "skip": one UPDATE that carries the scope (and onlyIf), so a missing, hidden or changed row
415
+ // is left alone, answers null and fails no transaction.
416
+ async function patchIfPresent(id, attributes, options, returning, onlyIf) {
417
+ if (formatOf(name, options) === "jsonapi") {
418
+ throw new TypeError(`${name}.patch() with missing: "skip" answers records, not JSON:API documents.`);
419
+ }
420
+ if (Object.keys(attributes).length < 1) {
421
+ // No change, so no UPDATE compares onlyIf: a read compares it in SQL instead (holdsOnlyIf).
422
+ const holds = onlyIf === undefined || await holdsOnlyIf(id, onlyIf, options);
423
+ const current = holds ? await get(id, {}, { ...passedOn(options), missing: "skip" }) : null;
424
+ return current ? patchAnswer(id, current, returning) : null;
425
+ }
426
+ const full = returning === "record";
427
+ const result = await hooked("patch", options, (callOptions) => jsonRest().patchMany(
428
+ {
429
+ data: [{ id, ...inputData(resource, attributes, "update", options.context) }],
430
+ missing: "skip",
431
+ ...(onlyIf === undefined ? {} : { onlyIf }),
432
+ returning: full ? "full" : "minimal",
433
+ transaction: callOptions.trx || undefined,
434
+ ...(full ? { format: "plain" } : {})
435
+ },
436
+ contextOf(callOptions)
437
+ ), (written) => manyResult(resource, written, "ids"));
438
+ const [row] = manyResult(resource, result, full ? "records" : "ids");
439
+ return row === undefined ? null : patchAnswer(id, row, returning);
440
+ }
441
+
442
+ /**
443
+ * Patches one record and answers it (`returning`: "record", "id" or "none").
444
+ * A missing or hidden record throws json-rest-api's not-found error (404 at
445
+ * the HTTP edge); with `missing: "skip"` it answers null, in one UPDATE that
446
+ * fails no transaction. `lockVersion` patches only while the record's
447
+ * lockVersionField still holds it (StaleRecordError, 409, otherwise), locking
448
+ * the row first; the lock version advances on every update. `onlyIf: { field:
449
+ * value }` patches only while the stored values still equal these, and
450
+ * answers null otherwise.
451
+ * @param {unknown} id
452
+ * @param {Record<string, unknown>} attributes
453
+ * @param {{ context?: object | null, trx?: object | null, returning?: "record" | "id" | "none", lockVersion?: number, onlyIf?: Record<string, unknown>, missing?: "throw" | "skip", format?: "jsonapi" }} [options]
454
+ */
455
+ async function patch(id, attributes, options = {}) {
456
+ if (!isRecord(attributes)) {
457
+ throw new TypeError(`${name}.patch() takes attributes as an object.`);
458
+ }
459
+ requirePatchConditions(name, lockVersionField, attributes, options);
460
+ const { lockVersion, onlyIf, missing: missingOption, ...callOptions } = options;
461
+ const missing = missingOf(name, "patch", missingOption);
462
+ const returning = pickReturning(SINGLE_RETURNING, callOptions.returning, "record", `${name}.patch()`);
463
+ if (lockVersion === undefined && missing === "skip") {
464
+ return patchIfPresent(id, attributes, callOptions, returning, onlyIf);
465
+ }
466
+ if (lockVersion === undefined && onlyIf === undefined) {
467
+ return patchRecord(id, attributes, callOptions, returning);
468
+ }
469
+ // A missing row throws, so the row's lock comes first, reading only the lock version. onlyIf is compared in SQL,
470
+ // by the UPDATE that changes fields or, with no changes, by a read (holdsOnlyIf), so a hidden field, which no read
471
+ // answers, is compared too, and an id compares however the caller gives it.
472
+ const compared = lockVersion === undefined ? [] : [lockVersionField];
473
+ return withTransaction(callOptions, async (transaction) => {
474
+ // The patch's own format goes on to its write; work in a transaction receives only the context and transaction.
475
+ const txOptions = { ...transaction, ...formatSetting(callOptions) };
476
+ const [current] = await lockMany([id], { ...transaction, missing, fields: compared });
477
+ if (!current) {
478
+ return null;
479
+ }
480
+ if (lockVersion !== undefined && Number(current[lockVersionField]) !== lockVersion) {
481
+ throw new StaleRecordError();
482
+ }
483
+ if (onlyIf !== undefined && Object.keys(attributes).length > 0) {
484
+ return patchIfPresent(id, attributes, txOptions, returning, onlyIf);
485
+ }
486
+ if (onlyIf !== undefined && !(await holdsOnlyIf(id, onlyIf, transaction))) {
487
+ return null;
488
+ }
489
+ return patchRecord(id, attributes, txOptions, returning, { advance: lockVersion !== undefined });
490
+ });
491
+ }
492
+
493
+ /**
494
+ * Deletes one record and answers its id. A missing or hidden record throws
495
+ * json-rest-api's not-found error; with `missing: "skip"` it answers null, in
496
+ * one DELETE that fails no transaction. It compares no lockVersion or onlyIf, and refuses them.
497
+ * @param {unknown} id
498
+ * @param {{ context?: object | null, trx?: object | null, missing?: "throw" | "skip" }} [options]
499
+ */
500
+ async function deleteRecord(id, options = {}) {
501
+ refuseConditions(name, "delete", options, ["lockVersion", "onlyIf"],
502
+ "To delete a record only while it holds some values, lock() it in withTransaction(), compare them, then delete it.");
503
+ const { missing: missingOption, ...callOptions } = options;
504
+ const format = formatOf(name, callOptions);
505
+ if (missingOf(name, "delete", missingOption) === "skip") {
506
+ const deleted = await hooked("delete", callOptions, (txOptions) => jsonRest().deleteMany(
507
+ { ids: [id], missing: "skip", returning: "minimal", transaction: txOptions.trx || undefined },
508
+ contextOf(txOptions)
509
+ ), (result) => manyResult(resource, result, "ids"));
510
+ return manyResult(resource, deleted, "ids").length > 0 ? id : null;
511
+ }
512
+ await hooked("delete", callOptions, (txOptions) => jsonRest().delete(
513
+ {
514
+ id,
515
+ returning: "none",
516
+ transaction: txOptions.trx || undefined,
517
+ format
518
+ },
519
+ contextOf(txOptions)
520
+ ), () => [String(id)]);
521
+ return id;
522
+ }
523
+
524
+ function manyRowWrite(operation, rows, options, request) {
525
+ const returning = pickReturning(MANY_RETURNING, options.returning, "none", `${name}.${operation}()`);
526
+ const askFor = hooks.afterWrite && (returning === "none" || returning === "count") ? "ids" : returning;
527
+ reportManyRow(name, operation, rows, options.chunkSize);
528
+ return hooked(operation, options, (callOptions) => request(
529
+ {
530
+ returning: MANY_RETURNING[askFor],
531
+ ...(options.chunkSize === undefined ? {} : { chunkSize: options.chunkSize }),
532
+ transaction: callOptions.trx || undefined,
533
+ ...(askFor === "records" ? { format: "plain" } : {})
534
+ },
535
+ contextOf(callOptions)
536
+ ), (result) => manyResult(resource, result, "ids")).then((result) => manyResult(resource, result, returning));
537
+ }
538
+
539
+ /**
540
+ * Creates every row with one INSERT per chunk, all or nothing. `onConflict:
541
+ * { keys, action: "ignore" | "merge", merge? }` upserts; the keys must include
542
+ * the ownership fields, and merge is refused under a row policy. A table with
543
+ * no owners and no row policy may key it on the id alone (`keys: ["id"]`).
544
+ * `returning`: "none" (default), "ids", "records" or "count". lockVersion and onlyIf, which compare a stored
545
+ * record, are refused.
546
+ * @param {Array<Record<string, unknown>>} rows
547
+ * @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "records" | "count", onConflict?: object, chunkSize?: number }} [options]
548
+ */
549
+ async function createMany(rows, options = {}) {
550
+ if (!Array.isArray(rows)) {
551
+ throw new TypeError(`${name}.createMany() takes an array of rows.`);
552
+ }
553
+ refuseConditions(name, "createMany", options, ["lockVersion", "onlyIf"],
554
+ "A create has no stored record to compare; onConflict says what happens to a record that exists.");
555
+ if (options.onConflict) {
556
+ requireUpsertRows(name, rows, options.onConflict, resource.idProperty);
557
+ }
558
+ return manyRowWrite("createMany", rows.length, options, (params, context) => jsonRest().postMany(
559
+ { ...params, data: rows.map((row) => inputData(resource, row, "create", options.context)),
560
+ ...(options.onConflict ? { onConflict: upsertConflict(resource, rows, options.onConflict, options.context, jsonRest().vars.schemaInfo.ownershipFields ?? []) } : {}) },
561
+ context
562
+ ));
563
+ }
564
+
565
+ /**
566
+ * Patches each row's own changes into the record with its id: `[{ id, ...changes }]`,
567
+ * one UPDATE per chunk. Ids the caller cannot see throw unless `missing: "skip"`;
568
+ * `onlyIf` changes only rows whose stored values still equal it. lockVersion is refused (see ONE_VERSION).
569
+ * @param {Array<Record<string, unknown> & { id: unknown }>} rows
570
+ * @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "records", onlyIf?: object, missing?: "throw" | "skip", chunkSize?: number }} [options]
571
+ */
572
+ async function patchMany(rows, options = {}) {
573
+ if (!Array.isArray(rows)) {
574
+ throw new TypeError(`${name}.patchMany() takes an array of { id, ...changes } rows.`);
575
+ }
576
+ refuseConditions(name, "patchMany", options, ["lockVersion"], ONE_VERSION);
577
+ requireConditionValues(name, "patchMany", options, ["onlyIf"]);
578
+ return manyRowWrite("patchMany", rows.length, options, (params, context) => jsonRest().patchMany(
579
+ {
580
+ ...params,
581
+ data: rows.map(({ id, ...changes }) => ({ id, ...inputData(resource, changes, "update", options.context) })),
582
+ ...(options.onlyIf ? { onlyIf: options.onlyIf } : {}),
583
+ ...(options.missing ? { missing: options.missing } : {})
584
+ },
585
+ context
586
+ ));
587
+ }
588
+
589
+ /**
590
+ * The same changes to every record the filters select (the caller's own rows only), in one statement
591
+ * that carries the scope and filters, so a row gone meanwhile is skipped. Empty filters change every
592
+ * row the caller owns, so they need `all: true`. `returning: "count"` answers how many changed. lockVersion is
593
+ * refused (see ONE_VERSION). `lockMode` is the row lock its selection takes before the write, as the locks take it
594
+ * (see handleLocks.js).
595
+ * @param {object} filters
596
+ * @param {Record<string, unknown>} attributes
597
+ * @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "records" | "count", onlyIf?: object, all?: boolean, chunkSize?: number, lockMode?: "update" | "noKeyUpdate" }} [options]
598
+ */
599
+ async function patchWhere(filters, attributes, options = {}) {
600
+ refuseConditions(name, "patchWhere", options, ["lockVersion"], ONE_VERSION);
601
+ requireConditionValues(name, "patchWhere", options, ["onlyIf"]);
602
+ const selection = whereSelection(name, "patchWhere", filters, options);
603
+ const mode = lockModeOf(name, "patchWhere", options.lockMode);
604
+ return manyRowWrite("patchWhere", 0, options, (params, context) => jsonRest().patchWhere(
605
+ { ...params, ...selection, ...mode, data: inputData(resource, attributes, "update", options.context),
606
+ ...(options.onlyIf ? { onlyIf: options.onlyIf } : {}) },
607
+ context
608
+ ));
609
+ }
610
+
611
+ /**
612
+ * Deletes every record the filters select (the caller's own rows only), as patchWhere selects them.
613
+ * Empty filters delete every row the caller owns, so they need `all: true`. `returning: "count"`
614
+ * answers how many it deleted, with no ids read back. lockVersion and onlyIf are refused: the filters are what
615
+ * it compares, on each row once it holds the row's lock.
616
+ * @param {object} filters
617
+ * @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "count", all?: boolean, chunkSize?: number }} [options]
618
+ */
619
+ async function deleteWhere(filters, options = {}) {
620
+ refuseConditions(name, "deleteWhere", options, ["lockVersion", "onlyIf"],
621
+ "Put the values the rows must hold in its filters: it compares them on each row it deletes.");
622
+ if (options.returning === "records") {
623
+ throw new TypeError(`${name}.deleteWhere() returning is "none", "ids" or "count".`);
624
+ }
625
+ const selection = whereSelection(name, "deleteWhere", filters, options);
626
+ return manyRowWrite("deleteWhere", 0, options, ({ format: _format, ...params }, context) => jsonRest().deleteWhere(
627
+ { ...params, ...selection },
628
+ context
629
+ ));
630
+ }
631
+
632
+ // Runs work on the record with row.id, locked, creating it first when missing (see lockOrCreate.js).
633
+ function lockOrCreateRecord(row, options, work) {
634
+ return lockOrCreate({ name, withTransaction, lock, createMany }, row, options, work);
635
+ }
636
+
637
+ /**
638
+ * `{ query }`: a SELECT-only Knex builder on the table, with ownership, the row
639
+ * policy and the filters applied, for a report the resource cannot express.
640
+ * (A builder is thenable, so it comes wrapped.) `purpose` is "report" (default),
641
+ * "bulk-write", "lock" or "claim".
642
+ * @param {{ filters?: object, alias?: string, purpose?: string }} [params]
643
+ * @param {{ context?: object | null, trx?: object | null }} [options]
644
+ */
645
+ async function scopedQuery(params = {}, options = {}) {
646
+ requireDefinedFilters(name, "scopedQuery", params.filters);
647
+ return jsonRest().scopedQuery({ ...params, transaction: options.trx || undefined }, contextOf(options));
648
+ }
649
+
650
+ /**
651
+ * Whether the caller's record with this id holds every value onlyIf names, compared in SQL as the UPDATE of a patch
652
+ * with changes compares them (json-rest-api's patchMany), so a hidden field, which no read answers, compares too, and
653
+ * an id compares however the caller gives it. onlyIf names the fields that UPDATE takes: stored fields other than the
654
+ * id, each with a scalar, a Date or null.
655
+ * @param {unknown} id
656
+ * @param {Record<string, unknown>} onlyIf
657
+ * @param {{ context?: object | null, trx?: object | null }} options
658
+ */
659
+ async function holdsOnlyIf(id, onlyIf, options) {
660
+ const { schemaStructure } = jsonRest().vars.schemaInfo;
661
+ const adapter = api.knex.helpers.getStorageAdapter(name);
662
+ let { query } = await scopedQuery({ filters: { id: [id] } }, options);
663
+ for (const [field, value] of Object.entries(onlyIf)) {
664
+ const definition = schemaStructure[field];
665
+ if (field === "id" || !definition || definition.virtual || definition.computed) {
666
+ throw new TypeError(`${name}.patch() onlyIf.${field} is not a stored field.`);
667
+ }
668
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
669
+ throw new TypeError(`${name}.patch() onlyIf.${field} must be a scalar, a Date or null.`);
670
+ }
671
+ query = value === null ? query.whereNull(column(field)) : query.where(column(field), adapter.translateFilterValue(field, value));
672
+ }
673
+ return (await query.first(column("id"))) !== undefined;
674
+ }
675
+
676
+ /**
677
+ * A field's storage column, qualified by the table or the given alias.
678
+ * @param {string} field
679
+ * @param {string} [alias]
680
+ */
681
+ function column(field, alias = resource.tableName) {
682
+ const adapter = api.knex.helpers.getStorageAdapter(name);
683
+ const storageColumn = field === "id" ? adapter.getIdColumn() : adapter.translateColumn(field);
684
+ return alias ? `${alias}.${storageColumn}` : storageColumn;
685
+ }
686
+
687
+ /**
688
+ * One SQL expression for a scoped query or a write's value: a literal string,
689
+ * with its values (and its identifiers, as `??` with column()) given as
690
+ * bindings. It is a value only: awaiting it, or running it any other way, throws.
691
+ * @param {string} sql
692
+ * @param {unknown[]} [bindings]
693
+ */
694
+ function raw(sql, bindings, ...rest) {
695
+ return valueOnlyRaw(`${name}.raw()`, api.knex.instance, [sql, bindings, ...rest]);
696
+ }
697
+
698
+ /**
699
+ * PostgreSQL's snapshot horizon, as decimal text, in the caller's transaction when there is one (see readChangeHorizon).
700
+ * @param {{ context?: object | null, trx?: object | null }} [options]
701
+ */
702
+ async function changeHorizon(options = {}) {
703
+ contextOf(options);
704
+ return readChangeHorizon(name, options.trx || api.knex.instance);
705
+ }
706
+
707
+ markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate: lockOrCreateRecord });
708
+ return Object.freeze({
709
+ name,
710
+ pageLimits,
711
+ withTransaction,
712
+ query,
713
+ queryAll,
714
+ get,
715
+ aggregate,
716
+ create,
717
+ findOrCreate,
718
+ patch,
719
+ delete: deleteRecord,
720
+ createMany,
721
+ patchMany,
722
+ patchWhere,
723
+ deleteWhere,
724
+ lock,
725
+ lockMany,
726
+ lockWhere,
727
+ lockOrCreate: lockOrCreateRecord,
728
+ claim,
729
+ changeHorizon,
730
+ scopedQuery,
731
+ joinSource,
732
+ column,
733
+ raw
734
+ });
735
+ }
736
+
737
+ export { MANY_ROW_CHANNEL, createResourceHandle };