@rebasepro/server-postgresql 0.0.1-canary.c53f5db → 0.0.1-canary.cbdd980

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.
Files changed (54) hide show
  1. package/dist/index.es.js +384 -1100
  2. package/dist/index.es.js.map +1 -1
  3. package/dist/index.umd.js +315 -1031
  4. package/dist/index.umd.js.map +1 -1
  5. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
  6. package/dist/server-postgresql/src/schema/introspect-db-inference.d.ts +5 -0
  7. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +44 -9
  8. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
  9. package/dist/types/src/controllers/auth.d.ts +8 -2
  10. package/dist/types/src/controllers/client.d.ts +13 -0
  11. package/dist/types/src/controllers/navigation.d.ts +18 -6
  12. package/dist/types/src/controllers/registry.d.ts +9 -1
  13. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  14. package/dist/types/src/rebase_context.d.ts +17 -0
  15. package/dist/types/src/types/collections.d.ts +21 -1
  16. package/dist/types/src/types/component_ref.d.ts +47 -0
  17. package/dist/types/src/types/cron.d.ts +1 -1
  18. package/dist/types/src/types/entity_views.d.ts +2 -1
  19. package/dist/types/src/types/index.d.ts +1 -0
  20. package/dist/types/src/types/properties.d.ts +68 -84
  21. package/dist/types/src/types/translations.d.ts +2 -0
  22. package/package.json +5 -5
  23. package/src/PostgresBackendDriver.ts +23 -6
  24. package/src/cli.ts +10 -2
  25. package/src/data-transformer.ts +84 -1
  26. package/src/schema/doctor.ts +14 -2
  27. package/src/schema/generate-drizzle-schema-logic.ts +59 -30
  28. package/src/schema/introspect-db-inference.ts +238 -0
  29. package/src/schema/introspect-db-logic.ts +365 -61
  30. package/src/schema/introspect-db.ts +66 -23
  31. package/src/services/EntityFetchService.ts +16 -0
  32. package/src/services/EntityPersistService.ts +88 -12
  33. package/test/generate-drizzle-schema.test.ts +295 -0
  34. package/test/introspect-db-generation.test.ts +32 -10
  35. package/test/property-ordering.test.ts +395 -0
  36. package/test/relations.test.ts +4 -4
  37. package/jest-all.log +0 -3128
  38. package/jest.log +0 -49
  39. package/scratch.ts +0 -41
  40. package/test-drizzle-bug.ts +0 -18
  41. package/test-drizzle-out/0000_cultured_freak.sql +0 -7
  42. package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
  43. package/test-drizzle-out/meta/0000_snapshot.json +0 -55
  44. package/test-drizzle-out/meta/0001_snapshot.json +0 -63
  45. package/test-drizzle-out/meta/_journal.json +0 -20
  46. package/test-drizzle-prompt.sh +0 -2
  47. package/test-policy-prompt.sh +0 -3
  48. package/test-programmatic.ts +0 -30
  49. package/test-programmatic2.ts +0 -59
  50. package/test-schema-no-policies.ts +0 -12
  51. package/test_drizzle_mock.js +0 -3
  52. package/test_find_changed.mjs +0 -32
  53. package/test_hash.js +0 -14
  54. package/test_output.txt +0 -3145
@@ -5,6 +5,18 @@ import { toSnakeCase } from "@rebasepro/utils";
5
5
  import { createHash } from "crypto";
6
6
  // --- Helper Functions ---
7
7
 
8
+ /**
9
+ * Resolve the SQL column name for a property.
10
+ * Uses the explicit `columnName` when set (e.g. from introspection),
11
+ * falling back to `toSnakeCase(propName)` for manually-authored collections.
12
+ */
13
+ const resolveColumnName = (propName: string, prop?: Property | null): string => {
14
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") {
15
+ return prop.columnName;
16
+ }
17
+ return toSnakeCase(propName);
18
+ };
19
+
8
20
  const getPrimaryKeyProp = (collection: EntityCollection): { name: string, type: "string" | "number", isUuid: boolean } => {
9
21
  if (collection.properties) {
10
22
  const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
@@ -46,7 +58,7 @@ const isIdProperty = (propName: string, prop: Property, collection: EntityCollec
46
58
  };
47
59
 
48
60
  const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCollection, collections: EntityCollection[]): string | null => {
49
- const colName = toSnakeCase(propName);
61
+ const colName = resolveColumnName(propName, prop);
50
62
  let columnDefinition: string;
51
63
 
52
64
  switch (prop.type) {
@@ -126,6 +138,10 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
126
138
  } else {
127
139
  columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
128
140
  }
141
+ // autoValue: database-level default for initial value on INSERT
142
+ if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
143
+ columnDefinition += `.default(sql\`now()\`)`;
144
+ }
129
145
  break;
130
146
  }
131
147
  case "map":
@@ -167,7 +183,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
167
183
  return null; // Cannot resolve target
168
184
  }
169
185
 
170
- const fkColumnName = toSnakeCase(relation.localKey);
186
+ const fkColumnName = relation.localKey;
171
187
  const targetTableVar = getTableVarName(getTableName(targetCollection));
172
188
  const pkProp = getPrimaryKeyProp(targetCollection);
173
189
  const targetIdField = pkProp.name;
@@ -461,6 +477,8 @@ const computeSharedRelationName = (
461
477
  export const generateSchema = async (collections: EntityCollection[], stripPolicies = false): Promise<string> => {
462
478
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
463
479
 
480
+
481
+
464
482
  const hasUuid = collections.some(c =>
465
483
  c.properties && Object.values(c.properties).some(
466
484
  (p: Property) => p.type === "string" && ((p as unknown as Record<string, unknown>).autoValue === "uuid" || (p as unknown as Record<string, unknown>).isId === "uuid")
@@ -496,7 +514,7 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
496
514
  Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
497
515
  if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
498
516
  const enumVarName = getEnumVarName(collectionPath, propName);
499
- const enumDbName = `${collectionPath}_${toSnakeCase(propName)}`;
517
+ const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
500
518
  const values = Array.isArray(prop.enum)
501
519
  ? prop.enum.map(v => String(v.id ?? v))
502
520
  : Object.keys(prop.enum);
@@ -560,8 +578,8 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
560
578
  const targetId = getPrimaryKeyName(targetCollection);
561
579
 
562
580
  schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
563
- schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${toSnakeCase(sourceColumn)}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
564
- schemaContent += ` ${targetColumn}: ${targetColType}(\"${toSnakeCase(targetColumn)}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
581
+ schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
582
+ schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
565
583
  schemaContent += "}, (table) => ({\n";
566
584
  schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
567
585
  schemaContent += "}));\n\n";
@@ -571,6 +589,7 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
571
589
  Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
572
590
  const columnString = getDrizzleColumn(propName, prop as Property, collection, collections);
573
591
  if (columnString) columns.add(columnString);
592
+
574
593
  });
575
594
 
576
595
  // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
@@ -682,31 +701,13 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
682
701
  if (rel.cardinality === "one") {
683
702
  if (rel.direction === "owning" && rel.localKey) {
684
703
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
685
- } else if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
686
- const sourceIdField = getPrimaryKeyName(collection);
687
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${rel.foreignKeyOnTarget}],\n relationName: \"${drizzleRelationName}\"\n })`);
688
- } else if (rel.direction === "inverse" && !rel.foreignKeyOnTarget) {
689
- // Handle inverse one-to-one relations where the FK is on the target table
690
- // but foreignKeyOnTarget is not explicitly specified
691
- // In this case, we need to find the corresponding owning relation on the target
692
- try {
693
- const targetCollection = rel.target();
694
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
695
-
696
- // Find the owning relation on the target that points back to this collection
697
- const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
698
- targetRel.direction === "owning" &&
699
- targetRel.cardinality === "one" &&
700
- targetRel.target().slug === collection.slug
701
- );
702
-
703
- if (correspondingRelation && correspondingRelation.localKey) {
704
- const sourceIdField = getPrimaryKeyName(collection);
705
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${correspondingRelation.localKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
706
- }
707
- } catch (e) {
708
- console.warn(`Could not resolve inverse one-to-one relation '${relationKey}':`, e);
709
- }
704
+ } else if (rel.direction === "inverse") {
705
+ // Inverse one-to-one: the FK lives on the TARGET table, not here.
706
+ // Drizzle pairs inverse relations via `relationName` alone — specifying
707
+ // `fields`/`references` on the inverse side is invalid and causes
708
+ // `normalizeRelation` to crash with "Cannot read properties of
709
+ // undefined (reading 'referencedTable')".
710
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
710
711
  }
711
712
  } else if (rel.cardinality === "many") {
712
713
  if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
@@ -746,6 +747,33 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
746
747
  console.warn(`Could not generate relation ${relationKey} for ${collection.name}:`, e);
747
748
  }
748
749
  }
750
+
751
+ // Synthesize missing reciprocal relations
752
+ for (const otherCollection of collections) {
753
+ if (otherCollection.slug === collection.slug) continue;
754
+
755
+ const otherRelations = resolveCollectionRelations(otherCollection);
756
+ for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
757
+ if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
758
+ try {
759
+ const otherTarget = otherRel.target();
760
+ if (otherTarget.slug === collection.slug) {
761
+ const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
762
+ const deduplicationKey = `${drizzleRelationName}::owning`;
763
+
764
+ if (!emittedRelationNames.has(deduplicationKey)) {
765
+ const otherTableVar = getTableVarName(getTableName(otherCollection));
766
+ const synthKey = `_synth_${otherTableVar}_${otherRel.foreignKeyOnTarget}`;
767
+ tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${otherRel.foreignKeyOnTarget}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
768
+ emittedRelationNames.add(deduplicationKey);
769
+ }
770
+ }
771
+ } catch (e) {
772
+ // ignore
773
+ }
774
+ }
775
+ }
776
+ }
749
777
  }
750
778
 
751
779
  if (tableRelations.length > 0) {
@@ -763,3 +791,4 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
763
791
 
764
792
  return schemaContent;
765
793
  };
794
+
@@ -0,0 +1,238 @@
1
+ import { humanize } from "./introspect-db-logic";
2
+
3
+ export interface InferenceResult {
4
+ propType?: string; // If the inference changes the base type
5
+ extra?: string; // String to append to the property definition
6
+ }
7
+
8
+ const ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
9
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ const CUID_REGEX = /^c[^\s-]{7,}$/i; // Basic CUID check
11
+ const COLOR_HEX_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
12
+
13
+ export function inferPropertyFromData(
14
+ columnName: string,
15
+ pgDataType: string,
16
+ currentPropType: string,
17
+ sampleValues: unknown[],
18
+ isPk: boolean
19
+ ): InferenceResult {
20
+ let result: InferenceResult = {};
21
+ let extraLines: string[] = [];
22
+
23
+ // Filter out null/undefined for analysis
24
+ const validValues = sampleValues.filter(v => v !== null && v !== undefined && v !== "");
25
+
26
+ if (validValues.length === 0) {
27
+ return result; // Not enough data
28
+ }
29
+
30
+ const colNameLower = columnName.toLowerCase();
31
+
32
+ // ── Number Analysis ──────────────────────────────────────────────────
33
+ if (currentPropType === "number") {
34
+ // Boolean stored as int
35
+ const allZeroOrOne = validValues.every(v => v === 0 || v === 1 || v === "0" || v === "1");
36
+ if (allZeroOrOne) {
37
+ result.propType = "boolean";
38
+ return result; // Don't do min/max if boolean
39
+ }
40
+
41
+ // Min / Max constraints
42
+ const numValues = validValues.map(v => Number(v)).filter(v => !isNaN(v));
43
+ if (numValues.length === validValues.length) {
44
+ const min = Math.min(...numValues);
45
+ const max = Math.max(...numValues);
46
+ // Example heuristic: percentages
47
+ if (min >= 0 && max <= 100 && (colNameLower.includes("percent") || colNameLower.includes("rate") || colNameLower.includes("score"))) {
48
+ extraLines.push(` validation: {\n min: 0,\n max: 100\n }`);
49
+ } else if (min >= 0 && (colNameLower.includes("count") || colNameLower.includes("total") || colNameLower.includes("amount"))) {
50
+ extraLines.push(` validation: {\n min: 0\n }`);
51
+ }
52
+ }
53
+
54
+ // Currency
55
+ if (colNameLower.includes("price") || colNameLower.includes("cost") || colNameLower.includes("amount") || colNameLower.includes("fee") || pgDataType === "money") {
56
+ extraLines.push(` ui: {\n currency: true\n }`);
57
+ }
58
+ }
59
+
60
+ // ── JSON / JSONB Analysis ────────────────────────────────────────────
61
+ if (currentPropType === "map" || pgDataType.includes("json")) {
62
+ // PostGres jsonb can be object or array
63
+ let allArrays = true;
64
+ let allObjects = true;
65
+
66
+ for (const v of validValues) {
67
+ let parsed = v;
68
+ if (typeof v === "string") {
69
+ try { parsed = JSON.parse(v); } catch (e) { allArrays = false; allObjects = false; break; }
70
+ }
71
+ if (Array.isArray(parsed)) {
72
+ allObjects = false;
73
+ } else if (typeof parsed === "object" && parsed !== null) {
74
+ allArrays = false;
75
+ } else {
76
+ allArrays = false;
77
+ allObjects = false;
78
+ }
79
+ }
80
+
81
+ if (allArrays && !allObjects) {
82
+ result.propType = "array";
83
+ // Infer inner type
84
+ let allNumbers = true;
85
+ let allStrings = true;
86
+ for (const v of validValues) {
87
+ let parsed = typeof v === "string" ? JSON.parse(v) : v;
88
+ for (const item of parsed) {
89
+ if (typeof item !== "number") allNumbers = false;
90
+ if (typeof item !== "string") allStrings = false;
91
+ }
92
+ }
93
+ const innerType = allNumbers ? "number" : allStrings ? "string" : "map";
94
+ extraLines.push(` of: { name: "${humanize(columnName)} Item", type: "${innerType}" }`);
95
+ } else {
96
+ result.propType = "map";
97
+
98
+ // Infer inner schema
99
+ if (allObjects && validValues.length > 0) {
100
+ const schema: Record<string, string> = {};
101
+ for (const v of validValues) {
102
+ let parsed = typeof v === "string" ? JSON.parse(v) : v;
103
+ for (const [k, val] of Object.entries(parsed)) {
104
+ if (val === null || val === undefined) continue;
105
+ const type = typeof val;
106
+ if (type === "string" || type === "number" || type === "boolean") {
107
+ if (!schema[k]) schema[k] = type;
108
+ else if (schema[k] !== type) schema[k] = "mixed";
109
+ } else if (Array.isArray(val)) {
110
+ if (!schema[k]) schema[k] = "array";
111
+ else if (schema[k] !== "array") schema[k] = "mixed";
112
+ } else if (type === "object") {
113
+ if (!schema[k]) schema[k] = "map";
114
+ else if (schema[k] !== "map") schema[k] = "mixed";
115
+ }
116
+ }
117
+ }
118
+
119
+ const keys = Object.keys(schema).filter(k => schema[k] !== "mixed");
120
+ if (keys.length > 0) {
121
+ const props = keys.map(k => {
122
+ return `\n ${k}: { name: "${humanize(k)}", type: "${schema[k]}" }`;
123
+ }).join(",");
124
+ extraLines.push(` properties: {${props}\n }`);
125
+ } else {
126
+ extraLines.push(` keyValue: true`);
127
+ }
128
+ } else {
129
+ extraLines.push(` keyValue: true`);
130
+ }
131
+ }
132
+ }
133
+
134
+ // ── String Analysis ──────────────────────────────────────────────────
135
+ if (currentPropType === "string") {
136
+ // Date/Time Strings
137
+ if (validValues.every(v => typeof v === 'string' && ISO_8601_REGEX.test(v))) {
138
+ result.propType = "date";
139
+ return result;
140
+ }
141
+
142
+ // Implicit Enums
143
+ const uniqueValues = new Set(validValues);
144
+ // Determine the maximum length among unique values
145
+ let maxEnumLength = 0;
146
+ for (const v of uniqueValues) {
147
+ if (typeof v === "string" && v.length > maxEnumLength) {
148
+ maxEnumLength = v.length;
149
+ }
150
+ }
151
+
152
+ // Ensure no empty string, max length makes sense, and fewer unique values than total values (unless small total)
153
+ if (uniqueValues.size > 0 && uniqueValues.size <= 5 && maxEnumLength <= 50 && validValues.length > uniqueValues.size && !uniqueValues.has("")) {
154
+ const isLikelyId = isPk || colNameLower.endsWith("_id");
155
+ if (!isLikelyId) {
156
+ const enumEntries = Array.from(uniqueValues).map(v => `{ id: ${JSON.stringify(v)}, label: ${JSON.stringify(humanize(v as string))} }`).join(", ");
157
+ extraLines.push(` enum: [${enumEntries}]`);
158
+ result.extra = extraLines.length > 0 ? "\n" + extraLines.join(",\n") + "," : "";
159
+ return result; // Skip other string checks if it's an enum
160
+ }
161
+ }
162
+
163
+ // UUID / CUID Detection
164
+ const allUuid = validValues.every(v => typeof v === 'string' && UUID_REGEX.test(v));
165
+ const allCuid = validValues.every(v => typeof v === 'string' && CUID_REGEX.test(v));
166
+ if (allUuid) {
167
+ if (isPk) extraLines.push(` isId: "uuid"`);
168
+ } else if (allCuid) {
169
+ if (isPk) extraLines.push(` isId: "cuid"`);
170
+ }
171
+
172
+ // Color Codes
173
+ const allColors = validValues.every(v => typeof v === 'string' && COLOR_HEX_REGEX.test(v));
174
+ if (allColors) {
175
+ extraLines.push(` ui: {\n color: true\n }`);
176
+ }
177
+
178
+ // Text Lengths, Multiline & Markdown
179
+ let maxLength = 0;
180
+ let hasNewlines = false;
181
+ let hasMarkdown = false;
182
+ for (const v of validValues) {
183
+ if (typeof v === "string") {
184
+ if (v.length > maxLength) maxLength = v.length;
185
+ if (v.includes("\n")) hasNewlines = true;
186
+ if (/^#{1,6}\s+.+/m.test(v) || /\*\*.+\*\*/.test(v) || /\[.+\]\(.+\)/.test(v) || /^\s*[-*]\s+.+/m.test(v)) {
187
+ hasMarkdown = true;
188
+ }
189
+ }
190
+ }
191
+
192
+ if (hasMarkdown) {
193
+ extraLines.push(` multiline: true,\n markdown: true`);
194
+ } else if (hasNewlines || maxLength > 100) {
195
+ extraLines.push(` multiline: true`);
196
+ }
197
+
198
+ if (maxLength > 0 && maxLength < 10000) { // arbitrary cap to avoid huge limits
199
+ // Pad the max length slightly for the constraint
200
+ const paddedMax = Math.ceil((maxLength * 1.5) / 10) * 10;
201
+ // Only add length constraint if it seems like a bounded string, not a long text
202
+ if (paddedMax < 255) {
203
+ extraLines.push(` validation: {\n max: ${paddedMax}\n }`);
204
+ }
205
+ }
206
+
207
+ // Storage & Media (Extracted from old logic)
208
+ const isUrl = colNameLower.endsWith("_url") || colNameLower.endsWith("_uri") || colNameLower.endsWith("_link");
209
+ const isMedia = colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover");
210
+
211
+ const allAbsoluteUrls = validValues.every(v => typeof v === 'string' && (v.startsWith("http://") || v.startsWith("https://")));
212
+ if (allAbsoluteUrls) {
213
+ const isImage = validValues.some(v => typeof v === 'string' && v.match(/\.(jpeg|jpg|gif|png|webp|svg)/i));
214
+ if (isImage || isMedia) {
215
+ extraLines.push(` ui: {\n url: "image"\n }`);
216
+ } else {
217
+ extraLines.push(` ui: {\n url: true\n }`);
218
+ }
219
+ } else {
220
+ const hasFileExtension = validValues.some(v => typeof v === 'string' && v.match(/\.[a-zA-Z0-9]+$/));
221
+ if (hasFileExtension) {
222
+ const firstVal = validValues[0] as string;
223
+ const lastSlash = firstVal.lastIndexOf('/');
224
+ let inferredStoragePath = lastSlash > 0 ? firstVal.substring(0, lastSlash) : "files";
225
+ extraLines.push(` storage: {\n storagePath: "${inferredStoragePath}"\n }`);
226
+ } else if (isUrl) {
227
+ if (isMedia) {
228
+ extraLines.push(` ui: {\n url: "image"\n }`);
229
+ } else {
230
+ extraLines.push(` ui: {\n url: true\n }`);
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ result.extra = extraLines.length > 0 ? "\n" + extraLines.join(",\n") + "," : "";
237
+ return result;
238
+ }