@jskit-ai/crud-server-generator 0.1.27 → 0.1.29

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.27",
4
+ version: "0.1.29",
5
5
  kind: "generator",
6
6
  description: "CRUD server generator with routes, actions, and persistence scaffolding.",
7
7
  options: {
@@ -15,9 +15,8 @@ export default Object.freeze({
15
15
  surface: {
16
16
  required: true,
17
17
  inputType: "text",
18
- defaultFromConfig: "surfaceDefaultId",
19
18
  promptLabel: "Target surface",
20
- promptHint: "Defaults to config.public.surfaceDefaultId. Must match an enabled surface id."
19
+ promptHint: "Must match an enabled surface id."
21
20
  },
22
21
  "ownership-filter": {
23
22
  required: true,
@@ -74,13 +73,19 @@ export default Object.freeze({
74
73
  server: {
75
74
  providers: [
76
75
  {
77
- entrypoint: "src/server/CrudServiceProvider.js",
78
- export: "CrudServiceProvider"
76
+ entrypoint: "src/server/CrudProvider.js",
77
+ export: "CrudProvider"
79
78
  }
80
79
  ]
81
80
  }
82
81
  },
83
82
  metadata: {
83
+ generatorSubcommands: {
84
+ "add-field": {
85
+ entrypoint: "src/server/subcommands/addField.js",
86
+ export: "runGeneratorSubcommand"
87
+ }
88
+ },
84
89
  apiSummary: {
85
90
  surfaces: [
86
91
  {
@@ -100,13 +105,13 @@ export default Object.freeze({
100
105
  mutations: {
101
106
  dependencies: {
102
107
  runtime: {
103
- "@jskit-ai/auth-core": "0.1.18",
104
- "@jskit-ai/crud-core": "0.1.27",
105
- "@jskit-ai/database-runtime": "0.1.19",
106
- "@jskit-ai/http-runtime": "0.1.18",
107
- "@jskit-ai/kernel": "0.1.19",
108
- "@jskit-ai/realtime": "0.1.18",
109
- "@jskit-ai/users-core": "0.1.28",
108
+ "@jskit-ai/auth-core": "0.1.20",
109
+ "@jskit-ai/crud-core": "0.1.29",
110
+ "@jskit-ai/database-runtime": "0.1.21",
111
+ "@jskit-ai/http-runtime": "0.1.20",
112
+ "@jskit-ai/kernel": "0.1.21",
113
+ "@jskit-ai/realtime": "0.1.20",
114
+ "@jskit-ai/users-core": "0.1.30",
110
115
  "@local/${option:namespace|kebab}": "file:packages/${option:namespace|kebab}",
111
116
  "typebox": "^1.0.81"
112
117
  },
@@ -145,8 +150,8 @@ export default Object.freeze({
145
150
  id: "crud-local-package-descriptor-${option:namespace|snake}"
146
151
  },
147
152
  {
148
- from: "templates/src/local-package/server/CrudServiceProvider.js",
149
- to: "packages/${option:namespace|kebab}/src/server/${option:namespace|pascal}ServiceProvider.js",
153
+ from: "templates/src/local-package/server/CrudProvider.js",
154
+ to: "packages/${option:namespace|kebab}/src/server/${option:namespace|pascal}Provider.js",
150
155
  reason: "Install app-local CRUD server provider.",
151
156
  category: "crud",
152
157
  id: "crud-local-package-server-provider-${option:namespace|snake}",
package/package.json CHANGED
@@ -1,22 +1,24 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-server-generator",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
7
7
  },
8
8
  "exports": {
9
- "./server/CrudServiceProvider": "./src/server/CrudServiceProvider.js",
9
+ "./server/CrudProvider": "./src/server/CrudProvider.js",
10
10
  "./server/crudModuleConfig": "./src/server/crudModuleConfig.js",
11
11
  "./shared": "./src/shared/index.js",
12
12
  "./shared/crud/crudResource": "./src/shared/crud/crudResource.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/crud-core": "0.1.27",
16
- "@jskit-ai/database-runtime": "0.1.19",
17
- "@jskit-ai/http-runtime": "0.1.18",
18
- "@jskit-ai/kernel": "0.1.19",
19
- "@jskit-ai/users-core": "0.1.28",
15
+ "@babel/parser": "^7.29.2",
16
+ "@jskit-ai/crud-core": "0.1.29",
17
+ "@jskit-ai/database-runtime": "0.1.21",
18
+ "@jskit-ai/http-runtime": "0.1.20",
19
+ "@jskit-ai/kernel": "0.1.21",
20
+ "@jskit-ai/users-core": "0.1.30",
21
+ "recast": "^0.23.11",
20
22
  "typebox": "^1.0.81"
21
23
  }
22
24
  }
@@ -1,4 +1,4 @@
1
- class CrudServiceProvider {
1
+ class CrudProvider {
2
2
  static id = "crud";
3
3
 
4
4
  static dependsOn = [];
@@ -8,4 +8,4 @@ class CrudServiceProvider {
8
8
  boot() {}
9
9
  }
10
10
 
11
- export { CrudServiceProvider };
11
+ export { CrudProvider };
@@ -8,7 +8,9 @@ import {
8
8
  resolveDatabaseConnectionFromEnvironment,
9
9
  toKnexClientId
10
10
  } from "@jskit-ai/database-runtime/shared";
11
- import { toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
11
+ import { checkCrudLookupFormControl } from "@jskit-ai/crud-core/shared/crudFieldMetaSupport";
12
+ import { normalizeCrudLookupNamespace } from "@jskit-ai/kernel/shared/support/crudLookup";
13
+ import { toCamelCase, toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
12
14
 
13
15
  const DEFAULT_ID_COLUMN = "id";
14
16
  const OWNERSHIP_FILTER_AUTO = "auto";
@@ -380,7 +382,14 @@ function renderResourceFieldSchema(column, { forOutput = false } = {}) {
380
382
 
381
383
  function renderInputNormalizer(column) {
382
384
  const typeKind = String(column.typeKind || "");
383
- if (typeKind === "string" || typeKind === "time") {
385
+ const nullable = column?.nullable === true;
386
+ if (typeKind === "string") {
387
+ return "normalizeText";
388
+ }
389
+ if (typeKind === "time") {
390
+ if (nullable) {
391
+ return "(value) => { const normalized = normalizeText(value); return normalized || null; }";
392
+ }
384
393
  return "normalizeText";
385
394
  }
386
395
  if (typeKind === "integer") {
@@ -393,9 +402,15 @@ function renderInputNormalizer(column) {
393
402
  return "normalizeBoolean";
394
403
  }
395
404
  if (typeKind === "datetime") {
405
+ if (nullable) {
406
+ return "(value) => { const normalized = normalizeText(value); return normalized ? toDatabaseDateTimeUtc(normalized) : null; }";
407
+ }
396
408
  return "toDatabaseDateTimeUtc";
397
409
  }
398
410
  if (typeKind === "date") {
411
+ if (nullable) {
412
+ return "(value) => { const normalized = normalizeText(value); return normalized ? toIsoString(normalized).slice(0, 10) : null; }";
413
+ }
399
414
  return "(value) => toIsoString(value).slice(0, 10)";
400
415
  }
401
416
  if (typeKind === "json") {
@@ -692,8 +707,233 @@ function renderMigrationIndexLines(snapshot) {
692
707
  return lines.join("\n");
693
708
  }
694
709
 
710
+ function renderMigrationForeignKeyLine(foreignKey = {}) {
711
+ const columns = Array.isArray(foreignKey.columns)
712
+ ? foreignKey.columns
713
+ .map((column) => normalizeText(column?.name))
714
+ .filter(Boolean)
715
+ : [];
716
+ const referencedColumns = Array.isArray(foreignKey.columns)
717
+ ? foreignKey.columns
718
+ .map((column) => normalizeText(column?.referencedName))
719
+ .filter(Boolean)
720
+ : [];
721
+ const referencedTableName = normalizeText(foreignKey.referencedTableName);
722
+ const foreignKeyName = normalizeText(foreignKey.name);
723
+ if (columns.length < 1 || referencedColumns.length < 1 || !referencedTableName) {
724
+ return "";
725
+ }
726
+
727
+ let line = ` table.foreign(${JSON.stringify(columns)}`;
728
+ if (foreignKeyName) {
729
+ line += `, ${JSON.stringify(foreignKeyName)}`;
730
+ }
731
+ line += `).references(${JSON.stringify(referencedColumns)}).inTable(${JSON.stringify(referencedTableName)})`;
732
+
733
+ const updateRule = normalizeText(foreignKey.updateRule).toUpperCase();
734
+ if (updateRule) {
735
+ line += `.onUpdate(${JSON.stringify(updateRule)})`;
736
+ }
737
+ const deleteRule = normalizeText(foreignKey.deleteRule).toUpperCase();
738
+ if (deleteRule) {
739
+ line += `.onDelete(${JSON.stringify(deleteRule)})`;
740
+ }
741
+ line += ";";
742
+
743
+ return line;
744
+ }
745
+
746
+ function renderMigrationForeignKeyLines(snapshot) {
747
+ const foreignKeys = Array.isArray(snapshot.foreignKeys) ? snapshot.foreignKeys : [];
748
+ const lines = foreignKeys
749
+ .map((foreignKey) => renderMigrationForeignKeyLine(foreignKey))
750
+ .filter(Boolean);
751
+ return lines.join("\n");
752
+ }
753
+
754
+ function mergeFieldMetaEntries(baseEntries = [], patchEntries = []) {
755
+ const mergedByKey = new Map();
756
+ for (const sourceEntry of [...baseEntries, ...patchEntries]) {
757
+ const key = normalizeText(sourceEntry?.key);
758
+ if (!key) {
759
+ continue;
760
+ }
761
+ const existing = mergedByKey.get(key) || {};
762
+ const next = {
763
+ ...existing,
764
+ ...sourceEntry,
765
+ key
766
+ };
767
+ if (existing.relation || sourceEntry.relation) {
768
+ next.relation = {
769
+ ...(existing.relation && typeof existing.relation === "object" ? existing.relation : {}),
770
+ ...(sourceEntry.relation && typeof sourceEntry.relation === "object" ? sourceEntry.relation : {})
771
+ };
772
+ }
773
+ if (existing.ui || sourceEntry.ui) {
774
+ next.ui = {
775
+ ...(existing.ui && typeof existing.ui === "object" ? existing.ui : {}),
776
+ ...(sourceEntry.ui && typeof sourceEntry.ui === "object" ? sourceEntry.ui : {})
777
+ };
778
+ }
779
+ mergedByKey.set(key, next);
780
+ }
781
+
782
+ return [...mergedByKey.values()].sort((left, right) => left.key.localeCompare(right.key));
783
+ }
784
+
785
+ function resolveLookupNamespaceFromTableName(tableName = "") {
786
+ const normalizedTableName = toSnakeCase(normalizeText(tableName));
787
+ if (!normalizedTableName) {
788
+ return "";
789
+ }
790
+
791
+ return normalizedTableName.replace(/_/g, "-");
792
+ }
793
+
794
+ function buildFieldMetaEntries({ outputColumns = [], writableColumns = [], snapshot = {} } = {}) {
795
+ const fieldColumns = [...outputColumns, ...writableColumns];
796
+ const fieldColumnsByName = new Map();
797
+ const fieldColumnsByKey = new Map();
798
+ for (const column of fieldColumns) {
799
+ const columnName = normalizeText(column?.name);
800
+ const key = normalizeText(column?.key);
801
+ if (columnName && !fieldColumnsByName.has(columnName)) {
802
+ fieldColumnsByName.set(columnName, column);
803
+ }
804
+ if (key && !fieldColumnsByKey.has(key)) {
805
+ fieldColumnsByKey.set(key, column);
806
+ }
807
+ }
808
+
809
+ const dbColumnEntries = [];
810
+ for (const column of fieldColumnsByKey.values()) {
811
+ const key = normalizeText(column?.key);
812
+ const name = normalizeText(column?.name);
813
+ if (!key || !name) {
814
+ continue;
815
+ }
816
+ if (toSnakeCase(key) === name) {
817
+ continue;
818
+ }
819
+ dbColumnEntries.push({
820
+ key,
821
+ dbColumn: name
822
+ });
823
+ }
824
+
825
+ const relationEntries = [];
826
+ const foreignKeys = Array.isArray(snapshot.foreignKeys) ? snapshot.foreignKeys : [];
827
+ for (const foreignKey of foreignKeys) {
828
+ const columns = Array.isArray(foreignKey?.columns) ? foreignKey.columns : [];
829
+ if (columns.length !== 1) {
830
+ const name = normalizeText(foreignKey?.name) || "unnamed_foreign_key";
831
+ throw new Error(
832
+ `CRUD generation supports only single-column foreign keys. Constraint "${name}" has ${columns.length} columns.`
833
+ );
834
+ }
835
+
836
+ const localColumnName = normalizeText(columns[0]?.name);
837
+ const referencedColumnName = normalizeText(columns[0]?.referencedName);
838
+ const referencedTableName = normalizeText(foreignKey?.referencedTableName);
839
+ if (!localColumnName || !referencedColumnName || !referencedTableName) {
840
+ continue;
841
+ }
842
+
843
+ const localColumn = fieldColumnsByName.get(localColumnName);
844
+ if (!localColumn || localColumn.isOwnerColumn === true) {
845
+ continue;
846
+ }
847
+
848
+ relationEntries.push({
849
+ key: localColumn.key,
850
+ relation: {
851
+ kind: "lookup",
852
+ namespace: resolveLookupNamespaceFromTableName(referencedTableName),
853
+ valueKey: toCamelCase(referencedColumnName)
854
+ },
855
+ ui: {
856
+ formControl: "autocomplete"
857
+ }
858
+ });
859
+ }
860
+
861
+ return mergeFieldMetaEntries(dbColumnEntries, relationEntries);
862
+ }
863
+
864
+ function renderFieldMetaEntryLines(entry = {}) {
865
+ const lines = ["RESOURCE_FIELD_META.push({"];
866
+ const topLevelProperties = [`key: ${JSON.stringify(entry.key)}`];
867
+ const dbColumn = normalizeText(entry.dbColumn);
868
+ if (dbColumn) {
869
+ topLevelProperties.push(`dbColumn: ${JSON.stringify(dbColumn)}`);
870
+ }
871
+
872
+ const relation = entry.relation && typeof entry.relation === "object" ? entry.relation : null;
873
+ if (relation) {
874
+ const targetResourceNamespace = normalizeCrudLookupNamespace(relation.targetResource);
875
+ const relationNamespace =
876
+ normalizeCrudLookupNamespace(relation.namespace) ||
877
+ normalizeCrudLookupNamespace(relation.apiPath) ||
878
+ normalizeCrudLookupNamespace(relation?.source?.path) ||
879
+ targetResourceNamespace;
880
+ if (!relationNamespace) {
881
+ throw new Error(`crud template context fieldMeta["${normalizeText(entry.key)}"] lookup relation requires namespace.`);
882
+ }
883
+ const relationLines = [
884
+ "relation: {",
885
+ ` kind: ${JSON.stringify(normalizeText(relation.kind) || "lookup")},`,
886
+ ` namespace: ${JSON.stringify(relationNamespace)},`,
887
+ ` valueKey: ${JSON.stringify(normalizeText(relation.valueKey) || "id")},`
888
+ ];
889
+ const labelKey = normalizeText(relation.labelKey);
890
+ if (labelKey) {
891
+ relationLines.push(` labelKey: ${JSON.stringify(labelKey)}`);
892
+ } else {
893
+ relationLines[relationLines.length - 1] = relationLines[relationLines.length - 1].replace(/,$/, "");
894
+ }
895
+ relationLines.push("}");
896
+ topLevelProperties.push(relationLines.join("\n"));
897
+ }
898
+
899
+ const formControl = checkCrudLookupFormControl(entry?.ui?.formControl, {
900
+ context: `resource.fieldMeta["${normalizeText(entry.key)}"].ui.formControl`,
901
+ defaultValue: relation ? "autocomplete" : ""
902
+ });
903
+ if (formControl) {
904
+ topLevelProperties.push(
905
+ [
906
+ "ui: {",
907
+ ` formControl: ${JSON.stringify(formControl)} // or "select"`,
908
+ "}"
909
+ ].join("\n")
910
+ );
911
+ }
912
+
913
+ for (const [index, propertyBlock] of topLevelProperties.entries()) {
914
+ const blockLines = String(propertyBlock || "").split("\n");
915
+ const isLastProperty = index >= topLevelProperties.length - 1;
916
+ const propertySuffix = isLastProperty ? "" : ",";
917
+ for (const [lineIndex, line] of blockLines.entries()) {
918
+ const isLastLine = lineIndex >= blockLines.length - 1;
919
+ lines.push(` ${line}${isLastLine ? propertySuffix : ""}`);
920
+ }
921
+ }
922
+
923
+ lines.push("});");
924
+ return lines.join("\n");
925
+ }
926
+
927
+ function renderResourceFieldMetaPushLines(entries = []) {
928
+ const sourceEntries = Array.isArray(entries) ? entries : [];
929
+ if (sourceEntries.length < 1) {
930
+ return "";
931
+ }
932
+
933
+ return sourceEntries.map((entry) => renderFieldMetaEntryLines(entry)).join("\n\n");
934
+ }
935
+
695
936
  function buildReplacementsFromSnapshot({
696
- namespace,
697
937
  snapshot,
698
938
  resolvedOwnershipFilter
699
939
  }) {
@@ -704,34 +944,26 @@ function buildReplacementsFromSnapshot({
704
944
  .filter((column) => !column.nullable && column.hasDefault !== true)
705
945
  .map((column) => column.key);
706
946
  const resourceColumns = [...outputColumns, ...writableColumns];
707
-
708
- const outputKeys = outputColumns.map((column) => column.key);
709
- const writeKeys = writableColumns.map((column) => column.key);
710
- const columnOverrides = {};
711
- const seenOverrideKeys = new Set();
712
- for (const column of [...outputColumns, ...writableColumns]) {
713
- const key = column.key;
714
- if (!key || seenOverrideKeys.has(key)) {
715
- continue;
716
- }
717
- seenOverrideKeys.add(key);
718
- const guessedColumn = toSnakeCase(key);
719
- const actualColumn = column.name;
720
- if (typeof actualColumn === "string" && actualColumn && actualColumn !== guessedColumn) {
721
- columnOverrides[key] = actualColumn;
722
- }
723
- }
724
- const createdAtColumn = scaffoldColumns.find((column) => column.isCreatedAtColumn)?.name || "";
725
- const updatedAtColumn = scaffoldColumns.find((column) => column.isUpdatedAtColumn)?.name || "";
947
+ const fieldMetaEntries = buildFieldMetaEntries({
948
+ outputColumns,
949
+ writableColumns,
950
+ snapshot
951
+ });
726
952
  const needsFiniteInteger = resourceColumns.some((column) => column.typeKind === "integer");
727
953
  const needsFiniteNumber = resourceColumns.some((column) => column.typeKind === "number");
728
954
  const needsDateTimeOutput = outputColumns.some((column) => column.typeKind === "datetime");
729
955
  const needsDateTimeInput = writableColumns.some((column) => column.typeKind === "datetime");
956
+ const needsNullableDateTimeInput = writableColumns.some(
957
+ (column) => column.typeKind === "datetime" && column.nullable === true
958
+ );
959
+ const needsNullableDateInput = writableColumns.some(
960
+ (column) => column.typeKind === "date" && column.nullable === true
961
+ );
730
962
  const needsDate = resourceColumns.some((column) => column.typeKind === "date");
731
963
  const needsJson = resourceColumns.some((column) => column.typeKind === "json");
732
964
  const needsNormalizeText = resourceColumns.some((column) =>
733
965
  column.typeKind === "string" || column.typeKind === "time"
734
- );
966
+ ) || needsNullableDateTimeInput || needsNullableDateInput;
735
967
  const needsNormalizeBoolean = resourceColumns.some((column) => column.typeKind === "boolean");
736
968
  const needsNormalizeIfInSource = writableColumns.length > 0;
737
969
  const outputColumnsWithNormalizer = outputColumns.filter(
@@ -769,13 +1001,10 @@ function buildReplacementsFromSnapshot({
769
1001
  __JSKIT_CRUD_RESOURCE_INPUT_NORMALIZATION_LINES__: renderResourceInputNormalizationLines(writableColumns),
770
1002
  __JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__: renderResourceOutputNormalizationLines(outputColumns),
771
1003
  __JSKIT_CRUD_RESOURCE_CREATE_REQUIRED_FIELDS__: JSON.stringify(createRequiredFieldKeys),
772
- __JSKIT_CRUD_REPOSITORY_OUTPUT_KEYS__: JSON.stringify(outputKeys),
773
- __JSKIT_CRUD_REPOSITORY_WRITE_KEYS__: JSON.stringify(writeKeys),
774
- __JSKIT_CRUD_REPOSITORY_COLUMN_OVERRIDES__: JSON.stringify(columnOverrides),
775
- __JSKIT_CRUD_REPOSITORY_CREATED_AT_COLUMN__: JSON.stringify(createdAtColumn),
776
- __JSKIT_CRUD_REPOSITORY_UPDATED_AT_COLUMN__: JSON.stringify(updatedAtColumn),
1004
+ __JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__: renderResourceFieldMetaPushLines(fieldMetaEntries),
777
1005
  __JSKIT_CRUD_MIGRATION_COLUMN_LINES__: renderMigrationColumnLines(snapshot),
778
- __JSKIT_CRUD_MIGRATION_INDEX_LINES__: renderMigrationIndexLines(snapshot)
1006
+ __JSKIT_CRUD_MIGRATION_INDEX_LINES__: renderMigrationIndexLines(snapshot),
1007
+ __JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__: renderMigrationForeignKeyLines(snapshot)
779
1008
  });
780
1009
 
781
1010
  return replacements;
@@ -839,7 +1068,6 @@ async function buildCrudTemplateContext(input = {}) {
839
1068
  );
840
1069
 
841
1070
  return buildReplacementsFromSnapshot({
842
- namespace,
843
1071
  snapshot,
844
1072
  resolvedOwnershipFilter
845
1073
  });
@@ -865,7 +1093,25 @@ const __testables = Object.freeze({
865
1093
  resolveOwnershipFilterForGeneration,
866
1094
  buildReplacementsFromSnapshot,
867
1095
  parseDotEnvLine,
868
- renderMigrationColumnLine
1096
+ renderMigrationColumnLine,
1097
+ renderMigrationForeignKeyLine,
1098
+ resolveScaffoldColumns,
1099
+ renderPropertyAccess,
1100
+ renderResourceFieldSchema,
1101
+ renderInputNormalizer,
1102
+ renderOutputNormalizerExpression,
1103
+ resolveGenerationSnapshot,
1104
+ buildFieldMetaEntries
869
1105
  });
870
1106
 
871
- export { buildTemplateContext, __testables };
1107
+ export {
1108
+ buildTemplateContext,
1109
+ resolveScaffoldColumns,
1110
+ renderPropertyAccess,
1111
+ resolveGenerationSnapshot,
1112
+ renderResourceFieldSchema,
1113
+ renderInputNormalizer,
1114
+ renderOutputNormalizerExpression,
1115
+ buildFieldMetaEntries,
1116
+ __testables
1117
+ };