@jskit-ai/crud-core 0.1.63 → 0.1.64
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/package.descriptor.mjs +4 -2
- package/package.json +18 -10
- package/src/server/crudModuleConfig.js +25 -5
- package/src/server/fieldAccess.js +9 -39
- package/src/server/listFilters.js +384 -389
- package/src/server/listQueryValidators.js +39 -77
- package/src/server/lookupHydration.js +4 -1
- package/src/server/repositorySupport.js +71 -121
- package/src/server/resourceRuntime/index.js +49 -74
- package/src/server/resourceRuntime/lookupHydration.js +4 -1
- package/src/server/routeContracts.js +74 -0
- package/src/server/serviceEvents.js +75 -4
- package/src/shared/crudFieldSupport.js +54 -0
- package/src/shared/crudNamespaceSupport.js +1 -27
- package/src/shared/crudResource.js +1 -0
- package/test/createCrudServiceFromResource.test.js +30 -28
- package/test/{crudFieldMetaSupport.test.js → crudFieldSupport.test.js} +1 -1
- package/test/crudModuleConfig.test.js +33 -0
- package/test/crudResource.test.js +97 -0
- package/test/listFilters.test.js +221 -59
- package/test/listQueryValidators.test.js +131 -97
- package/test/repositorySupport.test.js +241 -241
- package/test/resourceRuntime.test.js +204 -248
- package/test/routeContracts.test.js +146 -0
- package/test/serviceEvents.test.js +41 -1
- package/test/serviceMethods.test.js +12 -10
- package/src/shared/crudFieldMetaSupport.js +0 -153
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
|
|
7
7
|
import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
8
8
|
import { isRecord, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
9
|
-
import { Check, Errors } from "typebox/value";
|
|
10
9
|
import {
|
|
11
10
|
DEFAULT_LIST_LIMIT,
|
|
12
11
|
MAX_LIST_LIMIT,
|
|
@@ -20,11 +19,15 @@ import {
|
|
|
20
19
|
resolveColumnName,
|
|
21
20
|
resolveCrudIdColumn
|
|
22
21
|
} from "../repositorySupport.js";
|
|
22
|
+
import {
|
|
23
|
+
resolveStructuredSchemaTransportSchema,
|
|
24
|
+
validateSchemaPayload
|
|
25
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
23
26
|
import {
|
|
24
27
|
createCrudLookupRuntime,
|
|
25
28
|
hydrateCrudLookupRecords
|
|
26
29
|
} from "./lookupHydration.js";
|
|
27
|
-
import {
|
|
30
|
+
import { CRUD_FIELD_STORAGE_COLUMN } from "@jskit-ai/kernel/shared/support/crudFieldContract";
|
|
28
31
|
|
|
29
32
|
const LIST_ORDER_DIRECTION_ASC = "asc";
|
|
30
33
|
const LIST_ORDER_DIRECTION_DESC = "desc";
|
|
@@ -77,11 +80,11 @@ function resolveRepositoryDefaults(resource = {}, repositoryMapping = {}, { cont
|
|
|
77
80
|
|
|
78
81
|
const idColumn = normalizeText(resource.idColumn) || resolveColumnName("id", repositoryMapping.columnOverrides) || "id";
|
|
79
82
|
const createdAtColumn = repositoryMapping.outputKeys.includes("createdAt") &&
|
|
80
|
-
repositoryMapping.fieldStorageByKey?.createdAt ===
|
|
83
|
+
repositoryMapping.fieldStorageByKey?.createdAt === CRUD_FIELD_STORAGE_COLUMN
|
|
81
84
|
? resolveColumnName("createdAt", repositoryMapping.columnOverrides)
|
|
82
85
|
: "";
|
|
83
86
|
const updatedAtColumn = repositoryMapping.outputKeys.includes("updatedAt") &&
|
|
84
|
-
repositoryMapping.fieldStorageByKey?.updatedAt ===
|
|
87
|
+
repositoryMapping.fieldStorageByKey?.updatedAt === CRUD_FIELD_STORAGE_COLUMN
|
|
85
88
|
? resolveColumnName("updatedAt", repositoryMapping.columnOverrides)
|
|
86
89
|
: "";
|
|
87
90
|
|
|
@@ -112,7 +115,7 @@ function normalizeCrudVirtualFieldHandlers(
|
|
|
112
115
|
}
|
|
113
116
|
if (Object.keys(virtualFields).length > 0) {
|
|
114
117
|
throw new Error(
|
|
115
|
-
`${context} virtualFields contains registrations, but the resource does not declare any
|
|
118
|
+
`${context} virtualFields contains registrations, but the resource does not declare any storage.virtual fields.`
|
|
116
119
|
);
|
|
117
120
|
}
|
|
118
121
|
return Object.freeze([]);
|
|
@@ -131,7 +134,7 @@ function normalizeCrudVirtualFieldHandlers(
|
|
|
131
134
|
}
|
|
132
135
|
if (!expectedKeys.has(key)) {
|
|
133
136
|
throw new Error(
|
|
134
|
-
`${context} virtualFields["${key}"] is unknown; declare the field in resource
|
|
137
|
+
`${context} virtualFields["${key}"] is unknown; declare the field in the resource schema with storage.virtual true.`
|
|
135
138
|
);
|
|
136
139
|
}
|
|
137
140
|
if (!handlerConfig || typeof handlerConfig !== "object" || Array.isArray(handlerConfig)) {
|
|
@@ -289,48 +292,32 @@ function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = [], { idCol
|
|
|
289
292
|
});
|
|
290
293
|
}
|
|
291
294
|
|
|
292
|
-
function formatOutputValidationError(issue = {}) {
|
|
293
|
-
const path = Array.isArray(issue.path) ? issue.path.join(".") : "";
|
|
294
|
-
const value = issue.value;
|
|
295
|
-
const message = normalizeText(issue.message) || "Invalid value";
|
|
296
|
-
if (path) {
|
|
297
|
-
return `${path}: ${message}`;
|
|
298
|
-
}
|
|
299
|
-
if (value !== undefined) {
|
|
300
|
-
return `${message} (${JSON.stringify(value)})`;
|
|
301
|
-
}
|
|
302
|
-
return message;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
295
|
function resolveRecordOutputValidator(resource = {}, { context = "crudRepository" } = {}) {
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
308
|
-
throw new TypeError(`${context} requires operations.view.
|
|
296
|
+
const output = resource?.operations?.view?.output;
|
|
297
|
+
if (!output || typeof output !== "object" || Array.isArray(output)) {
|
|
298
|
+
throw new TypeError(`${context} requires operations.view.output.`);
|
|
309
299
|
}
|
|
310
|
-
|
|
311
|
-
|
|
300
|
+
const schema = resolveStructuredSchemaTransportSchema(output, {
|
|
301
|
+
context: `${context} operations.view.output`,
|
|
302
|
+
defaultMode: "replace"
|
|
303
|
+
});
|
|
304
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
305
|
+
throw new TypeError(`${context} requires operations.view.output to resolve to an object schema.`);
|
|
312
306
|
}
|
|
313
307
|
|
|
314
|
-
return
|
|
315
|
-
schema: outputValidator.schema,
|
|
316
|
-
normalize: typeof outputValidator.normalize === "function" ? outputValidator.normalize : null
|
|
317
|
-
});
|
|
308
|
+
return output;
|
|
318
309
|
}
|
|
319
310
|
|
|
320
311
|
function resolveOperationBodyValidator(resource = {}, operationKey = "", { context = "crudRepository" } = {}) {
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
return
|
|
324
|
-
normalize: null
|
|
325
|
-
});
|
|
312
|
+
const body = resource?.operations?.[operationKey]?.body;
|
|
313
|
+
if (body == null) {
|
|
314
|
+
return null;
|
|
326
315
|
}
|
|
327
|
-
if (!
|
|
328
|
-
throw new TypeError(`${context} operations.${operationKey}.
|
|
316
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
317
|
+
throw new TypeError(`${context} operations.${operationKey}.body must be an object when provided.`);
|
|
329
318
|
}
|
|
330
319
|
|
|
331
|
-
return
|
|
332
|
-
normalize: typeof bodyValidator.normalize === "function" ? bodyValidator.normalize : null
|
|
333
|
-
});
|
|
320
|
+
return body;
|
|
334
321
|
}
|
|
335
322
|
|
|
336
323
|
function extractExplicitFieldErrors(error) {
|
|
@@ -357,34 +344,21 @@ async function normalizeRepositoryInputPayload(
|
|
|
357
344
|
actionContextBase = {}
|
|
358
345
|
} = {}
|
|
359
346
|
) {
|
|
360
|
-
const
|
|
347
|
+
const input = operationKey === "patch"
|
|
361
348
|
? runtime.input?.patch
|
|
362
349
|
: runtime.input?.create;
|
|
363
350
|
const normalizedPayload = normalizeCrudRepositoryObjectInput(payload);
|
|
364
351
|
|
|
365
|
-
if (
|
|
352
|
+
if (!input) {
|
|
366
353
|
return normalizedPayload;
|
|
367
354
|
}
|
|
368
355
|
|
|
369
356
|
try {
|
|
370
|
-
const nextPayload =
|
|
371
|
-
phase,
|
|
372
|
-
|
|
373
|
-
recordId,
|
|
374
|
-
existingRecord,
|
|
375
|
-
context: actionContextBase?.callOptions?.context,
|
|
376
|
-
callOptions: actionContextBase?.callOptions,
|
|
377
|
-
repositoryOptions: actionContextBase?.repositoryOptions
|
|
357
|
+
const nextPayload = validateSchemaPayload(input, normalizedPayload, {
|
|
358
|
+
phase: "input",
|
|
359
|
+
context: `${runtime?.context || "crudRepository"} operations.${operationKey}.body`
|
|
378
360
|
});
|
|
379
|
-
|
|
380
|
-
return normalizedPayload;
|
|
381
|
-
}
|
|
382
|
-
if (!nextPayload || typeof nextPayload !== "object" || Array.isArray(nextPayload)) {
|
|
383
|
-
throw new TypeError(
|
|
384
|
-
`${runtime?.context || "crudRepository"} operations.${operationKey}.bodyValidator.normalize must return an object when it returns a value.`
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
return nextPayload;
|
|
361
|
+
return normalizeCrudRepositoryObjectInput(nextPayload);
|
|
388
362
|
} catch (error) {
|
|
389
363
|
const explicitFieldErrors = extractExplicitFieldErrors(error);
|
|
390
364
|
if (explicitFieldErrors) {
|
|
@@ -395,24 +369,25 @@ async function normalizeRepositoryInputPayload(
|
|
|
395
369
|
}
|
|
396
370
|
|
|
397
371
|
async function normalizeRepositoryOutputRecord(runtime = {}, record = {}, { operation = "list" } = {}) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
phase: "crudRepositoryOutput",
|
|
403
|
-
operation
|
|
372
|
+
try {
|
|
373
|
+
return validateSchemaPayload(runtime.output, record, {
|
|
374
|
+
phase: "output",
|
|
375
|
+
context: `${runtime?.context || "crudRepository"} operations.view.output`
|
|
404
376
|
});
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const explicitFieldErrors = extractExplicitFieldErrors(error);
|
|
379
|
+
if (explicitFieldErrors) {
|
|
380
|
+
const detailMessage = Object.entries(explicitFieldErrors)
|
|
381
|
+
.map(([field, message]) => `${field}: ${message}`)
|
|
382
|
+
.join(", ");
|
|
383
|
+
throw new TypeError(
|
|
384
|
+
`${runtime?.context || "crudRepository"} ${operation} output validation failed: ${detailMessage}.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
throw new TypeError(
|
|
388
|
+
`${runtime?.context || "crudRepository"} ${operation} output validation failed: ${String(error?.message || "Invalid value.")}.`
|
|
389
|
+
);
|
|
405
390
|
}
|
|
406
|
-
|
|
407
|
-
if (Check(outputRuntime.schema, normalizedRecord)) {
|
|
408
|
-
return normalizedRecord;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const issues = [...Errors(outputRuntime.schema, normalizedRecord)];
|
|
412
|
-
const formattedIssue = formatOutputValidationError(issues[0]);
|
|
413
|
-
throw new TypeError(
|
|
414
|
-
`${runtime?.context || "crudRepository"} ${operation} output validation failed: ${formattedIssue}.`
|
|
415
|
-
);
|
|
416
391
|
}
|
|
417
392
|
|
|
418
393
|
function encodeOrderedListCursorValue(value = null) {
|
|
@@ -1070,7 +1045,7 @@ async function listRecordsByIds(runtime, knex, ids = [], callOptions = {}) {
|
|
|
1070
1045
|
`${runtime.context || "crudRepository"} listByIds requires valueKey "${lookupValueKey}" to exist in output schema.`
|
|
1071
1046
|
);
|
|
1072
1047
|
}
|
|
1073
|
-
if (runtime.mapping.fieldStorageByKey?.[lookupValueKey] !==
|
|
1048
|
+
if (runtime.mapping.fieldStorageByKey?.[lookupValueKey] !== CRUD_FIELD_STORAGE_COLUMN) {
|
|
1074
1049
|
throw new TypeError(
|
|
1075
1050
|
`${runtime.context || "crudRepository"} listByIds requires valueKey "${lookupValueKey}" to be column-backed.`
|
|
1076
1051
|
);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
normalizeCrudLookupContainerKey,
|
|
7
7
|
resolveCrudLookupContainerKey
|
|
8
8
|
} from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
9
|
+
import { buildCrudFieldContractMap } from "@jskit-ai/kernel/shared/support/crudFieldContract";
|
|
9
10
|
import { normalizeCrudLookupApiPath } from "../lookupPathSupport.js";
|
|
10
11
|
|
|
11
12
|
const DEFAULT_LOOKUP_INCLUDE = "*";
|
|
@@ -136,7 +137,9 @@ function createCrudLookupRuntime(resource = {}, { outputKeys = [] } = {}) {
|
|
|
136
137
|
.filter(Boolean)
|
|
137
138
|
);
|
|
138
139
|
|
|
139
|
-
const sourceEntries =
|
|
140
|
+
const sourceEntries = Object.values(buildCrudFieldContractMap(resource, {
|
|
141
|
+
context: "crud lookup runtime field contract"
|
|
142
|
+
}));
|
|
140
143
|
const lookupEntries = [];
|
|
141
144
|
const seenKeys = new Set();
|
|
142
145
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createJsonApiResourceRouteContract } from "@jskit-ai/http-runtime/shared/validators/jsonApiRouteTransport";
|
|
2
|
+
import {
|
|
3
|
+
composeSchemaDefinitions,
|
|
4
|
+
recordIdParamsValidator
|
|
5
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
6
|
+
import {
|
|
7
|
+
createCrudCursorPaginationQueryValidator,
|
|
8
|
+
listSearchQueryValidator as defaultListSearchQueryValidator,
|
|
9
|
+
lookupIncludeQueryValidator as defaultLookupIncludeQueryValidator,
|
|
10
|
+
createCrudParentFilterQueryValidator
|
|
11
|
+
} from "./listQueryValidators.js";
|
|
12
|
+
|
|
13
|
+
function createCrudJsonApiRouteContracts({
|
|
14
|
+
resource = {},
|
|
15
|
+
routeParamsValidator = null,
|
|
16
|
+
listSearchQueryValidator = defaultListSearchQueryValidator,
|
|
17
|
+
lookupIncludeQueryValidator = defaultLookupIncludeQueryValidator
|
|
18
|
+
} = {}) {
|
|
19
|
+
const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator({
|
|
20
|
+
orderBy: resource?.defaultSort
|
|
21
|
+
});
|
|
22
|
+
const listParentFilterQueryValidator = createCrudParentFilterQueryValidator(resource);
|
|
23
|
+
const listRouteQueryValidator = composeSchemaDefinitions([
|
|
24
|
+
listCursorPaginationQueryValidator,
|
|
25
|
+
listSearchQueryValidator,
|
|
26
|
+
listParentFilterQueryValidator,
|
|
27
|
+
lookupIncludeQueryValidator
|
|
28
|
+
]);
|
|
29
|
+
const recordRouteParamsValidator = routeParamsValidator
|
|
30
|
+
? composeSchemaDefinitions([
|
|
31
|
+
routeParamsValidator,
|
|
32
|
+
recordIdParamsValidator
|
|
33
|
+
])
|
|
34
|
+
: recordIdParamsValidator;
|
|
35
|
+
const routeType = resource?.namespace;
|
|
36
|
+
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
listRouteContract: createJsonApiResourceRouteContract({
|
|
39
|
+
type: routeType,
|
|
40
|
+
query: listRouteQueryValidator,
|
|
41
|
+
output: resource?.operations?.view?.output,
|
|
42
|
+
outputKind: "collection"
|
|
43
|
+
}),
|
|
44
|
+
viewRouteContract: createJsonApiResourceRouteContract({
|
|
45
|
+
type: routeType,
|
|
46
|
+
query: lookupIncludeQueryValidator,
|
|
47
|
+
output: resource?.operations?.view?.output,
|
|
48
|
+
outputKind: "record"
|
|
49
|
+
}),
|
|
50
|
+
createRouteContract: createJsonApiResourceRouteContract({
|
|
51
|
+
type: routeType,
|
|
52
|
+
body: resource?.operations?.create?.body,
|
|
53
|
+
output: resource?.operations?.create?.output,
|
|
54
|
+
outputKind: "record",
|
|
55
|
+
successStatus: 201,
|
|
56
|
+
includeValidation400: true
|
|
57
|
+
}),
|
|
58
|
+
updateRouteContract: createJsonApiResourceRouteContract({
|
|
59
|
+
type: routeType,
|
|
60
|
+
body: resource?.operations?.patch?.body,
|
|
61
|
+
output: resource?.operations?.patch?.output,
|
|
62
|
+
outputKind: "record",
|
|
63
|
+
includeValidation400: true
|
|
64
|
+
}),
|
|
65
|
+
deleteRouteContract: createJsonApiResourceRouteContract({
|
|
66
|
+
type: routeType,
|
|
67
|
+
outputKind: "no-content",
|
|
68
|
+
successStatus: 204
|
|
69
|
+
}),
|
|
70
|
+
recordRouteParamsValidator
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { createCrudJsonApiRouteContracts };
|
|
@@ -3,6 +3,22 @@ import {
|
|
|
3
3
|
resolveCrudRecordChangedEvent
|
|
4
4
|
} from "../shared/crudNamespaceSupport.js";
|
|
5
5
|
|
|
6
|
+
function normalizeCrudEventEntityId(value = null) {
|
|
7
|
+
return value == null ? "" : String(value).trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolveCrudEntityIdFromResult({ result } = {}) {
|
|
11
|
+
return normalizeCrudEventEntityId(result?.id);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveCrudJsonApiEntityIdFromResult({ result } = {}) {
|
|
15
|
+
return normalizeCrudEventEntityId(result?.data?.id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveCrudEntityIdFromArgs({ args = [] } = {}) {
|
|
19
|
+
return normalizeCrudEventEntityId(args[0]);
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}) {
|
|
7
23
|
const namespace = requireCrudNamespace(resource?.namespace, { context: `${context} resource.namespace` });
|
|
8
24
|
const recordChangedEventName = resolveCrudRecordChangedEvent(namespace);
|
|
@@ -14,7 +30,7 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
|
|
|
14
30
|
source: "crud",
|
|
15
31
|
entity: "record",
|
|
16
32
|
operation: "created",
|
|
17
|
-
entityId:
|
|
33
|
+
entityId: resolveCrudEntityIdFromResult,
|
|
18
34
|
realtime: Object.freeze({
|
|
19
35
|
event: recordChangedEventName,
|
|
20
36
|
audience: "event_scope"
|
|
@@ -27,7 +43,7 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
|
|
|
27
43
|
source: "crud",
|
|
28
44
|
entity: "record",
|
|
29
45
|
operation: "updated",
|
|
30
|
-
entityId:
|
|
46
|
+
entityId: resolveCrudEntityIdFromResult,
|
|
31
47
|
realtime: Object.freeze({
|
|
32
48
|
event: recordChangedEventName,
|
|
33
49
|
audience: "event_scope"
|
|
@@ -40,7 +56,56 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
|
|
|
40
56
|
source: "crud",
|
|
41
57
|
entity: "record",
|
|
42
58
|
operation: "deleted",
|
|
43
|
-
entityId:
|
|
59
|
+
entityId: resolveCrudEntityIdFromResult,
|
|
60
|
+
realtime: Object.freeze({
|
|
61
|
+
event: recordChangedEventName,
|
|
62
|
+
audience: "event_scope"
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
])
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createCrudJsonApiServiceEvents(namespace = "", { context = "createCrudJsonApiServiceEvents" } = {}) {
|
|
70
|
+
const normalizedNamespace = requireCrudNamespace(namespace, {
|
|
71
|
+
context: `${context} namespace`
|
|
72
|
+
});
|
|
73
|
+
const recordChangedEventName = resolveCrudRecordChangedEvent(normalizedNamespace);
|
|
74
|
+
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
createDocument: Object.freeze([
|
|
77
|
+
Object.freeze({
|
|
78
|
+
type: "entity.changed",
|
|
79
|
+
source: "crud",
|
|
80
|
+
entity: "record",
|
|
81
|
+
operation: "created",
|
|
82
|
+
entityId: resolveCrudJsonApiEntityIdFromResult,
|
|
83
|
+
realtime: Object.freeze({
|
|
84
|
+
event: recordChangedEventName,
|
|
85
|
+
audience: "event_scope"
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
]),
|
|
89
|
+
patchDocumentById: Object.freeze([
|
|
90
|
+
Object.freeze({
|
|
91
|
+
type: "entity.changed",
|
|
92
|
+
source: "crud",
|
|
93
|
+
entity: "record",
|
|
94
|
+
operation: "updated",
|
|
95
|
+
entityId: resolveCrudEntityIdFromArgs,
|
|
96
|
+
realtime: Object.freeze({
|
|
97
|
+
event: recordChangedEventName,
|
|
98
|
+
audience: "event_scope"
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
]),
|
|
102
|
+
deleteDocumentById: Object.freeze([
|
|
103
|
+
Object.freeze({
|
|
104
|
+
type: "entity.changed",
|
|
105
|
+
source: "crud",
|
|
106
|
+
entity: "record",
|
|
107
|
+
operation: "deleted",
|
|
108
|
+
entityId: resolveCrudEntityIdFromArgs,
|
|
44
109
|
realtime: Object.freeze({
|
|
45
110
|
event: recordChangedEventName,
|
|
46
111
|
audience: "event_scope"
|
|
@@ -50,4 +115,10 @@ function createCrudServiceEvents(resource = {}, { context = "crudService" } = {}
|
|
|
50
115
|
});
|
|
51
116
|
}
|
|
52
117
|
|
|
53
|
-
export {
|
|
118
|
+
export {
|
|
119
|
+
createCrudServiceEvents,
|
|
120
|
+
createCrudJsonApiServiceEvents,
|
|
121
|
+
resolveCrudEntityIdFromArgs,
|
|
122
|
+
resolveCrudEntityIdFromResult,
|
|
123
|
+
resolveCrudJsonApiEntityIdFromResult
|
|
124
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_CRUD_LOOKUP_CONTAINER_KEY,
|
|
4
|
+
normalizeCrudLookupContainerKey
|
|
5
|
+
} from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
6
|
+
|
|
7
|
+
const CRUD_RUNTIME_LOOKUPS_FIELD_KEY = DEFAULT_CRUD_LOOKUP_CONTAINER_KEY;
|
|
8
|
+
const CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE = "autocomplete";
|
|
9
|
+
const CRUD_LOOKUP_FORM_CONTROL_SELECT = "select";
|
|
10
|
+
|
|
11
|
+
function checkCrudLookupFormControl(
|
|
12
|
+
value,
|
|
13
|
+
{
|
|
14
|
+
context = "crud field ui.formControl",
|
|
15
|
+
defaultValue = CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE
|
|
16
|
+
} = {}
|
|
17
|
+
) {
|
|
18
|
+
const resolvedValue = value === undefined || value === null || value === "" ? defaultValue : value;
|
|
19
|
+
if (resolvedValue === "") {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
resolvedValue === CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE ||
|
|
25
|
+
resolvedValue === CRUD_LOOKUP_FORM_CONTROL_SELECT
|
|
26
|
+
) {
|
|
27
|
+
return resolvedValue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error(
|
|
31
|
+
`${context} must be "${CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE}" or "${CRUD_LOOKUP_FORM_CONTROL_SELECT}". ` +
|
|
32
|
+
`Received: ${JSON.stringify(resolvedValue)}.`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isCrudRuntimeOutputOnlyFieldKey(
|
|
37
|
+
value = "",
|
|
38
|
+
{
|
|
39
|
+
lookupContainerKey = CRUD_RUNTIME_LOOKUPS_FIELD_KEY
|
|
40
|
+
} = {}
|
|
41
|
+
) {
|
|
42
|
+
const resolvedLookupContainerKey = normalizeCrudLookupContainerKey(lookupContainerKey, {
|
|
43
|
+
context: "crud runtime lookup container key"
|
|
44
|
+
});
|
|
45
|
+
return normalizeText(value) === resolvedLookupContainerKey;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE,
|
|
50
|
+
CRUD_LOOKUP_FORM_CONTROL_SELECT,
|
|
51
|
+
CRUD_RUNTIME_LOOKUPS_FIELD_KEY,
|
|
52
|
+
checkCrudLookupFormControl,
|
|
53
|
+
isCrudRuntimeOutputOnlyFieldKey
|
|
54
|
+
};
|
|
@@ -1,31 +1,5 @@
|
|
|
1
|
-
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
-
|
|
3
|
-
function normalizeCrudNamespace(value = "") {
|
|
4
|
-
return normalizeText(value)
|
|
5
|
-
.toLowerCase()
|
|
6
|
-
.replace(/[^a-z0-9-]+/g, "-")
|
|
7
|
-
.replace(/-+/g, "-")
|
|
8
|
-
.replace(/^-+|-+$/g, "");
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function requireCrudNamespace(namespace, { context = "requireCrudNamespace" } = {}) {
|
|
12
|
-
const normalizedNamespace = normalizeCrudNamespace(namespace);
|
|
13
|
-
if (!normalizedNamespace) {
|
|
14
|
-
throw new TypeError(`${context} requires a non-empty namespace.`);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return normalizedNamespace;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function resolveCrudRecordChangedEvent(namespace = "") {
|
|
21
|
-
const normalizedNamespace = requireCrudNamespace(namespace, {
|
|
22
|
-
context: "resolveCrudRecordChangedEvent"
|
|
23
|
-
});
|
|
24
|
-
return `${normalizedNamespace.replace(/-/g, "_")}.record.changed`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
1
|
export {
|
|
28
2
|
normalizeCrudNamespace,
|
|
29
3
|
requireCrudNamespace,
|
|
30
4
|
resolveCrudRecordChangedEvent
|
|
31
|
-
};
|
|
5
|
+
} from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
|
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { createSchema } from "json-rest-schema";
|
|
3
4
|
import { createCrudServiceFromResource } from "../src/server/createCrudServiceFromResource.js";
|
|
4
5
|
|
|
6
|
+
function createOperationSchemaDefinition(structure = {}, mode = "replace") {
|
|
7
|
+
return {
|
|
8
|
+
schema: createSchema(structure),
|
|
9
|
+
mode
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
function createResourceWithOutputSchema(overrides = {}) {
|
|
6
14
|
return {
|
|
7
15
|
namespace: "contacts",
|
|
8
16
|
operations: {
|
|
9
17
|
view: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
required: ["id", "name", "nullableSecret", "defaultedSecret"]
|
|
18
|
+
output: createOperationSchemaDefinition({
|
|
19
|
+
id: { type: "integer", required: true },
|
|
20
|
+
name: { type: "string", required: true },
|
|
21
|
+
optionalSecret: { type: "string", required: false },
|
|
22
|
+
nullableSecret: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: true,
|
|
25
|
+
nullable: true
|
|
26
|
+
},
|
|
27
|
+
defaultedSecret: {
|
|
28
|
+
type: "string",
|
|
29
|
+
required: true,
|
|
30
|
+
default: ""
|
|
26
31
|
}
|
|
27
|
-
}
|
|
32
|
+
})
|
|
28
33
|
}
|
|
29
34
|
},
|
|
30
35
|
...overrides
|
|
@@ -225,7 +230,7 @@ test("createCrudServiceFromResource readable field hooks require view output sch
|
|
|
225
230
|
|
|
226
231
|
await assert.rejects(
|
|
227
232
|
() => service.getRecord(1, {}),
|
|
228
|
-
/requires resource\.operations\.view\.
|
|
233
|
+
/requires resource\.operations\.view\.output for fieldAccess\.readable/
|
|
229
234
|
);
|
|
230
235
|
});
|
|
231
236
|
|
|
@@ -307,15 +312,12 @@ test("createCrudServiceFromResource readable filtering fails fast for required n
|
|
|
307
312
|
namespace: "contacts",
|
|
308
313
|
operations: {
|
|
309
314
|
view: {
|
|
310
|
-
|
|
311
|
-
schema: {
|
|
312
|
-
type: "
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
},
|
|
317
|
-
required: ["id", "strictSecret"]
|
|
318
|
-
}
|
|
315
|
+
output: {
|
|
316
|
+
schema: createSchema({
|
|
317
|
+
id: { type: "integer", required: true },
|
|
318
|
+
strictSecret: { type: "string", required: true }
|
|
319
|
+
}),
|
|
320
|
+
mode: "replace"
|
|
319
321
|
}
|
|
320
322
|
}
|
|
321
323
|
}
|
|
@@ -334,6 +336,6 @@ test("createCrudServiceFromResource readable filtering fails fast for required n
|
|
|
334
336
|
|
|
335
337
|
await assert.rejects(
|
|
336
338
|
() => service.getRecord(1, {}),
|
|
337
|
-
/cannot redact required non-nullable field "strictSecret" without
|
|
339
|
+
/cannot redact required non-nullable field "strictSecret" without a default value/
|
|
338
340
|
);
|
|
339
341
|
});
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import {
|
|
4
4
|
checkCrudLookupFormControl,
|
|
5
5
|
isCrudRuntimeOutputOnlyFieldKey
|
|
6
|
-
} from "../src/shared/
|
|
6
|
+
} from "../src/shared/crudFieldSupport.js";
|
|
7
7
|
|
|
8
8
|
test("checkCrudLookupFormControl defaults to autocomplete", () => {
|
|
9
9
|
assert.equal(checkCrudLookupFormControl(undefined), "autocomplete");
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolveCrudSurfacePolicyFromAppConfig } from "../src/server/crudModuleConfig.js";
|
|
4
|
+
|
|
5
|
+
test("resolveCrudSurfacePolicyFromAppConfig explains missing workspace surfaces for workspace-capable tenancy", () => {
|
|
6
|
+
assert.throws(
|
|
7
|
+
() =>
|
|
8
|
+
resolveCrudSurfacePolicyFromAppConfig(
|
|
9
|
+
{
|
|
10
|
+
namespace: "users",
|
|
11
|
+
surface: "admin",
|
|
12
|
+
ownershipFilter: "public",
|
|
13
|
+
relativePath: "/users"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
tenancyMode: "personal",
|
|
17
|
+
surfaceDefaultId: "home",
|
|
18
|
+
surfaceDefinitions: {
|
|
19
|
+
home: {
|
|
20
|
+
id: "home",
|
|
21
|
+
enabled: true,
|
|
22
|
+
requiresAuth: false,
|
|
23
|
+
requiresWorkspace: false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
context: "UsersProvider"
|
|
29
|
+
}
|
|
30
|
+
),
|
|
31
|
+
/UsersProvider cannot resolve surface "admin".*@jskit-ai\/workspaces-core.*"app" and "admin" surfaces/s
|
|
32
|
+
);
|
|
33
|
+
});
|