@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.
@@ -0,0 +1,226 @@
1
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { passedOn } from "@jskit-ai/resource-core/shared/callOptions";
3
+ import { isSystemContext } from "./contexts.js";
4
+ import { MissingScopeError } from "./errors.js";
5
+ import { resolveUserScopeValue, resolveWorkspaceScopeValue } from "./resourceHost.js";
6
+
7
+ // The checks that keep a handle's calls inside their scope: a call never reaches rows beyond
8
+ // the caller's because a context, a filter value or an expression was missing or misused.
9
+
10
+ // The owners a context must name for each ownership. A context naming none of them would match the rows whose owner is
11
+ // null; a workspace_or_shared table reads exactly those shared rows without a workspace, and refuses writes there.
12
+ const REQUIRED_OWNERS = Object.freeze({
13
+ public: Object.freeze([]),
14
+ workspace: Object.freeze(["workspace"]),
15
+ user: Object.freeze(["user"]),
16
+ workspace_user: Object.freeze(["workspace", "user"]),
17
+ workspace_or_shared: Object.freeze([])
18
+ });
19
+ const OWNER_RESOLVERS = Object.freeze({ workspace: resolveWorkspaceScopeValue, user: resolveUserScopeValue });
20
+
21
+ /**
22
+ * Refuses a context, other than a system context, that names no owner the table's ownership needs.
23
+ * @param {string} name The resource's name.
24
+ * @param {string} ownership
25
+ * @param {object | null | undefined} context
26
+ */
27
+ function requireOwners(name, ownership, context) {
28
+ if (isSystemContext(context)) {
29
+ return;
30
+ }
31
+ const missing = REQUIRED_OWNERS[ownership].find((owner) => !OWNER_RESOLVERS[owner](context));
32
+ if (missing) {
33
+ throw new MissingScopeError(name, missing);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Refuses a filter whose value is undefined, at any depth. json-rest-api drops such a filter, so a caller's missing
39
+ * value would widen the selection to every row it owns instead of none.
40
+ * @param {string} name
41
+ * @param {string} caller
42
+ * @param {unknown} filters
43
+ * @param {string} [path]
44
+ */
45
+ function requireDefinedFilters(name, caller, filters, path = "filters") {
46
+ if (!isRecord(filters)) {
47
+ return filters;
48
+ }
49
+ for (const [key, value] of Object.entries(filters)) {
50
+ if (value === undefined) {
51
+ throw new TypeError(`${name}.${caller}() ${path}.${key} is undefined: pass its value, or leave the filter out.`);
52
+ }
53
+ requireDefinedFilters(name, caller, value, `${path}.${key}`);
54
+ }
55
+ return filters;
56
+ }
57
+
58
+
59
+ /**
60
+ * A filtered write's selection: its filters, or every owned row when they are empty and `all: true` says so. An
61
+ * undefined filter value is refused, so a missing value never counts as a filter yet selects every row.
62
+ * @param {string} name The resource's name.
63
+ * @param {string} operation
64
+ * @param {unknown} filters
65
+ * @param {{ all?: boolean }} options
66
+ */
67
+ function whereSelection(name, operation, filters, options) {
68
+ if (!isRecord(filters)) {
69
+ throw new TypeError(`${name}.${operation}() takes filters as an object.`);
70
+ }
71
+ if (options.missing !== undefined) {
72
+ throw new TypeError(`${name}.${operation}() changes the rows the filters select when it runs, so it takes no missing.`);
73
+ }
74
+ requireDefinedFilters(name, operation, filters);
75
+ if (Object.keys(filters).length > 0) {
76
+ if (options.all !== undefined) {
77
+ throw new TypeError(`${name}.${operation}() takes filters or all: true, not both.`);
78
+ }
79
+ return { filters };
80
+ }
81
+ if (options.all !== true) {
82
+ throw new TypeError(`${name}.${operation}() with empty filters changes every row the caller owns; pass all: true to mean it.`);
83
+ }
84
+ return { all: true };
85
+ }
86
+
87
+ /**
88
+ * An upsert matches rows by its conflict keys alone, so its rows carry no id, which could name another owner's
89
+ * record, unless the id is the one key: json-rest-api allows that only on a table with no owners and no row policy.
90
+ * @param {string} name
91
+ * @param {unknown[]} rows
92
+ * @param {{ keys?: unknown }} onConflict
93
+ * @param {string | undefined} idProperty
94
+ */
95
+ function requireUpsertRows(name, rows, onConflict, idProperty) {
96
+ const keys = Array.isArray(onConflict.keys) ? onConflict.keys : [];
97
+ const byId = keys.length === 1 && (keys[0] === "id" || keys[0] === idProperty);
98
+ if (!byId && rows.some((row) => isRecord(row) && Object.hasOwn(row, "id"))) {
99
+ throw new TypeError(
100
+ `${name}.createMany() with onConflict takes rows without an id; the conflict keys find the existing rows. ` +
101
+ "Only onConflict: { keys: [\"id\"] }, on a table with no owners, matches rows by their id."
102
+ );
103
+ }
104
+ }
105
+
106
+ // The keys of a call's options, which get() takes third, after its params.
107
+ const OPTION_KEYS = Object.freeze(["context", "trx", "format", "missing", "returning"]);
108
+
109
+ /**
110
+ * get(), patch(), delete() and lock() throw json-rest-api's not-found error for
111
+ * a missing or hidden row, or answer null with `missing: "skip"`.
112
+ * @param {string} name
113
+ * @param {string} caller
114
+ * @param {unknown} [missing]
115
+ * @returns {"throw" | "skip"}
116
+ */
117
+ function missingOf(name, caller, missing = "throw") {
118
+ if (missing !== "throw" && missing !== "skip") {
119
+ throw new TypeError(`${name}.${caller}() missing is "throw" (the default) or "skip". Received: ${JSON.stringify(missing)}`);
120
+ }
121
+ return missing;
122
+ }
123
+
124
+ /**
125
+ * Refuses get(id, options): get takes its options third, after its params.
126
+ * @param {string} name
127
+ * @param {unknown} params
128
+ */
129
+ function requireReadParams(name, params) {
130
+ if (isRecord(params) && OPTION_KEYS.some((key) => Object.hasOwn(params, key))) {
131
+ throw new TypeError(`${name}.get() takes (id, params, options), and options go third: get(id, {}, options).`);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Refuses a write condition that the options name with no value (`{ lockVersion: undefined }`, `{ onlyIf: undefined }`,
137
+ * often a field read under the wrong name): run without it, the write would drop the condition it was meant to carry,
138
+ * and a patch with no changes would only read. A call without the condition leaves the key out.
139
+ * @param {string} name The resource's name.
140
+ * @param {string} method The handle method, such as "patch".
141
+ * @param {Record<string, unknown>} options
142
+ * @param {string[]} keys The condition keys the method takes.
143
+ */
144
+ function requireConditionValues(name, method, options, keys) {
145
+ for (const key of keys) {
146
+ if (Object.hasOwn(options, key) && options[key] === undefined) {
147
+ throw new TypeError(`${name}.${method}() options.${key} is undefined. Give it a value, or leave the key out for a ${method} without it.`);
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Refuses lockVersion or onlyIf, named at all, on a write that cannot compare them: run without them, the write would
154
+ * change or delete the rows whatever they hold.
155
+ * @param {string} name The resource's name.
156
+ * @param {string} method The handle method, such as "delete".
157
+ * @param {Record<string, unknown>} options
158
+ * @param {string[]} keys The conditions the method cannot compare.
159
+ * @param {string} instead What the caller does instead.
160
+ */
161
+ function refuseConditions(name, method, options, keys, instead) {
162
+ const named = keys.filter((key) => Object.hasOwn(options, key));
163
+ if (named.length > 0) {
164
+ throw new TypeError(`${name}.${method}() cannot compare ${named.join(" or ")}, so it refuses rather than write without them. ${instead}`);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Refuses a patch's lockVersion or onlyIf named with no value (see requireConditionValues); a lockVersion without a
170
+ * lockVersionField, or one that is not a version, or changes that write the version the patch advances itself; and an
171
+ * onlyIf that is not an object of field values.
172
+ * @param {string} name
173
+ * @param {string} lockVersionField
174
+ * @param {Record<string, unknown>} attributes
175
+ * @param {{ lockVersion?: unknown, onlyIf?: unknown }} options The patch's options.
176
+ */
177
+ function requirePatchConditions(name, lockVersionField, attributes, options) {
178
+ requireConditionValues(name, "patch", options, ["lockVersion", "onlyIf"]);
179
+ const { lockVersion, onlyIf } = options;
180
+ if (lockVersion !== undefined) {
181
+ if (!lockVersionField) {
182
+ throw new TypeError(`${name}.patch() lockVersion needs the resource to name its lockVersionField.`);
183
+ }
184
+ if (!Number.isSafeInteger(lockVersion) || Number(lockVersion) < 0) {
185
+ throw new TypeError(`${name}.patch() lockVersion must be a non-negative integer. Received: ${lockVersion}`);
186
+ }
187
+ if (Object.hasOwn(attributes, lockVersionField)) {
188
+ throw new TypeError(`${name}.patch() advances ${lockVersionField} itself, so the attributes must leave it out.`);
189
+ }
190
+ }
191
+ if (onlyIf !== undefined && !isRecord(onlyIf)) {
192
+ throw new TypeError(`${name}.patch() onlyIf must be an object of field values.`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The answer format a handle call asks for: "jsonapi" (the CRUD service's documents) or, by default, "plain" (records).
198
+ * @param {string} name The resource's name.
199
+ * @param {{ format?: string }} options
200
+ */
201
+ function formatOf(name, options) {
202
+ if (options.format !== undefined && options.format !== "jsonapi") {
203
+ throw new TypeError(`${name} options.format is "jsonapi" (the CRUD service's documents) or absent (records).`);
204
+ }
205
+ return options.format === "jsonapi" ? "jsonapi" : "plain";
206
+ }
207
+
208
+ /**
209
+ * Whether the filters give an empty list to a filter that matches one of a list (the id filter, or any filter whose
210
+ * search entry says `filterOperator: "in"`): such a read matches no row, so the handle answers it without a statement.
211
+ * @param {unknown} filters
212
+ * @param {{ searchSchemaStructure?: Record<string, { filterOperator?: string }> }} schemaInfo The table's compiled
213
+ * json-rest-api schema (`vars.schemaInfo`).
214
+ */
215
+ function matchesNoRow(filters, schemaInfo) {
216
+ if (!isRecord(filters)) {
217
+ return false;
218
+ }
219
+ const search = schemaInfo.searchSchemaStructure || {};
220
+ return Object.entries(filters).some(([key, value]) => Array.isArray(value) && value.length === 0 && search[key]?.filterOperator === "in");
221
+ }
222
+
223
+ export {
224
+ formatOf, matchesNoRow, missingOf, refuseConditions, requireConditionValues, requireDefinedFilters, requireOwners, requirePatchConditions,
225
+ requireReadParams, requireUpsertRows, passedOn, whereSelection
226
+ };
@@ -0,0 +1,309 @@
1
+ import { RestApiResourceError } 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
+ // Where each read takes `fields` and where its options, by argument position.
15
+ const FIELD_READS = Object.freeze({
16
+ get: Object.freeze({ fieldsAt: 1, optionsAt: 2 }),
17
+ query: Object.freeze({ fieldsAt: 0, optionsAt: 1 }),
18
+ queryAll: Object.freeze({ fieldsAt: 0, optionsAt: 1 }),
19
+ lock: Object.freeze({ fieldsAt: 1, optionsAt: 1 }),
20
+ lockMany: Object.freeze({ fieldsAt: 1, optionsAt: 1 }),
21
+ lockWhere: Object.freeze({ fieldsAt: 1, optionsAt: 1 }),
22
+ claim: Object.freeze({ fieldsAt: 1, optionsAt: 1 })
23
+ });
24
+
25
+ /**
26
+ * The resource's hidden fields a read's `fields` names.
27
+ * @param {{ name: string, schema: Record<string, any> }} resource
28
+ * @param {unknown} fields
29
+ */
30
+ function namedHiddenFields(resource, fields) {
31
+ const named = namedFields(resource, fields);
32
+ return named ? [...named].filter((field) => resource.schema[field]?.hidden === true) : [];
33
+ }
34
+
35
+ /**
36
+ * A hidden field's stored value as a read answers it, as json-rest-api reads its type: an id as text, a whole
37
+ * number or a non-decimal number as a number (text past the safe integers), 0 and 1 as booleans.
38
+ * @param {unknown} value
39
+ * @param {{ type: string, precision?: number, scale?: number }} definition
40
+ */
41
+ function storedValue(value, { type, precision, scale }) {
42
+ if (value == null) {
43
+ return null;
44
+ }
45
+ if (type === "id") {
46
+ return String(value);
47
+ }
48
+ if (type === "boolean") {
49
+ return value === 1 || value === "1" ? true : value === 0 || value === "0" ? false : value;
50
+ }
51
+ const decimal = precision !== undefined && scale !== undefined;
52
+ if ((type === "integer" || type === "number") && typeof value === "string" && !decimal) {
53
+ const number = Number(value);
54
+ return value.trim() !== "" && Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) ? number : value;
55
+ }
56
+ return value;
57
+ }
58
+
59
+ /**
60
+ * A handle's reads of the hidden fields a plain read's `fields` names, which json-rest-api leaves out: one more
61
+ * statement per 1,000 records, on the table as scopedQuery() gives it (the caller's ownership and the row policy
62
+ * applied), in the call's transaction.
63
+ * @param {object} dependencies
64
+ * @param {{ name: string, schema: Record<string, any> }} dependencies.resource
65
+ * @param {(params: { filters: object }, options: object) => Promise<{ query: any }>} dependencies.scopedQuery
66
+ * @param {(field: string) => string} dependencies.column
67
+ */
68
+ function createHiddenFieldReads({ resource, scopedQuery, column }) {
69
+ /**
70
+ * The hidden fields a plain read names, checked before it runs: each is a stored id, string, number or boolean.
71
+ * @param {unknown} fields
72
+ * @param {string | undefined} format
73
+ */
74
+ function named(fields, format) {
75
+ if (format === "jsonapi") {
76
+ return [];
77
+ }
78
+ const hidden = namedHiddenFields(resource, fields);
79
+ for (const field of hidden) {
80
+ const definition = resource.schema[field];
81
+ if (!READABLE_TYPES.has(definition.type) || definition.virtual === true || definition.storage?.virtual === true) {
82
+ throw new TypeError(
83
+ `${resource.name}: fields names the hidden field "${field}", but a read answers a hidden field only when it ` +
84
+ "is a stored id, string, integer, number or boolean."
85
+ );
86
+ }
87
+ }
88
+ return hidden;
89
+ }
90
+
91
+ /**
92
+ * The records, each with the hidden fields added. A record whose row the added statement no longer sees (deleted
93
+ * since, outside a transaction) is left out.
94
+ * @param {Array<Record<string, any>>} records
95
+ * @param {string[]} hidden
96
+ * @param {{ context?: object | null, trx?: object | null }} options
97
+ */
98
+ async function add(records, hidden, options) {
99
+ if (hidden.length < 1 || records.length < 1) {
100
+ return records;
101
+ }
102
+ const rows = new Map();
103
+ const ids = records.map(({ id }) => id);
104
+ for (let start = 0; start < ids.length; start += RECORDS_PER_READ) {
105
+ const { query } = await scopedQuery({ filters: { id: ids.slice(start, start + RECORDS_PER_READ) } }, options);
106
+ const read = await query.select({ id: column("id"), ...Object.fromEntries(hidden.map((field) => [field, column(field)])) });
107
+ for (const row of read) {
108
+ rows.set(String(row.id), row);
109
+ }
110
+ }
111
+ return records.flatMap((record) => {
112
+ const row = rows.get(String(record.id));
113
+ return row
114
+ ? [{ ...record, ...Object.fromEntries(hidden.map((field) => [field, storedValue(row[field], resource.schema[field])])) }]
115
+ : [];
116
+ });
117
+ }
118
+
119
+ /**
120
+ * One record with the hidden fields added. A record deleted since it was read is missing: null with
121
+ * `missing: "skip"`, else json-rest-api's not-found error.
122
+ * @param {Record<string, any>} record
123
+ * @param {string[]} hidden
124
+ * @param {{ context?: object | null, trx?: object | null, missing?: "throw" | "skip" }} options
125
+ */
126
+ async function addOne(record, hidden, options) {
127
+ const [read] = await add([record], hidden, options);
128
+ if (read || options.missing === "skip") {
129
+ return read ?? null;
130
+ }
131
+ throw new RestApiResourceError("Resource not found", { subtype: "not_found", resourceType: resource.name, resourceId: record.id });
132
+ }
133
+
134
+ return Object.freeze({
135
+ named,
136
+ add,
137
+ addOne
138
+ });
139
+ }
140
+
141
+ // Every resource handle's scopedQuery, whose builder carries every column, and its withTransaction and lockOrCreate,
142
+ // which answer the caller's own work: withoutHiddenFields tells them by identity, under any name.
143
+ const HANDLE_SCOPED_QUERIES = new WeakSet();
144
+ const HANDLE_WORK_RUNNERS = new WeakSet();
145
+ // The names an owner's own wrappers of those runners keep.
146
+ const WORK_RUNNER_NAMES = Object.freeze(new Set(["withTransaction", "lockOrCreate"]));
147
+
148
+ /**
149
+ * Records a new handle's scopedQuery, withTransaction and lockOrCreate for withoutHiddenFields.
150
+ * @param {{ scopedQuery: Function, withTransaction: Function, lockOrCreate: Function }} handle
151
+ */
152
+ function markHandleFunctions({ scopedQuery, withTransaction, lockOrCreate }) {
153
+ HANDLE_SCOPED_QUERIES.add(scopedQuery);
154
+ HANDLE_WORK_RUNNERS.add(withTransaction);
155
+ HANDLE_WORK_RUNNERS.add(lockOrCreate);
156
+ }
157
+
158
+ /**
159
+ * Whether a function answered a Knex value (a raw expression, a column's builder, a query builder an owner's join
160
+ * source answers) rather than rows: a value for the caller's own query, which it builds on and never awaits.
161
+ * @param {unknown} value
162
+ */
163
+ function isKnexValue(value) {
164
+ // readable-check: allow method-probe - data: Knex raw expressions and query builders compile through toSQL()
165
+ return isRecord(value) && typeof value.toSQL === "function";
166
+ }
167
+
168
+ /** @param {unknown} value */
169
+ function isPlainRecord(value) {
170
+ if (!isRecord(value)) {
171
+ return false;
172
+ }
173
+ const prototype = Object.getPrototypeOf(value);
174
+ return prototype === Object.prototype || prototype === null;
175
+ }
176
+
177
+ /**
178
+ * The value without the hidden fields, wherever a plain record in it, or in its arrays and maps, carries one: a copy
179
+ * of what changed (frozen when the original was), the value itself when nothing did. Builders, dates and other
180
+ * instances are left as they are.
181
+ * @param {unknown} value
182
+ * @param {Set<string>} hidden
183
+ * @param {WeakMap<object, unknown>} [seen]
184
+ */
185
+ function withoutHiddenValues(value, hidden, seen = new WeakMap()) {
186
+ if (!value || typeof value !== "object") {
187
+ return value;
188
+ }
189
+ if (seen.has(value)) {
190
+ return seen.get(value);
191
+ }
192
+ seen.set(value, value);
193
+ let changed = false;
194
+ const strip = (entry) => {
195
+ const next = withoutHiddenValues(entry, hidden, seen);
196
+ changed ||= next !== entry;
197
+ return next;
198
+ };
199
+ let copy;
200
+ if (Array.isArray(value)) {
201
+ copy = value.map(strip);
202
+ } else if (value instanceof Map) {
203
+ copy = new Map([...value].map(([key, entry]) => [key, strip(entry)]));
204
+ } else if (isPlainRecord(value)) {
205
+ copy = Object.create(Object.getPrototypeOf(value));
206
+ for (const [key, entry] of Object.entries(value)) {
207
+ if (hidden.has(key)) {
208
+ changed = true;
209
+ } else {
210
+ copy[key] = strip(entry);
211
+ }
212
+ }
213
+ } else {
214
+ return value;
215
+ }
216
+ const result = !changed ? value : Object.isFrozen(value) && !(value instanceof Map) ? Object.freeze(copy) : copy;
217
+ seen.set(value, result);
218
+ return result;
219
+ }
220
+
221
+ /**
222
+ * The repository an owner gives other features (a CRUD feature's `resourceApi.repository`): every function it holds,
223
+ * its sub-repositories' included, answers without the hidden fields of the tables the owner owns, and a read whose
224
+ * `fields` names a hidden field of the resource, or any scopedQuery() (its builder carries every column, hidden ones
225
+ * too), is refused, unless the call runs in a system context or one it derived (its options, the last record it
226
+ * takes). So a read the owner adds in decorateRepository cannot hand a hidden field out either; the owner's own
227
+ * code, its join sources included, reads with the repository itself. withTransaction and lockOrCreate answer the
228
+ * caller's own work as it is, and a function answering a Knex value (raw(), a join source's builder) answers it as
229
+ * it is, unawaited. A repository whose tables have no hidden fields stays as it is.
230
+ * @template {object} T
231
+ * @param {T} repository
232
+ * @param {{ name: string, schema: Record<string, any> } | ReadonlyArray<{ name: string, schema: Record<string, any> }>} resources
233
+ * The resource the repository serves, or it first and the other tables its owner owns.
234
+ * @returns {T}
235
+ */
236
+ function withoutHiddenFields(repository, resources) {
237
+ const owned = [resources].flat();
238
+ const hidden = new Set(owned.flatMap(({ schema }) => Object.keys(schema).filter((field) => schema[field]?.hidden === true)));
239
+ if (!isRecord(repository) || hidden.size < 1) {
240
+ return repository;
241
+ }
242
+ return guardedRepository(repository, owned[0], hidden, new WeakSet());
243
+ }
244
+
245
+ /**
246
+ * @param {object} repository
247
+ * @param {{ name: string, schema: Record<string, any> } | null} resource The resource whose reads it refuses by name; none for a sub-repository.
248
+ * @param {Set<string>} hidden
249
+ * @param {WeakSet<object>} guarded
250
+ */
251
+ function guardedRepository(repository, resource, hidden, guarded) {
252
+ guarded.add(repository);
253
+ const descriptors = Object.getOwnPropertyDescriptors(repository);
254
+ for (const [key, descriptor] of Object.entries(descriptors)) {
255
+ const { value } = descriptor;
256
+ // readable-check: allow method-probe - data: every function a repository holds is guarded, whatever its name
257
+ if (typeof value === "function") {
258
+ descriptors[key] = { ...descriptor, value: guardedFunction(repository, key, value, resource, hidden) };
259
+ } else if (isPlainRecord(value) && !guarded.has(value)) {
260
+ descriptors[key] = { ...descriptor, value: guardedRepository(value, null, hidden, guarded) };
261
+ }
262
+ }
263
+ return Object.freeze(Object.create(Object.getPrototypeOf(repository), descriptors));
264
+ }
265
+
266
+ /**
267
+ * @param {object} repository
268
+ * @param {string} key
269
+ * @param {Function} call
270
+ * @param {{ name: string, schema: Record<string, any> } | null} resource
271
+ * @param {Set<string>} hidden
272
+ */
273
+ function guardedFunction(repository, key, call, resource, hidden) {
274
+ const label = `${resource?.name ?? "repository"}.${key}()`;
275
+ const fieldRead = resource && Object.hasOwn(FIELD_READS, key) ? FIELD_READS[key] : null;
276
+ const builder = HANDLE_SCOPED_QUERIES.has(call) || (resource !== null && key === "scopedQuery");
277
+ const work = HANDLE_WORK_RUNNERS.has(call) || WORK_RUNNER_NAMES.has(key);
278
+ return function callWithoutHiddenFields(...args) {
279
+ const options = fieldRead ? args[fieldRead.optionsAt] : args.findLast(isRecord);
280
+ if (isJobContext(options?.context)) {
281
+ return call.apply(repository, args);
282
+ }
283
+ if (builder) {
284
+ return Promise.reject(new TypeError(
285
+ `${label} answers every column, its hidden fields too, which only the table's owner, or a system context, ` +
286
+ "reads: ask the owner for a method answering the rows or a join source."
287
+ ));
288
+ }
289
+ const named = fieldRead ? namedHiddenFields(resource, args[fieldRead.fieldsAt]?.fields) : [];
290
+ if (named.length > 0) {
291
+ return Promise.reject(new TypeError(
292
+ `${label} fields names the hidden field ${named.map((field) => `"${field}"`).join(", ")}, ` +
293
+ "which only the table's owner, or a system context, reads."
294
+ ));
295
+ }
296
+ const answer = call.apply(repository, args);
297
+ // A runner's answer is the caller's own work, and a Knex value (raw(), or an owner's join source answering a
298
+ // builder) carries no rows: both pass as they are, unawaited, so a builder never runs here.
299
+ if (work || isKnexValue(answer)) {
300
+ return answer;
301
+ }
302
+ // readable-check: allow method-probe - data: a repository's method may answer at once or with a promise
303
+ return typeof answer?.then === "function"
304
+ ? answer.then((value) => withoutHiddenValues(value, hidden))
305
+ : withoutHiddenValues(answer, hidden);
306
+ };
307
+ }
308
+
309
+ export { createHiddenFieldReads, markHandleFunctions, withoutHiddenFields };
@@ -0,0 +1,103 @@
1
+ import { missingOf, requireDefinedFilters } from "./handleGuards.js";
2
+ import { handleRecords, sparseFieldset, textList } from "./handleRecords.js";
3
+
4
+ /**
5
+ * A handle's locking reads: lock, lockMany, lockWhere and claim. Each needs the
6
+ * caller's transaction (`options.trx`), locks only rows the caller may see, and
7
+ * reads the fields `options.fields` names, or every field a one-record read
8
+ * answers, so a belongs-to field left out costs no check that its related
9
+ * record is visible.
10
+ * @param {object} dependencies
11
+ * @param {string} dependencies.name The resource's name.
12
+ * @param {{ name: string, schema: Record<string, any> }} dependencies.resource
13
+ * @param {() => Record<string, (params: object, context: object) => Promise<{ data: unknown[] }>>} dependencies.jsonRest
14
+ * The table's json-rest-api resource.
15
+ * @param {(params: { fields?: unknown }, kind: "view") => unknown} dependencies.readFields The fields a read names.
16
+ * @param {(options: object) => object} dependencies.contextOf The call's json-rest-api context, after its scope checks.
17
+ * @param {ReturnType<typeof import("./handleHidden.js").createHiddenFieldReads>} dependencies.hiddenFields The hidden
18
+ * fields `options.fields` names, read after the lock.
19
+ */
20
+ function createLockReads({ name, resource, jsonRest, readFields, contextOf, hiddenFields }) {
21
+ // A locking read (json-rest-api's lockByIds, lockWhere or claim) of the fields options.fields names, as records.
22
+ async function lockRead(method, read, options) {
23
+ const hidden = hiddenFields.named(options.fields, "plain");
24
+ const fields = readFields({ fields: options.fields }, "view");
25
+ const { data } = await jsonRest()[method]({
26
+ ...read,
27
+ transaction: options.trx,
28
+ format: "plain",
29
+ ...(fields ? { queryParams: { fields: sparseFieldset(resource, fields) } } : {})
30
+ }, contextOf(options));
31
+ return hiddenFields.add(handleRecords(resource, data, { fields }), hidden, options);
32
+ }
33
+
34
+ /**
35
+ * Locks the visible rows among the ids, in id order, and answers them; hidden or missing ids throw, or are
36
+ * left out with `missing: "skip"`. MySQL locks every listed id, a hidden one too: take ids from scoped reads.
37
+ * `fields` names the fields the lock reads, so a belongs-to field left out costs no check that its related
38
+ * record is visible; without it every field is read.
39
+ * @param {unknown[]} ids
40
+ * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[] }} options
41
+ */
42
+ async function lockMany(ids, options) {
43
+ return lockRead("lockByIds", { ids, ...(options.missing ? { missing: options.missing } : {}) }, options);
44
+ }
45
+
46
+ /**
47
+ * Locks the record's row until the transaction ends and answers it as the
48
+ * lock saw it. Needs options.trx. A missing or hidden record throws
49
+ * json-rest-api's not-found error, or answers null with `missing: "skip"`.
50
+ * `fields` reads only the fields the work needs (see lockMany).
51
+ * @param {unknown} id
52
+ * @param {{ context?: object | null, trx: object, missing?: "throw" | "skip", fields?: string[] }} options
53
+ */
54
+ async function lock(id, options) {
55
+ missingOf(name, "lock", options.missing);
56
+ const [record] = await lockMany([id], options);
57
+ return record ?? null;
58
+ }
59
+
60
+ /**
61
+ * Locks the caller's rows the filters select until the transaction ends, in id
62
+ * order, and answers them; it waits for transactions holding them, so a row that
63
+ * starts to match meanwhile is locked too. At most 1,000 rows (RestApiRowLimitError
64
+ * past that). It does not lock rows inserted later: unique numbers take a unique
65
+ * index, or a counter row taken with lock(). On MySQL it needs an index starting
66
+ * with the ownership columns (no owners: with a filtered column), or it refuses.
67
+ * `fields` reads only the fields the work needs (see lockMany).
68
+ * @param {object} filters
69
+ * @param {{ context?: object | null, trx: object, fields?: string[] }} options
70
+ */
71
+ async function lockWhere(filters, options) {
72
+ requireDefinedFilters(name, "lockWhere", filters);
73
+ return lockRead("lockWhere", { filters }, options);
74
+ }
75
+
76
+ /**
77
+ * Takes up to `take` (default 1) of the rows the filters select that no
78
+ * other transaction holds, in `sort` order, locked until the transaction ends.
79
+ * `fields` reads only the fields the work needs (see lockMany).
80
+ * @param {{ filters: object, sort?: string | string[], take?: number }} params
81
+ * @param {{ context?: object | null, trx: object, fields?: string[] }} options
82
+ */
83
+ async function claim({ filters, sort, take } = {}, options, ...rest) {
84
+ if (rest.length > 0) {
85
+ throw new TypeError(`${name}.claim() takes ({ filters, sort, take }, options).`);
86
+ }
87
+ requireDefinedFilters(name, "claim", filters);
88
+ return lockRead("claim", {
89
+ filters,
90
+ ...(take === undefined ? {} : { limit: take }),
91
+ ...(sort === undefined ? {} : { sort: textList(sort) })
92
+ }, options);
93
+ }
94
+
95
+ return Object.freeze({
96
+ lock,
97
+ lockMany,
98
+ lockWhere,
99
+ claim
100
+ });
101
+ }
102
+
103
+ export { createLockReads };