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