@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,58 @@
|
|
|
1
|
+
import { listForeignKeys } from "@jskit-ai/database-runtime/server/foreignKeyMigrations";
|
|
2
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
3
|
+
import { createResourceHost } from "./resourceHost.js";
|
|
4
|
+
import { createResourceRuntime } from "./resourceRuntime.js";
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ tableName: string, columns: string[], referencedTableName: string, referencedColumns: string[] }} ForeignKeyColumns */
|
|
7
|
+
|
|
8
|
+
const sameNames = (left, right) => left.length === right.length && left.every((name, index) => name === right[index]);
|
|
9
|
+
const sameKey = (left, right) => left.tableName === right.tableName && left.referencedTableName === right.referencedTableName &&
|
|
10
|
+
sameNames(left.columns, right.columns) && sameNames(left.referencedColumns, right.referencedColumns);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Each resource's declared foreignKeys in database columns, its fields mapped as json-rest-api stores them.
|
|
14
|
+
* @param {import("knex").Knex} knex
|
|
15
|
+
* @param {ReadonlyArray<Record<string, any>>} resources
|
|
16
|
+
*/
|
|
17
|
+
async function declaredForeignKeys(knex, resources) {
|
|
18
|
+
const declaring = resources.filter((resource) => resource.foreignKeys);
|
|
19
|
+
if (declaring.length < 1) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const api = await createResourceHost({ knex, logger: silentLogger });
|
|
23
|
+
await createResourceRuntime({ api }).own("jskit.foreign-key-check", declaring);
|
|
24
|
+
return declaring.flatMap((resource) => {
|
|
25
|
+
const adapter = api.knex.helpers.getStorageAdapter(resource.name);
|
|
26
|
+
const column = (field) => (field === "id" ? adapter.getIdColumn() : adapter.translateColumn(field));
|
|
27
|
+
return resource.foreignKeys.map((declared) => Object.freeze({
|
|
28
|
+
resource: resource.name,
|
|
29
|
+
declared,
|
|
30
|
+
tableName: resource.tableName,
|
|
31
|
+
columns: declared.columns.map(column),
|
|
32
|
+
referencedTableName: declared.referencedTableName,
|
|
33
|
+
referencedColumns: declared.referencedColumns ?? ["id"]
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compares the table resources' declared foreignKeys with the keys the connected database holds. json-rest-api relies
|
|
40
|
+
* on a declared key to skip work, so a declared key the database lacks changes results: it is `missing`, and the
|
|
41
|
+
* check fails on it. A database key on one of `tables` that nothing declares is `undeclared`: only work json-rest-api
|
|
42
|
+
* could have skipped. It reads the database, so it runs where one already exists, as `jskit test --databases` does,
|
|
43
|
+
* never at boot.
|
|
44
|
+
* @param {object} options
|
|
45
|
+
* @param {import("knex").Knex} options.knex
|
|
46
|
+
* @param {ReadonlyArray<Record<string, any>>} options.resources Table resources; those declaring foreignKeys are checked.
|
|
47
|
+
* @param {ReadonlyArray<string>} [options.tables] The tables whose undeclared keys are reported.
|
|
48
|
+
*/
|
|
49
|
+
async function checkForeignKeys({ knex, resources, tables = [] }) {
|
|
50
|
+
const [declared, databaseKeys] = await Promise.all([declaredForeignKeys(knex, resources), listForeignKeys(knex)]);
|
|
51
|
+
const reported = new Set(tables);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
missing: declared.filter((key) => !databaseKeys.some((real) => sameKey(key, real))),
|
|
54
|
+
undeclared: databaseKeys.filter((real) => reported.has(real.tableName) && !declared.some((key) => sameKey(key, real)))
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { checkForeignKeys };
|
|
@@ -0,0 +1,233 @@
|
|
|
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, SystemContextRequiredError } 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: none at all on an
|
|
23
|
+
* owned table, and any on a `system` table, which only a system context reaches.
|
|
24
|
+
* @param {string} name The resource's name.
|
|
25
|
+
* @param {string} ownership
|
|
26
|
+
* @param {object | null | undefined} context
|
|
27
|
+
*/
|
|
28
|
+
function requireOwners(name, ownership, context) {
|
|
29
|
+
if (isSystemContext(context)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (ownership === "system") {
|
|
33
|
+
throw new SystemContextRequiredError(name);
|
|
34
|
+
}
|
|
35
|
+
if (!context && ownership !== "public") {
|
|
36
|
+
throw new MissingScopeError(name);
|
|
37
|
+
}
|
|
38
|
+
const missing = REQUIRED_OWNERS[ownership].find((owner) => !OWNER_RESOLVERS[owner](context));
|
|
39
|
+
if (missing) {
|
|
40
|
+
throw new MissingScopeError(name, missing);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Refuses a filter whose value is undefined, at any depth. json-rest-api drops such a filter, so a caller's missing
|
|
46
|
+
* value would widen the selection to every row it owns instead of none.
|
|
47
|
+
* @param {string} name
|
|
48
|
+
* @param {string} caller
|
|
49
|
+
* @param {unknown} filters
|
|
50
|
+
* @param {string} [path]
|
|
51
|
+
*/
|
|
52
|
+
function requireDefinedFilters(name, caller, filters, path = "filters") {
|
|
53
|
+
if (!isRecord(filters)) {
|
|
54
|
+
return filters;
|
|
55
|
+
}
|
|
56
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
57
|
+
if (value === undefined) {
|
|
58
|
+
throw new TypeError(`${name}.${caller}() ${path}.${key} is undefined: pass its value, or leave the filter out.`);
|
|
59
|
+
}
|
|
60
|
+
requireDefinedFilters(name, caller, value, `${path}.${key}`);
|
|
61
|
+
}
|
|
62
|
+
return filters;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A filtered write's selection: its filters, or every owned row when they are empty and `all: true` says so. An
|
|
68
|
+
* undefined filter value is refused, so a missing value never counts as a filter yet selects every row.
|
|
69
|
+
* @param {string} name The resource's name.
|
|
70
|
+
* @param {string} operation
|
|
71
|
+
* @param {unknown} filters
|
|
72
|
+
* @param {{ all?: boolean }} options
|
|
73
|
+
*/
|
|
74
|
+
function whereSelection(name, operation, filters, options) {
|
|
75
|
+
if (!isRecord(filters)) {
|
|
76
|
+
throw new TypeError(`${name}.${operation}() takes filters as an object.`);
|
|
77
|
+
}
|
|
78
|
+
if (options.missing !== undefined) {
|
|
79
|
+
throw new TypeError(`${name}.${operation}() changes the rows the filters select when it runs, so it takes no missing.`);
|
|
80
|
+
}
|
|
81
|
+
requireDefinedFilters(name, operation, filters);
|
|
82
|
+
if (Object.keys(filters).length > 0) {
|
|
83
|
+
if (options.all !== undefined) {
|
|
84
|
+
throw new TypeError(`${name}.${operation}() takes filters or all: true, not both.`);
|
|
85
|
+
}
|
|
86
|
+
return { filters };
|
|
87
|
+
}
|
|
88
|
+
if (options.all !== true) {
|
|
89
|
+
throw new TypeError(`${name}.${operation}() with empty filters changes every row the caller owns; pass all: true to mean it.`);
|
|
90
|
+
}
|
|
91
|
+
return { all: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An upsert matches rows by its conflict keys alone, so its rows carry no id, which could name another owner's
|
|
96
|
+
* record, unless the id is the one key: json-rest-api allows that only on a table with no owners and no row policy.
|
|
97
|
+
* @param {string} name
|
|
98
|
+
* @param {unknown[]} rows
|
|
99
|
+
* @param {{ keys?: unknown }} onConflict
|
|
100
|
+
* @param {string | undefined} idProperty
|
|
101
|
+
*/
|
|
102
|
+
function requireUpsertRows(name, rows, onConflict, idProperty) {
|
|
103
|
+
const keys = Array.isArray(onConflict.keys) ? onConflict.keys : [];
|
|
104
|
+
const byId = keys.length === 1 && (keys[0] === "id" || keys[0] === idProperty);
|
|
105
|
+
if (!byId && rows.some((row) => isRecord(row) && Object.hasOwn(row, "id"))) {
|
|
106
|
+
throw new TypeError(
|
|
107
|
+
`${name}.createMany() with onConflict takes rows without an id; the conflict keys find the existing rows. ` +
|
|
108
|
+
"Only onConflict: { keys: [\"id\"] }, on a table with no owners, matches rows by their id."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The keys of a call's options, which get() takes third, after its params.
|
|
114
|
+
const OPTION_KEYS = Object.freeze(["context", "trx", "format", "missing", "returning"]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* get(), patch(), delete() and lock() throw json-rest-api's not-found error for
|
|
118
|
+
* a missing or hidden row, or answer null with `missing: "skip"`.
|
|
119
|
+
* @param {string} name
|
|
120
|
+
* @param {string} caller
|
|
121
|
+
* @param {unknown} [missing]
|
|
122
|
+
* @returns {"throw" | "skip"}
|
|
123
|
+
*/
|
|
124
|
+
function missingOf(name, caller, missing = "throw") {
|
|
125
|
+
if (missing !== "throw" && missing !== "skip") {
|
|
126
|
+
throw new TypeError(`${name}.${caller}() missing is "throw" (the default) or "skip". Received: ${JSON.stringify(missing)}`);
|
|
127
|
+
}
|
|
128
|
+
return missing;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Refuses get(id, options): get takes its options third, after its params.
|
|
133
|
+
* @param {string} name
|
|
134
|
+
* @param {unknown} params
|
|
135
|
+
*/
|
|
136
|
+
function requireReadParams(name, params) {
|
|
137
|
+
if (isRecord(params) && OPTION_KEYS.some((key) => Object.hasOwn(params, key))) {
|
|
138
|
+
throw new TypeError(`${name}.get() takes (id, params, options), and options go third: get(id, {}, options).`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Refuses a write condition that the options name with no value (`{ lockVersion: undefined }`, `{ onlyIf: undefined }`,
|
|
144
|
+
* often a field read under the wrong name): run without it, the write would drop the condition it was meant to carry,
|
|
145
|
+
* and a patch with no changes would only read. A call without the condition leaves the key out.
|
|
146
|
+
* @param {string} name The resource's name.
|
|
147
|
+
* @param {string} method The handle method, such as "patch".
|
|
148
|
+
* @param {Record<string, unknown>} options
|
|
149
|
+
* @param {string[]} keys The condition keys the method takes.
|
|
150
|
+
*/
|
|
151
|
+
function requireConditionValues(name, method, options, keys) {
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
if (Object.hasOwn(options, key) && options[key] === undefined) {
|
|
154
|
+
throw new TypeError(`${name}.${method}() options.${key} is undefined. Give it a value, or leave the key out for a ${method} without it.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Refuses lockVersion or onlyIf, named at all, on a write that cannot compare them: run without them, the write would
|
|
161
|
+
* change or delete the rows whatever they hold.
|
|
162
|
+
* @param {string} name The resource's name.
|
|
163
|
+
* @param {string} method The handle method, such as "delete".
|
|
164
|
+
* @param {Record<string, unknown>} options
|
|
165
|
+
* @param {string[]} keys The conditions the method cannot compare.
|
|
166
|
+
* @param {string} instead What the caller does instead.
|
|
167
|
+
*/
|
|
168
|
+
function refuseConditions(name, method, options, keys, instead) {
|
|
169
|
+
const named = keys.filter((key) => Object.hasOwn(options, key));
|
|
170
|
+
if (named.length > 0) {
|
|
171
|
+
throw new TypeError(`${name}.${method}() cannot compare ${named.join(" or ")}, so it refuses rather than write without them. ${instead}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refuses a patch's lockVersion or onlyIf named with no value (see requireConditionValues); a lockVersion without a
|
|
177
|
+
* lockVersionField, or one that is not a version, or changes that write the version the patch advances itself; and an
|
|
178
|
+
* onlyIf that is not an object of field values.
|
|
179
|
+
* @param {string} name
|
|
180
|
+
* @param {string} lockVersionField
|
|
181
|
+
* @param {Record<string, unknown>} attributes
|
|
182
|
+
* @param {{ lockVersion?: unknown, onlyIf?: unknown }} options The patch's options.
|
|
183
|
+
*/
|
|
184
|
+
function requirePatchConditions(name, lockVersionField, attributes, options) {
|
|
185
|
+
requireConditionValues(name, "patch", options, ["lockVersion", "onlyIf"]);
|
|
186
|
+
const { lockVersion, onlyIf } = options;
|
|
187
|
+
if (lockVersion !== undefined) {
|
|
188
|
+
if (!lockVersionField) {
|
|
189
|
+
throw new TypeError(`${name}.patch() lockVersion needs the resource to name its lockVersionField.`);
|
|
190
|
+
}
|
|
191
|
+
if (!Number.isSafeInteger(lockVersion) || Number(lockVersion) < 0) {
|
|
192
|
+
throw new TypeError(`${name}.patch() lockVersion must be a non-negative integer. Received: ${lockVersion}`);
|
|
193
|
+
}
|
|
194
|
+
if (Object.hasOwn(attributes, lockVersionField)) {
|
|
195
|
+
throw new TypeError(`${name}.patch() advances ${lockVersionField} itself, so the attributes must leave it out.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (onlyIf !== undefined && !isRecord(onlyIf)) {
|
|
199
|
+
throw new TypeError(`${name}.patch() onlyIf must be an object of field values.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The answer format a handle call asks for: "jsonapi" (the CRUD service's documents) or, by default, "plain" (records).
|
|
205
|
+
* @param {string} name The resource's name.
|
|
206
|
+
* @param {{ format?: string }} options
|
|
207
|
+
*/
|
|
208
|
+
function formatOf(name, options) {
|
|
209
|
+
if (options.format !== undefined && options.format !== "jsonapi") {
|
|
210
|
+
throw new TypeError(`${name} options.format is "jsonapi" (the CRUD service's documents) or absent (records).`);
|
|
211
|
+
}
|
|
212
|
+
return options.format === "jsonapi" ? "jsonapi" : "plain";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether the filters give an empty list to a filter that matches one of a list (the id filter, or any filter whose
|
|
217
|
+
* search entry says `filterOperator: "in"`): such a read matches no row, so the handle answers it without a statement.
|
|
218
|
+
* @param {unknown} filters
|
|
219
|
+
* @param {{ searchSchemaStructure?: Record<string, { filterOperator?: string }> }} schemaInfo The table's compiled
|
|
220
|
+
* json-rest-api schema (`vars.schemaInfo`).
|
|
221
|
+
*/
|
|
222
|
+
function matchesNoRow(filters, schemaInfo) {
|
|
223
|
+
if (!isRecord(filters)) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const search = schemaInfo.searchSchemaStructure || {};
|
|
227
|
+
return Object.entries(filters).some(([key, value]) => Array.isArray(value) && value.length === 0 && search[key]?.filterOperator === "in");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export {
|
|
231
|
+
formatOf, matchesNoRow, missingOf, refuseConditions, requireConditionValues, requireDefinedFilters, requireOwners, requirePatchConditions,
|
|
232
|
+
requireReadParams, requireUpsertRows, passedOn, whereSelection
|
|
233
|
+
};
|