@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,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 };
|