@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
@@ -148,27 +148,27 @@ describe("generateCollectionFile", () => {
148
148
  });
149
149
 
150
150
  describe("enum support", () => {
151
- it("generates enumValues for USER-DEFINED columns with matching enum", () => {
151
+ it("generates enum for USER-DEFINED columns with matching enum", () => {
152
152
  const enumMap = new Map([["order_status", ["pending", "shipped", "delivered"]]]);
153
153
  const meta = makeSimpleTable("orders", [
154
154
  mkCol("orders", "id", { data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
155
155
  mkCol("orders", "status", { data_type: "USER-DEFINED", udt_name: "order_status" }),
156
156
  ]);
157
157
  const result = generateCollectionFile("orders", meta, [], new Set(), new Map([["orders", meta]]), enumMap);
158
- expect(result).toContain('enumValues:');
158
+ expect(result).toContain('enum:');
159
159
  expect(result).toContain('{ id: "pending", label: "Pending" }');
160
160
  expect(result).toContain('{ id: "shipped", label: "Shipped" }');
161
161
  expect(result).toContain('{ id: "delivered", label: "Delivered" }');
162
162
  expect(result).toContain('type: "string"');
163
163
  });
164
164
 
165
- it("does NOT add enumValues for USER-DEFINED without matching enum", () => {
165
+ it("does NOT add enum for USER-DEFINED without matching enum", () => {
166
166
  const meta = makeSimpleTable("things", [
167
167
  mkCol("things", "id", { data_type: "uuid", udt_name: "uuid", is_nullable: "NO" }),
168
168
  mkCol("things", "geom", { data_type: "USER-DEFINED", udt_name: "geometry" }),
169
169
  ]);
170
170
  const result = generateCollectionFile("things", meta, [], new Set(), new Map([["things", meta]]), new Map());
171
- expect(result).not.toContain("enumValues");
171
+ expect(result).not.toContain("enum");
172
172
  });
173
173
 
174
174
  it("humanizes enum value labels with underscores", () => {
@@ -264,7 +264,7 @@ describe("generateCollectionFile", () => {
264
264
  ]);
265
265
  const result = generateCollectionFile("t", meta, [], new Set(), new Map([["t", meta]]), enumMap);
266
266
  expect(result).not.toContain("storagePath");
267
- expect(result).toContain("enumValues");
267
+ expect(result).toContain("enum:");
268
268
  });
269
269
  });
270
270
 
@@ -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"');
@@ -0,0 +1,395 @@
1
+ import {
2
+ computePropertyPriority, sortPropertiesOrder,
3
+ PropertyOrderingContext, PropertyOrderEntry,
4
+ } from "../src/schema/introspect-db-logic";
5
+
6
+ // ── Helpers ───────────────────────────────────────────────────────────
7
+
8
+ function mkCtx(overrides: Partial<PropertyOrderingContext> = {}): PropertyOrderingContext {
9
+ return {
10
+ propType: "string",
11
+ isPk: false,
12
+ isEnum: false,
13
+ isStorage: false,
14
+ pgDataType: "character varying",
15
+ originalIndex: 0,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ function mkEntry(key: string, overrides: Partial<PropertyOrderingContext> = {}): PropertyOrderEntry {
21
+ return { key, ctx: mkCtx(overrides) };
22
+ }
23
+
24
+ // ═══════════════════════════════════════════════════════════════════════
25
+ // computePropertyPriority() — tier assignment
26
+ // ═══════════════════════════════════════════════════════════════════════
27
+ describe("computePropertyPriority", () => {
28
+ describe("Tier 0: Identity / Primary keys", () => {
29
+ it("ranks 'id' PK as tier 0 (score 0)", () => {
30
+ const score = computePropertyPriority("id", mkCtx({ isPk: true }));
31
+ expect(score).toBeGreaterThanOrEqual(0);
32
+ expect(score).toBeLessThan(10);
33
+ });
34
+
35
+ it("ranks any PK column in tier 0", () => {
36
+ const score = computePropertyPriority("uuid", mkCtx({ isPk: true }));
37
+ expect(score).toBeGreaterThanOrEqual(0);
38
+ expect(score).toBeLessThan(10);
39
+ });
40
+
41
+ it("ranks unknown PK name with fallback score 5", () => {
42
+ const score = computePropertyPriority("pk_col", mkCtx({ isPk: true }));
43
+ expect(score).toBeGreaterThanOrEqual(5);
44
+ expect(score).toBeLessThan(10);
45
+ });
46
+ });
47
+
48
+ describe("Tier 1: Title / Name fields", () => {
49
+ it.each(["name", "title", "label", "display_name", "headline", "subject", "heading"])(
50
+ "ranks '%s' in tier 1 (10-19)",
51
+ (col) => {
52
+ const score = computePropertyPriority(col, mkCtx());
53
+ expect(score).toBeGreaterThanOrEqual(10);
54
+ expect(score).toBeLessThan(20);
55
+ },
56
+ );
57
+
58
+ it("ranks 'product_name' as partial match in tier 1b (17-19)", () => {
59
+ const score = computePropertyPriority("product_name", mkCtx());
60
+ expect(score).toBeGreaterThanOrEqual(17);
61
+ expect(score).toBeLessThan(20);
62
+ });
63
+
64
+ it("ranks 'page_title' as partial match in tier 1b", () => {
65
+ const score = computePropertyPriority("page_title", mkCtx());
66
+ expect(score).toBeGreaterThanOrEqual(17);
67
+ expect(score).toBeLessThan(20);
68
+ });
69
+ });
70
+
71
+ describe("Tier 2: Human identity fields", () => {
72
+ it.each(["first_name", "last_name", "full_name", "username", "email", "phone", "phone_number"])(
73
+ "ranks '%s' in tier 2 (20-29)",
74
+ (col) => {
75
+ const score = computePropertyPriority(col, mkCtx());
76
+ expect(score).toBeGreaterThanOrEqual(20);
77
+ expect(score).toBeLessThan(30);
78
+ },
79
+ );
80
+
81
+ it("ranks first_name before last_name", () => {
82
+ const first = computePropertyPriority("first_name", mkCtx());
83
+ const last = computePropertyPriority("last_name", mkCtx());
84
+ expect(first).toBeLessThan(last);
85
+ });
86
+ });
87
+
88
+ describe("Tier 3: Core descriptors", () => {
89
+ it.each(["slug", "code", "sku", "type", "status", "role", "category"])(
90
+ "ranks '%s' in tier 3 (30-39)",
91
+ (col) => {
92
+ const score = computePropertyPriority(col, mkCtx());
93
+ expect(score).toBeGreaterThanOrEqual(30);
94
+ expect(score).toBeLessThan(40);
95
+ },
96
+ );
97
+ });
98
+
99
+ describe("Tier 4: Short text, enums, booleans", () => {
100
+ it("ranks enum fields in tier 4", () => {
101
+ const score = computePropertyPriority("payment_method", mkCtx({ isEnum: true }));
102
+ expect(score).toBeGreaterThanOrEqual(40);
103
+ expect(score).toBeLessThan(50);
104
+ });
105
+
106
+ it("ranks boolean fields in tier 4", () => {
107
+ const score = computePropertyPriority("active", mkCtx({ propType: "boolean" }));
108
+ expect(score).toBeGreaterThanOrEqual(40);
109
+ expect(score).toBeLessThan(50);
110
+ });
111
+
112
+ it("ranks short string (varchar) in tier 4", () => {
113
+ const score = computePropertyPriority("color", mkCtx({ propType: "string", pgDataType: "character varying" }));
114
+ expect(score).toBeGreaterThanOrEqual(40);
115
+ expect(score).toBeLessThan(50);
116
+ });
117
+ });
118
+
119
+ describe("Tier 5: Numbers & user-facing dates", () => {
120
+ it("ranks number fields in tier 5", () => {
121
+ const score = computePropertyPriority("price", mkCtx({ propType: "number" }));
122
+ expect(score).toBeGreaterThanOrEqual(50);
123
+ expect(score).toBeLessThan(60);
124
+ });
125
+
126
+ it("ranks non-system date fields in tier 5", () => {
127
+ const score = computePropertyPriority("published_at", mkCtx({ propType: "date" }));
128
+ expect(score).toBeGreaterThanOrEqual(50);
129
+ expect(score).toBeLessThan(60);
130
+ });
131
+ });
132
+
133
+ describe("Tier 6: Relations", () => {
134
+ it("ranks relation fields in tier 6", () => {
135
+ const score = computePropertyPriority("author", mkCtx({ propType: "relation" }));
136
+ expect(score).toBeGreaterThanOrEqual(60);
137
+ expect(score).toBeLessThan(70);
138
+ });
139
+ });
140
+
141
+ describe("Tier 7: Long text fields", () => {
142
+ it.each(["description", "summary", "excerpt", "bio", "about"])(
143
+ "ranks '%s' in tier 7 (70-79)",
144
+ (col) => {
145
+ const score = computePropertyPriority(col, mkCtx());
146
+ expect(score).toBeGreaterThanOrEqual(70);
147
+ expect(score).toBeLessThan(80);
148
+ },
149
+ );
150
+ });
151
+
152
+ describe("Tier 8: Rich content fields", () => {
153
+ it.each(["content", "body", "html"])(
154
+ "ranks '%s' in tier 8 (80-89)",
155
+ (col) => {
156
+ const score = computePropertyPriority(col, mkCtx());
157
+ expect(score).toBeGreaterThanOrEqual(80);
158
+ expect(score).toBeLessThan(90);
159
+ },
160
+ );
161
+ });
162
+
163
+ describe("Tier 9: Media / storage fields", () => {
164
+ it("ranks storage fields in tier 9", () => {
165
+ const score = computePropertyPriority("profile_image", mkCtx({ isStorage: true }));
166
+ expect(score).toBeGreaterThanOrEqual(90);
167
+ expect(score).toBeLessThan(100);
168
+ });
169
+
170
+ it.each(["avatar", "photo_url", "logo", "cover_image", "thumbnail"])(
171
+ "ranks '%s' (media pattern) in tier 9",
172
+ (col) => {
173
+ const score = computePropertyPriority(col, mkCtx());
174
+ expect(score).toBeGreaterThanOrEqual(90);
175
+ expect(score).toBeLessThan(100);
176
+ },
177
+ );
178
+
179
+ it("ranks URL-suffix fields in tier 9", () => {
180
+ const score = computePropertyPriority("website_url", mkCtx());
181
+ expect(score).toBeGreaterThanOrEqual(90);
182
+ expect(score).toBeLessThan(100);
183
+ });
184
+ });
185
+
186
+ describe("Tier 10: JSON / Map types", () => {
187
+ it("ranks map types in tier 10", () => {
188
+ const score = computePropertyPriority("metadata", mkCtx({ propType: "map" }));
189
+ expect(score).toBeGreaterThanOrEqual(100);
190
+ expect(score).toBeLessThan(110);
191
+ });
192
+
193
+ it("ranks unknown map field names slightly higher", () => {
194
+ const known = computePropertyPriority("metadata", mkCtx({ propType: "map" }));
195
+ const unknown = computePropertyPriority("weird_json", mkCtx({ propType: "map" }));
196
+ expect(unknown).toBeGreaterThan(known);
197
+ });
198
+ });
199
+
200
+ describe("Tier 11: Array types", () => {
201
+ it("ranks array types in tier 11", () => {
202
+ const score = computePropertyPriority("tags", mkCtx({ propType: "array" }));
203
+ expect(score).toBeGreaterThanOrEqual(110);
204
+ expect(score).toBeLessThan(120);
205
+ });
206
+ });
207
+
208
+ describe("Tier 12: System timestamps", () => {
209
+ it.each(["created_at", "updated_at", "deleted_at", "archived_at"])(
210
+ "ranks '%s' in tier 12 (120-129)",
211
+ (col) => {
212
+ const score = computePropertyPriority(col, mkCtx({ propType: "date" }));
213
+ expect(score).toBeGreaterThanOrEqual(120);
214
+ expect(score).toBeLessThan(130);
215
+ },
216
+ );
217
+
218
+ it("ranks created_at before updated_at", () => {
219
+ const created = computePropertyPriority("created_at", mkCtx({ propType: "date" }));
220
+ const updated = computePropertyPriority("updated_at", mkCtx({ propType: "date" }));
221
+ expect(created).toBeLessThan(updated);
222
+ });
223
+
224
+ it("ranks updated_at before deleted_at", () => {
225
+ const updated = computePropertyPriority("updated_at", mkCtx({ propType: "date" }));
226
+ const deleted = computePropertyPriority("deleted_at", mkCtx({ propType: "date" }));
227
+ expect(updated).toBeLessThan(deleted);
228
+ });
229
+ });
230
+
231
+ describe("tiebreaking with originalIndex", () => {
232
+ it("preserves original column order within the same tier", () => {
233
+ const a = computePropertyPriority("color", mkCtx({ originalIndex: 0 }));
234
+ const b = computePropertyPriority("size", mkCtx({ originalIndex: 1 }));
235
+ expect(a).toBeLessThan(b);
236
+ });
237
+
238
+ it("tiebreaker is fractional (doesn't cross tier boundaries)", () => {
239
+ const lastInTier = computePropertyPriority("color", mkCtx({ originalIndex: 9999 }));
240
+ // Should still be in tier 4 (42.x) not tier 5 (50+)
241
+ expect(lastInTier).toBeLessThan(50);
242
+ });
243
+ });
244
+ });
245
+
246
+ // ═══════════════════════════════════════════════════════════════════════
247
+ // sortPropertiesOrder() — full sorting integration
248
+ // ═══════════════════════════════════════════════════════════════════════
249
+ describe("sortPropertiesOrder", () => {
250
+ it("places 'id' before 'name' before generic fields", () => {
251
+ const entries: PropertyOrderEntry[] = [
252
+ mkEntry("created_at", { propType: "date", originalIndex: 3 }),
253
+ mkEntry("name", { originalIndex: 1 }),
254
+ mkEntry("id", { isPk: true, originalIndex: 0 }),
255
+ mkEntry("status", { originalIndex: 2 }),
256
+ ];
257
+ const result = sortPropertiesOrder(entries);
258
+ expect(result).toEqual(["id", "name", "status", "created_at"]);
259
+ });
260
+
261
+ it("sorts a realistic user table correctly", () => {
262
+ const entries: PropertyOrderEntry[] = [
263
+ mkEntry("id", { isPk: true, propType: "string", pgDataType: "uuid", originalIndex: 0 }),
264
+ mkEntry("created_at", { propType: "date", originalIndex: 1 }),
265
+ mkEntry("updated_at", { propType: "date", originalIndex: 2 }),
266
+ mkEntry("email", { originalIndex: 3 }),
267
+ mkEntry("first_name", { originalIndex: 4 }),
268
+ mkEntry("last_name", { originalIndex: 5 }),
269
+ mkEntry("avatar", { isStorage: true, originalIndex: 6 }),
270
+ mkEntry("role", { isEnum: true, originalIndex: 7 }),
271
+ mkEntry("active", { propType: "boolean", originalIndex: 8 }),
272
+ mkEntry("bio", { pgDataType: "text", originalIndex: 9 }),
273
+ mkEntry("metadata", { propType: "map", pgDataType: "jsonb", originalIndex: 10 }),
274
+ ];
275
+ const result = sortPropertiesOrder(entries);
276
+ expect(result).toEqual([
277
+ "id", // tier 0: PK
278
+ "first_name", // tier 2: human identity
279
+ "last_name", // tier 2: human identity
280
+ "email", // tier 2: human identity
281
+ "role", // tier 4: enum
282
+ "active", // tier 4: boolean
283
+ "bio", // tier 7: long text
284
+ "avatar", // tier 9: storage
285
+ "metadata", // tier 10: map
286
+ "created_at", // tier 12: system timestamp
287
+ "updated_at", // tier 12: system timestamp
288
+ ]);
289
+ });
290
+
291
+ it("sorts a realistic product table correctly", () => {
292
+ const entries: PropertyOrderEntry[] = [
293
+ mkEntry("id", { isPk: true, propType: "number", pgDataType: "integer", originalIndex: 0 }),
294
+ mkEntry("created_at", { propType: "date", originalIndex: 1 }),
295
+ mkEntry("sku", { originalIndex: 2 }),
296
+ mkEntry("name", { originalIndex: 3 }),
297
+ mkEntry("description", { pgDataType: "text", originalIndex: 4 }),
298
+ mkEntry("price", { propType: "number", originalIndex: 5 }),
299
+ mkEntry("category", { propType: "relation", originalIndex: 6 }),
300
+ mkEntry("cover_image", { isStorage: true, originalIndex: 7 }),
301
+ mkEntry("active", { propType: "boolean", originalIndex: 8 }),
302
+ ];
303
+ const result = sortPropertiesOrder(entries);
304
+ expect(result).toEqual([
305
+ "id", // tier 0: PK
306
+ "name", // tier 1: title/name
307
+ "sku", // tier 3: descriptor
308
+ "category", // tier 3: descriptor (name match overrides relation tier)
309
+ "active", // tier 4: boolean
310
+ "price", // tier 5: number
311
+ "description", // tier 7: long text
312
+ "cover_image", // tier 9: storage
313
+ "created_at", // tier 12: system timestamp
314
+ ]);
315
+ });
316
+
317
+ it("sorts a blog post table correctly", () => {
318
+ const entries: PropertyOrderEntry[] = [
319
+ mkEntry("id", { isPk: true, propType: "string", pgDataType: "uuid", originalIndex: 0 }),
320
+ mkEntry("updated_at", { propType: "date", originalIndex: 1 }),
321
+ mkEntry("created_at", { propType: "date", originalIndex: 2 }),
322
+ mkEntry("content", { pgDataType: "text", originalIndex: 3 }),
323
+ mkEntry("title", { originalIndex: 4 }),
324
+ mkEntry("slug", { originalIndex: 5 }),
325
+ mkEntry("status", { isEnum: true, originalIndex: 6 }),
326
+ mkEntry("author", { propType: "relation", originalIndex: 7 }),
327
+ mkEntry("published_at", { propType: "date", originalIndex: 8 }),
328
+ mkEntry("cover_image", { isStorage: true, originalIndex: 9 }),
329
+ mkEntry("excerpt", { pgDataType: "text", originalIndex: 10 }),
330
+ ];
331
+ const result = sortPropertiesOrder(entries);
332
+ expect(result).toEqual([
333
+ "id", // tier 0: PK
334
+ "title", // tier 1: title
335
+ "slug", // tier 3: descriptor
336
+ "status", // tier 4: enum
337
+ "published_at", // tier 5: user-facing date
338
+ "author", // tier 6: relation
339
+ "excerpt", // tier 7: long text
340
+ "content", // tier 8: rich content
341
+ "cover_image", // tier 9: storage
342
+ "created_at", // tier 12: system timestamp
343
+ "updated_at", // tier 12: system timestamp
344
+ ]);
345
+ });
346
+
347
+ it("preserves original order for properties in the same tier", () => {
348
+ const entries: PropertyOrderEntry[] = [
349
+ mkEntry("color", { propType: "string", pgDataType: "character varying", originalIndex: 0 }),
350
+ mkEntry("size", { propType: "string", pgDataType: "character varying", originalIndex: 1 }),
351
+ mkEntry("weight", { propType: "string", pgDataType: "character varying", originalIndex: 2 }),
352
+ ];
353
+ const result = sortPropertiesOrder(entries);
354
+ // All three are tier 4 (short strings), should preserve original order
355
+ expect(result).toEqual(["color", "size", "weight"]);
356
+ });
357
+
358
+ it("handles tables with only system columns", () => {
359
+ const entries: PropertyOrderEntry[] = [
360
+ mkEntry("id", { isPk: true, originalIndex: 0 }),
361
+ mkEntry("created_at", { propType: "date", originalIndex: 1 }),
362
+ mkEntry("updated_at", { propType: "date", originalIndex: 2 }),
363
+ ];
364
+ const result = sortPropertiesOrder(entries);
365
+ expect(result).toEqual(["id", "created_at", "updated_at"]);
366
+ });
367
+
368
+ it("handles partial name matches (product_name, page_title)", () => {
369
+ const entries: PropertyOrderEntry[] = [
370
+ mkEntry("id", { isPk: true, originalIndex: 0 }),
371
+ mkEntry("product_name", { originalIndex: 1 }),
372
+ mkEntry("category", { originalIndex: 2 }),
373
+ mkEntry("price", { propType: "number", originalIndex: 3 }),
374
+ ];
375
+ const result = sortPropertiesOrder(entries);
376
+ // product_name should come after id but before category and price
377
+ expect(result[0]).toBe("id");
378
+ expect(result[1]).toBe("product_name");
379
+ });
380
+
381
+ it("handles mixed URL and media fields", () => {
382
+ const entries: PropertyOrderEntry[] = [
383
+ mkEntry("name", { originalIndex: 0 }),
384
+ mkEntry("website_url", { originalIndex: 1 }),
385
+ mkEntry("avatar", { isStorage: true, originalIndex: 2 }),
386
+ mkEntry("thumbnail", { originalIndex: 3 }),
387
+ ];
388
+ const result = sortPropertiesOrder(entries);
389
+ expect(result[0]).toBe("name");
390
+ // All media/url fields should cluster together after name
391
+ expect(result.indexOf("website_url")).toBeGreaterThan(result.indexOf("name"));
392
+ expect(result.indexOf("avatar")).toBeGreaterThan(result.indexOf("name"));
393
+ expect(result.indexOf("thumbnail")).toBeGreaterThan(result.indexOf("name"));
394
+ });
395
+ });
@@ -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