@jskit-ai/crud-server-generator 0.1.40 → 0.1.42

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.
@@ -5,23 +5,54 @@ 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
+ import { resolveCrudSurfacePolicyFromAppConfig } from "@jskit-ai/crud-core/server/crudModuleConfig";
11
12
  import { checkCrudLookupFormControl } from "@jskit-ai/crud-core/shared/crudFieldMetaSupport";
13
+ import {
14
+ importFreshModuleFromAbsolutePath,
15
+ loadAppConfigFromModuleUrl,
16
+ resolveRequiredAppRoot
17
+ } from "@jskit-ai/kernel/server/support";
12
18
  import { normalizeCrudLookupNamespace } from "@jskit-ai/kernel/shared/support/crudLookup";
13
19
  import { toCamelCase, toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
20
+ import descriptor from "../../package.descriptor.mjs";
14
21
 
15
22
  const DEFAULT_ID_COLUMN = "id";
16
- const OWNERSHIP_FILTER_AUTO = "auto";
17
- const OWNERSHIP_FILTER_VALUES = new Set([
18
- OWNERSHIP_FILTER_AUTO,
19
- "public",
20
- "user",
21
- "workspace",
22
- "workspace_user"
23
- ]);
23
+ const DEFAULT_OWNERSHIP_FILTER_VALUES = Object.freeze(["auto", "public", "user", "workspace", "workspace_user"]);
24
24
  const MYSQL_CLIENT_ID = "mysql2";
25
+ const CRUD_PERMISSION_OPERATIONS = Object.freeze(["list", "view", "create", "update", "delete"]);
26
+
27
+ function resolveAllowedValues(schema = {}, fallbackValues = []) {
28
+ const resolvedValues = [];
29
+ const seen = new Set();
30
+ for (const rawValue of Array.isArray(schema?.allowedValues) ? schema.allowedValues : []) {
31
+ const value = normalizeText(typeof rawValue === "string" ? rawValue : rawValue?.value).toLowerCase();
32
+ if (!value || seen.has(value)) {
33
+ continue;
34
+ }
35
+ seen.add(value);
36
+ resolvedValues.push(value);
37
+ }
38
+ if (resolvedValues.length > 0) {
39
+ return Object.freeze(resolvedValues);
40
+ }
41
+ return Object.freeze(
42
+ (Array.isArray(fallbackValues) ? fallbackValues : [])
43
+ .map((value) => normalizeText(value).toLowerCase())
44
+ .filter(Boolean)
45
+ );
46
+ }
47
+
48
+ const OWNERSHIP_FILTER_ALLOWED_VALUES = resolveAllowedValues(
49
+ descriptor?.options?.["ownership-filter"],
50
+ DEFAULT_OWNERSHIP_FILTER_VALUES
51
+ );
52
+ const OWNERSHIP_FILTER_AUTO = normalizeText(
53
+ descriptor?.options?.["ownership-filter"]?.defaultValue
54
+ ).toLowerCase() || "auto";
55
+ const OWNERSHIP_FILTER_VALUES = new Set(OWNERSHIP_FILTER_ALLOWED_VALUES);
25
56
 
26
57
  function resolveGlobalScaffoldCache() {
27
58
  const globalObject = globalThis;
@@ -54,15 +85,15 @@ function normalizeRequestedOwnershipFilter(value, { strict = false } = {}) {
54
85
  }
55
86
  if (strict) {
56
87
  throw new Error(
57
- `Invalid ownership filter "${normalized || String(value || "")}". Use: auto, public, user, workspace, workspace_user.`
88
+ `Invalid ownership filter "${normalized || String(value || "")}". Use: ${OWNERSHIP_FILTER_ALLOWED_VALUES.join(", ")}.`
58
89
  );
59
90
  }
60
91
  return OWNERSHIP_FILTER_AUTO;
61
92
  }
62
93
 
63
94
  function inferOwnershipFilterFromSnapshot(snapshot) {
64
- const hasWorkspace = snapshot?.hasWorkspaceOwnerColumn === true;
65
- const hasUser = snapshot?.hasUserOwnerColumn === true;
95
+ const hasWorkspace = snapshot?.hasWorkspaceIdColumn === true;
96
+ const hasUser = snapshot?.hasUserIdColumn === true;
66
97
  if (hasWorkspace && hasUser) {
67
98
  return "workspace_user";
68
99
  }
@@ -76,24 +107,24 @@ function inferOwnershipFilterFromSnapshot(snapshot) {
76
107
  }
77
108
 
78
109
  function assertOwnershipColumnsForFilter(snapshot, filter) {
79
- const hasWorkspace = snapshot?.hasWorkspaceOwnerColumn === true;
80
- const hasUser = snapshot?.hasUserOwnerColumn === true;
110
+ const hasWorkspace = snapshot?.hasWorkspaceIdColumn === true;
111
+ const hasUser = snapshot?.hasUserIdColumn === true;
81
112
  if (filter === "public") {
82
113
  return;
83
114
  }
84
115
  if (filter === "workspace" && !hasWorkspace) {
85
116
  throw new Error(
86
- 'Ownership filter "workspace" requires column "workspace_owner_id".'
117
+ 'Ownership filter "workspace" requires column "workspace_id".'
87
118
  );
88
119
  }
89
120
  if (filter === "user" && !hasUser) {
90
121
  throw new Error(
91
- 'Ownership filter "user" requires column "user_owner_id".'
122
+ 'Ownership filter "user" requires column "user_id".'
92
123
  );
93
124
  }
94
125
  if (filter === "workspace_user" && (!hasWorkspace || !hasUser)) {
95
126
  throw new Error(
96
- 'Ownership filter "workspace_user" requires both columns "workspace_owner_id" and "user_owner_id".'
127
+ 'Ownership filter "workspace_user" requires both columns "workspace_id" and "user_id".'
97
128
  );
98
129
  }
99
130
  }
@@ -187,7 +218,7 @@ async function importModuleFromApp(appRequire, moduleId, contextLabel) {
187
218
  }
188
219
 
189
220
  try {
190
- return await import(`${pathToFileURL(resolvedPath).href}?t=${Date.now()}_${Math.random()}`);
221
+ return await importFreshModuleFromAbsolutePath(resolvedPath);
191
222
  } catch (error) {
192
223
  throw new Error(
193
224
  `${contextLabel} failed loading "${moduleId}": ${String(error?.message || error || "unknown error")}`
@@ -195,6 +226,99 @@ async function importModuleFromApp(appRequire, moduleId, contextLabel) {
195
226
  }
196
227
  }
197
228
 
229
+ async function resolveCrudSurfaceRequiresWorkspace({
230
+ appRoot,
231
+ options,
232
+ surface = ""
233
+ } = {}) {
234
+ const namespace = normalizeText(options?.namespace);
235
+ const resolvedSurface = normalizeText(surface || options?.surface);
236
+ if (!namespace) {
237
+ throw new Error('crud template context requires option "namespace".');
238
+ }
239
+ if (!resolvedSurface) {
240
+ throw new Error('crud template context requires option "surface".');
241
+ }
242
+
243
+ const appConfig = await loadCrudAppConfig(appRoot);
244
+ const crudPolicy = resolveCrudSurfacePolicyFromAppConfig(
245
+ {
246
+ namespace,
247
+ surface: resolvedSurface,
248
+ ownershipFilter: options?.["ownership-filter"]
249
+ },
250
+ appConfig,
251
+ {
252
+ context: "crud template context"
253
+ }
254
+ );
255
+
256
+ return crudPolicy?.surfaceDefinition?.requiresWorkspace === true;
257
+ }
258
+
259
+ async function loadCrudAppConfig(appRoot = "") {
260
+ const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
261
+ context: "crud template context"
262
+ });
263
+ return loadAppConfigFromModuleUrl({
264
+ moduleUrl: pathToFileURL(path.join(resolvedAppRoot, "config", "public.js")).href
265
+ });
266
+ }
267
+
268
+ function resolveSurfaceDefinitions(appConfig = {}) {
269
+ const definitions = asRecord(appConfig?.surfaceDefinitions);
270
+ const resolved = {};
271
+ for (const [key, rawValue] of Object.entries(definitions)) {
272
+ const definition = asRecord(rawValue);
273
+ const id = normalizeText(definition.id || key).toLowerCase();
274
+ if (!id) {
275
+ continue;
276
+ }
277
+ resolved[id] = Object.freeze({
278
+ id,
279
+ enabled: definition.enabled !== false,
280
+ requiresWorkspace: definition.requiresWorkspace === true
281
+ });
282
+ }
283
+ return Object.freeze(resolved);
284
+ }
285
+
286
+ function resolveDefaultCrudSurfaceIdFromAppConfig(appConfig = {}) {
287
+ const surfaceDefinitions = resolveSurfaceDefinitions(appConfig);
288
+ const enabledSurfaceDefinitions = Object.values(surfaceDefinitions).filter((entry) => entry.enabled === true);
289
+ const hasEnabledWorkspaceSurface = enabledSurfaceDefinitions.some((entry) => entry.requiresWorkspace === true);
290
+ if (hasEnabledWorkspaceSurface) {
291
+ return "";
292
+ }
293
+
294
+ const homeSurface = surfaceDefinitions.home;
295
+ if (homeSurface?.enabled === true && homeSurface.requiresWorkspace !== true) {
296
+ return "home";
297
+ }
298
+
299
+ return "";
300
+ }
301
+
302
+ async function resolveCrudGenerationSurfaceId({
303
+ appRoot,
304
+ options
305
+ } = {}) {
306
+ const explicitSurface = normalizeText(options?.surface).toLowerCase();
307
+ if (explicitSurface) {
308
+ return explicitSurface;
309
+ }
310
+
311
+ const appConfig = await loadCrudAppConfig(appRoot);
312
+ const defaultSurface = resolveDefaultCrudSurfaceIdFromAppConfig(appConfig);
313
+ if (defaultSurface) {
314
+ return defaultSurface;
315
+ }
316
+
317
+ throw new Error(
318
+ 'crud template context requires option "surface" when the app has any enabled workspace surface or no enabled non-workspace "home" surface.'
319
+ );
320
+ }
321
+
198
322
  function resolveKnexFactory(moduleNamespace) {
199
323
  if (typeof moduleNamespace === "function") {
200
324
  return moduleNamespace;
@@ -221,7 +345,8 @@ async function resolveMysqlSnapshotFromDatabase({
221
345
  );
222
346
  }
223
347
 
224
- const connection = resolveDatabaseConnectionFromEnvironment(env, {
348
+ const connection = resolveKnexConnectionFromEnvironment(env, {
349
+ client: dbClient,
225
350
  defaultPort: 3306,
226
351
  context: "crud table introspection"
227
352
  });
@@ -269,16 +394,24 @@ function resolveColumnKey(column, idColumn) {
269
394
  function resolveScaffoldColumns(snapshot) {
270
395
  const idColumn = String(snapshot.idColumn || DEFAULT_ID_COLUMN);
271
396
  const sourceColumns = Array.isArray(snapshot.columns) ? snapshot.columns : [];
397
+ const foreignKeyColumnNames = new Set(
398
+ (Array.isArray(snapshot.foreignKeys) ? snapshot.foreignKeys : [])
399
+ .flatMap((foreignKey) => Array.isArray(foreignKey?.columns) ? foreignKey.columns : [])
400
+ .map((entry) => String(entry?.name || "").trim())
401
+ .filter(Boolean)
402
+ );
272
403
  const seenKeys = new Set();
273
404
 
274
405
  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;
406
+ const isWorkspaceIdColumn = column.name === "workspace_id";
407
+ const isUserIdColumn = column.name === "user_id";
408
+ const isOwnerColumn = isWorkspaceIdColumn || isUserIdColumn;
278
409
  const isIdColumn = column.name === idColumn;
410
+ const isForeignIdColumn = foreignKeyColumnNames.has(column.name) || /_id$/i.test(String(column.name || ""));
279
411
  const isCreatedAtColumn = column.name === "created_at";
280
412
  const isUpdatedAtColumn = column.name === "updated_at";
281
413
  const key = resolveColumnKey(column, idColumn);
414
+ const isRecordIdColumn = isIdColumn || isOwnerColumn || isForeignIdColumn || /Id$/.test(String(key || ""));
282
415
  if (!key) {
283
416
  throw new Error(`Could not derive API field key for column "${column.name}".`);
284
417
  }
@@ -297,6 +430,8 @@ function resolveScaffoldColumns(snapshot) {
297
430
  key,
298
431
  isOwnerColumn,
299
432
  isIdColumn,
433
+ isForeignIdColumn,
434
+ isRecordIdColumn,
300
435
  isCreatedAtColumn,
301
436
  isUpdatedAtColumn,
302
437
  writable: !isOwnerColumn && !isIdColumn && !isCreatedAtColumn && !isUpdatedAtColumn
@@ -327,9 +462,7 @@ function renderPropertyAccess(sourceName, key) {
327
462
 
328
463
  function renderIntegerSchema(column) {
329
464
  const options = [];
330
- if (column.isIdColumn === true) {
331
- options.push("minimum: 1");
332
- } else if (column.unsigned === true) {
465
+ if (column.unsigned === true) {
333
466
  options.push("minimum: 0");
334
467
  }
335
468
  if (options.length > 0) {
@@ -359,6 +492,11 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
359
492
  if (typeKind === "string") {
360
493
  schemaExpression = renderStringSchema(column, { forOutput });
361
494
  } else if (typeKind === "integer") {
495
+ if (column?.isRecordIdColumn === true) {
496
+ return forOutput
497
+ ? (column.nullable === true ? "nullableRecordIdSchema" : "recordIdSchema")
498
+ : (column.nullable === true ? "nullableRecordIdInputSchema" : "recordIdInputSchema");
499
+ }
362
500
  schemaExpression = renderIntegerSchema(column);
363
501
  } else if (typeKind === "number") {
364
502
  schemaExpression = "Type.Number()";
@@ -382,11 +520,14 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
382
520
  return schemaExpression;
383
521
  }
384
522
 
385
- function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false } = {}) {
523
+ function renderResourceValidatorsImport({ needsHtmlTimeSchemas = false, needsRecordIdSchemas = false } = {}) {
386
524
  const imports = [
387
525
  "normalizeObjectInput",
388
526
  "createCursorListValidator"
389
527
  ];
528
+ if (needsRecordIdSchemas) {
529
+ imports.push("recordIdSchema", "recordIdInputSchema", "nullableRecordIdSchema", "nullableRecordIdInputSchema");
530
+ }
390
531
  if (needsHtmlTimeSchemas) {
391
532
  imports.push("HTML_TIME_STRING_SCHEMA", "NULLABLE_HTML_TIME_STRING_SCHEMA");
392
533
  }
@@ -406,6 +547,12 @@ function renderInputNormalizer(column) {
406
547
  return "normalizeText";
407
548
  }
408
549
  if (typeKind === "integer") {
550
+ if (column?.isRecordIdColumn === true) {
551
+ if (nullable) {
552
+ return "(value) => normalizeRecordId(value, { fallback: null })";
553
+ }
554
+ return "normalizeRecordId";
555
+ }
409
556
  return "normalizeFiniteInteger";
410
557
  }
411
558
  if (typeKind === "number") {
@@ -434,10 +581,17 @@ function renderInputNormalizer(column) {
434
581
 
435
582
  function renderOutputNormalizerExpression(column) {
436
583
  const typeKind = String(column.typeKind || "");
584
+ const nullable = column?.nullable === true;
437
585
  if (typeKind === "string" || typeKind === "time") {
438
586
  return "normalizeText";
439
587
  }
440
588
  if (typeKind === "integer") {
589
+ if (column?.isRecordIdColumn === true) {
590
+ if (nullable) {
591
+ return "(value) => normalizeRecordId(value, { fallback: null })";
592
+ }
593
+ return "normalizeRecordId";
594
+ }
441
595
  return "normalizeFiniteInteger";
442
596
  }
443
597
  if (typeKind === "number") {
@@ -522,6 +676,7 @@ function renderResourceNormalizeSupportImport({
522
676
  needsNormalizeBoolean = false,
523
677
  needsNormalizeFiniteNumber = false,
524
678
  needsNormalizeFiniteInteger = false,
679
+ needsNormalizeRecordId = false,
525
680
  needsNormalizeIfInSource = false,
526
681
  needsNormalizeIfPresent = false,
527
682
  needsNormalizeOrNull = false
@@ -539,6 +694,9 @@ function renderResourceNormalizeSupportImport({
539
694
  if (needsNormalizeFiniteInteger) {
540
695
  imports.push("normalizeFiniteInteger");
541
696
  }
697
+ if (needsNormalizeRecordId) {
698
+ imports.push("normalizeRecordId");
699
+ }
542
700
  if (needsNormalizeIfInSource) {
543
701
  imports.push("normalizeIfInSource");
544
702
  }
@@ -596,15 +754,57 @@ function renderMigrationDefaultClause(column) {
596
754
  }
597
755
  }
598
756
 
757
+ if (column.typeKind === "string" && normalized.startsWith("'") && normalized.endsWith("'")) {
758
+ const unquoted = normalized
759
+ .slice(1, -1)
760
+ .replace(/\\'/g, "'")
761
+ .replace(/''/g, "'");
762
+ return `.defaultTo(${JSON.stringify(unquoted)})`;
763
+ }
764
+
599
765
  return `.defaultTo(${JSON.stringify(rawDefault)})`;
600
766
  }
601
767
 
602
- function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, primaryKeyColumns = [] } = {}) {
768
+ function renderMigrationSpecificStringType(column, { tableCollation = "" } = {}) {
769
+ const baseType = normalizeText(column?.columnType);
770
+ if (!baseType) {
771
+ return "";
772
+ }
773
+
774
+ const characterSetName = normalizeText(column?.characterSetName);
775
+ const collationName = normalizeText(column?.collationName);
776
+ const normalizedTableCollation = normalizeText(tableCollation);
777
+ if (!collationName || collationName === normalizedTableCollation) {
778
+ return "";
779
+ }
780
+
781
+ const parts = [baseType];
782
+ if (characterSetName) {
783
+ parts.push(`CHARACTER SET ${characterSetName}`);
784
+ }
785
+ parts.push(`COLLATE ${collationName}`);
786
+ return parts.join(" ");
787
+ }
788
+
789
+ function renderTemporalColumnBuilder(column, methodName) {
790
+ if (Number.isFinite(column?.datetimePrecision) && column.datetimePrecision > 0) {
791
+ return `table.${methodName}(${JSON.stringify(column.name)}, { precision: ${column.datetimePrecision} })`;
792
+ }
793
+ return `table.${methodName}(${JSON.stringify(column.name)})`;
794
+ }
795
+
796
+ function renderMigrationColumnLine(column, {
797
+ idColumn = DEFAULT_ID_COLUMN,
798
+ primaryKeyColumns = [],
799
+ foreignKeyColumnNames = new Set(),
800
+ tableCollation = ""
801
+ } = {}) {
603
802
  const isPrimary = Array.isArray(primaryKeyColumns) && primaryKeyColumns.includes(column.name);
604
803
  const isIdColumn = column.name === idColumn;
804
+ const isRecordIdColumn = isIdColumn || column.name === "workspace_id" || column.name === "user_id" || foreignKeyColumnNames.has(column.name) || /_id$/i.test(String(column.name || ""));
605
805
 
606
806
  if (isIdColumn && column.autoIncrement) {
607
- let line = `table.increments(${JSON.stringify(column.name)})`;
807
+ let line = `table.bigIncrements(${JSON.stringify(column.name)})`;
608
808
  if (column.unsigned) {
609
809
  line += ".unsigned()";
610
810
  }
@@ -615,25 +815,40 @@ function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, prima
615
815
  let line = "";
616
816
  const nameLiteral = JSON.stringify(column.name);
617
817
  const dataType = String(column.dataType || "").toLowerCase();
818
+ const specificStringType = renderMigrationSpecificStringType(column, {
819
+ tableCollation
820
+ });
618
821
 
619
822
  if (dataType === "varchar") {
620
- const maxLength = Number.isFinite(column.maxLength) ? column.maxLength : 255;
621
- line = `table.string(${nameLiteral}, ${maxLength})`;
823
+ if (specificStringType) {
824
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType)})`;
825
+ } else {
826
+ const maxLength = Number.isFinite(column.maxLength) ? column.maxLength : 255;
827
+ line = `table.string(${nameLiteral}, ${maxLength})`;
828
+ }
622
829
  } else if (dataType === "char") {
623
- line = `table.specificType(${nameLiteral}, ${JSON.stringify(column.columnType || "char(255)")})`;
830
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType || column.columnType || "char(255)")})`;
624
831
  } else if (dataType === "text") {
625
- line = `table.text(${nameLiteral})`;
832
+ if (specificStringType) {
833
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType)})`;
834
+ } else {
835
+ line = `table.text(${nameLiteral})`;
836
+ }
626
837
  } else if (dataType === "tinytext" || dataType === "mediumtext" || dataType === "longtext") {
627
- line = `table.text(${nameLiteral}, ${JSON.stringify(dataType)})`;
838
+ if (specificStringType) {
839
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType)})`;
840
+ } else {
841
+ line = `table.text(${nameLiteral}, ${JSON.stringify(dataType)})`;
842
+ }
628
843
  } else if (dataType === "enum") {
629
844
  const enumValues = Array.isArray(column.enumValues) ? column.enumValues : [];
630
845
  line = `table.enu(${nameLiteral}, ${JSON.stringify(enumValues)})`;
631
846
  } else if (dataType === "set") {
632
- line = `table.specificType(${nameLiteral}, ${JSON.stringify(column.columnType || "set")})`;
847
+ line = `table.specificType(${nameLiteral}, ${JSON.stringify(specificStringType || column.columnType || "set")})`;
633
848
  } else if (column.typeKind === "boolean") {
634
849
  line = `table.boolean(${nameLiteral})`;
635
850
  } else if (dataType === "int" || dataType === "integer") {
636
- line = `table.integer(${nameLiteral})`;
851
+ line = isRecordIdColumn ? `table.bigInteger(${nameLiteral})` : `table.integer(${nameLiteral})`;
637
852
  } else if (dataType === "smallint") {
638
853
  line = `table.smallint(${nameLiteral})`;
639
854
  } else if (dataType === "bigint") {
@@ -657,11 +872,11 @@ function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, prima
657
872
  } else if (dataType === "date") {
658
873
  line = `table.date(${nameLiteral})`;
659
874
  } else if (dataType === "time") {
660
- line = `table.time(${nameLiteral})`;
875
+ line = renderTemporalColumnBuilder(column, "time");
661
876
  } else if (dataType === "datetime") {
662
- line = `table.dateTime(${nameLiteral})`;
877
+ line = renderTemporalColumnBuilder(column, "dateTime");
663
878
  } else if (dataType === "timestamp") {
664
- line = `table.timestamp(${nameLiteral})`;
879
+ line = renderTemporalColumnBuilder(column, "timestamp");
665
880
  } else {
666
881
  throw new Error(
667
882
  `Unsupported MySQL type "${dataType}" in migration renderer for column "${column.name}".`
@@ -682,10 +897,18 @@ function renderMigrationColumnLine(column, { idColumn = DEFAULT_ID_COLUMN, prima
682
897
 
683
898
  function renderMigrationColumnLines(snapshot) {
684
899
  const columns = Array.isArray(snapshot.columns) ? snapshot.columns : [];
900
+ const foreignKeyColumnNames = new Set(
901
+ (Array.isArray(snapshot.foreignKeys) ? snapshot.foreignKeys : [])
902
+ .flatMap((foreignKey) => Array.isArray(foreignKey?.columns) ? foreignKey.columns : [])
903
+ .map((entry) => String(entry?.name || "").trim())
904
+ .filter(Boolean)
905
+ );
685
906
  const lines = columns.map((column) =>
686
907
  ` ${renderMigrationColumnLine(column, {
687
908
  idColumn: snapshot.idColumn,
688
- primaryKeyColumns: snapshot.primaryKeyColumns
909
+ primaryKeyColumns: snapshot.primaryKeyColumns,
910
+ foreignKeyColumnNames,
911
+ tableCollation: snapshot.tableCollation
689
912
  })}`
690
913
  );
691
914
  return lines.join("\n");
@@ -699,13 +922,23 @@ function renderMigrationIndexLine(index) {
699
922
 
700
923
  const columnsLiteral = JSON.stringify(columns);
701
924
  const indexName = normalizeText(index?.name);
925
+ const normalizedIndexType = normalizeText(index?.indexType).toUpperCase();
926
+ const storageEngineIndexType = normalizedIndexType && normalizedIndexType !== "BTREE"
927
+ ? normalizedIndexType.toLowerCase()
928
+ : "";
702
929
  if (index?.unique === true) {
930
+ if (indexName && storageEngineIndexType) {
931
+ return ` table.unique(${columnsLiteral}, { indexName: ${JSON.stringify(indexName)}, storageEngineIndexType: ${JSON.stringify(storageEngineIndexType)} });`;
932
+ }
703
933
  if (indexName) {
704
934
  return ` table.unique(${columnsLiteral}, ${JSON.stringify(indexName)});`;
705
935
  }
706
936
  return ` table.unique(${columnsLiteral});`;
707
937
  }
708
938
 
939
+ if (indexName && normalizedIndexType && normalizedIndexType !== "BTREE") {
940
+ return ` table.index(${columnsLiteral}, ${JSON.stringify(indexName)}, ${JSON.stringify(normalizedIndexType)});`;
941
+ }
709
942
  if (indexName) {
710
943
  return ` table.index(${columnsLiteral}, ${JSON.stringify(indexName)});`;
711
944
  }
@@ -764,6 +997,28 @@ function renderMigrationForeignKeyLines(snapshot) {
764
997
  return lines.join("\n");
765
998
  }
766
999
 
1000
+ function renderMigrationCheckConstraintLines(snapshot) {
1001
+ const tableName = normalizeText(snapshot?.tableName);
1002
+ const checkConstraints = Array.isArray(snapshot?.checkConstraints) ? snapshot.checkConstraints : [];
1003
+ if (!tableName || checkConstraints.length < 1) {
1004
+ return "";
1005
+ }
1006
+
1007
+ return checkConstraints
1008
+ .map((constraint) => {
1009
+ const name = normalizeText(constraint?.name);
1010
+ const clause = normalizeText(constraint?.clause);
1011
+ if (!name || !clause) {
1012
+ return "";
1013
+ }
1014
+
1015
+ const sql = `ALTER TABLE \`${tableName}\` ADD CONSTRAINT \`${name}\` CHECK (${clause})`;
1016
+ return ` await knex.raw(${JSON.stringify(sql)});`;
1017
+ })
1018
+ .filter(Boolean)
1019
+ .join("\n");
1020
+ }
1021
+
767
1022
  function mergeFieldMetaEntries(...entryGroups) {
768
1023
  const mergedByKey = new Map();
769
1024
  for (const sourceEntries of entryGroups) {
@@ -1100,10 +1355,184 @@ function renderRepositoryListConfigLines(snapshot = {}) {
1100
1355
  ].join("\n");
1101
1356
  }
1102
1357
 
1358
+ function buildCrudPermissionIds(namespace = "") {
1359
+ const permissionNamespace = toSnakeCase(namespace);
1360
+ if (!permissionNamespace) {
1361
+ return null;
1362
+ }
1363
+
1364
+ return Object.freeze(
1365
+ Object.fromEntries(
1366
+ CRUD_PERMISSION_OPERATIONS.map((operation) => [operation, `crud.${permissionNamespace}.${operation}`])
1367
+ )
1368
+ );
1369
+ }
1370
+
1371
+ function normalizeCrudOperation(operation = "", context = "CRUD operation") {
1372
+ const normalizedOperation = normalizeText(operation).toLowerCase();
1373
+ if (!CRUD_PERMISSION_OPERATIONS.includes(normalizedOperation)) {
1374
+ throw new Error(`Unknown ${context} "${normalizedOperation || String(operation || "")}".`);
1375
+ }
1376
+ return normalizedOperation;
1377
+ }
1378
+
1379
+ function renderRoleCatalogPermissionGrants(namespace = "", { requiresNamedPermissions = true } = {}) {
1380
+ const permissionIds = buildCrudPermissionIds(namespace);
1381
+ if (!requiresNamedPermissions || !permissionIds) {
1382
+ return "";
1383
+ }
1384
+
1385
+ return [
1386
+ "roleCatalog.roles.member.permissions.push(",
1387
+ ` ${JSON.stringify(permissionIds.list)},`,
1388
+ ` ${JSON.stringify(permissionIds.view)},`,
1389
+ ` ${JSON.stringify(permissionIds.create)},`,
1390
+ ` ${JSON.stringify(permissionIds.update)},`,
1391
+ ` ${JSON.stringify(permissionIds.delete)}`,
1392
+ ");"
1393
+ ].join("\n");
1394
+ }
1395
+
1396
+ function renderActionPermissionSupport(namespace = "", { requiresNamedPermissions = true } = {}) {
1397
+ if (!requiresNamedPermissions) {
1398
+ return [
1399
+ "const authenticatedPermission = Object.freeze({",
1400
+ ' require: "authenticated"',
1401
+ "});"
1402
+ ].join("\n");
1403
+ }
1404
+
1405
+ const permissionIds = buildCrudPermissionIds(namespace);
1406
+ if (!permissionIds) {
1407
+ return "";
1408
+ }
1409
+
1410
+ return [
1411
+ "const actionPermissions = Object.freeze({",
1412
+ ` list: ${JSON.stringify(permissionIds.list)},`,
1413
+ ` view: ${JSON.stringify(permissionIds.view)},`,
1414
+ ` create: ${JSON.stringify(permissionIds.create)},`,
1415
+ ` update: ${JSON.stringify(permissionIds.update)},`,
1416
+ ` delete: ${JSON.stringify(permissionIds.delete)}`,
1417
+ "});"
1418
+ ].join("\n");
1419
+ }
1420
+
1421
+ function renderActionPermissionExpression(operation = "", { requiresNamedPermissions = true } = {}) {
1422
+ const normalizedOperation = normalizeCrudOperation(operation, "CRUD permission operation");
1423
+
1424
+ if (!requiresNamedPermissions) {
1425
+ return "authenticatedPermission";
1426
+ }
1427
+
1428
+ return `{ require: "all", permissions: [actionPermissions.${normalizedOperation}] }`;
1429
+ }
1430
+
1431
+ function renderRouteWorkspaceSupportImports({ surfaceRequiresWorkspace = true } = {}) {
1432
+ if (!surfaceRequiresWorkspace) {
1433
+ return "";
1434
+ }
1435
+
1436
+ return [
1437
+ 'import { routeParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";',
1438
+ 'import { buildWorkspaceInputFromRouteParams } from "@jskit-ai/users-core/server/support/workspaceRouteInput";'
1439
+ ].join("\n");
1440
+ }
1441
+
1442
+ function renderActionWorkspaceValidatorImport({ surfaceRequiresWorkspace = true } = {}) {
1443
+ if (!surfaceRequiresWorkspace) {
1444
+ return "";
1445
+ }
1446
+
1447
+ return 'import { workspaceSlugParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";';
1448
+ }
1449
+
1450
+ function renderRouteParamsValidatorLine(operation = "", { surfaceRequiresWorkspace = true } = {}) {
1451
+ const normalizedOperation = normalizeCrudOperation(operation, "CRUD route params validator operation");
1452
+ if (normalizedOperation === "list" || normalizedOperation === "create") {
1453
+ if (!surfaceRequiresWorkspace) {
1454
+ return "";
1455
+ }
1456
+ return " paramsValidator: routeParamsValidator,";
1457
+ }
1458
+
1459
+ if (!surfaceRequiresWorkspace) {
1460
+ return " paramsValidator: recordIdParamsValidator,";
1461
+ }
1462
+
1463
+ return " paramsValidator: [routeParamsValidator, recordIdParamsValidator],";
1464
+ }
1465
+
1466
+ function renderRouteInputLines(operation = "", { surfaceRequiresWorkspace = true } = {}) {
1467
+ const normalizedOperation = normalizeCrudOperation(operation, "CRUD route input operation");
1468
+ const lines = [];
1469
+
1470
+ if (surfaceRequiresWorkspace) {
1471
+ lines.push(" ...buildWorkspaceInputFromRouteParams(request.input.params),");
1472
+ }
1473
+
1474
+ if (normalizedOperation === "list") {
1475
+ lines.push(" ...(request.input.query || {})");
1476
+ return lines.join("\n");
1477
+ }
1478
+
1479
+ if (normalizedOperation === "view") {
1480
+ lines.push(" recordId: request.input.params.recordId,");
1481
+ lines.push(" ...(request.input.query || {})");
1482
+ return lines.join("\n");
1483
+ }
1484
+
1485
+ if (normalizedOperation === "create") {
1486
+ lines.push(" payload: request.input.body");
1487
+ return lines.join("\n");
1488
+ }
1489
+
1490
+ if (normalizedOperation === "update") {
1491
+ lines.push(" recordId: request.input.params.recordId,");
1492
+ lines.push(" patch: request.input.body");
1493
+ return lines.join("\n");
1494
+ }
1495
+
1496
+ lines.push(" recordId: request.input.params.recordId");
1497
+ return lines.join("\n");
1498
+ }
1499
+
1500
+ function renderActionInputValidatorExpression(operation = "", { surfaceRequiresWorkspace = true } = {}) {
1501
+ const normalizedOperation = normalizeCrudOperation(operation, "CRUD action input validator operation");
1502
+ const validators = [];
1503
+
1504
+ if (surfaceRequiresWorkspace) {
1505
+ validators.push("workspaceSlugParamsValidator");
1506
+ }
1507
+
1508
+ if (normalizedOperation === "list") {
1509
+ validators.push(
1510
+ "listCursorPaginationQueryValidator",
1511
+ "listSearchQueryValidator",
1512
+ "listParentFilterQueryValidator",
1513
+ "lookupIncludeQueryValidator"
1514
+ );
1515
+ } else if (normalizedOperation === "view") {
1516
+ validators.push("recordIdParamsValidator", "lookupIncludeQueryValidator");
1517
+ } else if (normalizedOperation === "create") {
1518
+ validators.push("{ payload: resource.operations.create.bodyValidator }");
1519
+ } else if (normalizedOperation === "update") {
1520
+ validators.push("recordIdParamsValidator", "{ patch: resource.operations.patch.bodyValidator }");
1521
+ } else {
1522
+ validators.push("recordIdParamsValidator");
1523
+ }
1524
+
1525
+ return validators.length === 1 ? validators[0] : `[${validators.join(", ")}]`;
1526
+ }
1527
+
1103
1528
  function buildReplacementsFromSnapshot({
1529
+ namespace = "",
1104
1530
  snapshot,
1105
- resolvedOwnershipFilter
1531
+ resolvedOwnershipFilter,
1532
+ surfaceRequiresWorkspace = true,
1533
+ surfaceId = ""
1106
1534
  }) {
1535
+ const requiresNamedPermissions = surfaceRequiresWorkspace === true;
1107
1536
  const scaffoldColumns = resolveScaffoldColumns(snapshot);
1108
1537
  const outputColumns = scaffoldColumns.filter((column) => !column.isOwnerColumn);
1109
1538
  const writableColumns = scaffoldColumns.filter((column) => column.writable);
@@ -1116,7 +1545,8 @@ function buildReplacementsFromSnapshot({
1116
1545
  writableColumns,
1117
1546
  snapshot
1118
1547
  });
1119
- const needsFiniteInteger = resourceColumns.some((column) => column.typeKind === "integer");
1548
+ const needsFiniteInteger = resourceColumns.some((column) => column.typeKind === "integer" && column.isRecordIdColumn !== true);
1549
+ const needsRecordIdSchemas = resourceColumns.some((column) => column.typeKind === "integer" && column.isRecordIdColumn === true);
1120
1550
  const needsFiniteNumber = resourceColumns.some((column) => column.typeKind === "number");
1121
1551
  const needsDateTimeOutput = outputColumns.some((column) => column.typeKind === "datetime");
1122
1552
  const needsDateTimeInput = writableColumns.some((column) => column.typeKind === "datetime");
@@ -1143,9 +1573,84 @@ function buildReplacementsFromSnapshot({
1143
1573
  const replacements = Object.freeze({
1144
1574
  __JSKIT_CRUD_TABLE_NAME__: JSON.stringify(snapshot.tableName),
1145
1575
  __JSKIT_CRUD_ID_COLUMN__: JSON.stringify(snapshot.idColumn || DEFAULT_ID_COLUMN),
1576
+ __JSKIT_CRUD_SURFACE_ID__: JSON.stringify(normalizeText(surfaceId).toLowerCase()),
1146
1577
  __JSKIT_CRUD_RESOLVED_OWNERSHIP_FILTER__: resolvedOwnershipFilter,
1578
+ __JSKIT_CRUD_ACTION_PERMISSION_SUPPORT__: renderActionPermissionSupport(namespace, {
1579
+ requiresNamedPermissions
1580
+ }),
1581
+ __JSKIT_CRUD_ACTION_WORKSPACE_VALIDATOR_IMPORT__: renderActionWorkspaceValidatorImport({
1582
+ surfaceRequiresWorkspace
1583
+ }),
1584
+ __JSKIT_CRUD_LIST_ACTION_PERMISSION__: renderActionPermissionExpression("list", {
1585
+ requiresNamedPermissions
1586
+ }),
1587
+ __JSKIT_CRUD_LIST_ACTION_INPUT_VALIDATOR__: renderActionInputValidatorExpression("list", {
1588
+ surfaceRequiresWorkspace
1589
+ }),
1590
+ __JSKIT_CRUD_VIEW_ACTION_PERMISSION__: renderActionPermissionExpression("view", {
1591
+ requiresNamedPermissions
1592
+ }),
1593
+ __JSKIT_CRUD_VIEW_ACTION_INPUT_VALIDATOR__: renderActionInputValidatorExpression("view", {
1594
+ surfaceRequiresWorkspace
1595
+ }),
1596
+ __JSKIT_CRUD_CREATE_ACTION_PERMISSION__: renderActionPermissionExpression("create", {
1597
+ requiresNamedPermissions
1598
+ }),
1599
+ __JSKIT_CRUD_CREATE_ACTION_INPUT_VALIDATOR__: renderActionInputValidatorExpression("create", {
1600
+ surfaceRequiresWorkspace
1601
+ }),
1602
+ __JSKIT_CRUD_UPDATE_ACTION_PERMISSION__: renderActionPermissionExpression("update", {
1603
+ requiresNamedPermissions
1604
+ }),
1605
+ __JSKIT_CRUD_UPDATE_ACTION_INPUT_VALIDATOR__: renderActionInputValidatorExpression("update", {
1606
+ surfaceRequiresWorkspace
1607
+ }),
1608
+ __JSKIT_CRUD_DELETE_ACTION_PERMISSION__: renderActionPermissionExpression("delete", {
1609
+ requiresNamedPermissions
1610
+ }),
1611
+ __JSKIT_CRUD_DELETE_ACTION_INPUT_VALIDATOR__: renderActionInputValidatorExpression("delete", {
1612
+ surfaceRequiresWorkspace
1613
+ }),
1614
+ __JSKIT_CRUD_ROLE_CATALOG_PERMISSION_GRANTS__: renderRoleCatalogPermissionGrants(namespace, {
1615
+ requiresNamedPermissions
1616
+ }),
1617
+ __JSKIT_CRUD_ROUTE_SURFACE_REQUIRES_WORKSPACE__: String(surfaceRequiresWorkspace === true),
1618
+ __JSKIT_CRUD_ROUTE_WORKSPACE_SUPPORT_IMPORTS__: renderRouteWorkspaceSupportImports({
1619
+ surfaceRequiresWorkspace
1620
+ }),
1621
+ __JSKIT_CRUD_LIST_ROUTE_PARAMS_VALIDATOR_LINE__: renderRouteParamsValidatorLine("list", {
1622
+ surfaceRequiresWorkspace
1623
+ }),
1624
+ __JSKIT_CRUD_VIEW_ROUTE_PARAMS_VALIDATOR_LINE__: renderRouteParamsValidatorLine("view", {
1625
+ surfaceRequiresWorkspace
1626
+ }),
1627
+ __JSKIT_CRUD_CREATE_ROUTE_PARAMS_VALIDATOR_LINE__: renderRouteParamsValidatorLine("create", {
1628
+ surfaceRequiresWorkspace
1629
+ }),
1630
+ __JSKIT_CRUD_UPDATE_ROUTE_PARAMS_VALIDATOR_LINE__: renderRouteParamsValidatorLine("update", {
1631
+ surfaceRequiresWorkspace
1632
+ }),
1633
+ __JSKIT_CRUD_DELETE_ROUTE_PARAMS_VALIDATOR_LINE__: renderRouteParamsValidatorLine("delete", {
1634
+ surfaceRequiresWorkspace
1635
+ }),
1636
+ __JSKIT_CRUD_LIST_ROUTE_INPUT_LINES__: renderRouteInputLines("list", {
1637
+ surfaceRequiresWorkspace
1638
+ }),
1639
+ __JSKIT_CRUD_VIEW_ROUTE_INPUT_LINES__: renderRouteInputLines("view", {
1640
+ surfaceRequiresWorkspace
1641
+ }),
1642
+ __JSKIT_CRUD_CREATE_ROUTE_INPUT_LINES__: renderRouteInputLines("create", {
1643
+ surfaceRequiresWorkspace
1644
+ }),
1645
+ __JSKIT_CRUD_UPDATE_ROUTE_INPUT_LINES__: renderRouteInputLines("update", {
1646
+ surfaceRequiresWorkspace
1647
+ }),
1648
+ __JSKIT_CRUD_DELETE_ROUTE_INPUT_LINES__: renderRouteInputLines("delete", {
1649
+ surfaceRequiresWorkspace
1650
+ }),
1147
1651
  __JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__: renderResourceValidatorsImport({
1148
- needsHtmlTimeSchemas
1652
+ needsHtmlTimeSchemas,
1653
+ needsRecordIdSchemas
1149
1654
  }),
1150
1655
  __JSKIT_CRUD_RESOURCE_DATABASE_RUNTIME_IMPORT__: renderResourceDatabaseRuntimeImport({
1151
1656
  needsToIsoString: needsDateTimeOutput || needsDate,
@@ -1156,6 +1661,7 @@ function buildReplacementsFromSnapshot({
1156
1661
  needsNormalizeBoolean,
1157
1662
  needsNormalizeFiniteNumber: needsFiniteNumber,
1158
1663
  needsNormalizeFiniteInteger: needsFiniteInteger,
1664
+ needsNormalizeRecordId: needsRecordIdSchemas,
1159
1665
  needsNormalizeIfInSource,
1160
1666
  needsNormalizeIfPresent,
1161
1667
  needsNormalizeOrNull
@@ -1176,7 +1682,8 @@ function buildReplacementsFromSnapshot({
1176
1682
  __JSKIT_CRUD_LIST_CONFIG_LINES__: renderRepositoryListConfigLines(snapshot),
1177
1683
  __JSKIT_CRUD_MIGRATION_COLUMN_LINES__: renderMigrationColumnLines(snapshot),
1178
1684
  __JSKIT_CRUD_MIGRATION_INDEX_LINES__: renderMigrationIndexLines(snapshot),
1179
- __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__: renderMigrationForeignKeyLines(snapshot)
1685
+ __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__: renderMigrationForeignKeyLines(snapshot),
1686
+ __JSKIT_CRUD_MIGRATION_CHECK_CONSTRAINT_LINES__: renderMigrationCheckConstraintLines(snapshot)
1180
1687
  });
1181
1688
 
1182
1689
  return replacements;
@@ -1199,11 +1706,16 @@ async function resolveGenerationSnapshot({
1199
1706
  });
1200
1707
  }
1201
1708
 
1709
+ function resolveCrudGenerationTableName(options = {}) {
1710
+ return normalizeText(options?.["table-name"] || options?.namespace);
1711
+ }
1712
+
1202
1713
  function createCacheKey({ appRoot, options }) {
1203
1714
  const payload = {
1204
1715
  appRoot: path.resolve(String(appRoot || "")),
1205
1716
  options: {
1206
1717
  namespace: normalizeText(options?.namespace),
1718
+ surface: normalizeText(options?.surface),
1207
1719
  ownershipFilter: normalizeText(options?.["ownership-filter"]),
1208
1720
  tableName: normalizeText(options?.["table-name"]),
1209
1721
  idColumn: normalizeText(options?.["id-column"])
@@ -1221,10 +1733,14 @@ async function buildCrudTemplateContext(input = {}) {
1221
1733
  if (!namespace) {
1222
1734
  throw new Error('crud template context requires option "namespace".');
1223
1735
  }
1224
- const tableName = normalizeText(options["table-name"]);
1736
+ const tableName = resolveCrudGenerationTableName(options);
1225
1737
  if (!tableName) {
1226
1738
  throw new Error('crud template context requires option "table-name".');
1227
1739
  }
1740
+ const resolvedSurface = await resolveCrudGenerationSurfaceId({
1741
+ appRoot,
1742
+ options
1743
+ });
1228
1744
  const snapshot = await resolveGenerationSnapshot({
1229
1745
  appRoot,
1230
1746
  tableName,
@@ -1238,10 +1754,18 @@ async function buildCrudTemplateContext(input = {}) {
1238
1754
  enforceTableColumns: true
1239
1755
  }
1240
1756
  );
1757
+ const surfaceRequiresWorkspace = await resolveCrudSurfaceRequiresWorkspace({
1758
+ appRoot,
1759
+ options,
1760
+ surface: resolvedSurface
1761
+ });
1241
1762
 
1242
1763
  return buildReplacementsFromSnapshot({
1764
+ namespace,
1243
1765
  snapshot,
1244
- resolvedOwnershipFilter
1766
+ resolvedOwnershipFilter,
1767
+ surfaceRequiresWorkspace,
1768
+ surfaceId: resolvedSurface
1245
1769
  });
1246
1770
  }
1247
1771
 
@@ -1266,14 +1790,26 @@ const __testables = Object.freeze({
1266
1790
  buildReplacementsFromSnapshot,
1267
1791
  parseDotEnvLine,
1268
1792
  renderMigrationColumnLine,
1793
+ renderMigrationCheckConstraintLines,
1269
1794
  renderMigrationForeignKeyLine,
1270
1795
  resolveScaffoldColumns,
1271
1796
  renderPropertyAccess,
1272
1797
  renderResourceFieldSchema,
1273
1798
  renderInputNormalizer,
1274
1799
  renderOutputNormalizerExpression,
1800
+ resolveCrudGenerationTableName,
1275
1801
  resolveGenerationSnapshot,
1276
- buildFieldMetaEntries
1802
+ buildFieldMetaEntries,
1803
+ resolveDefaultCrudSurfaceIdFromAppConfig,
1804
+ resolveCrudGenerationSurfaceId,
1805
+ resolveCrudSurfaceRequiresWorkspace,
1806
+ buildCrudPermissionIds,
1807
+ renderRoleCatalogPermissionGrants,
1808
+ renderActionPermissionSupport,
1809
+ renderActionPermissionExpression,
1810
+ renderActionInputValidatorExpression,
1811
+ renderRouteParamsValidatorLine,
1812
+ renderRouteInputLines
1277
1813
  });
1278
1814
 
1279
1815
  export {
@@ -1285,5 +1821,6 @@ export {
1285
1821
  renderInputNormalizer,
1286
1822
  renderOutputNormalizerExpression,
1287
1823
  buildFieldMetaEntries,
1824
+ resolveCrudGenerationSurfaceId,
1288
1825
  __testables
1289
1826
  };