@jskit-ai/resource-crud-core 0.1.1
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 +37 -0
- package/package.json +16 -0
- package/src/shared/crudNamespaceSupport.js +31 -0
- package/src/shared/crudResource.js +393 -0
- package/test/crudResource.test.js +194 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export default Object.freeze({
|
|
2
|
+
packageVersion: 1,
|
|
3
|
+
packageId: "@jskit-ai/resource-crud-core",
|
|
4
|
+
version: "0.1.1",
|
|
5
|
+
kind: "runtime",
|
|
6
|
+
description: "CRUD-specific resource-definition helpers and namespace support.",
|
|
7
|
+
dependsOn: [
|
|
8
|
+
"@jskit-ai/kernel",
|
|
9
|
+
"@jskit-ai/resource-core"
|
|
10
|
+
],
|
|
11
|
+
capabilities: {
|
|
12
|
+
provides: ["resource.crud-core"],
|
|
13
|
+
requires: []
|
|
14
|
+
},
|
|
15
|
+
runtime: {
|
|
16
|
+
server: {
|
|
17
|
+
providers: []
|
|
18
|
+
},
|
|
19
|
+
client: {
|
|
20
|
+
providers: []
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
mutations: {
|
|
24
|
+
dependencies: {
|
|
25
|
+
runtime: {
|
|
26
|
+
"@jskit-ai/resource-crud-core": "0.1.1"
|
|
27
|
+
},
|
|
28
|
+
dev: {}
|
|
29
|
+
},
|
|
30
|
+
packageJson: {
|
|
31
|
+
scripts: {}
|
|
32
|
+
},
|
|
33
|
+
procfile: {},
|
|
34
|
+
files: [],
|
|
35
|
+
text: []
|
|
36
|
+
}
|
|
37
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/resource-crud-core",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./shared/crudNamespaceSupport": "./src/shared/crudNamespaceSupport.js",
|
|
10
|
+
"./shared/crudResource": "./src/shared/crudResource.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@jskit-ai/kernel": "0.1.56",
|
|
14
|
+
"@jskit-ai/resource-core": "0.1.1"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
export {
|
|
28
|
+
normalizeCrudNamespace,
|
|
29
|
+
requireCrudNamespace,
|
|
30
|
+
resolveCrudRecordChangedEvent
|
|
31
|
+
};
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSchema,
|
|
3
|
+
createCursorListValidator,
|
|
4
|
+
RECORD_ID_PATTERN
|
|
5
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
6
|
+
import { buildCrudOperationSchemaFields } from "@jskit-ai/kernel/shared/support/crudFieldContract";
|
|
7
|
+
import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
|
|
8
|
+
import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
9
|
+
import {
|
|
10
|
+
createSchemaDefinition,
|
|
11
|
+
defineResource,
|
|
12
|
+
normalizeSchemaDefinitionLike
|
|
13
|
+
} from "@jskit-ai/resource-core/shared/resource";
|
|
14
|
+
import { requireCrudNamespace, resolveCrudRecordChangedEvent } from "./crudNamespaceSupport.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CRUD_OPERATION_NAMES = Object.freeze([
|
|
17
|
+
"list",
|
|
18
|
+
"view",
|
|
19
|
+
"create",
|
|
20
|
+
"patch",
|
|
21
|
+
"delete"
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const SUPPORTED_CRUD_OPERATION_NAMES = Object.freeze([
|
|
25
|
+
"list",
|
|
26
|
+
"view",
|
|
27
|
+
"create",
|
|
28
|
+
"replace",
|
|
29
|
+
"patch",
|
|
30
|
+
"delete"
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const CRUD_OPERATION_SPECS = deepFreeze({
|
|
34
|
+
list: {
|
|
35
|
+
method: "GET",
|
|
36
|
+
outputKind: "list",
|
|
37
|
+
includeRealtimeEvent: true
|
|
38
|
+
},
|
|
39
|
+
view: {
|
|
40
|
+
method: "GET",
|
|
41
|
+
outputKind: "record"
|
|
42
|
+
},
|
|
43
|
+
create: {
|
|
44
|
+
method: "POST",
|
|
45
|
+
outputKind: "record",
|
|
46
|
+
bodyOperation: "create",
|
|
47
|
+
bodyMode: "create",
|
|
48
|
+
explicitBodyKeys: ["createBody", "body"]
|
|
49
|
+
},
|
|
50
|
+
replace: {
|
|
51
|
+
method: "PUT",
|
|
52
|
+
outputKind: "record",
|
|
53
|
+
bodyOperation: "replace",
|
|
54
|
+
bodyMode: "replace",
|
|
55
|
+
explicitBodyKeys: ["replaceBody", "body", "createBody"]
|
|
56
|
+
},
|
|
57
|
+
patch: {
|
|
58
|
+
method: "PATCH",
|
|
59
|
+
outputKind: "record",
|
|
60
|
+
bodyOperation: "patch",
|
|
61
|
+
bodyMode: "patch",
|
|
62
|
+
explicitBodyKeys: ["patchBody", "body"]
|
|
63
|
+
},
|
|
64
|
+
delete: {
|
|
65
|
+
method: "DELETE",
|
|
66
|
+
outputKind: "delete"
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function createCrudRecordIdFieldDefinition() {
|
|
71
|
+
return {
|
|
72
|
+
type: "string",
|
|
73
|
+
required: true,
|
|
74
|
+
minLength: 1,
|
|
75
|
+
pattern: RECORD_ID_PATTERN
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveCrudLookupContainerKey(resource = {}) {
|
|
80
|
+
return normalizeText(resource?.contract?.lookup?.containerKey);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveFieldEntries(resource = {}, operationName = "output") {
|
|
84
|
+
return buildCrudOperationSchemaFields(resource?.schema, operationName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createDerivedCrudRecordOutputDefinition(resource = {}) {
|
|
88
|
+
const outputFields = resolveFieldEntries(resource, "output");
|
|
89
|
+
if (Object.keys(outputFields).length < 1) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"defineCrudResource derived output requires explicit crud.output or at least one schema field with operations.output."
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fields = {
|
|
96
|
+
id: createCrudRecordIdFieldDefinition(),
|
|
97
|
+
...outputFields
|
|
98
|
+
};
|
|
99
|
+
const lookupContainerKey = resolveCrudLookupContainerKey(resource);
|
|
100
|
+
|
|
101
|
+
if (lookupContainerKey) {
|
|
102
|
+
fields[lookupContainerKey] = {
|
|
103
|
+
type: "object",
|
|
104
|
+
required: false
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return createSchemaDefinition(createSchema(fields), "replace", {
|
|
109
|
+
context: "defineCrudResource derived output"
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function createDerivedCrudBodyDefinition(resource = {}, operationName = "patch") {
|
|
114
|
+
let fields = resolveFieldEntries(resource, operationName);
|
|
115
|
+
|
|
116
|
+
if (operationName === "replace" && Object.keys(fields).length === 0) {
|
|
117
|
+
fields = resolveFieldEntries(resource, "create");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Object.keys(fields).length < 1) {
|
|
121
|
+
const fieldHint = operationName === "replace"
|
|
122
|
+
? "operations.replace or operations.create"
|
|
123
|
+
: `operations.${operationName}`;
|
|
124
|
+
throw new Error(
|
|
125
|
+
`defineCrudResource derived ${operationName} body requires explicit crud.${operationName}Body or at least one schema field with ${fieldHint}.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const defaultMode = operationName === "create"
|
|
130
|
+
? "create"
|
|
131
|
+
: operationName === "replace"
|
|
132
|
+
? "replace"
|
|
133
|
+
: "patch";
|
|
134
|
+
|
|
135
|
+
return createSchemaDefinition(createSchema(fields), defaultMode, {
|
|
136
|
+
context: `defineCrudResource derived ${operationName} body`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createCrudDeleteOutputDefinition() {
|
|
141
|
+
return createSchemaDefinition(createSchema({
|
|
142
|
+
id: createCrudRecordIdFieldDefinition(),
|
|
143
|
+
deleted: {
|
|
144
|
+
type: "boolean",
|
|
145
|
+
required: true
|
|
146
|
+
}
|
|
147
|
+
}), "replace", {
|
|
148
|
+
context: "defineCrudResource delete output"
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function requireCrudOperationName(value = "", { context = "crud operation name" } = {}) {
|
|
153
|
+
const normalizedName = normalizeText(value).toLowerCase();
|
|
154
|
+
if (SUPPORTED_CRUD_OPERATION_NAMES.includes(normalizedName)) {
|
|
155
|
+
return normalizedName;
|
|
156
|
+
}
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${context} must be one of: ${SUPPORTED_CRUD_OPERATION_NAMES.join(", ")}. ` +
|
|
159
|
+
`Received: ${JSON.stringify(value)}.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function requireCrudOperationSpec(operationName = "") {
|
|
164
|
+
const spec = CRUD_OPERATION_SPECS[operationName];
|
|
165
|
+
if (spec) {
|
|
166
|
+
return spec;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`createCrudOperationDefinition received unsupported operation "${operationName}".`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveCrudOperationNames(resource = {}) {
|
|
172
|
+
const hasConfiguredOperations = Array.isArray(resource?.crudOperations);
|
|
173
|
+
const configuredOperations = hasConfiguredOperations
|
|
174
|
+
? resource.crudOperations
|
|
175
|
+
: DEFAULT_CRUD_OPERATION_NAMES;
|
|
176
|
+
const names = [];
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
|
|
179
|
+
for (const rawName of configuredOperations) {
|
|
180
|
+
const operationName = requireCrudOperationName(rawName, {
|
|
181
|
+
context: "defineCrudResource crudOperations"
|
|
182
|
+
});
|
|
183
|
+
if (seen.has(operationName)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
seen.add(operationName);
|
|
187
|
+
names.push(operationName);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (hasConfiguredOperations) {
|
|
191
|
+
return names;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return names.length > 0 ? names : [...DEFAULT_CRUD_OPERATION_NAMES];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveFirstPresentValue(source = {}, keys = []) {
|
|
198
|
+
for (const key of Array.isArray(keys) ? keys : []) {
|
|
199
|
+
if (Object.hasOwn(source, key) && source[key] != null) {
|
|
200
|
+
return source[key];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolveExplicitCrudSchemaDefinition(crudConfig = {}, keys = [], {
|
|
207
|
+
context = "defineCrudResource schema definition",
|
|
208
|
+
defaultMode = "patch"
|
|
209
|
+
} = {}) {
|
|
210
|
+
const explicitValue = resolveFirstPresentValue(crudConfig, keys);
|
|
211
|
+
if (explicitValue == null) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return normalizeSchemaDefinitionLike(explicitValue, {
|
|
216
|
+
context,
|
|
217
|
+
defaultMode
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function createCrudListOutputDefinition(resolveRecordOutputDefinition, crudConfig = {}) {
|
|
222
|
+
const explicitListOutput = resolveExplicitCrudSchemaDefinition(crudConfig, ["listOutput"], {
|
|
223
|
+
context: "defineCrudResource crud.listOutput",
|
|
224
|
+
defaultMode: "replace"
|
|
225
|
+
});
|
|
226
|
+
if (explicitListOutput) {
|
|
227
|
+
return explicitListOutput;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const explicitListItemOutput = resolveExplicitCrudSchemaDefinition(crudConfig, ["listItemOutput"], {
|
|
231
|
+
context: "defineCrudResource crud.listItemOutput",
|
|
232
|
+
defaultMode: "replace"
|
|
233
|
+
});
|
|
234
|
+
if (explicitListItemOutput) {
|
|
235
|
+
return createCursorListValidator(
|
|
236
|
+
explicitListItemOutput
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return createCursorListValidator(resolveRecordOutputDefinition());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function createCrudRecordOutputDefinitionResolver(resource = {}, crudConfig = {}) {
|
|
244
|
+
let cachedDefinition = null;
|
|
245
|
+
let hasResolved = false;
|
|
246
|
+
|
|
247
|
+
return function resolveRecordOutputDefinition() {
|
|
248
|
+
if (hasResolved) {
|
|
249
|
+
return cachedDefinition;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
cachedDefinition = resolveExplicitCrudSchemaDefinition(crudConfig, ["output"], {
|
|
253
|
+
context: "defineCrudResource crud.output",
|
|
254
|
+
defaultMode: "replace"
|
|
255
|
+
}) || createDerivedCrudRecordOutputDefinition(resource);
|
|
256
|
+
hasResolved = true;
|
|
257
|
+
return cachedDefinition;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function resolveCrudBodyDefinition(spec, resource = {}, crudConfig = {}) {
|
|
262
|
+
const explicitBody = resolveExplicitCrudSchemaDefinition(
|
|
263
|
+
crudConfig,
|
|
264
|
+
spec.explicitBodyKeys,
|
|
265
|
+
{
|
|
266
|
+
context: `defineCrudResource operations.${spec.bodyOperation}.body`,
|
|
267
|
+
defaultMode: spec.bodyMode
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
if (explicitBody) {
|
|
271
|
+
return explicitBody;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return createDerivedCrudBodyDefinition(resource, spec.bodyOperation);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function resolveCrudOutputDefinition(spec, resolveRecordOutputDefinition, crudConfig = {}) {
|
|
278
|
+
if (spec.outputKind === "list") {
|
|
279
|
+
return createCrudListOutputDefinition(resolveRecordOutputDefinition, crudConfig);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (spec.outputKind === "record") {
|
|
283
|
+
return resolveRecordOutputDefinition();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (spec.outputKind === "delete") {
|
|
287
|
+
return resolveExplicitCrudSchemaDefinition(crudConfig, ["deleteOutput"], {
|
|
288
|
+
context: "defineCrudResource operations.delete.output",
|
|
289
|
+
defaultMode: "replace"
|
|
290
|
+
}) || createCrudDeleteOutputDefinition();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
throw new Error(`resolveCrudOutputDefinition received unsupported output kind "${spec.outputKind}".`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function createCrudOperationDefinition(operationName, {
|
|
297
|
+
namespace = "",
|
|
298
|
+
resource = {},
|
|
299
|
+
crudConfig = {},
|
|
300
|
+
resolveRecordOutputDefinition
|
|
301
|
+
} = {}) {
|
|
302
|
+
const spec = requireCrudOperationSpec(operationName);
|
|
303
|
+
const nextOperation = {
|
|
304
|
+
method: spec.method
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (spec.includeRealtimeEvent) {
|
|
308
|
+
nextOperation.realtime = {
|
|
309
|
+
events: [resolveCrudRecordChangedEvent(namespace)]
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (spec.bodyOperation) {
|
|
314
|
+
nextOperation.body = resolveCrudBodyDefinition(spec, resource, crudConfig);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
nextOperation.output = resolveCrudOutputDefinition(spec, resolveRecordOutputDefinition, crudConfig);
|
|
318
|
+
return nextOperation;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function createDefaultCrudOperations(resource = {}) {
|
|
322
|
+
const namespace = requireCrudNamespace(resource?.namespace, {
|
|
323
|
+
context: "createDefaultCrudOperations resource.namespace"
|
|
324
|
+
});
|
|
325
|
+
const crudConfig = normalizeObject(resource?.crud);
|
|
326
|
+
const resolveRecordOutputDefinition = createCrudRecordOutputDefinitionResolver(resource, crudConfig);
|
|
327
|
+
const operations = {};
|
|
328
|
+
|
|
329
|
+
for (const operationName of resolveCrudOperationNames(resource)) {
|
|
330
|
+
operations[operationName] = createCrudOperationDefinition(operationName, {
|
|
331
|
+
namespace,
|
|
332
|
+
resource,
|
|
333
|
+
crudConfig,
|
|
334
|
+
resolveRecordOutputDefinition
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return operations;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function mergeCrudOperationDefinition(baseDefinition, overrideDefinition) {
|
|
342
|
+
const normalizedBase = normalizeObject(baseDefinition);
|
|
343
|
+
const normalizedOverride = normalizeObject(overrideDefinition);
|
|
344
|
+
const mergedRealtime = {
|
|
345
|
+
...normalizeObject(normalizedBase.realtime),
|
|
346
|
+
...normalizeObject(normalizedOverride.realtime)
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
...normalizedBase,
|
|
351
|
+
...normalizedOverride,
|
|
352
|
+
...(Object.keys(mergedRealtime).length > 0 ? { realtime: mergedRealtime } : {})
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function mergeCrudOperations(defaultOperations = {}, overrides = {}) {
|
|
357
|
+
const baseEntries = normalizeObject(defaultOperations);
|
|
358
|
+
const overrideEntries = normalizeObject(overrides);
|
|
359
|
+
const merged = {};
|
|
360
|
+
|
|
361
|
+
for (const [operationName, baseDefinition] of Object.entries(baseEntries)) {
|
|
362
|
+
merged[operationName] = mergeCrudOperationDefinition(baseDefinition, overrideEntries[operationName]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
for (const [operationName, overrideDefinition] of Object.entries(overrideEntries)) {
|
|
366
|
+
if (Object.hasOwn(merged, operationName)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
merged[operationName] = overrideDefinition;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return merged;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function defineCrudResource(resource = {}) {
|
|
377
|
+
const source = normalizeObject(resource);
|
|
378
|
+
const {
|
|
379
|
+
crud: _crudConfig,
|
|
380
|
+
crudOperations: _crudOperations,
|
|
381
|
+
...authoredResource
|
|
382
|
+
} = source;
|
|
383
|
+
|
|
384
|
+
return defineResource({
|
|
385
|
+
...authoredResource,
|
|
386
|
+
operations: mergeCrudOperations(
|
|
387
|
+
createDefaultCrudOperations(source),
|
|
388
|
+
authoredResource.operations
|
|
389
|
+
)
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export { defineCrudResource };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createSchema, validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
4
|
+
import { createSchemaDefinition } from "@jskit-ai/resource-core/shared/resource";
|
|
5
|
+
import { defineCrudResource } from "../src/shared/crudResource.js";
|
|
6
|
+
|
|
7
|
+
function createContactsResource(overrides = {}) {
|
|
8
|
+
return defineCrudResource({
|
|
9
|
+
namespace: "contacts",
|
|
10
|
+
schema: {
|
|
11
|
+
name: {
|
|
12
|
+
type: "string",
|
|
13
|
+
maxLength: 190,
|
|
14
|
+
operations: {
|
|
15
|
+
output: { required: true },
|
|
16
|
+
create: { required: true },
|
|
17
|
+
patch: { required: false }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
createdAt: {
|
|
21
|
+
type: "dateTime",
|
|
22
|
+
operations: {
|
|
23
|
+
output: { required: true }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
contract: {
|
|
28
|
+
lookup: {
|
|
29
|
+
containerKey: "lookups"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
messages: {
|
|
33
|
+
validation: "Fix invalid values."
|
|
34
|
+
},
|
|
35
|
+
...overrides
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("defineCrudResource derives standard CRUD operations and resource messages", async () => {
|
|
40
|
+
const resource = createContactsResource();
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
Object.keys(resource.operations),
|
|
44
|
+
["list", "view", "create", "patch", "delete"]
|
|
45
|
+
);
|
|
46
|
+
assert.deepEqual(resource.operations.list.realtime?.events, ["contacts.record.changed"]);
|
|
47
|
+
assert.equal(resource.operations.view.messages, resource.messages);
|
|
48
|
+
|
|
49
|
+
const normalizedCreateBody = await validateSchemaPayload(resource.operations.create.body, {
|
|
50
|
+
name: " Example "
|
|
51
|
+
}, { phase: "input" });
|
|
52
|
+
assert.equal(normalizedCreateBody.name, "Example");
|
|
53
|
+
|
|
54
|
+
const normalizedViewOutput = await validateSchemaPayload(resource.operations.view.output, {
|
|
55
|
+
id: 7,
|
|
56
|
+
name: " Example ",
|
|
57
|
+
createdAt: "2026-05-01 12:30:00.000",
|
|
58
|
+
lookups: {}
|
|
59
|
+
}, { phase: "output" });
|
|
60
|
+
assert.equal(normalizedViewOutput.id, "7");
|
|
61
|
+
assert.equal(normalizedViewOutput.name, "Example");
|
|
62
|
+
assert.ok(normalizedViewOutput.createdAt instanceof Date);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("defineCrudResource preserves authored namespace and supports replace bodies", async () => {
|
|
66
|
+
const resource = createContactsResource({
|
|
67
|
+
namespace: "userProfile",
|
|
68
|
+
crudOperations: ["view", "create", "replace", "patch"],
|
|
69
|
+
crud: {
|
|
70
|
+
output: createSchema({
|
|
71
|
+
id: {
|
|
72
|
+
type: "string",
|
|
73
|
+
required: true,
|
|
74
|
+
minLength: 1
|
|
75
|
+
},
|
|
76
|
+
name: {
|
|
77
|
+
type: "string",
|
|
78
|
+
required: true,
|
|
79
|
+
minLength: 1
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
body: createSchema({
|
|
83
|
+
name: {
|
|
84
|
+
type: "string",
|
|
85
|
+
required: true,
|
|
86
|
+
minLength: 1
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.equal(resource.namespace, "userProfile");
|
|
93
|
+
assert.deepEqual(Object.keys(resource.operations), ["view", "create", "replace", "patch"]);
|
|
94
|
+
assert.equal(resource.operations.replace.body.mode, "replace");
|
|
95
|
+
|
|
96
|
+
const normalizedReplaceBody = await validateSchemaPayload(resource.operations.replace.body, {
|
|
97
|
+
name: " Example "
|
|
98
|
+
}, { phase: "input" });
|
|
99
|
+
assert.equal(normalizedReplaceBody.name, "Example");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("defineCrudResource supports explicit list item output and custom operation overrides", () => {
|
|
103
|
+
const resource = createContactsResource({
|
|
104
|
+
crudOperations: ["list", "view", "create", "patch"],
|
|
105
|
+
crud: {
|
|
106
|
+
output: createSchema({
|
|
107
|
+
id: {
|
|
108
|
+
type: "string",
|
|
109
|
+
required: true
|
|
110
|
+
}
|
|
111
|
+
}),
|
|
112
|
+
listItemOutput: createSchema({
|
|
113
|
+
id: {
|
|
114
|
+
type: "string",
|
|
115
|
+
required: true
|
|
116
|
+
},
|
|
117
|
+
label: {
|
|
118
|
+
type: "string",
|
|
119
|
+
required: true
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
},
|
|
123
|
+
operations: {
|
|
124
|
+
list: {
|
|
125
|
+
realtime: {
|
|
126
|
+
events: ["contacts.custom.changed"]
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
archive: {
|
|
130
|
+
method: "POST",
|
|
131
|
+
output: createSchemaDefinition(createSchema({
|
|
132
|
+
archived: {
|
|
133
|
+
type: "boolean",
|
|
134
|
+
required: true
|
|
135
|
+
}
|
|
136
|
+
}), "replace")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
assert.deepEqual(resource.operations.list.realtime?.events, ["contacts.custom.changed"]);
|
|
142
|
+
assert.equal(resource.operations.archive.method, "POST");
|
|
143
|
+
assert.equal(resource.operations.list.output.schema.toJsonSchema({ mode: "replace" }).properties.items.type, "array");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("defineCrudResource allows list-only resources with explicit list output and no canonical output schema", () => {
|
|
147
|
+
const resource = defineCrudResource({
|
|
148
|
+
namespace: "auditEntry",
|
|
149
|
+
crudOperations: ["list"],
|
|
150
|
+
crud: {
|
|
151
|
+
listItemOutput: createSchema({
|
|
152
|
+
id: {
|
|
153
|
+
type: "string",
|
|
154
|
+
required: true
|
|
155
|
+
},
|
|
156
|
+
label: {
|
|
157
|
+
type: "string",
|
|
158
|
+
required: true
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(Object.keys(resource.operations), ["list"]);
|
|
165
|
+
assert.equal(resource.operations.list.output.schema.toJsonSchema({ mode: "replace" }).properties.items.type, "array");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("defineCrudResource fails fast when enabled CRUD operations cannot be derived", () => {
|
|
169
|
+
assert.throws(() => defineCrudResource({
|
|
170
|
+
namespace: "assistantConfig",
|
|
171
|
+
crudOperations: ["view", "patch"],
|
|
172
|
+
crud: {
|
|
173
|
+
patchBody: createSchema({
|
|
174
|
+
systemPrompt: {
|
|
175
|
+
type: "string",
|
|
176
|
+
required: false
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}), /derived output requires explicit crud\.output or at least one schema field with operations\.output/);
|
|
181
|
+
|
|
182
|
+
assert.throws(() => defineCrudResource({
|
|
183
|
+
namespace: "assistantConfig",
|
|
184
|
+
crudOperations: ["patch"],
|
|
185
|
+
crud: {
|
|
186
|
+
output: createSchema({
|
|
187
|
+
id: {
|
|
188
|
+
type: "string",
|
|
189
|
+
required: true
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
}), /derived patch body requires explicit crud\.patchBody or at least one schema field with operations\.patch/);
|
|
194
|
+
});
|