@jskit-ai/resource-runtime 0.0.0-stage → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/locales/en.json +16 -0
- package/package.json +68 -4
- package/src/server/ResourceRuntimeProvider.js +30 -0
- package/src/server/contexts.js +192 -0
- package/src/server/errors.js +94 -0
- package/src/server/handleGuards.js +226 -0
- package/src/server/handleHidden.js +309 -0
- package/src/server/handleLocks.js +103 -0
- package/src/server/handleRecords.js +474 -0
- package/src/server/index.js +15 -0
- package/src/server/jsonRestValues.js +49 -0
- package/src/server/lockOrCreate.js +88 -0
- package/src/server/rawExpression.js +50 -0
- package/src/server/resourceHandle.js +724 -0
- package/src/server/resourceHost.js +686 -0
- package/src/server/resourceRuntime.js +300 -0
- package/src/server/standaloneResources.js +49 -0
- package/src/server/types.js +49 -0
- package/src/test/expectFlatQueries.js +117 -0
- package/src/test/expectScoped.js +111 -0
- package/src/test/index.js +122 -0
- package/test/callSettings.test.js +99 -0
- package/test/entrypoints.boundary.test.js +895 -0
- package/test/hiddenFields.test.js +203 -0
- package/test/i18nCatalog.test.js +18 -0
- package/test/lostInsertRace.test.js +64 -0
- package/test/manyRowWrites.mysql.test.js +145 -0
- package/test/queryFields.test.js +68 -0
- package/test/resourceRuntime.test.js +1892 -0
- package/test/testHelpers.test.js +298 -0
- package/test/workspaceScopeValue.test.js +49 -0
- package/test/writeValues.mysql.test.js +111 -0
- package/README.md +0 -3
|
@@ -0,0 +1,724 @@
|
|
|
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 { createJsonRestContext, isJobContext } from "./contexts.js";
|
|
6
|
+
import { createHiddenFieldReads, markHandleFunctions } from "./handleHidden.js";
|
|
7
|
+
import { createLockReads } from "./handleLocks.js";
|
|
8
|
+
import { findOrCreateRecord, lockOrCreate } from "./lockOrCreate.js";
|
|
9
|
+
import { valueOnlyRaw } from "./rawExpression.js";
|
|
10
|
+
import { MissingScopeError, PageSizeError, RowLimitError, StaleRecordError, UnpagedQueryError } from "./errors.js";
|
|
11
|
+
import {
|
|
12
|
+
formatOf, matchesNoRow, missingOf, passedOn, refuseConditions, requireConditionValues, requireDefinedFilters, requireOwners, requirePatchConditions,
|
|
13
|
+
requireReadParams, requireUpsertRows, whereSelection
|
|
14
|
+
} from "./handleGuards.js";
|
|
15
|
+
import {
|
|
16
|
+
aggregateMeasures,
|
|
17
|
+
MANY_RETURNING,
|
|
18
|
+
SINGLE_RETURNING,
|
|
19
|
+
handleRecords,
|
|
20
|
+
handleResult,
|
|
21
|
+
idOf,
|
|
22
|
+
inputData,
|
|
23
|
+
manyResult,
|
|
24
|
+
noRowsAggregate,
|
|
25
|
+
patchAnswer,
|
|
26
|
+
pickReturning,
|
|
27
|
+
quietCollections,
|
|
28
|
+
readQueryParams,
|
|
29
|
+
requestedFields,
|
|
30
|
+
textList,
|
|
31
|
+
upsertConflict
|
|
32
|
+
} from "./handleRecords.js";
|
|
33
|
+
import {
|
|
34
|
+
returnBadRequestWhenJsonRestFieldsetInvalid,
|
|
35
|
+
returnNullWhenJsonRestResourceMissing
|
|
36
|
+
} from "./resourceHost.js";
|
|
37
|
+
|
|
38
|
+
// Each many-row call publishes { resource, operation, rows, chunkSize } here while something
|
|
39
|
+
// subscribes, so a flat-query test can tell the statements chunking adds from statements that
|
|
40
|
+
// grow with the rows.
|
|
41
|
+
const MANY_ROW_CHANNEL = "jskit:resources:many-row";
|
|
42
|
+
const DEFAULT_CHUNK_SIZE = 500;
|
|
43
|
+
// Why a many-row patch refuses lockVersion: one version does not hold for many records.
|
|
44
|
+
const ONE_VERSION = "To change only the rows still at one version, give onlyIf the lock version field and that version; " +
|
|
45
|
+
"to compare each record's own version, patch() it with its lockVersion.";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reports one many-row call while a flat-query test listens: its rows and chunk
|
|
49
|
+
* size tell the test how many statements chunking adds, so a call over more rows
|
|
50
|
+
* is not taken for N+1.
|
|
51
|
+
* @param {string} resource
|
|
52
|
+
* @param {string} operation
|
|
53
|
+
* @param {number} rows
|
|
54
|
+
* @param {number | undefined} chunkSize
|
|
55
|
+
*/
|
|
56
|
+
function reportManyRow(resource, operation, rows, chunkSize) {
|
|
57
|
+
const channel = diagnosticsChannel.channel(MANY_ROW_CHANNEL);
|
|
58
|
+
if (channel.hasSubscribers) {
|
|
59
|
+
channel.publish(Object.freeze({
|
|
60
|
+
resource,
|
|
61
|
+
operation,
|
|
62
|
+
rows,
|
|
63
|
+
chunkSize: chunkSize || DEFAULT_CHUNK_SIZE
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The transactions handles opened: json-rest-api's own, which every handle call inside the work joins.
|
|
69
|
+
const handleTransactions = new WeakSet();
|
|
70
|
+
|
|
71
|
+
// A call's options.trx, read or write, is one a handle's withTransaction opened, never a raw Knex transaction.
|
|
72
|
+
function requireHandleTransaction(name, options) {
|
|
73
|
+
if (options.trx && !handleTransactions.has(options.trx)) {
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
`${name}: options.trx is not a transaction a handle opened. A raw Knex transaction or savepoint cannot carry ` +
|
|
76
|
+
"handle calls, reads included: run the work in withTransaction(options, work) and pass on the txOptions it gives work."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Opens a transaction for work. When work throws, json-rest-api rolls the transaction back and the caller gets the
|
|
82
|
+
// error work threw, with its status and code, reporting the transaction's outcome ("rolledBack") as transactionOutcome
|
|
83
|
+
// (not enumerable, so its serialized form is unchanged; a frozen error cannot carry it): a caller that retries a lost
|
|
84
|
+
// race, such as one that turned a duplicate key into its own 409, sees that nothing was written. A failed write inside
|
|
85
|
+
// work (an error that reports a transactionOutcome, which said "pending") arrives as json-rest-api's RestApiWriteError,
|
|
86
|
+
// which reports the outcome after the rollback, as a failed commit does.
|
|
87
|
+
// Errors work threw that a transaction marked with its outcome: an enclosing transaction still passes them on as work's
|
|
88
|
+
// own, not as a failed write's.
|
|
89
|
+
const outcomeMarked = new WeakSet();
|
|
90
|
+
|
|
91
|
+
async function openTransaction(api, options, work) {
|
|
92
|
+
let own = null;
|
|
93
|
+
try {
|
|
94
|
+
return await api.transaction(async (trx) => {
|
|
95
|
+
handleTransactions.add(trx);
|
|
96
|
+
try {
|
|
97
|
+
return await work(Object.freeze({ ...passedOn(options), trx }));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
own = { error };
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!own || error?.cause !== own.error || (own.error?.transactionOutcome !== undefined && !outcomeMarked.has(own.error))) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
if (Object.isExtensible(own.error)) {
|
|
108
|
+
Object.defineProperty(own.error, "transactionOutcome", { value: error.transactionOutcome, configurable: true });
|
|
109
|
+
outcomeMarked.add(own.error);
|
|
110
|
+
}
|
|
111
|
+
throw own.error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A call's own format setting, for the handle's nested call that answers for it.
|
|
116
|
+
function formatSetting(options) {
|
|
117
|
+
return options.format === undefined ? {} : { format: options.format };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Whether the call runs inside a transaction (`options.trx` is set): the handle's own reader, never handed out.
|
|
121
|
+
function inTransaction(options) {
|
|
122
|
+
return Boolean(options.trx);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A write given no transaction owns one, so it is outermost and may run again.
|
|
126
|
+
function write(options, run) {
|
|
127
|
+
return inTransaction(options) ? run() : retryDeadlocks(run);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One owned table's handle: every read and write of the table, each applying
|
|
132
|
+
* the scope of the call's `options.context` (ownership and row policy) and
|
|
133
|
+
* joining `options.trx` when there is one. Handles answer records:
|
|
134
|
+
* `{ id, ...fields }`, relationships as ids and included records nested.
|
|
135
|
+
* `options.format: "jsonapi"` is the CRUD service's HTTP edge, which answers
|
|
136
|
+
* JSON:API documents instead.
|
|
137
|
+
* @param {object} dependencies
|
|
138
|
+
* @param {import("./types.js").RuntimeJsonRestApi} dependencies.api
|
|
139
|
+
* @param {import("@jskit-ai/resource-core/shared/tableResource").TableResource} dependencies.resource
|
|
140
|
+
* @param {{ afterWrite?: (event: { operation: string, ids: string[], options: object }) => unknown }} dependencies.hooks
|
|
141
|
+
* @param {{ requestRowLimit: number, serverPageSize: number }} dependencies.settings
|
|
142
|
+
* @param {(name: string) => (import("@jskit-ai/resource-core/shared/tableResource").TableResource | null)} dependencies.resourceNamed
|
|
143
|
+
* The application's table resources by name, which shape included records.
|
|
144
|
+
*/
|
|
145
|
+
function createResourceHandle({ api, resource, hooks, settings, resourceNamed }) {
|
|
146
|
+
const name = resource.name;
|
|
147
|
+
const lockVersionField = resource.lockVersionField || "";
|
|
148
|
+
const quiet = { list: quietCollections(resource, "hydrateOnList"), view: quietCollections(resource, "hydrateOnView") };
|
|
149
|
+
|
|
150
|
+
function jsonRest() {
|
|
151
|
+
return api.resources[name];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {{ context?: object | null }} options */
|
|
155
|
+
function contextOf(options) {
|
|
156
|
+
requireHandleTransaction(name, options);
|
|
157
|
+
if (!options.context && resource.ownership !== "public") {
|
|
158
|
+
throw new MissingScopeError(name);
|
|
159
|
+
}
|
|
160
|
+
requireOwners(name, resource.ownership, options.context);
|
|
161
|
+
return createJsonRestContext(options.context);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The fields a list or a one-record read names (see requestedFields).
|
|
165
|
+
const readFields = (params, kind) => requestedFields(params, quiet[kind], () => jsonRest().vars.schemaInfo);
|
|
166
|
+
// The hidden fields a plain read names, which json-rest-api leaves out (see handleHidden.js).
|
|
167
|
+
const hiddenFields = createHiddenFieldReads({ resource, 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).
|
|
594
|
+
* @param {object} filters
|
|
595
|
+
* @param {Record<string, unknown>} attributes
|
|
596
|
+
* @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "records" | "count", onlyIf?: object, all?: boolean, chunkSize?: number }} [options]
|
|
597
|
+
*/
|
|
598
|
+
async function patchWhere(filters, attributes, options = {}) {
|
|
599
|
+
refuseConditions(name, "patchWhere", options, ["lockVersion"], ONE_VERSION);
|
|
600
|
+
requireConditionValues(name, "patchWhere", options, ["onlyIf"]);
|
|
601
|
+
const selection = whereSelection(name, "patchWhere", filters, options);
|
|
602
|
+
return manyRowWrite("patchWhere", 0, options, (params, context) => jsonRest().patchWhere(
|
|
603
|
+
{ ...params, ...selection, data: inputData(resource, attributes, "update", options.context),
|
|
604
|
+
...(options.onlyIf ? { onlyIf: options.onlyIf } : {}) },
|
|
605
|
+
context
|
|
606
|
+
));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Deletes every record the filters select (the caller's own rows only), as patchWhere selects them.
|
|
611
|
+
* Empty filters delete every row the caller owns, so they need `all: true`. `returning: "count"`
|
|
612
|
+
* answers how many it deleted, with no ids read back. lockVersion and onlyIf are refused: the filters are what
|
|
613
|
+
* it compares, on each row once it holds the row's lock.
|
|
614
|
+
* @param {object} filters
|
|
615
|
+
* @param {{ context?: object | null, trx?: object | null, returning?: "none" | "ids" | "count", all?: boolean, chunkSize?: number }} [options]
|
|
616
|
+
*/
|
|
617
|
+
async function deleteWhere(filters, options = {}) {
|
|
618
|
+
refuseConditions(name, "deleteWhere", options, ["lockVersion", "onlyIf"],
|
|
619
|
+
"Put the values the rows must hold in its filters: it compares them on each row it deletes.");
|
|
620
|
+
if (options.returning === "records") {
|
|
621
|
+
throw new TypeError(`${name}.deleteWhere() returning is "none", "ids" or "count".`);
|
|
622
|
+
}
|
|
623
|
+
const selection = whereSelection(name, "deleteWhere", filters, options);
|
|
624
|
+
return manyRowWrite("deleteWhere", 0, options, ({ format: _format, ...params }, context) => jsonRest().deleteWhere(
|
|
625
|
+
{ ...params, ...selection },
|
|
626
|
+
context
|
|
627
|
+
));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Runs work on the record with row.id, locked, creating it first when missing (see lockOrCreate.js).
|
|
631
|
+
function lockOrCreateRecord(row, options, work) {
|
|
632
|
+
return lockOrCreate({ name, withTransaction, lock, createMany }, row, options, work);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* `{ query }`: a SELECT-only Knex builder on the table, with ownership, the row
|
|
637
|
+
* policy and the filters applied, for a report the resource cannot express.
|
|
638
|
+
* (A builder is thenable, so it comes wrapped.) `purpose` is "report" (default),
|
|
639
|
+
* "bulk-write", "lock" or "claim".
|
|
640
|
+
* @param {{ filters?: object, alias?: string, purpose?: string }} [params]
|
|
641
|
+
* @param {{ context?: object | null, trx?: object | null }} [options]
|
|
642
|
+
*/
|
|
643
|
+
async function scopedQuery(params = {}, options = {}) {
|
|
644
|
+
requireDefinedFilters(name, "scopedQuery", params.filters);
|
|
645
|
+
return jsonRest().scopedQuery({ ...params, transaction: options.trx || undefined }, contextOf(options));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Whether the caller's record with this id holds every value onlyIf names, compared in SQL as the UPDATE of a patch
|
|
650
|
+
* with changes compares them (json-rest-api's patchMany), so a hidden field, which no read answers, compares too, and
|
|
651
|
+
* an id compares however the caller gives it. onlyIf names the fields that UPDATE takes: stored fields other than the
|
|
652
|
+
* id, each with a scalar, a Date or null.
|
|
653
|
+
* @param {unknown} id
|
|
654
|
+
* @param {Record<string, unknown>} onlyIf
|
|
655
|
+
* @param {{ context?: object | null, trx?: object | null }} options
|
|
656
|
+
*/
|
|
657
|
+
async function holdsOnlyIf(id, onlyIf, options) {
|
|
658
|
+
const { schemaStructure } = jsonRest().vars.schemaInfo;
|
|
659
|
+
const adapter = api.knex.helpers.getStorageAdapter(name);
|
|
660
|
+
let { query } = await scopedQuery({ filters: { id: [id] } }, options);
|
|
661
|
+
for (const [field, value] of Object.entries(onlyIf)) {
|
|
662
|
+
const definition = schemaStructure[field];
|
|
663
|
+
if (field === "id" || !definition || definition.virtual || definition.computed) {
|
|
664
|
+
throw new TypeError(`${name}.patch() onlyIf.${field} is not a stored field.`);
|
|
665
|
+
}
|
|
666
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date)) {
|
|
667
|
+
throw new TypeError(`${name}.patch() onlyIf.${field} must be a scalar, a Date or null.`);
|
|
668
|
+
}
|
|
669
|
+
query = value === null ? query.whereNull(column(field)) : query.where(column(field), adapter.translateFilterValue(field, value));
|
|
670
|
+
}
|
|
671
|
+
return (await query.first(column("id"))) !== undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* A field's storage column, qualified by the table or the given alias.
|
|
676
|
+
* @param {string} field
|
|
677
|
+
* @param {string} [alias]
|
|
678
|
+
*/
|
|
679
|
+
function column(field, alias = resource.tableName) {
|
|
680
|
+
const adapter = api.knex.helpers.getStorageAdapter(name);
|
|
681
|
+
const storageColumn = field === "id" ? adapter.getIdColumn() : adapter.translateColumn(field);
|
|
682
|
+
return alias ? `${alias}.${storageColumn}` : storageColumn;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* One SQL expression for a scoped query or a write's value: a literal string,
|
|
687
|
+
* with its values (and its identifiers, as `??` with column()) given as
|
|
688
|
+
* bindings. It is a value only: awaiting it, or running it any other way, throws.
|
|
689
|
+
* @param {string} sql
|
|
690
|
+
* @param {unknown[]} [bindings]
|
|
691
|
+
*/
|
|
692
|
+
function raw(sql, bindings, ...rest) {
|
|
693
|
+
return valueOnlyRaw(`${name}.raw()`, api.knex.instance, [sql, bindings, ...rest]);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate: lockOrCreateRecord });
|
|
697
|
+
return Object.freeze({
|
|
698
|
+
name,
|
|
699
|
+
pageLimits,
|
|
700
|
+
withTransaction,
|
|
701
|
+
query,
|
|
702
|
+
queryAll,
|
|
703
|
+
get,
|
|
704
|
+
aggregate,
|
|
705
|
+
create,
|
|
706
|
+
findOrCreate,
|
|
707
|
+
patch,
|
|
708
|
+
delete: deleteRecord,
|
|
709
|
+
createMany,
|
|
710
|
+
patchMany,
|
|
711
|
+
patchWhere,
|
|
712
|
+
deleteWhere,
|
|
713
|
+
lock,
|
|
714
|
+
lockMany,
|
|
715
|
+
lockWhere,
|
|
716
|
+
lockOrCreate: lockOrCreateRecord,
|
|
717
|
+
claim,
|
|
718
|
+
scopedQuery,
|
|
719
|
+
column,
|
|
720
|
+
raw
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export { MANY_ROW_CHANNEL, createResourceHandle };
|