@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.
@@ -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
+ });