@jskit-ai/crud-server-generator 0.1.39 → 0.1.41

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/crud-server-generator",
4
- version: "0.1.39",
4
+ version: "0.1.41",
5
5
  kind: "generator",
6
6
  description: "CRUD server generator with routes, actions, and persistence scaffolding.",
7
7
  options: {
@@ -15,6 +15,7 @@ export default Object.freeze({
15
15
  surface: {
16
16
  required: true,
17
17
  inputType: "text",
18
+ validationType: "enabled-surface-id",
18
19
  promptLabel: "Target surface",
19
20
  promptHint: "Must match an enabled surface id."
20
21
  },
@@ -45,6 +46,13 @@ export default Object.freeze({
45
46
  defaultValue: "id",
46
47
  promptLabel: "Id column",
47
48
  promptHint: "Primary key column used by CRUD endpoints (default: id)."
49
+ },
50
+ force: {
51
+ required: false,
52
+ inputType: "flag",
53
+ defaultValue: "",
54
+ promptLabel: "Force overwrite",
55
+ promptHint: "Overwrite generated scaffold files if the namespace package directory already exists."
48
56
  }
49
57
  },
50
58
  optionPolicies: {
@@ -90,8 +98,14 @@ export default Object.freeze({
90
98
  "ownership-filter",
91
99
  "table-name",
92
100
  "id-column",
93
- "directory-prefix"
94
- ]
101
+ "directory-prefix",
102
+ "force"
103
+ ],
104
+ createTarget: {
105
+ pathTemplate: "packages/${option:namespace|kebab}",
106
+ label: "package directory",
107
+ allowExistingEmptyDirectory: false
108
+ }
95
109
  },
96
110
  "scaffold-field": {
97
111
  entrypoint: "src/server/subcommands/addField.js",
@@ -134,13 +148,13 @@ export default Object.freeze({
134
148
  mutations: {
135
149
  dependencies: {
136
150
  runtime: {
137
- "@jskit-ai/auth-core": "0.1.30",
138
- "@jskit-ai/crud-core": "0.1.39",
139
- "@jskit-ai/database-runtime": "0.1.31",
140
- "@jskit-ai/http-runtime": "0.1.30",
141
- "@jskit-ai/kernel": "0.1.31",
142
- "@jskit-ai/realtime": "0.1.30",
143
- "@jskit-ai/users-core": "0.1.41",
151
+ "@jskit-ai/auth-core": "0.1.32",
152
+ "@jskit-ai/crud-core": "0.1.41",
153
+ "@jskit-ai/database-runtime": "0.1.33",
154
+ "@jskit-ai/http-runtime": "0.1.32",
155
+ "@jskit-ai/kernel": "0.1.33",
156
+ "@jskit-ai/realtime": "0.1.32",
157
+ "@jskit-ai/users-core": "0.1.43",
144
158
  "@local/${option:namespace|kebab}": "file:packages/${option:namespace|kebab}",
145
159
  "typebox": "^1.0.81"
146
160
  },
@@ -203,6 +217,17 @@ export default Object.freeze({
203
217
  category: "crud",
204
218
  id: "crud-local-package-server-action-ids-${option:namespace|snake}"
205
219
  },
220
+ {
221
+ from: "templates/src/local-package/server/listConfig.js",
222
+ to: "packages/${option:namespace|kebab}/src/server/listConfig.js",
223
+ reason: "Install app-local CRUD list configuration.",
224
+ category: "crud",
225
+ id: "crud-local-package-server-list-config-${option:namespace|snake}",
226
+ templateContext: {
227
+ entrypoint: "src/server/buildTemplateContext.js",
228
+ export: "buildTemplateContext"
229
+ }
230
+ },
206
231
  {
207
232
  from: "templates/src/local-package/server/registerRoutes.js",
208
233
  to: "packages/${option:namespace|kebab}/src/server/registerRoutes.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-server-generator",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -13,11 +13,11 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@babel/parser": "^7.29.2",
16
- "@jskit-ai/crud-core": "0.1.39",
17
- "@jskit-ai/database-runtime": "0.1.31",
18
- "@jskit-ai/http-runtime": "0.1.30",
19
- "@jskit-ai/kernel": "0.1.31",
20
- "@jskit-ai/users-core": "0.1.41",
16
+ "@jskit-ai/crud-core": "0.1.41",
17
+ "@jskit-ai/database-runtime": "0.1.33",
18
+ "@jskit-ai/http-runtime": "0.1.32",
19
+ "@jskit-ai/kernel": "0.1.33",
20
+ "@jskit-ai/users-core": "0.1.43",
21
21
  "recast": "^0.23.11",
22
22
  "typebox": "^1.0.81"
23
23
  }
@@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url";
5
5
  import {
6
6
  normalizeText,
7
7
  resolveDatabaseClientFromEnvironment,
8
- resolveDatabaseConnectionFromEnvironment,
8
+ resolveKnexConnectionFromEnvironment,
9
9
  toKnexClientId
10
10
  } from "@jskit-ai/database-runtime/shared";
11
11
  import { checkCrudLookupFormControl } from "@jskit-ai/crud-core/shared/crudFieldMetaSupport";
@@ -61,8 +61,8 @@ function normalizeRequestedOwnershipFilter(value, { strict = false } = {}) {
61
61
  }
62
62
 
63
63
  function inferOwnershipFilterFromSnapshot(snapshot) {
64
- const hasWorkspace = snapshot?.hasWorkspaceOwnerColumn === true;
65
- const hasUser = snapshot?.hasUserOwnerColumn === true;
64
+ const hasWorkspace = snapshot?.hasWorkspaceIdColumn === true;
65
+ const hasUser = snapshot?.hasUserIdColumn === true;
66
66
  if (hasWorkspace && hasUser) {
67
67
  return "workspace_user";
68
68
  }
@@ -76,24 +76,24 @@ function inferOwnershipFilterFromSnapshot(snapshot) {
76
76
  }
77
77
 
78
78
  function assertOwnershipColumnsForFilter(snapshot, filter) {
79
- const hasWorkspace = snapshot?.hasWorkspaceOwnerColumn === true;
80
- const hasUser = snapshot?.hasUserOwnerColumn === true;
79
+ const hasWorkspace = snapshot?.hasWorkspaceIdColumn === true;
80
+ const hasUser = snapshot?.hasUserIdColumn === true;
81
81
  if (filter === "public") {
82
82
  return;
83
83
  }
84
84
  if (filter === "workspace" && !hasWorkspace) {
85
85
  throw new Error(
86
- 'Ownership filter "workspace" requires column "workspace_owner_id".'
86
+ 'Ownership filter "workspace" requires column "workspace_id".'
87
87
  );
88
88
  }
89
89
  if (filter === "user" && !hasUser) {
90
90
  throw new Error(
91
- 'Ownership filter "user" requires column "user_owner_id".'
91
+ 'Ownership filter "user" requires column "user_id".'
92
92
  );
93
93
  }
94
94
  if (filter === "workspace_user" && (!hasWorkspace || !hasUser)) {
95
95
  throw new Error(
96
- 'Ownership filter "workspace_user" requires both columns "workspace_owner_id" and "user_owner_id".'
96
+ 'Ownership filter "workspace_user" requires both columns "workspace_id" and "user_id".'
97
97
  );
98
98
  }
99
99
  }
@@ -221,7 +221,8 @@ async function resolveMysqlSnapshotFromDatabase({
221
221
  );
222
222
  }
223
223
 
224
- const connection = resolveDatabaseConnectionFromEnvironment(env, {
224
+ const connection = resolveKnexConnectionFromEnvironment(env, {
225
+ client: dbClient,
225
226
  defaultPort: 3306,
226
227
  context: "crud table introspection"
227
228
  });
@@ -269,16 +270,24 @@ function resolveColumnKey(column, idColumn) {
269
270
  function resolveScaffoldColumns(snapshot) {
270
271
  const idColumn = String(snapshot.idColumn || DEFAULT_ID_COLUMN);
271
272
  const sourceColumns = Array.isArray(snapshot.columns) ? snapshot.columns : [];
273
+ const foreignKeyColumnNames = new Set(
274
+ (Array.isArray(snapshot.foreignKeys) ? snapshot.foreignKeys : [])
275
+ .flatMap((foreignKey) => Array.isArray(foreignKey?.columns) ? foreignKey.columns : [])
276
+ .map((entry) => String(entry?.name || "").trim())
277
+ .filter(Boolean)
278
+ );
272
279
  const seenKeys = new Set();
273
280
 
274
281
  const columns = sourceColumns.map((column) => {
275
- const isWorkspaceOwnerColumn = column.name === "workspace_owner_id";
276
- const isUserOwnerColumn = column.name === "user_owner_id";
277
- const isOwnerColumn = isWorkspaceOwnerColumn || isUserOwnerColumn;
282
+ const isWorkspaceIdColumn = column.name === "workspace_id";
283
+ const isUserIdColumn = column.name === "user_id";
284
+ const isOwnerColumn = isWorkspaceIdColumn || isUserIdColumn;
278
285
  const isIdColumn = column.name === idColumn;
286
+ const isForeignIdColumn = foreignKeyColumnNames.has(column.name) || /_id$/i.test(String(column.name || ""));
279
287
  const isCreatedAtColumn = column.name === "created_at";
280
288
  const isUpdatedAtColumn = column.name === "updated_at";
281
289
  const key = resolveColumnKey(column, idColumn);
290
+ const isRecordIdColumn = isIdColumn || isOwnerColumn || isForeignIdColumn || /Id$/.test(String(key || ""));
282
291
  if (!key) {
283
292
  throw new Error(`Could not derive API field key for column "${column.name}".`);
284
293
  }
@@ -297,6 +306,8 @@ function resolveScaffoldColumns(snapshot) {
297
306
  key,
298
307
  isOwnerColumn,
299
308
  isIdColumn,
309
+ isForeignIdColumn,
310
+ isRecordIdColumn,
300
311
  isCreatedAtColumn,
301
312
  isUpdatedAtColumn,
302
313
  writable: !isOwnerColumn && !isIdColumn && !isCreatedAtColumn && !isUpdatedAtColumn
@@ -327,9 +338,7 @@ function renderPropertyAccess(sourceName, key) {
327
338
 
328
339
  function renderIntegerSchema(column) {
329
340
  const options = [];
330
- if (column.isIdColumn === true) {
331
- options.push("minimum: 1");
332
- } else if (column.unsigned === true) {
341
+ if (column.unsigned === true) {
333
342
  options.push("minimum: 0");
334
343
  }
335
344
  if (options.length > 0) {
@@ -359,6 +368,11 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
359
368
  if (typeKind === "string") {
360
369
  schemaExpression = renderStringSchema(column, { forOutput });
361
370
  } else if (typeKind === "integer") {
371
+ if (column?.isRecordIdColumn === true) {
372
+ return forOutput
373
+ ? (column.nullable === true ? "nullableRecordIdSchema" : "recordIdSchema")
374
+ : (column.nullable === true ? "nullableRecordIdInputSchema" : "recordIdInputSchema");
375
+ }
362
376
  schemaExpression = renderIntegerSchema(column);
363
377
  } else if (typeKind === "number") {
364
378
  schemaExpression = "Type.Number()";
@@ -382,11 +396,14 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
382
396
  return schemaExpression;
383
397
  }
384
398
 
385
- function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false } = {}) {
399
+ function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false, needsRecordIdSchemas = false } = {}) {
386
400
  const imports = [
387
401
  "normalizeObjectInput",
388
402
  "createCursorListValidator"
389
403
  ];
404
+ if (needsRecordIdSchemas) {
405
+ imports.push("recordIdSchema", "recordIdInputSchema", "nullableRecordIdSchema", "nullableRecordIdInputSchema");
406
+ }
390
407
  if (needsHtmlTimeSchemas) {
391
408
  imports.push("HTML_TIME_STRING_SCHEMA", "NULLABLE_HTML_TIME_STRING_SCHEMA");
392
409
  }
@@ -406,6 +423,12 @@ function renderInputNormalizer(column) {
406
423
  return "normalizeText";
407
424
  }
408
425
  if (typeKind === "integer") {
426
+ if (column?.isRecordIdColumn === true) {
427
+ if (nullable) {
428
+ return "(value) => normalizeRecordId(value, { fallback: null })";
429
+ }
430
+ return "normalizeRecordId";
431
+ }
409
432
  return "normalizeFiniteInteger";
410
433
  }
411
434
  if (typeKind === "number") {
@@ -434,10 +457,17 @@ function renderInputNormalizer(column) {
434
457
 
435
458
  function renderOutputNormalizerExpression(column) {
436
459
  const typeKind = String(column.typeKind || "");
460
+ const nullable = column?.nullable === true;
437
461
  if (typeKind === "string" || typeKind === "time") {
438
462
  return "normalizeText";
439
463
  }
440
464
  if (typeKind === "integer") {
465
+ if (column?.isRecordIdColumn === true) {
466
+ if (nullable) {
467
+ return "(value) => normalizeRecordId(value, { fallback: null })";
468
+ }
469
+ return "normalizeRecordId";
470
+ }
441
471
  return "normalizeFiniteInteger";
442
472
  }
443
473
  if (typeKind === "number") {
@@ -522,6 +552,7 @@ function renderResourceNormalizeSupportImport({
522
552
  needsNormalizeBoolean = false,
523
553
  needsNormalizeFiniteNumber = false,
524
554
  needsNormalizeFiniteInteger = false,
555
+ needsNormalizeRecordId = false,
525
556
  needsNormalizeIfInSource = false,
526
557
  needsNormalizeIfPresent = false,
527
558
  needsNormalizeOrNull = false
@@ -539,6 +570,9 @@ function renderResourceNormalizeSupportImport({
539
570
  if (needsNormalizeFiniteInteger) {
540
571
  imports.push("normalizeFiniteInteger");
541
572
  }
573
+ if (needsNormalizeRecordId) {
574
+ imports.push("normalizeRecordId");
575
+ }
542
576
  if (needsNormalizeIfInSource) {
543
577
  imports.push("normalizeIfInSource");
544
578
  }
@@ -596,15 +630,57 @@ function renderMigrationDefaultClause(column) {
596
630
  }
597
631
  }
598
632
 
633
+ if (column.typeKind === "string" && normalized.startsWith("'") && normalized.endsWith("'")) {
634
+ const unquoted = normalized
635
+ .slice(1, -1)
636
+ .replace(/\\'/g, "'")
637
+ .replace(/''/g, "'");
638
+ return `.defaultTo(${JSON.stringify(unquoted)})`;
639
+ }
640
+
599
641
  return `.defaultTo(${JSON.stringify(rawDefault)})`;
600
642
  }
601
643
 
602
- function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, primaryKeyColumns = [] } = {}) {
644
+ function renderMigrationSpecificStringType(column, { tableCollation = "" } = {}) {
645
+ const baseType = normalizeText(column?.columnType);
646
+ if (!baseType) {
647
+ return "";
648
+ }
649
+
650
+ const characterSetName = normalizeText(column?.characterSetName);
651
+ const collationName = normalizeText(column?.collationName);
652
+ const normalizedTableCollation = normalizeText(tableCollation);
653
+ if (!collationName || collationName === normalizedTableCollation) {
654
+ return "";
655
+ }
656
+
657
+ const parts = [baseType];
658
+ if (characterSetName) {
659
+ parts.push(`CHARACTER SET ${characterSetName}`);
660
+ }
661
+ parts.push(`COLLATE ${collationName}`);
662
+ return parts.join(" ");
663
+ }
664
+
665
+ function renderTemporalColumnBuilder(column, methodName) {
666
+ if (Number.isFinite(column?.datetimePrecision) && column.datetimePrecision > 0) {
667
+ return `table.${methodName}(${JSON.stringify(column.name)}, { precision: ${column.datetimePrecision} })`;
668
+ }
669
+ return `table.${methodName}(${JSON.stringify(column.name)})`;
670
+ }
671
+
672
+ function renderMigrationColumnLine(column, {
673
+ idColumn = DEFAULT_ID_COLUMN,
674
+ primaryKeyColumns = [],
675
+ foreignKeyColumnNames = new Set(),
676
+ tableCollation = ""
677
+ } = {}) {
603
678
  const isPrimary = Array.isArray(primaryKeyColumns) && primaryKeyColumns.includes(column.name);
604
679
  const isIdColumn = column.name === idColumn;
680
+ const isRecordIdColumn = isIdColumn || column.name === "workspace_id" || column.name === "user_id" || foreignKeyColumnNames.has(column.name) || /_id$/i.test(String(column.name || ""));
605
681
 
606
682
  if (isIdColumn && column.autoIncrement) {
607
- let line = `table.increments(${JSON.stringify(column.name)})`;
683
+ let line = `table.bigIncrements(${JSON.stringify(column.name)})`;
608
684
  if (column.unsigned) {
609
685
  line += ".unsigned()";
610
686
  }
@@ -615,25 +691,40 @@ function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, prima
615
691
  let line = "";
616
692
  const nameLiteral = JSON.stringify(column.name);
617
693
  const dataType = String(column.dataType || "").toLowerCase();
694
+ const specificStringType = renderMigrationSpecificStringType(column, {
695
+ tableCollation
696
+ });
618
697
 
619
698
  if (dataType === "varchar") {
620
- const maxLength = Number.isFinite(column.maxLength) ? column.maxLength : 255;
621
- line = `table.string(${nameLiteral}, ${maxLength})`;
699
+ if (specificStringType) {
700
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType)})`;
701
+ } else {
702
+ const maxLength = Number.isFinite(column.maxLength) ? column.maxLength : 255;
703
+ line = `table.string(${nameLiteral}, ${maxLength})`;
704
+ }
622
705
  } else if (dataType === "char") {
623
- line = `table.specificType(${nameLiteral}, ${JSON.stringify(column.columnType || "char(255)")})`;
706
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType || column.columnType || "char(255)")})`;
624
707
  } else if (dataType === "text") {
625
- line = `table.text(${nameLiteral})`;
708
+ if (specificStringType) {
709
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType)})`;
710
+ } else {
711
+ line = `table.text(${nameLiteral})`;
712
+ }
626
713
  } else if (dataType === "tinytext" || dataType === "mediumtext" || dataType === "longtext") {
627
- line = `table.text(${nameLiteral}, ${JSON.stringify(dataType)})`;
714
+ if (specificStringType) {
715
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType)})`;
716
+ } else {
717
+ line = `table.text(${nameLiteral}, ${JSON.stringify(dataType)})`;
718
+ }
628
719
  } else if (dataType === "enum") {
629
720
  const enumValues = Array.isArray(column.enumValues) ? column.enumValues : [];
630
721
  line = `table.enu(${nameLiteral}, ${JSON.stringify(enumValues)})`;
631
722
  } else if (dataType === "set") {
632
- line = `table.specificType(${nameLiteral}, ${JSON.stringify(column.columnType || "set")})`;
723
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType || column.columnType || "set")})`;
633
724
  } else if (column.typeKind === "boolean") {
634
725
  line = `table.boolean(${nameLiteral})`;
635
726
  } else if (dataType === "int" || dataType === "integer") {
636
- line = `table.integer(${nameLiteral})`;
727
+ line = isRecordIdColumn ? `table.bigInteger(${nameLiteral})` : `table.integer(${nameLiteral})`;
637
728
  } else if (dataType === "smallint") {
638
729
  line = `table.smallint(${nameLiteral})`;
639
730
  } else if (dataType === "bigint") {
@@ -657,11 +748,11 @@ function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, prima
657
748
  } else if (dataType === "date") {
658
749
  line = `table.date(${nameLiteral})`;
659
750
  } else if (dataType === "time") {
660
- line = `table.time(${nameLiteral})`;
751
+ line = renderTemporalColumnBuilder(column, "time");
661
752
  } else if (dataType === "datetime") {
662
- line = `table.dateTime(${nameLiteral})`;
753
+ line = renderTemporalColumnBuilder(column, "dateTime");
663
754
  } else if (dataType === "timestamp") {
664
- line = `table.timestamp(${nameLiteral})`;
755
+ line = renderTemporalColumnBuilder(column, "timestamp");
665
756
  } else {
666
757
  throw new Error(
667
758
  `Unsupported MySQL type "${dataType}" in migration renderer for column "${column.name}".`
@@ -682,10 +773,18 @@ function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, prima
682
773
 
683
774
  function renderMigrationColumnLines(snapshot) {
684
775
  const columns = Array.isArray(snapshot.columns) ? snapshot.columns : [];
776
+ const foreignKeyColumnNames = new Set(
777
+ (Array.isArray(snapshot.foreignKeys) ? snapshot.foreignKeys : [])
778
+ .flatMap((foreignKey) => Array.isArray(foreignKey?.columns) ? foreignKey.columns : [])
779
+ .map((entry) => String(entry?.name || "").trim())
780
+ .filter(Boolean)
781
+ );
685
782
  const lines = columns.map((column) =>
686
783
  ` ${renderMigrationColumnLine(column, {
687
784
  idColumn: snapshot.idColumn,
688
- primaryKeyColumns: snapshot.primaryKeyColumns
785
+ primaryKeyColumns: snapshot.primaryKeyColumns,
786
+ foreignKeyColumnNames,
787
+ tableCollation: snapshot.tableCollation
689
788
  })}`
690
789
  );
691
790
  return lines.join("\n");
@@ -699,13 +798,23 @@ function renderMigrationIndexLine(index) {
699
798
 
700
799
  const columnsLiteral = JSON.stringify(columns);
701
800
  const indexName = normalizeText(index?.name);
801
+ const normalizedIndexType = normalizeText(index?.indexType).toUpperCase();
802
+ const storageEngineIndexType = normalizedIndexType && normalizedIndexType !== "BTREE"
803
+ ? normalizedIndexType.toLowerCase()
804
+ : "";
702
805
  if (index?.unique === true) {
806
+ if (indexName && storageEngineIndexType) {
807
+ return ` table.unique(${columnsLiteral}, { indexName: ${JSON.stringify(indexName)}, storageEngineIndexType: ${JSON.stringify(storageEngineIndexType)} });`;
808
+ }
703
809
  if (indexName) {
704
810
  return ` table.unique(${columnsLiteral}, ${JSON.stringify(indexName)});`;
705
811
  }
706
812
  return ` table.unique(${columnsLiteral});`;
707
813
  }
708
814
 
815
+ if (indexName && normalizedIndexType && normalizedIndexType !== "BTREE") {
816
+ return ` table.index(${columnsLiteral}, ${JSON.stringify(indexName)}, ${JSON.stringify(normalizedIndexType)});`;
817
+ }
709
818
  if (indexName) {
710
819
  return ` table.index(${columnsLiteral}, ${JSON.stringify(indexName)});`;
711
820
  }
@@ -764,6 +873,28 @@ function renderMigrationForeignKeyLines(snapshot) {
764
873
  return lines.join("\n");
765
874
  }
766
875
 
876
+ function renderMigrationCheckConstraintLines(snapshot) {
877
+ const tableName = normalizeText(snapshot?.tableName);
878
+ const checkConstraints = Array.isArray(snapshot?.checkConstraints) ? snapshot.checkConstraints : [];
879
+ if (!tableName || checkConstraints.length < 1) {
880
+ return "";
881
+ }
882
+
883
+ return checkConstraints
884
+ .map((constraint) => {
885
+ const name = normalizeText(constraint?.name);
886
+ const clause = normalizeText(constraint?.clause);
887
+ if (!name || !clause) {
888
+ return "";
889
+ }
890
+
891
+ const sql = `ALTER TABLE \`${tableName}\` ADD CONSTRAINT \`${name}\` CHECK (${clause})`;
892
+ return ` await knex.raw(${JSON.stringify(sql)});`;
893
+ })
894
+ .filter(Boolean)
895
+ .join("\n");
896
+ }
897
+
767
898
  function mergeFieldMetaEntries(...entryGroups) {
768
899
  const mergedByKey = new Map();
769
900
  for (const sourceEntries of entryGroups) {
@@ -1116,7 +1247,8 @@ function buildReplacementsFromSnapshot({
1116
1247
  writableColumns,
1117
1248
  snapshot
1118
1249
  });
1119
- const needsFiniteInteger = resourceColumns.some((column) => column.typeKind === "integer");
1250
+ const needsFiniteInteger = resourceColumns.some((column) => column.typeKind === "integer" && column.isRecordIdColumn !== true);
1251
+ const needsRecordIdSchemas = resourceColumns.some((column) => column.typeKind === "integer" && column.isRecordIdColumn === true);
1120
1252
  const needsFiniteNumber = resourceColumns.some((column) => column.typeKind === "number");
1121
1253
  const needsDateTimeOutput = outputColumns.some((column) => column.typeKind === "datetime");
1122
1254
  const needsDateTimeInput = writableColumns.some((column) => column.typeKind === "datetime");
@@ -1145,7 +1277,8 @@ function buildReplacementsFromSnapshot({
1145
1277
  __JSKIT_CRUD_ID_COLUMN__: JSON.stringify(snapshot.idColumn || DEFAULT_ID_COLUMN),
1146
1278
  __JSKIT_CRUD_RESOLVED_OWNERSHIP_FILTER__: resolvedOwnershipFilter,
1147
1279
  __JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__: renderResourceValidatorsImport({
1148
- needsHtmlTimeSchemas
1280
+ needsHtmlTimeSchemas,
1281
+ needsRecordIdSchemas
1149
1282
  }),
1150
1283
  __JSKIT_CRUD_RESOURCE_DATABASE_RUNTIME_IMPORT__: renderResourceDatabaseRuntimeImport({
1151
1284
  needsToIsoString: needsDateTimeOutput || needsDate,
@@ -1156,6 +1289,7 @@ function buildReplacementsFromSnapshot({
1156
1289
  needsNormalizeBoolean,
1157
1290
  needsNormalizeFiniteNumber: needsFiniteNumber,
1158
1291
  needsNormalizeFiniteInteger: needsFiniteInteger,
1292
+ needsNormalizeRecordId: needsRecordIdSchemas,
1159
1293
  needsNormalizeIfInSource,
1160
1294
  needsNormalizeIfPresent,
1161
1295
  needsNormalizeOrNull
@@ -1176,7 +1310,8 @@ function buildReplacementsFromSnapshot({
1176
1310
  __JSKIT_CRUD_LIST_CONFIG_LINES__: renderRepositoryListConfigLines(snapshot),
1177
1311
  __JSKIT_CRUD_MIGRATION_COLUMN_LINES__: renderMigrationColumnLines(snapshot),
1178
1312
  __JSKIT_CRUD_MIGRATION_INDEX_LINES__: renderMigrationIndexLines(snapshot),
1179
- __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__: renderMigrationForeignKeyLines(snapshot)
1313
+ __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__: renderMigrationForeignKeyLines(snapshot),
1314
+ __JSKIT_CRUD_MIGRATION_CHECK_CONSTRAINT_LINES__: renderMigrationCheckConstraintLines(snapshot)
1180
1315
  });
1181
1316
 
1182
1317
  return replacements;
@@ -1266,6 +1401,7 @@ const __testables = Object.freeze({
1266
1401
  buildReplacementsFromSnapshot,
1267
1402
  parseDotEnvLine,
1268
1403
  renderMigrationColumnLine,
1404
+ renderMigrationCheckConstraintLines,
1269
1405
  renderMigrationForeignKeyLine,
1270
1406
  resolveScaffoldColumns,
1271
1407
  renderPropertyAccess,
@@ -5,10 +5,12 @@ import {
5
5
  } from "@jskit-ai/database-runtime/shared";
6
6
  import {
7
7
  normalizeObjectInput,
8
- createCursorListValidator
8
+ createCursorListValidator,
9
+ recordIdSchema
9
10
  } from "@jskit-ai/kernel/shared/validators";
10
11
  import {
11
12
  normalizeText,
13
+ normalizeRecordId,
12
14
  normalizeFiniteNumber,
13
15
  normalizeIfPresent
14
16
  } from "@jskit-ai/kernel/shared/support/normalize";
@@ -17,7 +19,7 @@ const RESOURCE_LOOKUP_CONTAINER_KEY = "lookups";
17
19
 
18
20
  const recordOutputSchema = Type.Object(
19
21
  {
20
- id: Type.Integer({ minimum: 1 }),
22
+ id: recordIdSchema,
21
23
  textField: Type.String({ minLength: 1, maxLength: 160 }),
22
24
  dateField: Type.String({ minLength: 1 }),
23
25
  numberField: Type.Number(),
@@ -71,7 +73,7 @@ const recordOutputValidator = Object.freeze({
71
73
  normalize(payload = {}) {
72
74
  const source = normalizeObjectInput(payload);
73
75
  const normalized = {
74
- id: Number(source.id),
76
+ id: normalizeRecordId(source.id, { fallback: "" }),
75
77
  textField: normalizeText(source.textField),
76
78
  dateField: toIsoString(source.dateField),
77
79
  numberField: normalizeFiniteNumber(source.numberField),
@@ -116,7 +118,7 @@ const patchBodyValidator = Object.freeze({
116
118
  const deleteOutputValidator = Object.freeze({
117
119
  schema: Type.Object(
118
120
  {
119
- id: Type.Integer({ minimum: 1 }),
121
+ id: recordIdSchema,
120
122
  deleted: Type.Literal(true)
121
123
  },
122
124
  { additionalProperties: false }
@@ -125,7 +127,7 @@ const deleteOutputValidator = Object.freeze({
125
127
  const source = normalizeObjectInput(payload);
126
128
 
127
129
  return {
128
- id: Number(source.id),
130
+ id: normalizeRecordId(source.id, { fallback: "" }),
129
131
  deleted: true
130
132
  };
131
133
  }
@@ -11,6 +11,7 @@ __JSKIT_CRUD_MIGRATION_COLUMN_LINES__
11
11
  __JSKIT_CRUD_MIGRATION_INDEX_LINES__
12
12
  __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__
13
13
  });
14
+ __JSKIT_CRUD_MIGRATION_CHECK_CONSTRAINT_LINES__
14
15
  };
15
16
 
16
17
  exports.down = async function down(knex) {
@@ -7,6 +7,7 @@ import {
7
7
  crudRepositoryUpdateById,
8
8
  crudRepositoryDeleteById
9
9
  } from "@jskit-ai/crud-core/server/repositoryMethods";
10
+ import { createWithTransaction } from "@jskit-ai/database-runtime/shared";
10
11
  import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
11
12
  import { LIST_CONFIG } from "./listConfig.js";
12
13
 
@@ -16,6 +17,8 @@ const repositoryRuntime = createCrudRepositoryRuntime(resource, {
16
17
  });
17
18
 
18
19
  function createRepository(knex, options = {}) {
20
+ const withTransaction = createWithTransaction(knex);
21
+
19
22
  async function list(query = {}, callOptions = {}) {
20
23
  return crudRepositoryList(repositoryRuntime, knex, query, options, callOptions);
21
24
  }
@@ -41,6 +44,7 @@ function createRepository(knex, options = {}) {
41
44
  }
42
45
 
43
46
  return Object.freeze({
47
+ withTransaction,
44
48
  list,
45
49
  findById,
46
50
  listByIds,
@@ -65,7 +65,7 @@ const patchBodyValidator = Object.freeze({
65
65
  const deleteOutputValidator = Object.freeze({
66
66
  schema: Type.Object(
67
67
  {
68
- id: Type.Integer({ minimum: 1 }),
68
+ id: recordIdSchema,
69
69
  deleted: Type.Literal(true)
70
70
  },
71
71
  { additionalProperties: false }
@@ -74,7 +74,7 @@ const deleteOutputValidator = Object.freeze({
74
74
  const source = normalizeObjectInput(payload);
75
75
 
76
76
  return {
77
- id: Number(source.id),
77
+ id: normalizeRecordId(source.id, { fallback: "" }),
78
78
  deleted: true
79
79
  };
80
80
  }
@@ -107,8 +107,8 @@ function createSnapshot() {
107
107
  idColumn: "id",
108
108
  columns: [
109
109
  { name: "id", key: "id", typeKind: "integer", nullable: false, unsigned: true },
110
- { name: "workspace_owner_id", key: "workspaceOwnerId", typeKind: "integer", nullable: true, unsigned: true },
111
- { name: "user_owner_id", key: "userOwnerId", typeKind: "integer", nullable: true, unsigned: true },
110
+ { name: "workspace_id", key: "workspaceId", typeKind: "integer", nullable: true, unsigned: true },
111
+ { name: "user_id", key: "userId", typeKind: "integer", nullable: true, unsigned: true },
112
112
  { name: "created_at", key: "createdAt", typeKind: "datetime", nullable: false },
113
113
  { name: "updated_at", key: "updatedAt", typeKind: "datetime", nullable: false },
114
114
  { name: "first_name", key: "firstName", typeKind: "string", nullable: true, maxLength: 160 },
@@ -145,15 +145,21 @@ test("scaffold-field patches CRUD resource file using DB snapshot metadata", asy
145
145
  assert.deepEqual(result.touchedFiles, [resourceFile]);
146
146
 
147
147
  const content = await readFile(path.join(appRoot, resourceFile), "utf8");
148
- assert.match(content, /categoryId: Type\.Union\(\[Type\.Integer\(\{ minimum: 0 \}\), Type\.Null\(\)\]\)/);
149
- assert.match(content, /normalizeIfInSource\(source, normalized, "categoryId", normalizeFiniteInteger\);/);
150
- assert.match(content, /categoryId: normalizeOrNull\(source\.categoryId, normalizeFiniteInteger\)/);
148
+ assert.match(content, /categoryId: nullableRecordIdSchema/);
149
+ assert.match(
150
+ content,
151
+ /normalizeIfInSource\(source, normalized, "categoryId", \(value\) => normalizeRecordId\(value, \{ fallback: null \}\)\);/
152
+ );
153
+ assert.match(
154
+ content,
155
+ /categoryId: normalizeOrNull\(source\.categoryId, \(value\) => normalizeRecordId\(value, \{ fallback: null \}\)\)/
156
+ );
151
157
  assert.match(content, /RESOURCE_FIELD_META\.push\(\{/);
152
158
  assert.match(content, /key: "categoryId"/);
153
159
  assert.match(content, /namespace: "customer-categories"/);
154
160
  assert.match(content, /valueKey: "id"/);
155
161
  assert.match(content, /formControl: "autocomplete" \/\/ or "select"/);
156
- assert.match(content, /normalizeFiniteInteger/);
162
+ assert.match(content, /normalizeRecordId/);
157
163
 
158
164
  const secondRun = await runGeneratorSubcommand({
159
165
  appRoot,
@@ -8,8 +8,8 @@ import { buildTemplateContext, __testables } from "../src/server/buildTemplateCo
8
8
 
9
9
  function createSnapshot({
10
10
  tableName = "contacts",
11
- hasWorkspaceOwnerColumn = true,
12
- hasUserOwnerColumn = true,
11
+ hasWorkspaceIdColumn = true,
12
+ hasUserIdColumn = true,
13
13
  hasCreatedAtColumn = true
14
14
  } = {}) {
15
15
  const createdAtColumn = hasCreatedAtColumn
@@ -29,16 +29,20 @@ function createSnapshot({
29
29
  maxLength: null,
30
30
  numericPrecision: null,
31
31
  numericScale: null,
32
+ datetimePrecision: null,
33
+ characterSetName: "",
34
+ collationName: "",
32
35
  enumValues: Object.freeze([])
33
36
  })
34
37
  ]
35
38
  : [];
36
39
  return Object.freeze({
37
40
  tableName,
41
+ tableCollation: "utf8mb4_general_ci",
38
42
  idColumn: "id",
39
43
  primaryKeyColumns: Object.freeze(["id"]),
40
- hasWorkspaceOwnerColumn,
41
- hasUserOwnerColumn,
44
+ hasWorkspaceIdColumn,
45
+ hasUserIdColumn,
42
46
  columns: Object.freeze([
43
47
  Object.freeze({
44
48
  name: "id",
@@ -55,11 +59,14 @@ function createSnapshot({
55
59
  maxLength: null,
56
60
  numericPrecision: 10,
57
61
  numericScale: 0,
62
+ datetimePrecision: null,
63
+ characterSetName: "",
64
+ collationName: "",
58
65
  enumValues: Object.freeze([])
59
66
  }),
60
67
  Object.freeze({
61
- name: "workspace_owner_id",
62
- key: "workspaceOwnerId",
68
+ name: "workspace_id",
69
+ key: "workspaceId",
63
70
  dataType: "int",
64
71
  columnType: "int unsigned",
65
72
  typeKind: "integer",
@@ -72,11 +79,14 @@ function createSnapshot({
72
79
  maxLength: null,
73
80
  numericPrecision: 10,
74
81
  numericScale: 0,
82
+ datetimePrecision: null,
83
+ characterSetName: "",
84
+ collationName: "",
75
85
  enumValues: Object.freeze([])
76
86
  }),
77
87
  Object.freeze({
78
- name: "user_owner_id",
79
- key: "userOwnerId",
88
+ name: "user_id",
89
+ key: "userId",
80
90
  dataType: "int",
81
91
  columnType: "int unsigned",
82
92
  typeKind: "integer",
@@ -89,6 +99,9 @@ function createSnapshot({
89
99
  maxLength: null,
90
100
  numericPrecision: 10,
91
101
  numericScale: 0,
102
+ datetimePrecision: null,
103
+ characterSetName: "",
104
+ collationName: "",
92
105
  enumValues: Object.freeze([])
93
106
  }),
94
107
  Object.freeze({
@@ -106,6 +119,9 @@ function createSnapshot({
106
119
  maxLength: 160,
107
120
  numericPrecision: null,
108
121
  numericScale: null,
122
+ datetimePrecision: null,
123
+ characterSetName: "utf8mb4",
124
+ collationName: "utf8mb4_general_ci",
109
125
  enumValues: Object.freeze([])
110
126
  }),
111
127
  ...createdAtColumn,
@@ -124,30 +140,34 @@ function createSnapshot({
124
140
  maxLength: null,
125
141
  numericPrecision: null,
126
142
  numericScale: null,
143
+ datetimePrecision: null,
144
+ characterSetName: "",
145
+ collationName: "",
127
146
  enumValues: Object.freeze([])
128
147
  })
129
148
  ]),
130
149
  indexes: Object.freeze([]),
131
- foreignKeys: Object.freeze([])
150
+ foreignKeys: Object.freeze([]),
151
+ checkConstraints: Object.freeze([])
132
152
  });
133
153
  }
134
154
 
135
155
  test("resolveOwnershipFilterForGeneration infers ownership filter for table introspection mode", () => {
136
156
  const snapshotBoth = createSnapshot({
137
- hasWorkspaceOwnerColumn: true,
138
- hasUserOwnerColumn: true
157
+ hasWorkspaceIdColumn: true,
158
+ hasUserIdColumn: true
139
159
  });
140
160
  const snapshotWorkspaceOnly = createSnapshot({
141
- hasWorkspaceOwnerColumn: true,
142
- hasUserOwnerColumn: false
161
+ hasWorkspaceIdColumn: true,
162
+ hasUserIdColumn: false
143
163
  });
144
164
  const snapshotUserOnly = createSnapshot({
145
- hasWorkspaceOwnerColumn: false,
146
- hasUserOwnerColumn: true
165
+ hasWorkspaceIdColumn: false,
166
+ hasUserIdColumn: true
147
167
  });
148
168
  const snapshotPublic = createSnapshot({
149
- hasWorkspaceOwnerColumn: false,
150
- hasUserOwnerColumn: false
169
+ hasWorkspaceIdColumn: false,
170
+ hasUserIdColumn: false
151
171
  });
152
172
 
153
173
  assert.equal(
@@ -178,8 +198,8 @@ test("resolveOwnershipFilterForGeneration infers ownership filter for table intr
178
198
 
179
199
  test("resolveOwnershipFilterForGeneration rejects explicit ownership filters when required columns are missing", () => {
180
200
  const snapshotPublic = createSnapshot({
181
- hasWorkspaceOwnerColumn: false,
182
- hasUserOwnerColumn: false
201
+ hasWorkspaceIdColumn: false,
202
+ hasUserIdColumn: false
183
203
  });
184
204
 
185
205
  assert.throws(
@@ -187,14 +207,14 @@ test("resolveOwnershipFilterForGeneration rejects explicit ownership filters whe
187
207
  __testables.resolveOwnershipFilterForGeneration(snapshotPublic, "workspace", {
188
208
  enforceTableColumns: true
189
209
  }),
190
- /requires column "workspace_owner_id"/
210
+ /requires column "workspace_id"/
191
211
  );
192
212
  assert.throws(
193
213
  () =>
194
214
  __testables.resolveOwnershipFilterForGeneration(snapshotPublic, "user", {
195
215
  enforceTableColumns: true
196
216
  }),
197
- /requires column "user_owner_id"/
217
+ /requires column "user_id"/
198
218
  );
199
219
  });
200
220
 
@@ -221,13 +241,13 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
221
241
  assert.equal(replacements.__JSKIT_CRUD_TABLE_NAME__, "\"contacts\"");
222
242
  assert.equal(replacements.__JSKIT_CRUD_ID_COLUMN__, "\"id\"");
223
243
  assert.equal(replacements.__JSKIT_CRUD_RESOLVED_OWNERSHIP_FILTER__, "workspace_user");
224
- assert.match(replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__, /table\.increments\("id"\)/);
244
+ assert.match(replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__, /table\.bigIncrements\("id"\)/);
225
245
  assert.match(replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__, /table\.string\("first_name", 160\)/);
226
246
  assert.equal(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, "");
227
247
  assert.match(replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__, /updatedAt: Type\.String/);
228
248
  assert.match(
229
249
  replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
230
- /id: Type\.Integer\(\{ minimum: 1 \}\),/
250
+ /id: recordIdSchema,/
231
251
  );
232
252
  assert.match(replacements.__JSKIT_CRUD_RESOURCE_CREATE_SCHEMA_PROPERTIES__, /firstName: Type\.String/);
233
253
  assert.match(
@@ -332,8 +352,8 @@ test("buildReplacementsFromSnapshot renders append-only field meta entries from
332
352
 
333
353
  test("buildReplacementsFromSnapshot renders enum field meta options as select controls", () => {
334
354
  const baseSnapshot = createSnapshot({
335
- hasWorkspaceOwnerColumn: false,
336
- hasUserOwnerColumn: false
355
+ hasWorkspaceIdColumn: false,
356
+ hasUserIdColumn: false
337
357
  });
338
358
  const snapshot = {
339
359
  ...baseSnapshot,
@@ -374,7 +394,7 @@ test("buildReplacementsFromSnapshot renders enum field meta options as select co
374
394
  test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
375
395
  const line = __testables.renderMigrationColumnLine(
376
396
  {
377
- name: "workspace_owner_id",
397
+ name: "workspace_id",
378
398
  dataType: "int",
379
399
  columnType: "int unsigned",
380
400
  typeKind: "integer",
@@ -398,10 +418,144 @@ test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
398
418
  assert.equal(line.includes(".defaultTo("), false);
399
419
  });
400
420
 
421
+ test("renderMigrationColumnLine unwraps quoted string defaults", () => {
422
+ const stringLine = __testables.renderMigrationColumnLine({
423
+ name: "name",
424
+ dataType: "varchar",
425
+ columnType: "varchar(255)",
426
+ typeKind: "string",
427
+ nullable: false,
428
+ hasDefault: true,
429
+ defaultValue: "''",
430
+ autoIncrement: false,
431
+ unsigned: false,
432
+ extra: "",
433
+ maxLength: 255,
434
+ numericPrecision: null,
435
+ numericScale: null,
436
+ datetimePrecision: null,
437
+ characterSetName: "utf8mb4",
438
+ collationName: "utf8mb4_general_ci",
439
+ enumValues: []
440
+ });
441
+ const enumLine = __testables.renderMigrationColumnLine({
442
+ name: "temperament",
443
+ dataType: "enum",
444
+ columnType: "enum('friendly','unknown')",
445
+ typeKind: "string",
446
+ nullable: false,
447
+ hasDefault: true,
448
+ defaultValue: "'unknown'",
449
+ autoIncrement: false,
450
+ unsigned: false,
451
+ extra: "",
452
+ maxLength: null,
453
+ numericPrecision: null,
454
+ numericScale: null,
455
+ datetimePrecision: null,
456
+ characterSetName: "utf8mb4",
457
+ collationName: "utf8mb4_general_ci",
458
+ enumValues: ["friendly", "unknown"]
459
+ });
460
+
461
+ assert.match(stringLine, /\.defaultTo\(""\)/);
462
+ assert.match(enumLine, /\.defaultTo\("unknown"\)/);
463
+ });
464
+
465
+ test("renderMigrationColumnLine preserves datetime precision", () => {
466
+ const line = __testables.renderMigrationColumnLine({
467
+ name: "deleted_at",
468
+ dataType: "datetime",
469
+ columnType: "datetime(3)",
470
+ typeKind: "datetime",
471
+ nullable: true,
472
+ hasDefault: false,
473
+ defaultValue: null,
474
+ autoIncrement: false,
475
+ unsigned: false,
476
+ extra: "",
477
+ maxLength: null,
478
+ numericPrecision: null,
479
+ numericScale: null,
480
+ datetimePrecision: 3,
481
+ characterSetName: "",
482
+ collationName: "",
483
+ enumValues: []
484
+ });
485
+
486
+ assert.match(line, /table\.dateTime\("deleted_at", \{ precision: 3 \}\)/);
487
+ });
488
+
489
+ test("buildReplacementsFromSnapshot preserves custom collations, hash unique indexes, and check constraints", () => {
490
+ const snapshot = createSnapshot({
491
+ tableName: "services",
492
+ hasWorkspaceIdColumn: true,
493
+ hasUserIdColumn: false
494
+ });
495
+ const replacements = __testables.buildReplacementsFromSnapshot({
496
+ snapshot: {
497
+ ...snapshot,
498
+ columns: Object.freeze([
499
+ snapshot.columns[0],
500
+ snapshot.columns[1],
501
+ Object.freeze({
502
+ name: "settings_json",
503
+ key: "settingsJson",
504
+ dataType: "longtext",
505
+ columnType: "longtext",
506
+ typeKind: "string",
507
+ nullable: true,
508
+ hasDefault: false,
509
+ defaultValue: null,
510
+ autoIncrement: false,
511
+ unsigned: false,
512
+ extra: "",
513
+ maxLength: null,
514
+ numericPrecision: null,
515
+ numericScale: null,
516
+ datetimePrecision: null,
517
+ characterSetName: "utf8mb4",
518
+ collationName: "utf8mb4_bin",
519
+ enumValues: Object.freeze([])
520
+ })
521
+ ]),
522
+ indexes: Object.freeze([
523
+ Object.freeze({
524
+ name: "uq_services_workspace_settings",
525
+ unique: true,
526
+ indexType: "HASH",
527
+ columns: Object.freeze(["workspace_id", "settings_json"])
528
+ })
529
+ ]),
530
+ foreignKeys: Object.freeze([]),
531
+ checkConstraints: Object.freeze([
532
+ Object.freeze({
533
+ name: "settings_json",
534
+ clause: "json_valid(`settings_json`)"
535
+ })
536
+ ])
537
+ },
538
+ resolvedOwnershipFilter: "workspace"
539
+ });
540
+
541
+ assert.match(
542
+ replacements.__JSKIT_CRUD_MIGRATION_COLUMN_LINES__,
543
+ /table\.specificType\("settings_json", "longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"\)/
544
+ );
545
+ assert.match(
546
+ replacements.__JSKIT_CRUD_MIGRATION_INDEX_LINES__,
547
+ /storageEngineIndexType: "hash"/
548
+ );
549
+ assert.match(
550
+ replacements.__JSKIT_CRUD_MIGRATION_CHECK_CONSTRAINT_LINES__,
551
+ /ALTER TABLE `services` ADD CONSTRAINT `settings_json` CHECK \(json_valid\(`settings_json`\)\)/
552
+ );
553
+ });
554
+
401
555
  test("buildReplacementsFromSnapshot normalizes nullable temporal inputs without invalid date errors", () => {
402
556
  const snapshot = createSnapshot({
403
- hasWorkspaceOwnerColumn: false,
404
- hasUserOwnerColumn: false
557
+ hasWorkspaceIdColumn: false,
558
+ hasUserIdColumn: false
405
559
  });
406
560
  const temporalColumns = [
407
561
  ...snapshot.columns.filter((column) => column.key !== "updatedAt"),
@@ -31,7 +31,7 @@ test("crudResource normalizes list output", () => {
31
31
  nextCursor: " 8 "
32
32
  });
33
33
 
34
- assert.equal(normalized.items[0].id, 7);
34
+ assert.equal(normalized.items[0].id, "7");
35
35
  assert.equal(normalized.items[0].textField, "Example text");
36
36
  assert.equal(normalized.items[0].dateField, "2026-03-10T00:00:00.000Z");
37
37
  assert.equal(normalized.items[0].numberField, 99);
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import descriptor from "../package.descriptor.mjs";
4
+
5
+ test("crud-server-generator surface option validates against enabled surface ids", () => {
6
+ assert.equal(descriptor.kind, "generator");
7
+ assert.equal(descriptor.options?.surface?.validationType, "enabled-surface-id");
8
+ assert.equal(descriptor.metadata?.generatorSubcommands?.scaffold?.optionNames?.includes("surface"), true);
9
+ assert.equal(descriptor.metadata?.generatorSubcommands?.scaffold?.optionNames?.includes("force"), true);
10
+ assert.equal(descriptor.metadata?.generatorSubcommands?.scaffold?.createTarget?.pathTemplate, "packages/${option:namespace|kebab}");
11
+ });
12
+
13
+ test("crud-server-generator installs listConfig alongside server templates", () => {
14
+ const files = descriptor.mutations?.files || [];
15
+ const listConfigTemplate = files.find((entry) => entry.from === "templates/src/local-package/server/listConfig.js");
16
+
17
+ assert.ok(listConfigTemplate);
18
+ assert.equal(
19
+ listConfigTemplate.to,
20
+ "packages/${option:namespace|kebab}/src/server/listConfig.js"
21
+ );
22
+ assert.deepEqual(listConfigTemplate.templateContext, {
23
+ entrypoint: "src/server/buildTemplateContext.js",
24
+ export: "buildTemplateContext"
25
+ });
26
+ });