@jskit-ai/crud-server-generator 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.
@@ -285,13 +285,15 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
285
285
  /buildWorkspaceInputFromRouteParams/
286
286
  );
287
287
  assert.equal(replacements.__JSKIT_CRUD_ROUTE_SURFACE_REQUIRES_WORKSPACE__, "true");
288
- assert.equal(
289
- replacements.__JSKIT_CRUD_LIST_ACTION_INPUT_VALIDATOR__,
290
- "[workspaceSlugParamsValidator, listCursorPaginationQueryValidator, listSearchQueryValidator, listParentFilterQueryValidator, lookupIncludeQueryValidator]"
291
- );
288
+ assert.match(replacements.__JSKIT_CRUD_LIST_ACTION_INPUT__, /composeSchemaDefinitions\(\[/);
289
+ assert.match(replacements.__JSKIT_CRUD_LIST_ACTION_INPUT__, /workspaceSlugParamsValidator,/);
292
290
  assert.equal(
293
291
  replacements.__JSKIT_CRUD_VIEW_ROUTE_PARAMS_VALIDATOR_LINE__,
294
- " paramsValidator: [routeParamsValidator, recordIdParamsValidator],"
292
+ " params: recordRouteParamsValidator,"
293
+ );
294
+ assert.match(
295
+ replacements.__JSKIT_CRUD_ROUTE_VALIDATOR_CONSTANTS__,
296
+ /const recordRouteParamsValidator = composeSchemaDefinitions\(/
295
297
  );
296
298
  assert.match(
297
299
  replacements.__JSKIT_CRUD_ROLE_CATALOG_PERMISSION_GRANTS__,
@@ -303,42 +305,36 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
303
305
  );
304
306
  assert.match(replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__, /table\.bigIncrements\("id"\)/);
305
307
  assert.match(replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__, /table\.string\("first_name", 160\)/);
306
- assert.equal(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, "");
307
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__, /updatedAt: Type\.String/);
308
+ assert.equal(Object.hasOwn(replacements, "__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__"), false);
309
+ assert.equal(Object.hasOwn(replacements, "__JSKIT_CRUD_RESOURCE_CREATE_SCHEMA_PROPERTIES__"), false);
310
+ assert.equal(Object.hasOwn(replacements, "__JSKIT_CRUD_RESOURCE_PATCH_SCHEMA_PROPERTIES__"), false);
311
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /updatedAt: \{/);
308
312
  assert.match(
309
- replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
310
- /id: recordIdSchema,/
313
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
314
+ /firstName: \{[\s\S]*type: "string",[\s\S]*maxLength: 160,[\s\S]*required: true,[\s\S]*search: true,[\s\S]*output: \{ required: true \},[\s\S]*create: \{ required: true \},[\s\S]*patch: \{ required: false \}[\s\S]*\},/s
311
315
  );
312
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_CREATE_SCHEMA_PROPERTIES__, /firstName: Type\.String/);
313
316
  assert.match(
314
- replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
315
- /normalizeIfInSource\(source, normalized, "firstName", normalizeText\);/
316
- );
317
- assert.doesNotMatch(
318
- replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
319
- /\(value\) =>/
320
- );
321
- assert.doesNotMatch(
322
- replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
323
- /value == null/
317
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
318
+ /createdAt: \{[\s\S]*type: "dateTime",[\s\S]*default: "now\(\)",[\s\S]*storage: \{ writeSerializer: "datetime-utc" \}[\s\S]*output: \{ required: true \}[\s\S]*\},/s
324
319
  );
320
+ assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /^\s*id:\s*\{/m);
321
+ assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /pattern: RECORD_ID_PATTERN/);
325
322
  assert.match(
326
- replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__,
327
- /firstName: normalizeIfPresent\(source\.firstName, normalizeText\),/
323
+ replacements.__JSKIT_CRUD_JSONREST_SCHEMA_PROPERTIES__,
324
+ /createdAt: \{[\s\S]*storage: \{ serialize: serializeNullableDateTime \}[\s\S]*\},/s
328
325
  );
329
- assert.match(
330
- replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__,
331
- /orderBy: \[\s+{\s+column: "created_at",\s+direction: "desc"\s+}\s+\]/s
326
+ assert.doesNotMatch(
327
+ replacements.__JSKIT_CRUD_JSONREST_SCHEMA_PROPERTIES__,
328
+ /workspaceId: \{[^}]*storage: \{/s
332
329
  );
333
- assert.match(
334
- replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__,
335
- /\/\/ searchColumns: \["name"\],\s+orderBy:/s
330
+ assert.doesNotMatch(
331
+ replacements.__JSKIT_CRUD_JSONREST_SCHEMA_PROPERTIES__,
332
+ /howManyCars: \{[^}]*storage: \{/s
336
333
  );
337
334
  assert.doesNotMatch(
338
- replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__,
339
- /== null \?/
335
+ replacements.__JSKIT_CRUD_JSONREST_SCHEMA_PROPERTIES__,
336
+ /^\s*id:\s*\{/m
340
337
  );
341
- assert.equal(replacements.__JSKIT_CRUD_RESOURCE_CREATE_REQUIRED_FIELDS__, "[\"firstName\"]");
342
338
  assert.equal(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, "");
343
339
  });
344
340
 
@@ -364,14 +360,12 @@ test("buildReplacementsFromSnapshot omits named permissions and role grants when
364
360
  assert.equal(replacements.__JSKIT_CRUD_ACTION_WORKSPACE_VALIDATOR_IMPORT__, "");
365
361
  assert.equal(replacements.__JSKIT_CRUD_ROUTE_WORKSPACE_SUPPORT_IMPORTS__, "");
366
362
  assert.equal(replacements.__JSKIT_CRUD_ROUTE_SURFACE_REQUIRES_WORKSPACE__, "false");
367
- assert.equal(
368
- replacements.__JSKIT_CRUD_CREATE_ACTION_INPUT_VALIDATOR__,
369
- "{ payload: resource.operations.create.bodyValidator }"
370
- );
363
+ assert.equal(replacements.__JSKIT_CRUD_CREATE_ACTION_INPUT__, "resource.operations.create.body");
364
+ assert.equal(replacements.__JSKIT_CRUD_DELETE_ACTION_INPUT__, "recordIdParamsValidator");
371
365
  assert.equal(replacements.__JSKIT_CRUD_LIST_ROUTE_PARAMS_VALIDATOR_LINE__, "");
372
366
  assert.equal(
373
367
  replacements.__JSKIT_CRUD_VIEW_ROUTE_PARAMS_VALIDATOR_LINE__,
374
- " paramsValidator: recordIdParamsValidator,"
368
+ " params: recordRouteParamsValidator,"
375
369
  );
376
370
  assert.equal(
377
371
  replacements.__JSKIT_CRUD_VIEW_ROUTE_INPUT_LINES__,
@@ -469,17 +463,14 @@ test("buildReplacementsFromSnapshot omits default list ordering when created_at
469
463
  const snapshot = createSnapshot({
470
464
  hasCreatedAtColumn: false
471
465
  });
472
- const replacements = __testables.buildReplacementsFromSnapshot({
466
+ __testables.buildReplacementsFromSnapshot({
473
467
  namespace: "contacts",
474
468
  snapshot,
475
469
  resolvedOwnershipFilter: "workspace_user"
476
470
  });
477
-
478
- assert.doesNotMatch(replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__, /orderBy/);
479
- assert.match(replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__, /searchColumns/);
480
471
  });
481
472
 
482
- test("buildReplacementsFromSnapshot renders append-only field meta entries from foreign keys", () => {
473
+ test("buildReplacementsFromSnapshot renders inline field relation metadata from foreign keys", () => {
483
474
  const snapshot = {
484
475
  ...createSnapshot(),
485
476
  columns: Object.freeze([
@@ -524,17 +515,15 @@ test("buildReplacementsFromSnapshot renders append-only field meta entries from
524
515
  resolvedOwnershipFilter: "workspace_user"
525
516
  });
526
517
 
527
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /RESOURCE_FIELD_META\.push\(\{/);
528
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /key: "vetId"/);
529
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /namespace: "customer-categories"/);
518
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /vetId: \{/);
530
519
  assert.match(
531
- replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__,
532
- /formControl: "autocomplete" \/\/ or "select"/
520
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
521
+ /relation: \{ kind: "lookup", namespace: "customer-categories", valueKey: "id" \}.*belongsTo: "customerCategories".*as: "vet".*ui: \{ formControl: "autocomplete" \}/s
533
522
  );
534
523
  assert.match(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, /table\.foreign\(\["vet_id"\]/);
535
524
  });
536
525
 
537
- test("buildReplacementsFromSnapshot renders enum field meta options as select controls", () => {
526
+ test("buildReplacementsFromSnapshot renders inline enum field ui options as select controls", () => {
538
527
  const baseSnapshot = createSnapshot({
539
528
  hasWorkspaceIdColumn: false,
540
529
  hasUserIdColumn: false
@@ -569,11 +558,11 @@ test("buildReplacementsFromSnapshot renders enum field meta options as select co
569
558
  resolvedOwnershipFilter: "public"
570
559
  });
571
560
 
572
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /key: "temperament"/);
573
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /formControl: "select"/);
574
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /options: \[/);
575
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"value": "friendly_excitable"/);
576
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"label": "Friendly Excitable"/);
561
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /temperament: \{/);
562
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /ui: \{ formControl: "select"/);
563
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /options: \[/);
564
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /"value":"friendly_excitable"/);
565
+ assert.match(replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__, /"label":"Friendly Excitable"/);
577
566
  });
578
567
 
579
568
  test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
@@ -738,7 +727,7 @@ test("buildReplacementsFromSnapshot preserves custom collations, hash unique ind
738
727
  );
739
728
  });
740
729
 
741
- test("resolveScaffoldColumns derives resource numeric bounds from check constraints", () => {
730
+ test("resolveScaffoldColumns derives canonical resource numeric bounds from check constraints", () => {
742
731
  const snapshot = createSnapshot({
743
732
  tableName: "batch_receivals",
744
733
  hasWorkspaceIdColumn: false,
@@ -863,25 +852,25 @@ test("resolveScaffoldColumns derives resource numeric bounds from check constrai
863
852
  const moistureLevel = scaffoldColumns.find((column) => column.name === "moisture_level");
864
853
  const severity = scaffoldColumns.find((column) => column.name === "severity");
865
854
 
866
- assert.equal(
867
- __testables.renderResourceFieldSchema(inputWeight),
868
- "Type.Number({ minimum: 0.001 })"
855
+ assert.match(
856
+ __testables.renderCanonicalResourceFieldSchema(inputWeight),
857
+ /type: "number".*min: 0.001.*required: true.*search: true.*create: \{ required: true \}/s
869
858
  );
870
- assert.equal(
871
- __testables.renderResourceFieldSchema(batchedDailySequence),
872
- "Type.Integer({ minimum: 1 })"
859
+ assert.match(
860
+ __testables.renderCanonicalResourceFieldSchema(batchedDailySequence),
861
+ /type: "integer".*min: 1.*required: true.*search: true.*create: \{ required: true \}/s
873
862
  );
874
- assert.equal(
875
- __testables.renderResourceFieldSchema(moistureLevel),
876
- "Type.Union([Type.Number({ minimum: 0, maximum: 100 }), Type.Null()])"
863
+ assert.match(
864
+ __testables.renderCanonicalResourceFieldSchema(moistureLevel),
865
+ /type: "number".*min: 0.*max: 100.*nullable: true.*search: true.*create: \{ required: false \}/s
877
866
  );
878
- assert.equal(
879
- __testables.renderResourceFieldSchema(severity),
880
- "Type.Union([Type.Integer({ minimum: 1, maximum: 10 }), Type.Null()])"
867
+ assert.match(
868
+ __testables.renderCanonicalResourceFieldSchema(severity),
869
+ /type: "integer".*min: 1.*max: 10.*nullable: true.*search: true.*create: \{ required: false \}/s
881
870
  );
882
871
  });
883
872
 
884
- test("buildReplacementsFromSnapshot normalizes nullable temporal inputs without invalid date errors", () => {
873
+ test("buildReplacementsFromSnapshot renders canonical nullable temporal fields without invalid date errors", () => {
885
874
  const snapshot = createSnapshot({
886
875
  hasWorkspaceIdColumn: false,
887
876
  hasUserIdColumn: false
@@ -951,96 +940,107 @@ test("buildReplacementsFromSnapshot normalizes nullable temporal inputs without
951
940
  });
952
941
 
953
942
  assert.match(
954
- replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
955
- /normalizeIfInSource\(source, normalized, "scheduledAt", \(value\) => \{ const normalized = normalizeText\(value\); return normalized \? toIsoString\(normalized\) : null; \}\);/
943
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
944
+ /scheduledAt: \{[\s\S]*type: "dateTime",[\s\S]*nullable: true,[\s\S]*storage: \{ writeSerializer: "datetime-utc" \},[\s\S]*create: \{ required: false \}[\s\S]*\},/s
956
945
  );
957
946
  assert.match(
958
- replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
959
- /normalizeIfInSource\(source, normalized, "birthDate", \(value\) => \{ const normalized = normalizeText\(value\); return normalized \? toIsoString\(normalized\)\.slice\(0, 10\) : null; \}\);/
947
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
948
+ /birthDate: \{[\s\S]*type: "date",[\s\S]*nullable: true,[\s\S]*create: \{ required: false \}[\s\S]*\},/s
960
949
  );
961
950
  assert.match(
962
- replacements.__JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__,
963
- /normalizeIfInSource\(source, normalized, "preferredTime", \(value\) => \{ const normalized = normalizeText\(value\); return normalized \|\| null; \}\);/
964
- );
965
- assert.doesNotMatch(
966
- replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__,
967
- /writeSerializer: "datetime-utc"/
951
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
952
+ /preferredTime: \{[\s\S]*type: "time",[\s\S]*nullable: true,[\s\S]*create: \{ required: false \}[\s\S]*\},/s
968
953
  );
969
954
  });
970
955
 
971
- test("crud repository template defines explicit one-line CRUD methods over repository primitives", async () => {
956
+ test("crud repository template defines a json-rest-api adapter over the injected internal host", async () => {
972
957
  const testDirectory = path.dirname(fileURLToPath(import.meta.url));
973
958
  const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "repository.js");
974
959
  const templateSource = await readFile(templatePath, "utf8");
975
- assert.match(
976
- templateSource,
977
- /from "@jskit-ai\/crud-core\/server\/resourceRuntime";/
978
- );
979
- assert.match(templateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
980
- assert.match(templateSource, /const REPOSITORY_CONFIG = Object\.freeze\(\{/);
981
- assert.match(templateSource, /const resourceRuntime = createCrudResourceRuntime\(resource, knex, \{/);
982
- assert.match(templateSource, /\.\.\.options,/);
983
- assert.match(templateSource, /\.\.\.REPOSITORY_CONFIG/);
984
- assert.match(templateSource, /return resourceRuntime\.list\(query, callOptions\);/);
985
- assert.match(templateSource, /return resourceRuntime\.findById\(recordId, callOptions\);/);
986
- assert.match(templateSource, /return resourceRuntime\.listByIds\(ids, callOptions\);/);
987
- assert.match(templateSource, /return resourceRuntime\.listByForeignIds\(ids, foreignKey, callOptions\);/);
988
- assert.match(templateSource, /return resourceRuntime\.create\(payload, callOptions\);/);
989
- assert.match(templateSource, /return resourceRuntime\.updateById\(recordId, patch, callOptions\);/);
990
- assert.match(templateSource, /return resourceRuntime\.deleteById\(recordId, callOptions\);/);
991
- assert.match(templateSource, /async function listByForeignIds\(ids = \[\], foreignKey = "", callOptions = \{\}\) \{/);
992
- assert.match(templateSource, /withTransaction: resourceRuntime\.withTransaction/);
960
+ assert.doesNotMatch(templateSource, /from "@jskit-ai\/http-runtime\/shared";/);
961
+ assert.match(templateSource, /returnNullWhenJsonRestResourceMissing/);
962
+ assert.match(templateSource, /return api\.resources\.\$\{option:namespace\|camel\}\.query\(/);
963
+ assert.match(templateSource, /return returnNullWhenJsonRestResourceMissing\(\(\) =>\s+api\.resources\.\$\{option:namespace\|camel\}\.get\(/s);
964
+ assert.match(templateSource, /return api\.resources\.\$\{option:namespace\|camel\}\.post\(/);
965
+ assert.match(templateSource, /return returnNullWhenJsonRestResourceMissing\(\(\) =>\s+api\.resources\.\$\{option:namespace\|camel\}\.patch\(/s);
966
+ assert.match(templateSource, /return returnNullWhenJsonRestResourceMissing\(async \(\) => \{\s+await api\.resources\.\$\{option:namespace\|camel\}\.delete\(/s);
967
+ assert.match(templateSource, /createJsonRestContext\(options\?\.context \|\| null\)/);
968
+ assert.match(templateSource, /buildJsonRestQueryParams\(RESOURCE_TYPE, query\)/);
969
+ assert.match(templateSource, /createJsonApiInputRecord\(RESOURCE_TYPE, payload\)/);
970
+ assert.doesNotMatch(templateSource, /function toJsonRestContext\(context = null\)/);
971
+ assert.doesNotMatch(templateSource, /function normalizeArrayInput\(value\)/);
972
+ assert.doesNotMatch(templateSource, /function buildJsonRestQueryParams\(query = \{\}/);
973
+ assert.doesNotMatch(templateSource, /function createJsonApiInputRecord\(attributes = \{\}/);
974
+ assert.doesNotMatch(templateSource, /normalizeVisibilityContext/);
975
+ assert.match(templateSource, /withTransaction,/);
993
976
  assert.match(templateSource, /return Object\.freeze\(\{/);
994
- assert.doesNotMatch(templateSource, /crudRepositoryList/);
977
+ assert.doesNotMatch(templateSource, /createCrudResourceRuntime|resourceRuntime\./);
995
978
  });
996
979
 
997
- test("crud actions and routes templates share LIST_CONFIG for cursor validation", async () => {
980
+ test("crud provider template derives json-rest host options directly from the shared resource", async () => {
981
+ const testDirectory = path.dirname(fileURLToPath(import.meta.url));
982
+ const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "CrudProvider.js");
983
+ const templateSource = await readFile(templatePath, "utf8");
984
+
985
+ assert.match(templateSource, /import \{ createCrudJsonApiServiceEvents \} from "@jskit-ai\/crud-core\/server\/serviceEvents";/);
986
+ assert.match(templateSource, /createJsonRestResourceScopeOptions/);
987
+ assert.match(templateSource, /import \{ createService \} from "\.\/service\.js";/);
988
+ assert.doesNotMatch(templateSource, /serviceEvents\s*\}\s*from "\.\/service\.js";/);
989
+ assert.match(templateSource, /import \{ resource \} from "\.\.\/shared\/\$\{option:namespace\|singular\|camel\}Resource\.js";/);
990
+ assert.doesNotMatch(templateSource, /normalizeRecordId/);
991
+ assert.match(templateSource, /toDatabaseDateTimeUtc/);
992
+ assert.doesNotMatch(templateSource, /register\$\{option:namespace\|pascal\}JsonRestResources/);
993
+ assert.match(templateSource, /const baseServiceEvents = createCrudJsonApiServiceEvents\(CRUD_MODULE_CONFIG\.namespace\);/);
994
+ assert.match(templateSource, /const serviceEvents = \{\s+\.\.\.baseServiceEvents\s+\};/s);
995
+ assert.match(templateSource, /const api = app\.make\(INTERNAL_JSON_REST_API\);/);
996
+ assert.match(templateSource, /await addResourceIfMissing\(\s*api,\s*__JSKIT_CRUD_JSONREST_SCOPE_NAME__,\s*createJsonRestResourceScopeOptions\(resource,/s);
997
+ });
998
+
999
+ test("crud actions and routes templates derive cursor validation and route contracts from the shared resource", async () => {
998
1000
  const testDirectory = path.dirname(fileURLToPath(import.meta.url));
999
1001
  const actionsTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "actions.js");
1000
1002
  const registerRoutesTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "registerRoutes.js");
1001
- const listConfigTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "listConfig.js");
1002
1003
 
1003
1004
  const actionsTemplateSource = await readFile(actionsTemplatePath, "utf8");
1004
1005
  const registerRoutesTemplateSource = await readFile(registerRoutesTemplatePath, "utf8");
1005
- const listConfigTemplateSource = await readFile(listConfigTemplatePath, "utf8");
1006
1006
 
1007
1007
  assert.match(actionsTemplateSource, /createCrudCursorPaginationQueryValidator/);
1008
- assert.match(actionsTemplateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
1009
- assert.match(actionsTemplateSource, /const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator\(LIST_CONFIG\);/);
1008
+ assert.match(actionsTemplateSource, /import \{ resource \} from "\.\.\/shared\/\$\{option:namespace\|singular\|camel\}Resource\.js";/);
1009
+ assert.match(actionsTemplateSource, /const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator\(\{\s+orderBy: resource\.defaultSort\s+\}\);/s);
1010
1010
  assert.match(actionsTemplateSource, /__JSKIT_CRUD_ACTION_PERMISSION_SUPPORT__/);
1011
1011
  assert.match(actionsTemplateSource, /__JSKIT_CRUD_LIST_ACTION_PERMISSION__/);
1012
+ assert.match(actionsTemplateSource, /output: null,/);
1012
1013
  assert.doesNotMatch(actionsTemplateSource, /ACTIONS_REQUIRE_NAMED_PERMISSIONS/);
1013
1014
  assert.doesNotMatch(actionsTemplateSource, /createActionPermission/);
1014
- assert.match(registerRoutesTemplateSource, /createCrudCursorPaginationQueryValidator/);
1015
- assert.match(registerRoutesTemplateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
1016
- assert.match(registerRoutesTemplateSource, /const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator\(LIST_CONFIG\);/);
1017
- assert.match(listConfigTemplateSource, /const LIST_CONFIG = Object\.freeze\(\{/);
1018
- assert.match(listConfigTemplateSource, /__JSKIT_CRUD_LIST_CONFIG_LINES__/);
1015
+ assert.match(registerRoutesTemplateSource, /createCrudJsonApiRouteContracts/);
1016
+ assert.match(registerRoutesTemplateSource, /const \{\s+listRouteContract,\s+viewRouteContract,\s+createRouteContract,\s+updateRouteContract,\s+deleteRouteContract,\s+recordRouteParamsValidator\s+\} = createCrudJsonApiRouteContracts\(\{/s);
1017
+ assert.match(registerRoutesTemplateSource, /resource__JSKIT_CRUD_ROUTE_CONTRACTS_RESOURCE_ARGS__/);
1018
+ assert.doesNotMatch(registerRoutesTemplateSource, /wrapResponse/);
1019
+ assert.match(registerRoutesTemplateSource, /reply\.code\(204\)\.send\(response\);/);
1020
+ assert.doesNotMatch(registerRoutesTemplateSource, /withStandardErrorResponses/);
1019
1021
  });
1020
1022
 
1021
- test("crud service template defines explicit service methods over shared service primitives and preserves overridable default events", async () => {
1023
+ test("crud service template preserves JSON:API output and emits entity ids from resource documents", async () => {
1022
1024
  const testDirectory = path.dirname(fileURLToPath(import.meta.url));
1023
1025
  const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "service.js");
1024
1026
  const templateSource = await readFile(templatePath, "utf8");
1025
1027
 
1026
- assert.match(
1027
- templateSource,
1028
- /from "@jskit-ai\/crud-core\/server\/serviceEvents";/
1029
- );
1030
- assert.match(
1031
- templateSource,
1032
- /from "@jskit-ai\/crud-core\/server\/serviceMethods";/
1033
- );
1034
- assert.match(templateSource, /const serviceRuntime = createCrudServiceRuntime\(resource,/);
1035
- assert.match(templateSource, /const baseServiceEvents = createCrudServiceEvents\(resource,/);
1036
- assert.match(templateSource, /const serviceEvents = Object\.freeze\(\{/);
1037
- assert.match(templateSource, /createRecord: \[\.\.\.baseServiceEvents\.createRecord\],/);
1038
- assert.match(templateSource, /function createService\(\{ \$\{option:namespace\|camel\}Repository, fieldAccess = DEFAULT_FIELD_ACCESS \} = \{\}\)/);
1039
- assert.match(templateSource, /async function listRecords\(query = \{\}, options = \{\}\)/);
1040
- assert.match(templateSource, /return crudServiceListRecords\(serviceRuntime, \$\{option:namespace\|camel\}Repository, fieldAccess, query, options\);/);
1041
- assert.match(templateSource, /async function updateRecord\(recordId, payload = \{\}, options = \{\}\)/);
1042
- assert.match(templateSource, /return crudServiceUpdateRecord\(serviceRuntime, \$\{option:namespace\|camel\}Repository, fieldAccess, recordId, payload, options\);/);
1028
+ assert.doesNotMatch(templateSource, /crudNamespaceSupport/);
1029
+ assert.doesNotMatch(templateSource, /serviceEvents/);
1030
+ assert.doesNotMatch(templateSource, /recordChangedEventName/);
1031
+ assert.doesNotMatch(templateSource, /resolveJsonApiResultRecordId/);
1032
+ assert.doesNotMatch(templateSource, /entityId:/);
1033
+ assert.match(templateSource, /function return404IfNotFound\(document = null\) \{/);
1034
+ assert.match(templateSource, /throw new AppError\(404, "Document not found\."\);/);
1035
+ assert.match(templateSource, /function createService\(\{ \$\{option:namespace\|camel\}Repository \} = \{\}\)/);
1036
+ assert.match(templateSource, /throw new TypeError\("createService requires \$\{option:namespace\|camel\}Repository\."\);/);
1037
+ assert.match(templateSource, /returnJsonApiDocument/);
1038
+ assert.match(templateSource, /async function queryDocuments\(query = \{\}, options = \{\}\)/);
1039
+ assert.match(templateSource, /returnJsonApiDocument\(await \$\{option:namespace\|camel\}Repository\.queryDocuments\(query, \{/);
1040
+ assert.match(templateSource, /async function patchDocumentById\(recordId, payload = \{\}, options = \{\}\)/);
1041
+ assert.match(templateSource, /returnJsonApiDocument\(return404IfNotFound\(await \$\{option:namespace\|camel\}Repository\.patchDocumentById\(recordId, payload, \{/);
1043
1042
  assert.match(templateSource, /return Object\.freeze\(\{/);
1043
+ assert.match(templateSource, /export \{ createService \};/);
1044
1044
  });
1045
1045
 
1046
1046
  test("crud generator renders time columns with html-time-compatible schemas", async () => {
@@ -1050,16 +1050,12 @@ test("crud generator renders time columns with html-time-compatible schemas", as
1050
1050
 
1051
1051
  assert.match(
1052
1052
  templateSource,
1053
- /NULLABLE_HTML_TIME_STRING_SCHEMA/
1053
+ /type: "time"/
1054
1054
  );
1055
- assert.match(
1056
- templateSource,
1057
- /HTML_TIME_STRING_SCHEMA/
1058
- );
1059
- assert.doesNotMatch(templateSource, /format: "time"/);
1055
+ assert.doesNotMatch(templateSource, /HTML_TIME_STRING_SCHEMA|NULLABLE_HTML_TIME_STRING_SCHEMA/);
1060
1056
  });
1061
1057
 
1062
- test("buildReplacementsFromSnapshot uses shared framework time schemas in generated resources", () => {
1058
+ test("buildReplacementsFromSnapshot renders canonical nullable time fields", () => {
1063
1059
  const snapshot = createSnapshot({
1064
1060
  tableName: "opening_hours"
1065
1061
  });
@@ -1090,28 +1086,12 @@ test("buildReplacementsFromSnapshot uses shared framework time schemas in genera
1090
1086
  });
1091
1087
 
1092
1088
  assert.match(
1093
- replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__,
1094
- /(^|\n)\s*NULLABLE_HTML_TIME_STRING_SCHEMA(,|\n)/m
1095
- );
1096
- assert.doesNotMatch(
1097
- replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__,
1098
- /(^|\n)\s*HTML_TIME_STRING_SCHEMA(,|\n)/m
1099
- );
1100
- assert.match(
1101
- replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
1102
- /fromTime: NULLABLE_HTML_TIME_STRING_SCHEMA/
1103
- );
1104
- assert.match(
1105
- replacements.__JSKIT_CRUD_RESOURCE_CREATE_SCHEMA_PROPERTIES__,
1106
- /fromTime: NULLABLE_HTML_TIME_STRING_SCHEMA/
1107
- );
1108
- assert.doesNotMatch(
1109
- replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
1110
- /Type\.String\(\{ pattern:/
1089
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
1090
+ /fromTime: \{[\s\S]*type: "time",[\s\S]*nullable: true,[\s\S]*output: \{ required: true \},[\s\S]*create: \{ required: false \},[\s\S]*patch: \{ required: false \}[\s\S]*\},/s
1111
1091
  );
1112
1092
  });
1113
1093
 
1114
- test("buildReplacementsFromSnapshot imports only the non-nullable time schema when nullable time fields are absent", () => {
1094
+ test("buildReplacementsFromSnapshot renders canonical non-nullable time fields", () => {
1115
1095
  const snapshot = createSnapshot({
1116
1096
  tableName: "opening_hours"
1117
1097
  });
@@ -1142,74 +1122,30 @@ test("buildReplacementsFromSnapshot imports only the non-nullable time schema wh
1142
1122
  });
1143
1123
 
1144
1124
  assert.match(
1145
- replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__,
1146
- /(^|\n)\s*HTML_TIME_STRING_SCHEMA(,|\n)/m
1147
- );
1148
- assert.doesNotMatch(
1149
- replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__,
1150
- /(^|\n)\s*NULLABLE_HTML_TIME_STRING_SCHEMA(,|\n)/m
1151
- );
1152
- assert.match(
1153
- replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
1154
- /fromTime: HTML_TIME_STRING_SCHEMA/
1125
+ replacements.__JSKIT_CRUD_RESOURCE_SCHEMA_PROPERTIES__,
1126
+ /fromTime: \{[\s\S]*type: "time",[\s\S]*required: true,[\s\S]*output: \{ required: true \},[\s\S]*create: \{ required: true \},[\s\S]*patch: \{ required: false \}[\s\S]*\},/s
1155
1127
  );
1156
- assert.match(
1157
- replacements.__JSKIT_CRUD_RESOURCE_CREATE_SCHEMA_PROPERTIES__,
1158
- /fromTime: HTML_TIME_STRING_SCHEMA/
1159
- );
1160
- });
1161
-
1162
- test("buildReplacementsFromSnapshot only imports record-id validator helpers that the resource actually uses", () => {
1163
- const snapshot = createSnapshot({
1164
- tableName: "pollen_types",
1165
- columns: [
1166
- {
1167
- name: "id",
1168
- dataType: "bigint",
1169
- columnType: "bigint unsigned",
1170
- nullable: false,
1171
- key: "id"
1172
- },
1173
- {
1174
- name: "name",
1175
- dataType: "varchar",
1176
- columnType: "varchar(32)",
1177
- nullable: false,
1178
- maxLength: 32,
1179
- key: "name"
1180
- }
1181
- ]
1182
- });
1183
-
1184
- const replacements = __testables.buildReplacementsFromSnapshot({
1185
- namespace: "pollen-types",
1186
- snapshot,
1187
- resolvedOwnershipFilter: "public"
1188
- });
1189
-
1190
- assert.match(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /recordIdSchema/);
1191
- assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /recordIdInputSchema/);
1192
- assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /nullableRecordIdSchema/);
1193
- assert.doesNotMatch(replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__, /nullableRecordIdInputSchema/);
1194
1128
  });
1195
1129
 
1196
- test("crud provider template uses shared lookup provider helpers instead of inline wiring", async () => {
1130
+ test("crud provider template injects the shared internal json-rest-api host and registers local resources at boot", async () => {
1197
1131
  const testDirectory = path.dirname(fileURLToPath(import.meta.url));
1198
1132
  const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "CrudProvider.js");
1199
1133
  const templateSource = await readFile(templatePath, "utf8");
1200
1134
 
1201
1135
  assert.match(
1202
1136
  templateSource,
1203
- /from "@jskit-ai\/crud-core\/server\/lookups";/
1137
+ /from "@jskit-ai\/json-rest-api-core\/server\/jsonRestApiHost";/
1204
1138
  );
1205
- assert.match(templateSource, /resolveLookup: createCrudLookupResolver\(scope\)/);
1206
1139
  assert.match(
1207
1140
  templateSource,
1208
- /return createCrudLookup\(scope\.make\("repository\.\$\{option:namespace\|snake\}"\), \{\s*ownershipFilter: crudPolicy\.ownershipFilter\s*\}\);/
1141
+ /const api = scope\.make\(INTERNAL_JSON_REST_API\);/
1209
1142
  );
1143
+ assert.match(templateSource, /return createRepository\(\{\s*api,\s*knex\s*\}\);/s);
1144
+ assert.match(templateSource, /await addResourceIfMissing\(\s*api,\s*__JSKIT_CRUD_JSONREST_SCOPE_NAME__,\s*createJsonRestResourceScopeOptions\(resource,/s);
1145
+ assert.doesNotMatch(templateSource, /register\$\{option:namespace\|pascal\}JsonRestResources/);
1210
1146
  assert.match(
1211
1147
  templateSource,
1212
1148
  /routeSurfaceRequiresWorkspace: crudPolicy\.surfaceDefinition\.requiresWorkspace === true,/
1213
1149
  );
1214
- assert.doesNotMatch(templateSource, /normalizePathname\(relation\.apiPath\)/);
1150
+ assert.doesNotMatch(templateSource, /createCrudLookup|lookup\.\$\{option:namespace\|snake\}/);
1215
1151
  });
@@ -1,47 +1,42 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
3
4
  import { crudResource } from "../src/shared/crud/crudResource.js";
4
5
 
5
- test("crudResource normalizes create payload", () => {
6
- const normalized = crudResource.operations.create.bodyValidator.normalize({
6
+ test("crudResource normalizes create payload through schema validation", async () => {
7
+ const normalized = await validateSchemaPayload(crudResource.operations.create.body, {
7
8
  textField: " Example text ",
8
9
  dateField: "2026-03-11",
9
10
  numberField: "42.5"
10
- });
11
+ }, { phase: "input" });
11
12
 
12
- assert.deepEqual(normalized, {
13
- textField: "Example text",
14
- dateField: "2026-03-11T00:00:00.000Z",
15
- numberField: 42.5
16
- });
13
+ assert.equal(normalized.textField, "Example text");
14
+ assert.equal(normalized.numberField, 42.5);
15
+ assert.ok(normalized.dateField instanceof Date);
16
+ assert.equal(normalized.dateField.toISOString(), "2026-03-11T00:00:00.000Z");
17
17
  });
18
18
 
19
- test("crudResource normalizes list output", () => {
20
- const normalized = crudResource.operations.list.outputValidator.normalize({
21
- items: [
22
- {
23
- id: "7",
24
- textField: " Example text ",
25
- dateField: "2026-03-10",
26
- numberField: "99",
27
- createdAt: "2026-03-11 00:00:00.000",
28
- updatedAt: "2026-03-11 00:00:00.000"
29
- }
30
- ],
31
- nextCursor: " 8 "
32
- });
19
+ test("crudResource normalizes record output through schema validation", async () => {
20
+ const normalized = await validateSchemaPayload(crudResource.operations.view.output, {
21
+ id: 7,
22
+ textField: " Example text ",
23
+ dateField: "2026-03-10",
24
+ numberField: "99",
25
+ createdAt: "2026-03-11 00:00:00.000",
26
+ updatedAt: "2026-03-11 00:00:00.000"
27
+ }, { phase: "output" });
33
28
 
34
- assert.equal(normalized.items[0].id, "7");
35
- assert.equal(normalized.items[0].textField, "Example text");
36
- assert.equal(normalized.items[0].dateField, "2026-03-10T00:00:00.000Z");
37
- assert.equal(normalized.items[0].numberField, 99);
38
- assert.match(normalized.items[0].createdAt, /T/);
39
- assert.match(normalized.items[0].updatedAt, /T/);
40
- assert.equal(normalized.nextCursor, "8");
29
+ assert.equal(normalized.id, "7");
30
+ assert.equal(normalized.textField, "Example text");
31
+ assert.ok(normalized.dateField instanceof Date);
32
+ assert.equal(normalized.dateField.toISOString(), "2026-03-10T00:00:00.000Z");
33
+ assert.equal(normalized.numberField, 99);
34
+ assert.ok(normalized.createdAt instanceof Date);
35
+ assert.ok(normalized.updatedAt instanceof Date);
41
36
  });
42
37
 
43
38
  test("crudResource list operation exposes output validator only", () => {
44
- assert.equal(typeof crudResource.operations.list.outputValidator?.normalize, "function");
45
- assert.equal(crudResource.operations.list.inputValidator, undefined);
39
+ assert.equal(crudResource.operations.list.output?.normalize, undefined);
40
+ assert.equal(crudResource.operations.list.input, undefined);
46
41
  assert.deepEqual(crudResource.operations.list.realtime?.events, ["crud.record.changed"]);
47
42
  });