@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,687 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AutoFilterPlugin,
|
|
3
|
+
JsonRestApi,
|
|
4
|
+
QueryProjectionsPlugin,
|
|
5
|
+
REST_API_FIELDSET_ERROR_CODE,
|
|
6
|
+
RestApiKnexPlugin,
|
|
7
|
+
RestApiPlugin,
|
|
8
|
+
RowPolicyPlugin
|
|
9
|
+
} from "json-rest-api";
|
|
10
|
+
import {
|
|
11
|
+
normalizeObject,
|
|
12
|
+
normalizeOpaqueId,
|
|
13
|
+
normalizeRecordId,
|
|
14
|
+
normalizeText,
|
|
15
|
+
normalizeUniqueTextList
|
|
16
|
+
} from "@jskit-ai/kernel/shared/support/normalize";
|
|
17
|
+
import { resolveCrudResourceScopeName } from "@jskit-ai/resource-crud-core/shared/crudLookup";
|
|
18
|
+
import { isTableResource } from "@jskit-ai/resource-core/shared/tableResource";
|
|
19
|
+
import {
|
|
20
|
+
normalizeJsonApiFieldList,
|
|
21
|
+
normalizeJsonApiFieldsets
|
|
22
|
+
} from "@jskit-ai/kernel/shared/support/jsonApiFieldsets";
|
|
23
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
24
|
+
import { resolveRequest } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
|
|
25
|
+
import { ROUTE_VISIBILITY_WORKSPACE, ROUTE_VISIBILITY_WORKSPACE_USER } from "@jskit-ai/kernel/shared/support/visibility";
|
|
26
|
+
import { createJsonRestContext } from "./contexts.js";
|
|
27
|
+
import { isPlainJsonRestObject, resolveJsonRestCollectionRelationships } from "./jsonRestValues.js";
|
|
28
|
+
import { valueOnlyRaw } from "./rawExpression.js";
|
|
29
|
+
|
|
30
|
+
const JSON_REST_AUTOFILTER_PRESETS = Object.freeze({
|
|
31
|
+
public: Object.freeze([]),
|
|
32
|
+
workspace: Object.freeze([
|
|
33
|
+
Object.freeze({
|
|
34
|
+
field: "workspaceId",
|
|
35
|
+
resolver: "workspace"
|
|
36
|
+
})
|
|
37
|
+
]),
|
|
38
|
+
user: Object.freeze([
|
|
39
|
+
Object.freeze({
|
|
40
|
+
field: "userId",
|
|
41
|
+
resolver: "user"
|
|
42
|
+
})
|
|
43
|
+
]),
|
|
44
|
+
workspace_user: Object.freeze([
|
|
45
|
+
Object.freeze({
|
|
46
|
+
field: "workspaceId",
|
|
47
|
+
resolver: "workspace"
|
|
48
|
+
}),
|
|
49
|
+
Object.freeze({
|
|
50
|
+
field: "userId",
|
|
51
|
+
resolver: "user"
|
|
52
|
+
})
|
|
53
|
+
]),
|
|
54
|
+
// A workspace's own rows, and the rows whose workspaceId is null, which every workspace reads. A context with no
|
|
55
|
+
// workspace reads only the shared rows, so this preset's resolver answers null where the others answer nothing.
|
|
56
|
+
workspace_or_shared: Object.freeze([
|
|
57
|
+
Object.freeze({
|
|
58
|
+
field: "workspaceId",
|
|
59
|
+
resolver: "workspaceOrShared",
|
|
60
|
+
shared: true
|
|
61
|
+
})
|
|
62
|
+
])
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const JSON_REST_RESERVED_QUERY_KEYS = Object.freeze(new Set([
|
|
66
|
+
"cursor",
|
|
67
|
+
"limit",
|
|
68
|
+
"include",
|
|
69
|
+
"sort",
|
|
70
|
+
"fields"
|
|
71
|
+
]));
|
|
72
|
+
|
|
73
|
+
function cloneJsonRestResourceValue(value, { writeSerializers = {} } = {}) {
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
return value.map((entry) => cloneJsonRestResourceValue(entry, { writeSerializers }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!isPlainJsonRestObject(value)) {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const next = {};
|
|
83
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
84
|
+
next[key] = cloneJsonRestResourceValue(entry, { writeSerializers });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isPlainJsonRestObject(next.storage)) {
|
|
88
|
+
const serializerKey = normalizeText(next.storage.writeSerializer).toLowerCase();
|
|
89
|
+
if (next.storage.virtual === true) {
|
|
90
|
+
next.virtual = true;
|
|
91
|
+
}
|
|
92
|
+
if (serializerKey) {
|
|
93
|
+
const serializer = writeSerializers[serializerKey];
|
|
94
|
+
if (typeof serializer !== "function") {
|
|
95
|
+
throw new Error(`Unsupported json-rest-api write serializer: ${JSON.stringify(serializerKey)}.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
next.storage = {
|
|
99
|
+
...next.storage,
|
|
100
|
+
serialize: serializer
|
|
101
|
+
};
|
|
102
|
+
delete next.storage.writeSerializer;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
isPlainJsonRestObject(next.relation) &&
|
|
107
|
+
normalizeText(next.relation.kind).toLowerCase() === "collection"
|
|
108
|
+
) {
|
|
109
|
+
next.virtual = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeScopeValue(value) {
|
|
116
|
+
if (value == null) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
const normalized = value.trim();
|
|
122
|
+
return normalized || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof value === "number") {
|
|
126
|
+
return Number.isFinite(value) ? String(value) : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof value === "bigint") {
|
|
130
|
+
return String(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeJsonRestFilterValue(value) {
|
|
137
|
+
if (value == null) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(value)) {
|
|
142
|
+
const entries = value
|
|
143
|
+
.map((entry) => normalizeJsonRestFilterValue(entry))
|
|
144
|
+
.filter((entry) => entry !== undefined);
|
|
145
|
+
return entries.length > 0 ? entries : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isPlainJsonRestObject(value)) {
|
|
149
|
+
const entries = Object.entries(value)
|
|
150
|
+
.map(([key, entry]) => [normalizeText(key), normalizeJsonRestFilterValue(entry)])
|
|
151
|
+
.filter(([key, entry]) => key && entry !== undefined);
|
|
152
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (typeof value === "string") {
|
|
156
|
+
const normalized = normalizeText(value);
|
|
157
|
+
return normalized || undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof value === "number") {
|
|
161
|
+
return Number.isFinite(value) ? value : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (typeof value === "bigint") {
|
|
165
|
+
return String(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (typeof value === "boolean") {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeJsonRestList(value) {
|
|
176
|
+
if (Array.isArray(value)) {
|
|
177
|
+
return value
|
|
178
|
+
.map((entry) => normalizeText(entry))
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const normalized = normalizeText(value);
|
|
183
|
+
if (!normalized) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return normalized
|
|
188
|
+
.split(",")
|
|
189
|
+
.map((entry) => normalizeText(entry))
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeJsonRestQueryField(fieldName = "", fieldDefinition = {}, projectionDefinition = null) {
|
|
194
|
+
const normalizedFieldName = normalizeText(fieldName);
|
|
195
|
+
const sourceProjection = isPlainJsonRestObject(projectionDefinition)
|
|
196
|
+
? projectionDefinition
|
|
197
|
+
: isPlainJsonRestObject(fieldDefinition.storage?.queryProjection)
|
|
198
|
+
? fieldDefinition.storage.queryProjection
|
|
199
|
+
: null;
|
|
200
|
+
if (!normalizedFieldName || !sourceProjection) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const sourceFieldDefinition = isPlainJsonRestObject(fieldDefinition) ? fieldDefinition : {};
|
|
205
|
+
// readable-check: allow method-probe - data: a query projection in a resource names its select() or project()
|
|
206
|
+
const select = typeof sourceProjection.select === "function"
|
|
207
|
+
? sourceProjection.select
|
|
208
|
+
// readable-check: allow method-probe - data: a query projection in a resource names its select() or project()
|
|
209
|
+
: typeof sourceProjection.project === "function"
|
|
210
|
+
? sourceProjection.project
|
|
211
|
+
: null;
|
|
212
|
+
const type = normalizeText(sourceProjection.type || sourceFieldDefinition.type);
|
|
213
|
+
const { project: _project, ...projection } = sourceProjection;
|
|
214
|
+
return {
|
|
215
|
+
...projection,
|
|
216
|
+
...(type ? { type } : {}),
|
|
217
|
+
...(select ? { select: queryFieldSelect(normalizedFieldName, select) } : {})
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A query field's select() as json-rest-api calls it. The owner's select receives `raw`, which builds one SQL expression
|
|
223
|
+
* as a value only (a string with its bindings; awaiting it throws), `column(field)`, which names a field's column, and
|
|
224
|
+
* the read's `filters`, never json-rest-api's Knex, transaction or context, so its SQL can only be the field's value.
|
|
225
|
+
* @param {string} fieldName
|
|
226
|
+
* @param {(tools: { raw: Function, column: (field: string) => string, filters: Readonly<Record<string, unknown>> }) => unknown} select
|
|
227
|
+
*/
|
|
228
|
+
function queryFieldSelect(fieldName, select) {
|
|
229
|
+
return ({ db, column, context }) => select(Object.freeze({
|
|
230
|
+
raw: (sql, bindings, ...rest) => valueOnlyRaw(`Query field "${fieldName}" raw()`, db, [sql, bindings, ...rest]),
|
|
231
|
+
column,
|
|
232
|
+
filters: Object.freeze({ ...normalizeObject(context?.queryParams?.filters) })
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isJsonRestVirtualField(fieldDefinition = null) {
|
|
237
|
+
return fieldDefinition?.virtual === true ||
|
|
238
|
+
fieldDefinition?.storage?.virtual === true ||
|
|
239
|
+
Boolean(fieldDefinition?.storage?.queryProjection);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function applyJsonRestQueryFields(scopeOptions = {}, extraQueryFields = {}) {
|
|
243
|
+
const schema = normalizeObject(scopeOptions.schema);
|
|
244
|
+
const queryFields = {
|
|
245
|
+
...normalizeObject(scopeOptions.queryFields)
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
for (const [fieldName, fieldDefinition] of Object.entries(schema)) {
|
|
249
|
+
const queryField = normalizeJsonRestQueryField(fieldName, fieldDefinition);
|
|
250
|
+
if (!queryField) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
queryFields[fieldName] = queryField;
|
|
255
|
+
delete schema[fieldName];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const key of Object.keys(queryFields)) {
|
|
259
|
+
const schemaFieldDefinition = schema[key];
|
|
260
|
+
if (!schemaFieldDefinition) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!isJsonRestVirtualField(schemaFieldDefinition)) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`json-rest-api query field "${key}" conflicts with a column-backed schema field.`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
delete schema[key];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const [fieldName, projectionDefinition] of Object.entries(normalizeObject(extraQueryFields))) {
|
|
272
|
+
const key = normalizeText(fieldName);
|
|
273
|
+
if (!key) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const schemaFieldDefinition = schema[key];
|
|
278
|
+
if (schemaFieldDefinition && !isJsonRestVirtualField(schemaFieldDefinition)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`json-rest-api query field "${key}" conflicts with a column-backed schema field.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const queryField = normalizeJsonRestQueryField(key, schemaFieldDefinition, projectionDefinition);
|
|
285
|
+
if (!queryField) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
queryFields[key] = queryField;
|
|
290
|
+
if (schemaFieldDefinition) {
|
|
291
|
+
delete schema[key];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (Object.keys(queryFields).length > 0) {
|
|
296
|
+
scopeOptions.queryFields = queryFields;
|
|
297
|
+
} else {
|
|
298
|
+
delete scopeOptions.queryFields;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveJsonRestDefaultExcludedFields(resource) {
|
|
303
|
+
const defaultExclude = resource.defaultExclude;
|
|
304
|
+
if (defaultExclude == null) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
if (!Array.isArray(defaultExclude)) {
|
|
308
|
+
throw new TypeError("json-rest-api resource defaultExclude must be an array.");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return normalizeUniqueTextList(defaultExclude);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function applyJsonRestDefaultExclusions(scopeOptions = {}, resource = {}) {
|
|
315
|
+
const excludedFields = resolveJsonRestDefaultExcludedFields(resource);
|
|
316
|
+
if (excludedFields.length < 1) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const schema = normalizeObject(scopeOptions.schema);
|
|
321
|
+
const queryFields = normalizeObject(scopeOptions.queryFields);
|
|
322
|
+
const idProperty = normalizeText(scopeOptions.idProperty, {
|
|
323
|
+
fallback: "id"
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
for (const field of excludedFields) {
|
|
327
|
+
if (field === "id" || field === idProperty) {
|
|
328
|
+
throw new TypeError(
|
|
329
|
+
`json-rest-api resource defaultExclude cannot exclude identifier field "${field}".`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const definitions = Object.hasOwn(schema, field) ? schema : queryFields;
|
|
334
|
+
if (!Object.hasOwn(definitions, field)) {
|
|
335
|
+
throw new TypeError(
|
|
336
|
+
`json-rest-api resource defaultExclude references unknown field "${field}".`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
definitions[field] = {
|
|
341
|
+
...normalizeObject(definitions[field]),
|
|
342
|
+
normallyHidden: true
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function buildJsonRestQueryParams(resourceType = "", query = {}, { include = undefined } = {}) {
|
|
348
|
+
const normalizedResourceType = normalizeText(resourceType);
|
|
349
|
+
const source = normalizeObject(query);
|
|
350
|
+
const filters = {};
|
|
351
|
+
|
|
352
|
+
for (const [rawKey, rawValue] of Object.entries(source)) {
|
|
353
|
+
const key = normalizeText(rawKey);
|
|
354
|
+
if (!key || JSON_REST_RESERVED_QUERY_KEYS.has(key)) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const normalizedValue = normalizeJsonRestFilterValue(rawValue);
|
|
359
|
+
if (normalizedValue === undefined) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
filters[key] = normalizedValue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const queryParams = {};
|
|
367
|
+
|
|
368
|
+
if (Object.keys(filters).length > 0) {
|
|
369
|
+
queryParams.filters = filters;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const includeValues = normalizeJsonRestList(include === undefined ? source.include : include);
|
|
373
|
+
if (includeValues.length > 0) {
|
|
374
|
+
queryParams.include = includeValues;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const sortValues = normalizeJsonRestList(source.sort);
|
|
378
|
+
if (sortValues.length > 0) {
|
|
379
|
+
queryParams.sort = sortValues;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const cursor = normalizeText(source.cursor);
|
|
383
|
+
const limitText = normalizeText(source.limit);
|
|
384
|
+
if (cursor || limitText) {
|
|
385
|
+
queryParams.page = {
|
|
386
|
+
...(cursor ? { after: cursor } : {}),
|
|
387
|
+
...(limitText ? { size: limitText } : {})
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const fieldsets = normalizeJsonApiFieldsets(source.fields, {
|
|
392
|
+
primaryType: normalizedResourceType
|
|
393
|
+
});
|
|
394
|
+
if (Object.keys(fieldsets).length > 0) {
|
|
395
|
+
const jsonRestFieldsets = new Map();
|
|
396
|
+
for (const [type, fields] of Object.entries(fieldsets)) {
|
|
397
|
+
const scopeName = resolveCrudResourceScopeName(type);
|
|
398
|
+
if (!scopeName) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
jsonRestFieldsets.set(scopeName, [
|
|
402
|
+
...(jsonRestFieldsets.get(scopeName) || []),
|
|
403
|
+
...fields
|
|
404
|
+
]);
|
|
405
|
+
}
|
|
406
|
+
queryParams.fields = Object.fromEntries(
|
|
407
|
+
[...jsonRestFieldsets.entries()].map(([scopeName, fields]) => [
|
|
408
|
+
scopeName,
|
|
409
|
+
normalizeJsonApiFieldList(fields).join(",")
|
|
410
|
+
])
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return queryParams;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// `filters: { id }`: one id, or a list of them.
|
|
418
|
+
const ID_FILTER = Object.freeze({ type: "array", actualField: "id", filterOperator: "in" });
|
|
419
|
+
|
|
420
|
+
// Primary key types whose ids are text: json-rest-api's default normalizer takes numeric ids only, so such a table
|
|
421
|
+
// takes each id as written ("Resource document id is invalid" otherwise).
|
|
422
|
+
const TEXT_ID_TYPES = new Set(["uuid", "string"]);
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The schema field that is the table's primary key: the one marked `primary`, else the id property's.
|
|
426
|
+
* @param {{ schema?: Record<string, any>, idProperty?: string }} scopeOptions
|
|
427
|
+
*/
|
|
428
|
+
function primaryField(scopeOptions) {
|
|
429
|
+
const schema = normalizeObject(scopeOptions.schema);
|
|
430
|
+
return Object.values(schema).find((definition) => definition?.primary === true) ?? schema[scopeOptions.idProperty || "id"];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* json-rest-api's options for a table resource: its own keys, with `ownership`
|
|
435
|
+
* as json-rest-api's autofilter preset (none for a public or a system table,
|
|
436
|
+
* which have no owner columns),
|
|
437
|
+
* `defaultExclude` as normally hidden fields, symbolic write serializers
|
|
438
|
+
* resolved and collection relations derived. The owner's server-only extras
|
|
439
|
+
* (row policy, id normalizer, query fields) are added here, never in the
|
|
440
|
+
* shared definition; a table whose primary key is a uuid or a string takes its
|
|
441
|
+
* ids as text unless the owner gives its own `normalizeId`.
|
|
442
|
+
* @param {Record<string, any>} resource A table resource from defineTableResource.
|
|
443
|
+
* @param {{ writeSerializers?: Record<string, Function>, normalizeId?: Function, rowPolicy?: Function, searchSchema?: Record<string, any>, queryFields?: Record<string, any> }} [extras]
|
|
444
|
+
*/
|
|
445
|
+
function resourceScopeOptions(
|
|
446
|
+
resource,
|
|
447
|
+
{
|
|
448
|
+
writeSerializers = {},
|
|
449
|
+
normalizeId,
|
|
450
|
+
rowPolicy,
|
|
451
|
+
searchSchema,
|
|
452
|
+
queryFields
|
|
453
|
+
} = {}
|
|
454
|
+
) {
|
|
455
|
+
if (!isTableResource(resource)) {
|
|
456
|
+
throw new TypeError("A resource the runtime registers must be a table resource from defineTableResource.");
|
|
457
|
+
}
|
|
458
|
+
const {
|
|
459
|
+
name: _name,
|
|
460
|
+
ownership,
|
|
461
|
+
defaultExclude: _defaultExclude,
|
|
462
|
+
...tableOptions
|
|
463
|
+
} = resource;
|
|
464
|
+
const scopeOptions = cloneJsonRestResourceValue(tableOptions, {
|
|
465
|
+
writeSerializers: normalizeObject(writeSerializers)
|
|
466
|
+
});
|
|
467
|
+
if (ownership !== "public" && ownership !== "system") {
|
|
468
|
+
scopeOptions.autofilter = ownership;
|
|
469
|
+
}
|
|
470
|
+
if (isPlainJsonRestObject(searchSchema)) {
|
|
471
|
+
scopeOptions.searchSchema = {
|
|
472
|
+
...normalizeObject(scopeOptions.searchSchema),
|
|
473
|
+
...searchSchema
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
// Every filtered call selects records by id: `filters: { id }` takes one id or a list. The handle owns that filter,
|
|
477
|
+
// so a resource's own scalar id entry cannot narrow it to one id.
|
|
478
|
+
if (Object.hasOwn(normalizeObject(scopeOptions.searchSchema), "id") || scopeOptions.schema?.id?.search) {
|
|
479
|
+
throw new TypeError(
|
|
480
|
+
`Table resource "${resource.name}" declares its own id filter: every handle filters by id, one id or a list ` +
|
|
481
|
+
"(filters: { id }), so remove its searchSchema id entry and any search: true on its id field."
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
scopeOptions.searchSchema = { ...normalizeObject(scopeOptions.searchSchema), id: ID_FILTER };
|
|
485
|
+
applyJsonRestQueryFields(scopeOptions, queryFields);
|
|
486
|
+
applyJsonRestDefaultExclusions(scopeOptions, resource);
|
|
487
|
+
const collectionRelationships = resolveJsonRestCollectionRelationships(scopeOptions);
|
|
488
|
+
if (Object.keys(collectionRelationships).length > 0) {
|
|
489
|
+
if (
|
|
490
|
+
scopeOptions.relationships !== undefined &&
|
|
491
|
+
scopeOptions.relationships !== null &&
|
|
492
|
+
!isPlainJsonRestObject(scopeOptions.relationships)
|
|
493
|
+
) {
|
|
494
|
+
throw new TypeError(
|
|
495
|
+
"json-rest-api resource relationships must be an object when collection relations are derived."
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
scopeOptions.relationships = {
|
|
500
|
+
...collectionRelationships,
|
|
501
|
+
...normalizeObject(scopeOptions.relationships)
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (normalizeId !== undefined) {
|
|
506
|
+
scopeOptions.normalizeId = normalizeId;
|
|
507
|
+
} else if (TEXT_ID_TYPES.has(primaryField(scopeOptions)?.type)) {
|
|
508
|
+
scopeOptions.normalizeId = normalizeOpaqueId;
|
|
509
|
+
}
|
|
510
|
+
if (rowPolicy !== undefined) {
|
|
511
|
+
scopeOptions.rowPolicy = rowPolicy;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return scopeOptions;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const WORKSPACE_SCOPE_KINDS = new Set([ROUTE_VISIBILITY_WORKSPACE, ROUTE_VISIBILITY_WORKSPACE_USER]);
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The visibility scope's owner when the scope is a workspace's. Its kind is
|
|
521
|
+
* the scopeKind, else the visibility. Workspaces' visibility resolver sets a
|
|
522
|
+
* workspace kind, but an application's resolver may add another kind, whose
|
|
523
|
+
* owner is not a workspace id. A hand-built context naming only the owner,
|
|
524
|
+
* with no kind, is a workspace's.
|
|
525
|
+
* @param {{ scopeKind?: string | null, visibility?: string, scopeOwnerId?: unknown } | null} [visibilityContext]
|
|
526
|
+
*/
|
|
527
|
+
function workspaceScopeOwnerId(visibilityContext) {
|
|
528
|
+
// Outside a route, a context carries no visibility.
|
|
529
|
+
if (!visibilityContext) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
const kind = normalizeText(visibilityContext.scopeKind || visibilityContext.visibility).toLowerCase();
|
|
533
|
+
return !kind || WORKSPACE_SCOPE_KINDS.has(kind) ? visibilityContext.scopeOwnerId : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The id of the workspace a context acts on; null when it names none. It reads, in this order:
|
|
538
|
+
* 1. the context's workspace (`context.workspace.id`);
|
|
539
|
+
* 2. the workspace the request resolved (`context.requestMeta.resolvedWorkspaceContext.workspace.id`);
|
|
540
|
+
* 3. the request's workspace (`context.requestMeta.request.workspace.id`);
|
|
541
|
+
* 4. the owner of a workspace visibility scope (a `user` or other scope's owner is not a workspace);
|
|
542
|
+
* 5. the `workspaceId` scope value (`context.scopeValues.workspaceId`).
|
|
543
|
+
* One execution acts on one workspace, so the sources it finds must agree: two different ids throw.
|
|
544
|
+
* @param {object | null} [context]
|
|
545
|
+
* @returns {string | null}
|
|
546
|
+
*/
|
|
547
|
+
function resolveWorkspaceScopeValue(context = null) {
|
|
548
|
+
const workspaceIds = [
|
|
549
|
+
context?.workspace?.id,
|
|
550
|
+
context?.requestMeta?.resolvedWorkspaceContext?.workspace?.id,
|
|
551
|
+
resolveRequest(context)?.workspace?.id,
|
|
552
|
+
workspaceScopeOwnerId(context?.visibilityContext),
|
|
553
|
+
context?.scopeValues?.workspaceId
|
|
554
|
+
].map(normalizeScopeValue).filter(Boolean);
|
|
555
|
+
const [workspaceId = null] = workspaceIds;
|
|
556
|
+
const otherWorkspaceId = workspaceIds.find((id) => id !== workspaceId);
|
|
557
|
+
if (otherWorkspaceId) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`safety invariant: a context names two workspaces (${workspaceId}, ${otherWorkspaceId}); one execution acts on one workspace.`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
return workspaceId;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function resolveUserScopeValue(context = null) {
|
|
566
|
+
const scopedUserId = normalizeScopeValue(context?.scopeValues?.userId);
|
|
567
|
+
const visibleUserId = normalizeScopeValue(context?.visibilityContext?.userId);
|
|
568
|
+
if (scopedUserId && visibleUserId && scopedUserId !== visibleUserId) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
`safety invariant: a context names two users (${scopedUserId}, ${visibleUserId}); one execution acts for one user.`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return scopedUserId || visibleUserId;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function isJsonRestResourceMissingError(error = null) {
|
|
577
|
+
return normalizeText(error?.code) === "REST_API_RESOURCE" &&
|
|
578
|
+
normalizeText(error?.subtype) === "not_found";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function returnNullWhenJsonRestResourceMissing(run) {
|
|
582
|
+
// readable-check: allow method-probe - input validation: the caller hands the operation to guard
|
|
583
|
+
if (typeof run !== "function") {
|
|
584
|
+
throw new TypeError("returnNullWhenJsonRestResourceMissing requires run function.");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
return await run();
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (isJsonRestResourceMissingError(error) &&
|
|
591
|
+
(!error.transactionOutcome || ["none", "rolledBack"].includes(error.transactionOutcome))) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function isJsonRestSparseFieldError(error = null) {
|
|
600
|
+
return normalizeText(error?.code) === REST_API_FIELDSET_ERROR_CODE;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function returnBadRequestWhenJsonRestFieldsetInvalid(run) {
|
|
604
|
+
// readable-check: allow method-probe - input validation: the caller hands the operation to guard
|
|
605
|
+
if (typeof run !== "function") {
|
|
606
|
+
throw new TypeError("returnBadRequestWhenJsonRestFieldsetInvalid requires run function.");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
return await run();
|
|
611
|
+
} catch (error) {
|
|
612
|
+
if (!isJsonRestSparseFieldError(error)) {
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const mapped = new AppError(400, error.message, {
|
|
617
|
+
code: "JSON_API_FIELDSET_INVALID"
|
|
618
|
+
});
|
|
619
|
+
Object.defineProperty(mapped, "cause", { value: error, configurable: true });
|
|
620
|
+
if (error.transactionOutcome) {
|
|
621
|
+
mapped.transactionOutcome = error.transactionOutcome;
|
|
622
|
+
}
|
|
623
|
+
throw mapped;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The json-rest-api instance behind the resource runtime: its REST, query
|
|
629
|
+
* projection, Knex, row policy and autofilter plugins, with JSKIT's ownership
|
|
630
|
+
* presets and the resolvers that read a context's workspace and user.
|
|
631
|
+
* @param {object} dependencies
|
|
632
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} dependencies.knex
|
|
633
|
+
* @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} dependencies.logger Receives json-rest-api's log calls.
|
|
634
|
+
*/
|
|
635
|
+
async function createResourceHost({ knex, logger }) {
|
|
636
|
+
// readable-check: allow method-probe - input validation: the application passes its own Knex instance
|
|
637
|
+
if (typeof knex !== "function" || typeof knex.transaction !== "function") {
|
|
638
|
+
throw new TypeError("createResourceHost requires the application's Knex instance.");
|
|
639
|
+
}
|
|
640
|
+
// readable-check: allow method-probe - input validation: the caller passes the application's logger
|
|
641
|
+
if (!logger || ["debug", "info", "warn", "error"].some((level) => typeof logger[level] !== "function")) {
|
|
642
|
+
throw new TypeError("createResourceHost requires a logger with debug, info, warn and error.");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** @type {import("json-rest-api").JsonRestApi<Record<string, import("./types.js").JsonRestResource>>} */
|
|
646
|
+
const api = new JsonRestApi({
|
|
647
|
+
name: "jskit-internal-json-rest-api",
|
|
648
|
+
logger
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// A numbered page is one statement: json-rest-api counts no rows for it.
|
|
652
|
+
await api.use(RestApiPlugin, {
|
|
653
|
+
format: "plain",
|
|
654
|
+
returning: "full",
|
|
655
|
+
normalizeId: normalizeRecordId,
|
|
656
|
+
enablePaginationCounts: false
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
await api.use(QueryProjectionsPlugin);
|
|
660
|
+
await api.use(RestApiKnexPlugin, { knex });
|
|
661
|
+
await api.use(RowPolicyPlugin);
|
|
662
|
+
// A workspace or user owner the context does not name resolves to nothing (undefined), which json-rest-api refuses
|
|
663
|
+
// with REST_API_AUTOFILTER_CONTEXT wherever the table is read: an include, a link's visibility check or a link
|
|
664
|
+
// write as well as the primary read. Null would read the rows whose owner is NULL instead.
|
|
665
|
+
await api.use(AutoFilterPlugin, {
|
|
666
|
+
resolvers: {
|
|
667
|
+
workspace: ({ context }) => resolveWorkspaceScopeValue(context) ?? undefined,
|
|
668
|
+
user: ({ context }) => resolveUserScopeValue(context) ?? undefined,
|
|
669
|
+
workspaceOrShared: ({ context }) => resolveWorkspaceScopeValue(context)
|
|
670
|
+
},
|
|
671
|
+
presets: JSON_REST_AUTOFILTER_PRESETS
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
return api;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export {
|
|
678
|
+
JSON_REST_AUTOFILTER_PRESETS,
|
|
679
|
+
buildJsonRestQueryParams,
|
|
680
|
+
createJsonRestContext,
|
|
681
|
+
createResourceHost,
|
|
682
|
+
resourceScopeOptions,
|
|
683
|
+
returnNullWhenJsonRestResourceMissing,
|
|
684
|
+
returnBadRequestWhenJsonRestFieldsetInvalid,
|
|
685
|
+
resolveWorkspaceScopeValue,
|
|
686
|
+
resolveUserScopeValue
|
|
687
|
+
};
|