@rebasepro/server-postgres 0.10.1-canary.7609565 → 0.10.1-canary.7801eed

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 (47) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/chunk-DSJWtz9O.js +40 -0
  3. package/dist/collections/validate-relations.d.ts +53 -0
  4. package/dist/data-transformer.d.ts +3 -3
  5. package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
  6. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  7. package/dist/index.es.js +1093 -4941
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  10. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  11. package/dist/services/FetchService.d.ts +21 -8
  12. package/dist/services/PersistService.d.ts +12 -0
  13. package/dist/services/RelationService.d.ts +39 -8
  14. package/dist/services/cdc/junction-tables.d.ts +38 -0
  15. package/dist/services/nested-path.d.ts +59 -0
  16. package/dist/services/realtimeService.d.ts +19 -0
  17. package/dist/services/row-pipeline.d.ts +2 -2
  18. package/dist/src-3VmUJ8Xn.js +3994 -0
  19. package/dist/src-3VmUJ8Xn.js.map +1 -0
  20. package/dist/src-D5xBTl32.js +346 -0
  21. package/dist/src-D5xBTl32.js.map +1 -0
  22. package/dist/utils/drizzle-conditions.d.ts +71 -18
  23. package/package.json +8 -9
  24. package/src/PostgresBootstrapper.ts +55 -2
  25. package/src/auth/ensure-tables.ts +23 -0
  26. package/src/auth/schema-version.ts +260 -0
  27. package/src/cli-errors.ts +1 -1
  28. package/src/cli-helpers.ts +4 -3
  29. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  30. package/src/collections/buildRegistry.ts +7 -0
  31. package/src/collections/validate-relations.ts +280 -0
  32. package/src/data-transformer.ts +28 -38
  33. package/src/schema/doctor.ts +14 -14
  34. package/src/schema/ensure-collection-tables.test.ts +156 -0
  35. package/src/schema/ensure-collection-tables.ts +297 -0
  36. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  37. package/src/schema/generate-postgres-ddl-logic.ts +31 -24
  38. package/src/schema/introspect-db-inference.ts +13 -13
  39. package/src/schema/introspect-db-logic.ts +25 -29
  40. package/src/services/FetchService.ts +116 -126
  41. package/src/services/PersistService.ts +126 -88
  42. package/src/services/RelationService.ts +157 -86
  43. package/src/services/cdc/junction-tables.ts +91 -0
  44. package/src/services/nested-path.ts +145 -0
  45. package/src/services/realtimeService.ts +60 -0
  46. package/src/services/row-pipeline.ts +5 -6
  47. package/src/utils/drizzle-conditions.ts +268 -330
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
1
+ import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
3
  import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
4
4
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
@@ -97,12 +97,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
97
97
  columnDefinition = `uuid("${colName}")`;
98
98
  } else if (stringProp.columnType === "uuid") {
99
99
  columnDefinition = `uuid("${colName}")`;
100
- } else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
101
- columnDefinition = `text("${colName}")`;
102
100
  } else if (stringProp.columnType === "char") {
103
101
  columnDefinition = `char("${colName}")`;
104
- } else {
102
+ } else if (stringProp.columnType === "varchar") {
105
103
  columnDefinition = `varchar("${colName}")`;
104
+ } else {
105
+ // `text` is the default, and the only length-unbounded choice.
106
+ // Ask for `varchar` explicitly if you want the length constraint.
107
+ columnDefinition = `text("${colName}")`;
106
108
  }
107
109
  if (isIdProperty(propName, prop, collection)) {
108
110
  columnDefinition += ".primaryKey()";
@@ -222,16 +224,11 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
222
224
  case "relation": {
223
225
  const refProp = prop as RelationProperty;
224
226
  const resolvedRelations = resolveCollectionRelations(collection);
225
- const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
227
+ const relation = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
226
228
 
227
- // Only owning one-to-one/many-to-one relations create a column here.
228
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
229
- return null;
230
- }
231
-
232
- // The localKey property is the source of truth for the FK column name.
233
- if (!relation.localKey) {
234
- logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
229
+ // Only `belongsTo` puts a column on this table; every other kind
230
+ // is a column on the target, a junction row, or a join chain.
231
+ if (!relation || relation.kind !== "belongsTo") {
235
232
  return null;
236
233
  }
237
234
 
@@ -252,7 +249,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
252
249
  const targetTableVar = getTableVarName(getTableName(targetCollection));
253
250
  const pkProp = getPrimaryKeyProp(targetCollection);
254
251
  const targetIdField = pkProp.name;
255
- const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`);
252
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`);
256
253
 
257
254
  const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
258
255
  const required = prop.validation?.required;
@@ -274,14 +271,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
274
271
  const refProp = prop as ReferenceProperty;
275
272
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
276
273
  if (!targetCollection) {
277
- columnDefinition = `varchar("${colName}")`;
274
+ columnDefinition = `text("${colName}")`;
278
275
  break;
279
276
  }
280
277
 
281
278
  const pkProp = getPrimaryKeyProp(targetCollection);
282
279
  const targetTableVar = getTableVarName(getTableName(targetCollection));
283
280
  const targetIdField = pkProp.name;
284
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`);
281
+ const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`);
285
282
 
286
283
  const required = prop.validation?.required;
287
284
  const onDelete = required ? "cascade" : "set null";
@@ -400,26 +397,22 @@ const generateSinglePolicyCode = (collection: CollectionConfig, rule: SecurityRu
400
397
  * Falls back to the local relation name when the counterpart can't be resolved.
401
398
  */
402
399
  const computeSharedRelationName = (
403
- rel: Relation,
400
+ rel: ResolvedRelation,
404
401
  sourceCollection: CollectionConfig,
405
402
  _collections: CollectionConfig[]
406
403
  ): string => {
407
404
  const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
408
405
 
409
- // --- owning one (belongs-to) ---
410
- if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
411
- // Normalise the localKey to the actual Drizzle property name so that
412
- // the owning side produces the same relation name as the inverse side
413
- // (which resolves foreignKeyOnTarget via the same helper).
406
+ // Both sides of a link must derive the same name, so each resolves the
407
+ // column to its Drizzle property key and builds the name from the table
408
+ // that actually owns it.
409
+ if (rel.kind === "belongsTo") {
414
410
  const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
415
411
  return `${getTableName(sourceCollection)}_${normalisedKey}`;
416
412
  }
417
413
 
418
- // --- inverse many (one-to-many has-many) ---
419
- if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
420
- // The owning table is the *target*, the FK column is foreignKeyOnTarget.
421
- // Resolve to the Drizzle property key on the target so it matches the
422
- // owning side's normalised localKey.
414
+ if (rel.kind === "hasMany" || rel.kind === "hasOne") {
415
+ // The owning table is the *target*; the column is foreignKeyOnTarget.
423
416
  try {
424
417
  const targetCollection = rel.target();
425
418
  const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
@@ -429,40 +422,8 @@ const computeSharedRelationName = (
429
422
  }
430
423
  }
431
424
 
432
- // --- inverse one (one-to-one inverse) ---
433
- if (rel.direction === "inverse" && rel.cardinality === "one") {
434
- if (rel.foreignKeyOnTarget) {
435
- // FK lives on the target table — resolve to Drizzle property key
436
- try {
437
- const targetCollection = rel.target();
438
- const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
439
- return `${getTableName(targetCollection)}_${normalisedFK}`;
440
- } catch {
441
- return fallback;
442
- }
443
- }
444
- // No explicit foreignKeyOnTarget — try to find the corresponding owning relation
445
- try {
446
- const targetCollection = rel.target();
447
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
448
- const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
449
- targetRel.direction === "owning" &&
450
- targetRel.cardinality === "one" &&
451
- targetRel.localKey &&
452
- targetRel.target().slug === sourceCollection.slug
453
- );
454
- if (correspondingRelation && correspondingRelation.localKey) {
455
- return `${getTableName(targetCollection)}_${correspondingRelation.localKey}`;
456
- }
457
- } catch {
458
- // ignore
459
- }
460
- return fallback;
461
- }
462
-
463
- // --- M2M owning (through) — keep local name (already shared via junction wiring) ---
464
- // --- M2M inverse — keep local name ---
465
- // --- joinPath — not emitted as Drizzle relations ---
425
+ // manyToMany is named through its junction wiring; `via` is not emitted as
426
+ // a Drizzle relation at all. Both keep the local name.
466
427
  return fallback;
467
428
  };
468
429
 
@@ -519,7 +480,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
519
480
  const allTablesToGenerate = new Map<string, {
520
481
  collection: CollectionConfig,
521
482
  isJunction?: boolean,
522
- relation?: Relation,
483
+ relation?: ResolvedRelation,
523
484
  sourceCollection?: CollectionConfig
524
485
  }>();
525
486
 
@@ -558,7 +519,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
558
519
 
559
520
  const resolvedRelations = resolveCollectionRelations(collection);
560
521
  for (const relation of Object.values(resolvedRelations)) {
561
- if (relation.through) { // Standard M2M junction table
522
+ if (isManyToMany(relation)) { // Standard M2M junction table
562
523
  const junctionTableName = relation.through.table;
563
524
  if (!allTablesToGenerate.has(junctionTableName)) {
564
525
  allTablesToGenerate.set(junctionTableName, {
@@ -584,7 +545,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
584
545
  sourceCollection
585
546
  }] of allTablesToGenerate.entries()) {
586
547
  const tableVarName = getTableVarName(tableName);
587
- if (isJunction && relation && sourceCollection && relation.through) {
548
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
588
549
  const targetCollection = relation.target();
589
550
  const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : undefined) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : undefined);
590
551
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -597,8 +558,10 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
597
558
  const onDelete = relation.onDelete ?? "cascade";
598
559
  const refOptions = `{ onDelete: \"${onDelete}\" }`;
599
560
 
600
- const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar");
601
- const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar");
561
+ // `text`, matching the string default: a junction column must have the
562
+ // same type as the primary key it references.
563
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text");
564
+ const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text");
602
565
  const sourceId = getPrimaryKeyName(sourceCollection);
603
566
  const targetId = getPrimaryKeyName(targetCollection);
604
567
 
@@ -637,7 +600,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
637
600
  // We should generate a basic id column if one was completely omitted.
638
601
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
639
602
  if (!hasIdColumn) {
640
- columns.add(" id: varchar(\"id\").primaryKey()");
603
+ columns.add(" id: text(\"id\").primaryKey()");
641
604
  }
642
605
 
643
606
  schemaContent += `${Array.from(columns).join(",\n")}`;
@@ -669,7 +632,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
669
632
 
670
633
  if (isJunction) {
671
634
  const relationInfo = Array.from(allTablesToGenerate.values()).find(v => v.isJunction && getTableName(v.collection) === tableName);
672
- if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
635
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
673
636
  const {
674
637
  relation,
675
638
  sourceCollection
@@ -693,9 +656,9 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
693
656
  try {
694
657
  const targetRelations = resolveCollectionRelations(targetCollection);
695
658
  for (const [, targetRel] of Object.entries(targetRelations)) {
696
- if (targetRel.direction === "inverse" &&
659
+ if (targetRel.kind !== "belongsTo" &&
697
660
  targetRel.cardinality === "many" &&
698
- targetRel.inverseRelationName === owningRelationName) {
661
+ targetRel.relationName === owningRelationName) {
699
662
  inverseRelationName = targetRel.relationName ?? null;
700
663
  break;
701
664
  }
@@ -740,54 +703,43 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
740
703
  // Skip if we've already emitted a relation with this drizzleRelationName
741
704
  // for this table — prevents duplicate definitions when
742
705
  // resolveCollectionRelations returns alias entries for the same FK.
743
- const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
706
+ const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
744
707
  if (emittedRelationNames.has(deduplicationKey)) continue;
745
708
  emittedRelationNames.add(deduplicationKey);
746
709
 
747
- if (rel.cardinality === "one") {
748
- if (rel.direction === "owning" && rel.localKey) {
710
+ switch (rel.kind) {
711
+ case "belongsTo":
749
712
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
750
- } else if (rel.direction === "inverse") {
751
- // Inverse one-to-one: the FK lives on the TARGET table, not here.
752
- // Drizzle pairs inverse relations via `relationName` alone — specifying
753
- // `fields`/`references` on the inverse side is invalid and causes
754
- // `normalizeRelation` to crash with "Cannot read properties of
713
+ break;
714
+
715
+ case "hasOne":
716
+ // The foreign key lives on the TARGET table. Drizzle pairs
717
+ // the two sides by `relationName` alone — giving
718
+ // `fields`/`references` here is invalid and crashes
719
+ // `normalizeRelation` with "Cannot read properties of
755
720
  // undefined (reading 'referencedTable')".
756
721
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
757
- }
758
- } else if (rel.cardinality === "many") {
759
- if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
760
- // One-to-many inverse relation
722
+ break;
723
+
724
+ case "hasMany":
761
725
  tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
762
- } else if (rel.through) {
763
- // Many-to-many owning relation with explicit junction table
726
+ break;
727
+
728
+ case "manyToMany": {
729
+ // Both sides point at the junction. This used to have a
730
+ // second arm that searched the *target's* relations for an
731
+ // owning many-to-many whose name matched, to borrow its
732
+ // junction table — unnecessary now that each side names
733
+ // its own.
764
734
  const junctionTableVar = getTableVarName(rel.through.table);
765
735
  tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
766
- } else if (rel.direction === "inverse" && rel.inverseRelationName) {
767
- // Many-to-many inverse relation - find the corresponding owning relation's junction table
768
- try {
769
- const targetCollection = rel.target();
770
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
771
-
772
- // Find the corresponding owning many-to-many relation on the target
773
- const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
774
- targetRel.direction === "owning" &&
775
- targetRel.cardinality === "many" &&
776
- targetRel.through &&
777
- targetRel.relationName === rel.inverseRelationName
778
- );
779
-
780
- if (correspondingRelation && correspondingRelation.through) {
781
- const junctionTableVar = getTableVarName(correspondingRelation.through.table);
782
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
783
- } else {
784
- logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
785
- }
786
- } catch (e) {
787
- logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
788
- }
736
+ break;
789
737
  }
790
- // joinPath relations don't generate Drizzle relations - they use existing user tables
738
+
739
+ case "via":
740
+ // A join chain is resolved at query time, not modelled
741
+ // as a Drizzle relation.
742
+ break;
791
743
  }
792
744
  } catch (e) {
793
745
  logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
@@ -800,12 +752,12 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
800
752
 
801
753
  const otherRelations = resolveCollectionRelations(otherCollection);
802
754
  for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
803
- if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
755
+ if (hasForeignKeyOnTarget(otherRel)) {
804
756
  try {
805
757
  const otherTarget = otherRel.target();
806
758
  if (otherTarget.slug === collection.slug) {
807
759
  const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
808
- const deduplicationKey = `${drizzleRelationName}::owning`;
760
+ const deduplicationKey = `${drizzleRelationName}::belongsTo`;
809
761
 
810
762
  if (!emittedRelationNames.has(deduplicationKey)) {
811
763
  const otherTableVar = getTableVarName(getTableName(otherCollection));
@@ -1,10 +1,10 @@
1
- import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
1
+ import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo } from "@rebasepro/types";
2
2
  import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
3
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
4
4
 
5
5
  // --- Helper Functions ---
6
6
 
7
- const resolveColumnName = (propName: string, prop?: Property | null): string => {
7
+ export const resolveColumnName = (propName: string, prop?: Property | null): string => {
8
8
  if (prop && "columnName" in prop && typeof prop.columnName === "string") {
9
9
  return prop.columnName;
10
10
  }
@@ -36,7 +36,7 @@ const getPrimaryKeyName = (collection: CollectionConfig): string => {
36
36
  return getPrimaryKeyProp(collection).name;
37
37
  };
38
38
 
39
- const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
39
+ export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
40
40
  if ("isId" in prop && Boolean(prop.isId)) return true;
41
41
  const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
42
42
  return !hasExplicitId && propName === "id";
@@ -90,7 +90,7 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
90
90
  return `${ddl};\n`;
91
91
  };
92
92
 
93
- const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
93
+ export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
94
94
  switch (prop.type) {
95
95
  case "string": {
96
96
  const stringProp = prop as StringProperty;
@@ -103,13 +103,18 @@ const getSqlColumnType = (propName: string, prop: Property, collection: Collecti
103
103
  if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
104
104
  return "UUID";
105
105
  }
106
- if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
107
- return "TEXT";
108
- }
109
106
  if (stringProp.columnType === "char") {
110
107
  return "CHAR(255)";
111
108
  }
112
- return "VARCHAR(255)";
109
+ if (stringProp.columnType === "varchar") {
110
+ return "VARCHAR(255)";
111
+ }
112
+ // `text` is the default. The two generators disagreed here before:
113
+ // this one emitted VARCHAR(255) while the drizzle path emitted a bare
114
+ // `varchar()`, which Postgres treats as unbounded — so the same
115
+ // property produced a capped column down one path and an uncapped one
116
+ // down the other.
117
+ return "TEXT";
113
118
  }
114
119
  case "number": {
115
120
  const numProp = prop as NumberProperty;
@@ -165,28 +170,28 @@ const getSqlColumnType = (propName: string, prop: Property, collection: Collecti
165
170
  case "relation": {
166
171
  const refProp = prop as RelationProperty;
167
172
  const resolvedRelations = resolveCollectionRelations(collection);
168
- const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
169
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
170
- throw new Error(`Relation ${propName} is not an owning one-to-one/many-to-one relation`);
173
+ const relation = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
174
+ if (relation?.kind !== "belongsTo") {
175
+ throw new Error(`Relation ${propName} does not put a column on this table (only \`belongsTo\` does)`);
171
176
  }
172
177
  let targetCollection: CollectionConfig;
173
178
  try {
174
179
  targetCollection = relation.target();
175
180
  } catch {
176
- return "VARCHAR(255)";
181
+ return "TEXT";
177
182
  }
178
183
  const pkProp = getPrimaryKeyProp(targetCollection);
179
- return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
184
+ return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
180
185
  }
181
186
  case "reference": {
182
187
  const refProp = prop as ReferenceProperty;
183
188
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
184
- if (!targetCollection) return "VARCHAR(255)";
189
+ if (!targetCollection) return "TEXT";
185
190
  const pkProp = getPrimaryKeyProp(targetCollection);
186
- return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "VARCHAR(255)");
191
+ return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
187
192
  }
188
193
  default:
189
- return "VARCHAR(255)";
194
+ return "TEXT";
190
195
  }
191
196
  };
192
197
 
@@ -233,7 +238,7 @@ export const generatePostgresDdl = async (
233
238
  const allTablesToGenerate = new Map<string, {
234
239
  collection: CollectionConfig,
235
240
  isJunction?: boolean,
236
- relation?: Relation,
241
+ relation?: ResolvedRelation,
237
242
  sourceCollection?: CollectionConfig
238
243
  }>();
239
244
 
@@ -246,7 +251,7 @@ export const generatePostgresDdl = async (
246
251
 
247
252
  const resolvedRelations = resolveCollectionRelations(collection);
248
253
  for (const relation of Object.values(resolvedRelations)) {
249
- if (relation.through) {
254
+ if (isManyToMany(relation)) {
250
255
  const junctionTableName = relation.through.table;
251
256
  if (!allTablesToGenerate.has(junctionTableName)) {
252
257
  allTablesToGenerate.set(junctionTableName, {
@@ -279,7 +284,7 @@ export const generatePostgresDdl = async (
279
284
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
280
285
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
281
286
 
282
- if (isJunction && relation && sourceCollection && relation.through) {
287
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
283
288
  const targetCollection = relation.target();
284
289
  const sourceTable = getTableName(sourceCollection);
285
290
  const targetTable = getTableName(targetCollection);
@@ -287,8 +292,10 @@ export const generatePostgresDdl = async (
287
292
  const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
288
293
  const { sourceColumn, targetColumn } = relation.through;
289
294
 
290
- const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : (getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "VARCHAR(255)");
291
- const targetColType = isNumericId(targetCollection) ? "INTEGER" : (getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "VARCHAR(255)");
295
+ // TEXT, matching the string default: a junction column has to have the
296
+ // same type as the primary key it references.
297
+ const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : (getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "TEXT");
298
+ const targetColType = isNumericId(targetCollection) ? "INTEGER" : (getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "TEXT");
292
299
  const sourceId = getPrimaryKeyName(sourceCollection);
293
300
  const targetId = getPrimaryKeyName(targetCollection);
294
301
 
@@ -329,9 +336,9 @@ export const generatePostgresDdl = async (
329
336
  if (prop.type === "relation") {
330
337
  const refProp = prop as RelationProperty;
331
338
  const resolvedRelations = resolveCollectionRelations(collection);
332
- const relInfo = findRelation(resolvedRelations, refProp.relationName ?? propName);
339
+ const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
333
340
 
334
- if (!relInfo || relInfo.direction !== "owning" || relInfo.cardinality !== "one" || !relInfo.localKey) {
341
+ if (relInfo?.kind !== "belongsTo") {
335
342
  return;
336
343
  }
337
344
 
@@ -424,7 +431,7 @@ export const generatePostgresDdl = async (
424
431
  // Backwards compatibility: add default id primary key if missing
425
432
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
426
433
  if (!hasPk) {
427
- columns.unshift(' "id" VARCHAR(255) PRIMARY KEY');
434
+ columns.unshift(' "id" TEXT PRIMARY KEY');
428
435
  }
429
436
 
430
437
  ddl += columns.join(",\n");
@@ -51,10 +51,11 @@ export function inferPropertyFromData(
51
51
  }
52
52
  }
53
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
- }
54
+ // No currency heuristic. `admin.currency` was never a declared option and
55
+ // nothing reads it, so a column called `price` got a block of config that did
56
+ // nothing — invisible because this generator emits *source text*, which no
57
+ // typechecker ever saw. Reinstate it by adding `currency` to
58
+ // AdminNumberOptions first, and something that renders it.
58
59
  }
59
60
 
60
61
  // ── JSON / JSONB Analysis ────────────────────────────────────────────
@@ -169,11 +170,10 @@ export function inferPropertyFromData(
169
170
  if (isPk) extraLines.push(" isId: \"cuid\"");
170
171
  }
171
172
 
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
- }
173
+ // No colour heuristic. `admin.color` was never a declared option and nothing
174
+ // reads it — the same dead branch `currency` was. This generator emits *source
175
+ // text*, so no typechecker ever saw either. Reinstate by adding the option and
176
+ // something that renders it first.
177
177
 
178
178
  // Text Lengths, Multiline & Markdown
179
179
  let maxLength = 0;
@@ -212,9 +212,9 @@ export function inferPropertyFromData(
212
212
  if (allAbsoluteUrls) {
213
213
  const isImage = validValues.some(v => typeof v === "string" && v.match(/\.(jpeg|jpg|gif|png|webp|svg)/i));
214
214
  if (isImage || isMedia) {
215
- extraLines.push(" ui: {\n url: \"image\"\n }");
215
+ extraLines.push(" url: true,\n admin: {\n urlPreview: \"image\"\n }");
216
216
  } else {
217
- extraLines.push(" ui: {\n url: true\n }");
217
+ extraLines.push(" url: true");
218
218
  }
219
219
  } else {
220
220
  const hasFileExtension = validValues.some(v => typeof v === "string" && v.match(/\.[a-zA-Z0-9]+$/));
@@ -225,9 +225,9 @@ export function inferPropertyFromData(
225
225
  extraLines.push(` storage: {\n storagePath: "${inferredStoragePath}"\n }`);
226
226
  } else if (isUrl) {
227
227
  if (isMedia) {
228
- extraLines.push(" ui: {\n url: \"image\"\n }");
228
+ extraLines.push(" url: true,\n admin: {\n urlPreview: \"image\"\n }");
229
229
  } else {
230
- extraLines.push(" ui: {\n url: true\n }");
230
+ extraLines.push(" url: true");
231
231
  }
232
232
  }
233
233
  }