@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/locales/en.json +16 -0
  3. package/package.json +68 -4
  4. package/src/server/ResourceRuntimeProvider.js +30 -0
  5. package/src/server/changeHorizon.js +26 -0
  6. package/src/server/contexts.js +192 -0
  7. package/src/server/errors.js +137 -0
  8. package/src/server/foreignKeyCheck.js +58 -0
  9. package/src/server/handleGuards.js +233 -0
  10. package/src/server/handleHidden.js +410 -0
  11. package/src/server/handleLocks.js +135 -0
  12. package/src/server/handleRecords.js +474 -0
  13. package/src/server/index.js +18 -0
  14. package/src/server/jsonRestValues.js +49 -0
  15. package/src/server/lockOrCreate.js +92 -0
  16. package/src/server/rawExpression.js +50 -0
  17. package/src/server/resourceHandle.js +737 -0
  18. package/src/server/resourceHost.js +687 -0
  19. package/src/server/resourceRuntime.js +300 -0
  20. package/src/server/standaloneResources.js +49 -0
  21. package/src/server/types.js +49 -0
  22. package/src/test/expectFlatQueries.js +117 -0
  23. package/src/test/expectScoped.js +111 -0
  24. package/src/test/index.js +123 -0
  25. package/test/callSettings.test.js +100 -0
  26. package/test/changes.postgres.test.js +141 -0
  27. package/test/entrypoints.boundary.test.js +895 -0
  28. package/test/foreignKeys.test.js +94 -0
  29. package/test/hiddenFields.test.js +244 -0
  30. package/test/i18nCatalog.test.js +18 -0
  31. package/test/locks.postgres.test.js +345 -0
  32. package/test/lostInsertRace.test.js +65 -0
  33. package/test/manyRowWrites.mysql.test.js +154 -0
  34. package/test/queryFields.test.js +69 -0
  35. package/test/requestCallOptions.test.js +155 -0
  36. package/test/resourceRuntime.test.js +2098 -0
  37. package/test/storedIntegers.mysql.test.js +4 -0
  38. package/test/storedIntegers.postgres.test.js +5 -0
  39. package/test/support/storedIntegerCases.js +152 -0
  40. package/test/testHelpers.test.js +290 -0
  41. package/test/workspaceScopeValue.test.js +49 -0
  42. package/test/writeValues.mysql.test.js +111 -0
  43. 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
+ };