@rebasepro/server-postgresql 0.0.1-canary.6e26b67 → 0.0.1-canary.892f711

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 (46) hide show
  1. package/dist/index.es.js +184 -186
  2. package/dist/index.es.js.map +1 -1
  3. package/dist/index.umd.js +184 -186
  4. package/dist/index.umd.js.map +1 -1
  5. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +6 -0
  6. package/dist/types/src/controllers/auth.d.ts +2 -2
  7. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  8. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  9. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  10. package/dist/types/src/types/collections.d.ts +11 -10
  11. package/dist/types/src/types/cron.d.ts +1 -1
  12. package/dist/types/src/types/entity_views.d.ts +4 -6
  13. package/dist/types/src/types/formex.d.ts +40 -0
  14. package/dist/types/src/types/index.d.ts +2 -0
  15. package/dist/types/src/types/plugins.d.ts +6 -3
  16. package/dist/types/src/types/properties.d.ts +71 -87
  17. package/dist/types/src/types/slots.d.ts +20 -10
  18. package/dist/types/src/types/translations.d.ts +4 -0
  19. package/package.json +5 -5
  20. package/src/PostgresBackendDriver.ts +9 -0
  21. package/src/cli.ts +2 -2
  22. package/src/schema/doctor.ts +14 -2
  23. package/src/schema/generate-drizzle-schema-logic.ts +24 -30
  24. package/src/schema/introspect-db-logic.ts +33 -30
  25. package/src/services/EntityPersistService.ts +7 -1
  26. package/test/generate-drizzle-schema.test.ts +214 -0
  27. package/test/introspect-db-generation.test.ts +27 -5
  28. package/test/relations.test.ts +4 -4
  29. package/jest-all.log +0 -3128
  30. package/jest.log +0 -49
  31. package/scratch.ts +0 -41
  32. package/test-drizzle-bug.ts +0 -18
  33. package/test-drizzle-out/0000_cultured_freak.sql +0 -7
  34. package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
  35. package/test-drizzle-out/meta/0000_snapshot.json +0 -55
  36. package/test-drizzle-out/meta/0001_snapshot.json +0 -63
  37. package/test-drizzle-out/meta/_journal.json +0 -20
  38. package/test-drizzle-prompt.sh +0 -2
  39. package/test-policy-prompt.sh +0 -3
  40. package/test-programmatic.ts +0 -30
  41. package/test-programmatic2.ts +0 -59
  42. package/test-schema-no-policies.ts +0 -12
  43. package/test_drizzle_mock.js +0 -3
  44. package/test_find_changed.mjs +0 -32
  45. package/test_hash.js +0 -14
  46. package/test_output.txt +0 -3145
@@ -281,6 +281,7 @@ export function generateCollectionFile(
281
281
  const imports = new Set<string>(['import { PostgresCollection } from "@rebasepro/types";']);
282
282
 
283
283
  let propsOutput = ``;
284
+ let relationsOutput = ``;
284
285
  const propertiesOrder: string[] = [];
285
286
 
286
287
  // Detect composite primary keys
@@ -289,7 +290,8 @@ export function generateCollectionFile(
289
290
  // Map columns
290
291
  for (const col of meta.columns) {
291
292
  // Skip foreign keys since we handle them as relations
292
- if (meta.fks.some((fk) => fk.column_name === col.column_name)) continue;
293
+ // Exception: Do not skip if it's part of the primary key!
294
+ if (meta.fks.some((fk) => fk.column_name === col.column_name) && !meta.pks.includes(col.column_name)) continue;
293
295
 
294
296
  propertiesOrder.push(col.column_name);
295
297
 
@@ -302,22 +304,22 @@ export function generateCollectionFile(
302
304
 
303
305
  const colNameLower = col.column_name.toLowerCase();
304
306
 
305
- // Enum values — generate real enumValues from the PG enum
307
+ // Enum values — generate real enum from the PG enum
306
308
  if (isEnumColumn && colEnumValues) {
307
309
  const enumEntries = colEnumValues
308
310
  .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
309
311
  .join(", ");
310
- extra = `\n enumValues: [${enumEntries}],`;
312
+ extra = `\n enum: [${enumEntries}],`;
311
313
  }
312
314
 
313
315
  // Date auto-value heuristics
314
316
  if (propType === "date") {
315
317
  if (colNameLower === "created_at" || colNameLower === "createdat") {
316
- extra = `\n autoValue: "on_create",\n readOnly: true,\n hideFromCollection: true,`;
318
+ extra = `\n autoValue: "on_create",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
317
319
  } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
318
- extra = `\n autoValue: "on_update",\n readOnly: true,\n hideFromCollection: true,`;
320
+ extra = `\n autoValue: "on_update",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
319
321
  } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
320
- extra = `\n autoValue: "on_create",\n readOnly: true,`;
322
+ extra = `\n autoValue: "on_create",\n ui: {\n readOnly: true\n },`;
321
323
  }
322
324
  }
323
325
 
@@ -330,7 +332,7 @@ export function generateCollectionFile(
330
332
  // We'll just call mapPgType on the baseType
331
333
  innerType = mapPgType(baseType);
332
334
  }
333
- extra = `\n of: { type: "${innerType}" },`;
335
+ extra = `\n of: { name: "${humanize(col.column_name)} Item", type: "${innerType}" },`;
334
336
  } else if (propType === "map") {
335
337
  extra = `\n keyValue: true,`;
336
338
  }
@@ -370,6 +372,7 @@ export function generateCollectionFile(
370
372
  propsOutput += `
371
373
  ${col.column_name}: {
372
374
  name: "${humanName}",
375
+ columnName: "${col.column_name}",
373
376
  type: "${propType}",${extra}
374
377
  },`;
375
378
  }
@@ -378,7 +381,12 @@ export function generateCollectionFile(
378
381
  for (const fk of meta.fks) {
379
382
  const targetTableName = fk.foreign_table_name;
380
383
  if (!joinTables.has(targetTableName)) {
381
- const relName = fk.column_name.replace(/_id$/, "");
384
+ let relName = fk.column_name.replace(/_id$/, "");
385
+ if (meta.pks.includes(fk.column_name) && relName === fk.column_name) {
386
+ // If the FK is also the PK and its name doesn't imply a relation (like "id"),
387
+ // use the target table name to avoid conflicting with the PK property.
388
+ relName = targetTableName;
389
+ }
382
390
  // Push the relation property key, not the FK column name
383
391
  propertiesOrder.push(relName);
384
392
 
@@ -401,21 +409,19 @@ export function generateCollectionFile(
401
409
  }
402
410
 
403
411
  // Map Inverse Relations (1-to-many where OTHER table points to THIS table)
412
+ // These go into the `relations` array so they render as subcollection tabs.
404
413
  const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
405
414
  for (const fk of inverseFks) {
406
415
  const sourceTableName = fk.table_name;
407
- propertiesOrder.push(sourceTableName);
408
416
 
409
417
  const targetCollectionCamel = toCollectionVarName(sourceTableName);
410
418
  imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
411
419
 
412
420
  const inverseRelName = fk.column_name.replace(/_id$/, "");
413
- const relHumanName = humanize(sourceTableName);
414
421
 
415
- propsOutput += `
416
- ${sourceTableName}: {
417
- name: "${relHumanName}",
418
- type: "relation",
422
+ relationsOutput += `
423
+ {
424
+ relationName: "${sourceTableName}",
419
425
  target: () => ${targetCollectionCamel},
420
426
  cardinality: "many",
421
427
  direction: "inverse",
@@ -425,6 +431,7 @@ export function generateCollectionFile(
425
431
  }
426
432
 
427
433
  // Map Many-to-Many Relations (Join Tables)
434
+ // These also go into the `relations` array so they render as subcollection tabs.
428
435
  const relatedJoinTables = Array.from(joinTables).filter((jt) => {
429
436
  const jtMeta = tablesMap.get(jt);
430
437
  return jtMeta ? jtMeta.fks.some((fk) => fk.foreign_table_name === tableName) : false;
@@ -444,15 +451,10 @@ export function generateCollectionFile(
444
451
  const otherFk = selfRefFks[1];
445
452
 
446
453
  const relPropName = `${tableName}_via_${otherFk.column_name.replace(/_id$/, "")}`;
447
- propertiesOrder.push(relPropName);
448
-
449
- // Self-ref: import is the same collection (use a lazy reference)
450
- const relHumanName = humanize(otherFk.column_name.replace(/_id$/, ""));
451
454
 
452
- propsOutput += `
453
- ${relPropName}: {
454
- name: "${relHumanName}",
455
- type: "relation",
455
+ relationsOutput += `
456
+ {
457
+ relationName: "${relPropName}",
456
458
  target: () => ${tableName}Collection,
457
459
  cardinality: "many",
458
460
  direction: "owning",
@@ -469,7 +471,6 @@ export function generateCollectionFile(
469
471
 
470
472
  if (otherFk) {
471
473
  const targetTableName = otherFk.foreign_table_name;
472
- propertiesOrder.push(targetTableName);
473
474
 
474
475
  const targetCollectionCamel = toCollectionVarName(targetTableName);
475
476
  imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
@@ -478,19 +479,17 @@ export function generateCollectionFile(
478
479
  const direction = tableName < targetTableName ? "owning" : "inverse";
479
480
 
480
481
  const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
481
- const relHumanName = humanize(targetTableName);
482
482
 
483
483
  let throughCode = "";
484
484
  if (direction === "owning" && thisFk) {
485
- throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n }`;
485
+ throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n },`;
486
486
  } else if (direction === "inverse") {
487
487
  throughCode = `\n // Make sure the target collection configures the 'through' property.`;
488
488
  }
489
489
 
490
- propsOutput += `
491
- ${targetTableName}: {
492
- name: "${relHumanName}",
493
- type: "relation",
490
+ relationsOutput += `
491
+ {
492
+ relationName: "${targetTableName}",
494
493
  target: () => ${targetCollectionCamel},
495
494
  cardinality: "many",
496
495
  direction: "${direction}",${throughCode}
@@ -498,6 +497,10 @@ export function generateCollectionFile(
498
497
  }
499
498
  }
500
499
 
500
+ const relationsBlock = relationsOutput
501
+ ? `\n relations: [${relationsOutput}\n ],`
502
+ : "";
503
+
501
504
  const fileContent = `${Array.from(imports).join("\n")}
502
505
 
503
506
  const ${tableName}Collection: PostgresCollection = {
@@ -508,7 +511,7 @@ const ${tableName}Collection: PostgresCollection = {
508
511
  icon: "${icon}",
509
512
  group: "App",
510
513
  properties: {${propsOutput}
511
- },
514
+ },${relationsBlock}
512
515
  propertiesOrder: ${JSON.stringify(propertiesOrder, null, 8).replace(/]$/, " ]")}
513
516
  };
514
517
 
@@ -111,7 +111,7 @@ export class EntityPersistService {
111
111
  targetColumnName = relation.localKey;
112
112
  } else if (relation.foreignKeyOnTarget) {
113
113
  targetColumnName = relation.foreignKeyOnTarget;
114
- } else if (relation.joinPath && relation.joinPath.length > 0) {
114
+ } else if (relation.joinPath && relation.joinPath.length === 1) {
115
115
  const targetTableName = getTableName(targetCollection);
116
116
  const relevantJoinStep = relation.joinPath.find(joinStep => joinStep.table === targetTableName);
117
117
 
@@ -123,6 +123,12 @@ export class EntityPersistService {
123
123
  const targetColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to);
124
124
  targetColumnName = targetColumnNames[0];
125
125
  }
126
+ } else if (relation.joinPath && relation.joinPath.length > 1) {
127
+ // For multi-hop relations (like many-to-many through a junction table),
128
+ // there is no direct foreign key on the target table pointing to the parent.
129
+ // The relationship is managed via the junction table.
130
+ // We shouldn't inject the parent ID directly into the target entity payload.
131
+ break;
126
132
  } else {
127
133
  throw new Error(`Relation '${relationKey}' lacks configuration for path-based saving.`);
128
134
  }
@@ -666,6 +666,53 @@ using: "{is_locked} = false" }
666
666
  expect(result).toContain('as: "restrictive"');
667
667
  });
668
668
 
669
+ it("should enable RLS on every table even without any security rules", async () => {
670
+ const collections: EntityCollection[] = [{
671
+ slug: "public_data",
672
+ table: "public_data",
673
+ name: "Public Data",
674
+ properties: { title: { type: "string" } }
675
+ // No securityRules defined — table should still have .enableRLS()
676
+ }];
677
+
678
+ const result = await generateSchema(collections);
679
+ expect(result).toContain(".enableRLS()");
680
+ // No policies should be generated
681
+ expect(result).not.toContain("pgPolicy(");
682
+ });
683
+
684
+ it("should enable RLS on tables that do have security rules", async () => {
685
+ const collections: EntityCollection[] = [{
686
+ slug: "secure_data",
687
+ table: "secure_data",
688
+ name: "Secure Data",
689
+ properties: { title: { type: "string" } },
690
+ securityRules: [
691
+ { operation: "select", access: "public" }
692
+ ]
693
+ }];
694
+
695
+ const result = await generateSchema(collections);
696
+ expect(result).toContain(".enableRLS()");
697
+ expect(result).toContain("pgPolicy(");
698
+ });
699
+
700
+ it("should fall back to deny-all (sql`false`) when no USING clause can be generated", async () => {
701
+ const collections: EntityCollection[] = [{
702
+ slug: "deny_test",
703
+ table: "deny_test",
704
+ name: "Deny Test",
705
+ properties: { title: { type: "string" } },
706
+ securityRules: [
707
+ { operation: "select" }
708
+ // No access, ownerField, or using — should produce sql`false`
709
+ ]
710
+ }];
711
+
712
+ const result = await generateSchema(collections);
713
+ expect(result).toContain("sql`false`");
714
+ });
715
+
669
716
  });
670
717
  });
671
718
  // V2 improvements tests
@@ -986,3 +1033,170 @@ isId: true }
986
1033
  expect(cleanResult).toContain("user_name: varchar(\"user_name\").primaryKey()");
987
1034
  });
988
1035
  });
1036
+
1037
+ // ── columnName tests ────────────────────────────────────────────────────
1038
+ describe("generateDrizzleSchema columnName support", () => {
1039
+ const cleanSchema = (schema: string) => {
1040
+ return schema
1041
+ .replace(/\/\/.*$/gm, "")
1042
+ .replace(/\/\*[\s\S]*?\*\//g, "")
1043
+ .replace(/\n{2,}/g, "\n")
1044
+ .replace(/\s+/g, " ")
1045
+ .trim();
1046
+ };
1047
+
1048
+ it("should use explicit columnName as the SQL column name instead of deriving from the property key", async () => {
1049
+ const collections: EntityCollection[] = [{
1050
+ slug: "billing",
1051
+ table: "company_billing_config",
1052
+ name: "Billing",
1053
+ properties: {
1054
+ employee_number_140a: {
1055
+ type: "string",
1056
+ name: "Employee Number 140a",
1057
+ columnName: "employee_number_140a",
1058
+ },
1059
+ contract_number_140a: {
1060
+ type: "string",
1061
+ name: "Contract Number 140a",
1062
+ columnName: "contract_number_140a",
1063
+ },
1064
+ },
1065
+ }];
1066
+
1067
+ const result = await generateSchema(collections);
1068
+ const cleanResult = cleanSchema(result);
1069
+
1070
+ // Must use the exact columnName, NOT toSnakeCase(propKey) which would produce "employee_number_140_a"
1071
+ expect(cleanResult).toContain('employee_number_140a: varchar("employee_number_140a")');
1072
+ expect(cleanResult).toContain('contract_number_140a: varchar("contract_number_140a")');
1073
+
1074
+ // Must NOT contain the broken snake_case version
1075
+ expect(cleanResult).not.toContain("employee_number_140_a");
1076
+ expect(cleanResult).not.toContain("contract_number_140_a");
1077
+ });
1078
+
1079
+ it("should fall back to toSnakeCase when columnName is not set (manually-authored collections)", async () => {
1080
+ const collections: EntityCollection[] = [{
1081
+ slug: "products",
1082
+ table: "products",
1083
+ name: "Products",
1084
+ properties: {
1085
+ productName: {
1086
+ type: "string",
1087
+ name: "Product Name",
1088
+ // No columnName — should derive from key
1089
+ },
1090
+ },
1091
+ }];
1092
+
1093
+ const result = await generateSchema(collections);
1094
+ const cleanResult = cleanSchema(result);
1095
+
1096
+ // JS key stays camelCase, SQL column name gets snake_cased
1097
+ expect(cleanResult).toContain('productName: varchar("product_name")');
1098
+ });
1099
+
1100
+ it("should handle mixed properties — some with columnName, some without", async () => {
1101
+ const collections: EntityCollection[] = [{
1102
+ slug: "config",
1103
+ table: "app_config",
1104
+ name: "Config",
1105
+ properties: {
1106
+ // Introspected: has explicit columnName
1107
+ fee_number_140a: {
1108
+ type: "string",
1109
+ name: "Fee Number",
1110
+ columnName: "fee_number_140a",
1111
+ },
1112
+ // Manually added: no columnName
1113
+ displayName: {
1114
+ type: "string",
1115
+ name: "Display Name",
1116
+ },
1117
+ },
1118
+ }];
1119
+
1120
+ const result = await generateSchema(collections);
1121
+ const cleanResult = cleanSchema(result);
1122
+
1123
+ // Introspected prop uses exact columnName
1124
+ expect(cleanResult).toContain('fee_number_140a: varchar("fee_number_140a")');
1125
+ // Manual prop: JS key stays camelCase, SQL column gets snake_cased
1126
+ expect(cleanResult).toContain('displayName: varchar("display_name")');
1127
+ });
1128
+
1129
+ it("should use columnName for all property types, not just strings", async () => {
1130
+ const collections: EntityCollection[] = [{
1131
+ slug: "metrics",
1132
+ table: "metrics",
1133
+ name: "Metrics",
1134
+ properties: {
1135
+ count_v2: {
1136
+ type: "number",
1137
+ name: "Count V2",
1138
+ columnName: "count_v2",
1139
+ },
1140
+ is_active_v2: {
1141
+ type: "boolean",
1142
+ name: "Is Active V2",
1143
+ columnName: "is_active_v2",
1144
+ },
1145
+ created_at_v2: {
1146
+ type: "date",
1147
+ name: "Created At V2",
1148
+ columnName: "created_at_v2",
1149
+ },
1150
+ metadata_v2: {
1151
+ type: "map",
1152
+ name: "Metadata V2",
1153
+ columnName: "metadata_v2",
1154
+ },
1155
+ },
1156
+ }];
1157
+
1158
+ const result = await generateSchema(collections);
1159
+ const cleanResult = cleanSchema(result);
1160
+
1161
+ expect(cleanResult).toContain('count_v2: numeric("count_v2")');
1162
+ expect(cleanResult).toContain('is_active_v2: boolean("is_active_v2")');
1163
+ expect(cleanResult).toContain('created_at_v2: timestamp("created_at_v2"');
1164
+ expect(cleanResult).toContain('metadata_v2: jsonb("metadata_v2")');
1165
+ });
1166
+
1167
+ it("should reproduce and prevent the medmot bug: digit+letter column names", async () => {
1168
+ // This is the exact scenario from the medmot project that caused the production failure
1169
+ const collections: EntityCollection[] = [{
1170
+ slug: "company_billing_config",
1171
+ table: "company_billing_config",
1172
+ name: "Company Billing Config",
1173
+ properties: {
1174
+ employee_number_140a: { type: "string", name: "Employee Number", columnName: "employee_number_140a" },
1175
+ contract_number_140a: { type: "string", name: "Contract Number", columnName: "contract_number_140a" },
1176
+ amount: { type: "number", name: "Amount" },
1177
+ id: { type: "number", name: "ID", isId: "increment" },
1178
+ service_provider_140a: { type: "string", name: "Service Provider", columnName: "service_provider_140a" },
1179
+ internal_area_code_140a: { type: "string", name: "Internal Area Code", columnName: "internal_area_code_140a" },
1180
+ fee_number_140a: { type: "string", name: "Fee Number", columnName: "fee_number_140a" },
1181
+ receiver_market_participant_140a: { type: "string", name: "Receiver Market Participant", columnName: "receiver_market_participant_140a" },
1182
+ employee_value_number_140a: { type: "string", name: "Employee Value Number", columnName: "employee_value_number_140a" },
1183
+ sender_market_participant_140a: { type: "string", name: "Sender Market Participant", columnName: "sender_market_participant_140a" },
1184
+ processing_indicator_140a: { type: "string", name: "Processing Indicator", columnName: "processing_indicator_140a" },
1185
+ insurance_id_140a: { type: "string", name: "Insurance ID", columnName: "insurance_id_140a" },
1186
+ company_id: { type: "number", name: "Company ID" },
1187
+ },
1188
+ }];
1189
+
1190
+ const result = await generateSchema(collections);
1191
+
1192
+ // Every _140a column must stay _140a, not become _140_a
1193
+ const brokenPattern = /_140_a/;
1194
+ expect(result).not.toMatch(brokenPattern);
1195
+
1196
+ // Spot-check a few exact columns
1197
+ expect(result).toContain('"employee_number_140a"');
1198
+ expect(result).toContain('"contract_number_140a"');
1199
+ expect(result).toContain('"service_provider_140a"');
1200
+ expect(result).toContain('"insurance_id_140a"');
1201
+ });
1202
+ });
@@ -285,22 +285,40 @@ describe("generateCollectionFile", () => {
285
285
  });
286
286
 
287
287
  describe("inverse relation (other table -> this table)", () => {
288
- it("generates a one-to-many inverse relation", () => {
288
+ it("generates a one-to-many inverse relation in the relations array", () => {
289
289
  const allFks: ForeignKeyRow[] = [mkFk("comments", "post_id", "posts")];
290
290
  const meta = makeSimpleTable("posts", [
291
291
  mkCol("posts", "id", { data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
292
292
  ]);
293
293
  const result = generateCollectionFile("posts", meta, allFks, new Set(), new Map([["posts", meta]]), new Map());
294
294
  expect(result).toContain('import commentsCollection from "./comments"');
295
+ // Should be in the relations array, not as an inline property
296
+ expect(result).toContain('relations: [');
297
+ expect(result).toContain('relationName: "comments"');
295
298
  expect(result).toContain('cardinality: "many"');
296
299
  expect(result).toContain('direction: "inverse"');
297
300
  expect(result).toContain('inverseRelationName: "post"');
298
301
  expect(result).toContain('foreignKeyOnTarget: "post_id"');
302
+ // Should NOT appear as an inline property with type: "relation"
303
+ const propsSection = result.split('properties:')[1].split('relations:')[0];
304
+ expect(propsSection).not.toContain('"relation"');
305
+ });
306
+
307
+ it("does NOT include inverse relations in propertiesOrder", () => {
308
+ const allFks: ForeignKeyRow[] = [mkFk("comments", "post_id", "posts")];
309
+ const meta = makeSimpleTable("posts", [
310
+ mkCol("posts", "id", { data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
311
+ ]);
312
+ const result = generateCollectionFile("posts", meta, allFks, new Set(), new Map([["posts", meta]]), new Map());
313
+ const orderMatch = result.match(/propertiesOrder:\s*(\[[\s\S]*?\])/);
314
+ expect(orderMatch).toBeTruthy();
315
+ const orderBlock = orderMatch![1];
316
+ expect(orderBlock).not.toContain('"comments"');
299
317
  });
300
318
  });
301
319
 
302
320
  describe("many-to-many relations", () => {
303
- it("generates owning M2M with through config for alphabetically-first table", () => {
321
+ it("generates owning M2M with through config in relations array", () => {
304
322
  const jtFks: ForeignKeyRow[] = [
305
323
  mkFk("articles_tags", "article_id", "articles"),
306
324
  mkFk("articles_tags", "tag_id", "tags"),
@@ -317,13 +335,15 @@ describe("generateCollectionFile", () => {
317
335
  const joinTables = new Set(["articles_tags"]);
318
336
 
319
337
  const result = generateCollectionFile("articles", articlesMeta, [], joinTables, tablesMap, new Map());
338
+ expect(result).toContain('relations: [');
339
+ expect(result).toContain('relationName: "tags"');
320
340
  expect(result).toContain('direction: "owning"');
321
341
  expect(result).toContain('table: "articles_tags"');
322
342
  expect(result).toContain('sourceColumn: "article_id"');
323
343
  expect(result).toContain('targetColumn: "tag_id"');
324
344
  });
325
345
 
326
- it("generates inverse M2M for alphabetically-second table", () => {
346
+ it("generates inverse M2M in relations array", () => {
327
347
  const jtFks: ForeignKeyRow[] = [
328
348
  mkFk("articles_tags", "article_id", "articles"),
329
349
  mkFk("articles_tags", "tag_id", "tags"),
@@ -340,12 +360,13 @@ describe("generateCollectionFile", () => {
340
360
  const joinTables = new Set(["articles_tags"]);
341
361
 
342
362
  const result = generateCollectionFile("tags", tagsMeta, [], joinTables, tablesMap, new Map());
363
+ expect(result).toContain('relations: [');
343
364
  expect(result).toContain('direction: "inverse"');
344
365
  });
345
366
  });
346
367
 
347
368
  describe("self-referencing M2M", () => {
348
- it("generates self-ref M2M with _via_ property name", () => {
369
+ it("generates self-ref M2M with _via_ relation name in relations array", () => {
349
370
  const jtFks: ForeignKeyRow[] = [
350
371
  mkFk("user_friends", "user_id", "users"),
351
372
  mkFk("user_friends", "friend_id", "users"),
@@ -362,7 +383,8 @@ describe("generateCollectionFile", () => {
362
383
  const joinTables = new Set(["user_friends"]);
363
384
 
364
385
  const result = generateCollectionFile("users", usersMeta, [], joinTables, tablesMap, new Map());
365
- expect(result).toContain("users_via_friend");
386
+ expect(result).toContain('relations: [');
387
+ expect(result).toContain('relationName: "users_via_friend"');
366
388
  expect(result).toContain('table: "user_friends"');
367
389
  expect(result).toContain('sourceColumn: "user_id"');
368
390
  expect(result).toContain('targetColumn: "friend_id"');
@@ -382,8 +382,8 @@ relationName: "author" }
382
382
  // Should create owning relation on profiles
383
383
  expect(cleanResult).toContain("export const profilesRelations = drizzleRelations(profiles, ({ one, many }) => ({ \"author\": one(authors, { fields: [profiles.author_id], references: [authors.id], relationName: \"profiles_author_id\" }) }));");
384
384
 
385
- // Should create inverse relation on authors (this was previously missing)
386
- expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"profile\": one(profiles, { fields: [authors.id], references: [profiles.author_id], relationName: \"profiles_author_id\" }) }));");
385
+ // Should create inverse relation on authors inverse side has NO fields/references
386
+ expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"profile\": one(profiles, { relationName: \"profiles_author_id\" }) }));");
387
387
  });
388
388
 
389
389
  it("should generate owning one-to-many relations", async () => {
@@ -818,9 +818,9 @@ relationName: "user" }
818
818
  `"user": one(users, { fields: [profiles.user_id], references: [users.id], relationName: \"${expectedSharedName}\" })`
819
819
  );
820
820
 
821
- // Inverse side (users → profiles)
821
+ // Inverse side (users → profiles) — no fields/references, paired by relationName only
822
822
  expect(cleanResult).toContain(
823
- `"profile": one(profiles, { fields: [users.id], references: [profiles.user_id], relationName: \"${expectedSharedName}\" })`
823
+ `"profile": one(profiles, { relationName: \"${expectedSharedName}\" })`
824
824
  );
825
825
 
826
826
  // Both must match