@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e

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 (136) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index.es.js +1254 -591
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +1254 -591
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
  10. package/dist/server-postgresql/src/auth/services.d.ts +7 -3
  11. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
  12. package/dist/server-postgresql/src/connection.d.ts +34 -1
  13. package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
  14. package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
  15. package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
  16. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  17. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  18. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
  19. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  20. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
  21. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
  22. package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
  23. package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
  24. package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
  25. package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
  26. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
  27. package/dist/types/src/controllers/auth.d.ts +2 -0
  28. package/dist/types/src/controllers/client.d.ts +119 -7
  29. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  30. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  31. package/dist/types/src/controllers/data.d.ts +34 -7
  32. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  33. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  34. package/dist/types/src/controllers/email.d.ts +34 -0
  35. package/dist/types/src/controllers/index.d.ts +1 -0
  36. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  37. package/dist/types/src/controllers/navigation.d.ts +5 -5
  38. package/dist/types/src/controllers/registry.d.ts +6 -3
  39. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  40. package/dist/types/src/controllers/storage.d.ts +24 -26
  41. package/dist/types/src/rebase_context.d.ts +8 -4
  42. package/dist/types/src/types/backend.d.ts +4 -1
  43. package/dist/types/src/types/builders.d.ts +5 -4
  44. package/dist/types/src/types/chips.d.ts +1 -1
  45. package/dist/types/src/types/collections.d.ts +169 -125
  46. package/dist/types/src/types/cron.d.ts +102 -0
  47. package/dist/types/src/types/data_source.d.ts +1 -1
  48. package/dist/types/src/types/entity_actions.d.ts +8 -8
  49. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  50. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  51. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  52. package/dist/types/src/types/entity_views.d.ts +8 -8
  53. package/dist/types/src/types/export_import.d.ts +3 -3
  54. package/dist/types/src/types/index.d.ts +1 -0
  55. package/dist/types/src/types/plugins.d.ts +72 -18
  56. package/dist/types/src/types/properties.d.ts +118 -33
  57. package/dist/types/src/types/relations.d.ts +1 -1
  58. package/dist/types/src/types/slots.d.ts +30 -6
  59. package/dist/types/src/types/translations.d.ts +44 -0
  60. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  61. package/drizzle-test/0000_woozy_junta.sql +6 -0
  62. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  63. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  64. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  65. package/drizzle-test/meta/0000_snapshot.json +47 -0
  66. package/drizzle-test/meta/0001_snapshot.json +48 -0
  67. package/drizzle-test/meta/0002_snapshot.json +38 -0
  68. package/drizzle-test/meta/0003_snapshot.json +48 -0
  69. package/drizzle-test/meta/_journal.json +34 -0
  70. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  71. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  72. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  73. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  74. package/drizzle-test-out/meta/_journal.json +20 -0
  75. package/drizzle.test.config.ts +10 -0
  76. package/package.json +88 -89
  77. package/scratch.ts +41 -0
  78. package/src/PostgresBackendDriver.ts +63 -79
  79. package/src/PostgresBootstrapper.ts +7 -8
  80. package/src/auth/ensure-tables.ts +158 -86
  81. package/src/auth/services.ts +109 -50
  82. package/src/cli.ts +259 -16
  83. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  84. package/src/connection.ts +70 -48
  85. package/src/data-transformer.ts +155 -116
  86. package/src/databasePoolManager.ts +6 -5
  87. package/src/history/HistoryService.ts +3 -12
  88. package/src/interfaces.ts +3 -3
  89. package/src/schema/auth-schema.ts +26 -3
  90. package/src/schema/doctor-cli.ts +47 -0
  91. package/src/schema/doctor.ts +595 -0
  92. package/src/schema/generate-drizzle-schema-logic.ts +204 -57
  93. package/src/schema/generate-drizzle-schema.ts +6 -6
  94. package/src/schema/test-schema.ts +11 -0
  95. package/src/services/BranchService.ts +5 -5
  96. package/src/services/EntityFetchService.ts +317 -188
  97. package/src/services/EntityPersistService.ts +15 -17
  98. package/src/services/RelationService.ts +299 -37
  99. package/src/services/entity-helpers.ts +39 -13
  100. package/src/services/entityService.ts +11 -9
  101. package/src/services/realtimeService.ts +58 -29
  102. package/src/utils/drizzle-conditions.ts +25 -24
  103. package/src/websocket.ts +52 -21
  104. package/test/auth-services.test.ts +131 -39
  105. package/test/batch-many-to-many-regression.test.ts +573 -0
  106. package/test/branchService.test.ts +22 -12
  107. package/test/data-transformer-hardening.test.ts +417 -0
  108. package/test/data-transformer.test.ts +175 -0
  109. package/test/doctor.test.ts +182 -0
  110. package/test/entityService.errors.test.ts +31 -16
  111. package/test/entityService.relations.test.ts +155 -59
  112. package/test/entityService.subcollection-search.test.ts +107 -57
  113. package/test/entityService.test.ts +105 -47
  114. package/test/generate-drizzle-schema.test.ts +262 -69
  115. package/test/historyService.test.ts +31 -16
  116. package/test/n-plus-one-regression.test.ts +314 -0
  117. package/test/postgresDataDriver.test.ts +260 -168
  118. package/test/realtimeService.test.ts +70 -39
  119. package/test/relation-pipeline-gaps.test.ts +637 -0
  120. package/test/relations.test.ts +492 -39
  121. package/test-drizzle-bug.ts +18 -0
  122. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  123. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  124. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  125. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  126. package/test-drizzle-out/meta/_journal.json +20 -0
  127. package/test-drizzle-prompt.sh +2 -0
  128. package/test-policy-prompt.sh +3 -0
  129. package/test-programmatic.ts +30 -0
  130. package/test-programmatic2.ts +59 -0
  131. package/test-schema-no-policies.ts +12 -0
  132. package/test_drizzle_mock.js +2 -2
  133. package/test_find_changed.mjs +3 -1
  134. package/test_hash.js +14 -0
  135. package/tsconfig.json +1 -1
  136. package/vite.config.ts +5 -5
@@ -1,12 +1,15 @@
1
1
  import { HistoryService, findChangedFields } from "../src/history/HistoryService";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { DrizzleClient } from "../src/interfaces";
4
+ import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
4
5
 
5
6
  describe("HistoryService - changedFields and history insertion logic", () => {
6
7
  describe("findChangedFields", () => {
7
8
  it("should return null when identical flat objects are compared", () => {
8
- const oldValues = { title: "Hello", description: "World" };
9
- const newValues = { title: "Hello", description: "World" };
9
+ const oldValues = { title: "Hello",
10
+ description: "World" };
11
+ const newValues = { title: "Hello",
12
+ description: "World" };
10
13
  const result = findChangedFields(oldValues, newValues);
11
14
  expect(result).toBeNull();
12
15
  });
@@ -19,19 +22,25 @@ describe("HistoryService - changedFields and history insertion logic", () => {
19
22
  });
20
23
 
21
24
  it("should skip properties starting with double underscore", () => {
22
- const oldValues = { title: "Hello", __internal: 123 };
23
- const newValues = { title: "Hello", __internal: 456 };
25
+ const oldValues = { title: "Hello",
26
+ __internal: 123 };
27
+ const newValues = { title: "Hello",
28
+ __internal: 456 };
24
29
  const result = findChangedFields(oldValues, newValues);
25
30
  expect(result).toBeNull();
26
31
  });
27
32
 
28
33
  it("should return null for deeply identical relations", () => {
29
34
  const oldValues = {
30
- author: { id: "1", path: "authors", __type: "relation" },
35
+ author: { id: "1",
36
+ path: "authors",
37
+ __type: "relation" },
31
38
  tags: [{ id: "1" }, { id: "2" }]
32
39
  };
33
40
  const newValues = {
34
- author: { id: "1", path: "authors", __type: "relation" },
41
+ author: { id: "1",
42
+ path: "authors",
43
+ __type: "relation" },
35
44
  tags: [{ id: "1" }, { id: "2" }]
36
45
  };
37
46
  const result = findChangedFields(oldValues as Record<string, unknown>, newValues as Record<string, unknown>);
@@ -40,15 +49,19 @@ describe("HistoryService - changedFields and history insertion logic", () => {
40
49
 
41
50
  it("should detect changes in relation properties when IDs differ", () => {
42
51
  const oldValues = {
43
- author: { id: "1", path: "authors", __type: "relation" }
52
+ author: { id: "1",
53
+ path: "authors",
54
+ __type: "relation" }
44
55
  };
45
56
  const newValues = {
46
- author: { id: "2", path: "authors", __type: "relation" }
57
+ author: { id: "2",
58
+ path: "authors",
59
+ __type: "relation" }
47
60
  };
48
61
  const result = findChangedFields(oldValues as Record<string, unknown>, newValues as Record<string, unknown>);
49
62
  expect(result).toEqual(["author"]);
50
63
  });
51
-
64
+
52
65
  it("should detect differences in relation arrays", () => {
53
66
  const oldValues = {
54
67
  tags: [{ id: "1" }]
@@ -69,8 +82,8 @@ describe("HistoryService - changedFields and history insertion logic", () => {
69
82
  db = {
70
83
  execute: jest.fn().mockResolvedValue({})
71
84
  } as unknown as jest.Mocked<NodePgDatabase>;
72
- historyService = new HistoryService(db as unknown as DrizzleClient, {} as any);
73
- jest.spyOn(console, 'error').mockImplementation(() => {});
85
+ historyService = new HistoryService(db as unknown as DrizzleClient, {} as unknown as PostgresCollectionRegistry);
86
+ jest.spyOn(console, "error").mockImplementation(() => {});
74
87
  });
75
88
 
76
89
  afterEach(() => {
@@ -83,7 +96,7 @@ describe("HistoryService - changedFields and history insertion logic", () => {
83
96
  entityId: "1",
84
97
  action: "update",
85
98
  previousValues: { title: "same" },
86
- values: { title: "same" },
99
+ values: { title: "same" }
87
100
  });
88
101
 
89
102
  // db.execute should not be called since there is no data to log
@@ -95,15 +108,17 @@ describe("HistoryService - changedFields and history insertion logic", () => {
95
108
  tableName: "posts",
96
109
  entityId: "1",
97
110
  action: "update",
98
- previousValues: { title: "old", tags: [{ id: 1 }] },
99
- values: { title: "new", tags: [{ id: 2 }] }
111
+ previousValues: { title: "old",
112
+ tags: [{ id: 1 }] },
113
+ values: { title: "new",
114
+ tags: [{ id: 2 }] }
100
115
  });
101
116
 
102
117
  // Since it's a difference, db.execute should be called. (plus 2 prune calls)
103
118
  expect(db.execute.mock.calls.length).toBeGreaterThanOrEqual(1);
104
-
119
+
105
120
  const executedSql = db.execute.mock.calls[0][0] as unknown as { query: string; sql?: string; strings?: string[]; values?: unknown[] };
106
-
121
+
107
122
  // Drizzle wraps SQL in its own SQL type which contains sql strings and params.
108
123
  const serializedSql = JSON.stringify(executedSql);
109
124
  // The syntax we added is ARRAY[?]::text[] or similar
@@ -0,0 +1,314 @@
1
+ /**
2
+ * N+1 Query Regression Test
3
+ *
4
+ * Validates that batch-loading of owning relations (e.g. posts → author)
5
+ * uses O(1) SQL queries instead of one query per entity.
6
+ *
7
+ * Root cause of the original bug:
8
+ * `batchFetchRelatedEntities` for owning relations was passing parent
9
+ * entity IDs (post IDs) to the query instead of the FK values (author IDs).
10
+ * This meant `WHERE authors.id IN (1,2,3,...50)` used post IDs — completely
11
+ * wrong. The fix reads FK values from the parent table first, then queries
12
+ * the target table with correct FK values.
13
+ */
14
+ import { RelationService } from "../src/services/RelationService";
15
+ import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
16
+ import type { EntityCollection, Relation } from "@rebasepro/types";
17
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
18
+
19
+ // ─── Mock Tables ──────────────────────────────────────────────────
20
+ const mockAuthorsTable = {
21
+ id: { name: "id",
22
+ dataType: "number" },
23
+ name: { name: "name" },
24
+ _def: { tableName: "authors" }
25
+ };
26
+
27
+ const mockPostsTable = {
28
+ id: { name: "id",
29
+ dataType: "number" },
30
+ title: { name: "title" },
31
+ author_id: { name: "author_id",
32
+ dataType: "number" },
33
+ _def: { tableName: "posts" }
34
+ };
35
+
36
+ // ─── Mock Collections ─────────────────────────────────────────────
37
+ let authorsCollection: EntityCollection;
38
+
39
+ const postsCollection: EntityCollection = {
40
+ slug: "posts",
41
+ name: "Posts",
42
+ table: "posts",
43
+ properties: {
44
+ id: { type: "number" },
45
+ title: { type: "string" },
46
+ author: {
47
+ type: "relation",
48
+ relationName: "author"
49
+ }
50
+ },
51
+ relations: [
52
+ {
53
+ relationName: "author",
54
+ target: () => authorsCollection,
55
+ cardinality: "one",
56
+ direction: "owning",
57
+ localKey: "author_id"
58
+ }
59
+ ],
60
+ idField: "id"
61
+ };
62
+
63
+ authorsCollection = {
64
+ slug: "authors",
65
+ name: "Authors",
66
+ table: "authors",
67
+ properties: {
68
+ id: { type: "number" },
69
+ name: { type: "string" }
70
+ },
71
+ idField: "id"
72
+ };
73
+
74
+ // ─── Data generators ─────────────────────────────────────────────
75
+ const NUM_POSTS = 50;
76
+ const NUM_AUTHORS = 5;
77
+
78
+ function generateMockPosts(count: number): Array<{ id: number; title: string; author_id: number }> {
79
+ return Array.from({ length: count }, (_, i) => ({
80
+ id: i + 1,
81
+ title: `Post ${i + 1}`,
82
+ author_id: (i % NUM_AUTHORS) + 1
83
+ }));
84
+ }
85
+
86
+ function generateMockAuthors(count: number): Array<{ id: number; name: string }> {
87
+ return Array.from({ length: count }, (_, i) => ({
88
+ id: i + 1,
89
+ name: `Author ${i + 1}`
90
+ }));
91
+ }
92
+
93
+ // ─── Tests ────────────────────────────────────────────────────────
94
+ describe("N+1 Query Regression: batchFetchRelatedEntities (owning relations)", () => {
95
+ let registry: PostgresCollectionRegistry;
96
+ let selectCallCount: number;
97
+
98
+ /**
99
+ * Creates a mock Drizzle database that counts `select()` calls.
100
+ * Routes responses based on the table being queried.
101
+ */
102
+ function createSpiedDb(
103
+ mockPosts: ReturnType<typeof generateMockPosts>,
104
+ mockAuthors: ReturnType<typeof generateMockAuthors>
105
+ ) {
106
+ selectCallCount = 0;
107
+ let currentFromTable: string | undefined;
108
+ let currentSelectColumns: Record<string, unknown> | undefined;
109
+
110
+ function resolveResults(): unknown[] {
111
+ if (currentFromTable === "posts") {
112
+ // If selecting specific columns (parentId, fkValue shape from the FK lookup)
113
+ if (currentSelectColumns && Object.keys(currentSelectColumns).some(k => k === "parentId" || k === "fkValue")) {
114
+ return mockPosts.map(p => ({
115
+ parentId: p.id,
116
+ fkValue: p.author_id
117
+ }));
118
+ }
119
+ return mockPosts;
120
+ }
121
+ if (currentFromTable === "authors") {
122
+ return mockAuthors;
123
+ }
124
+ return [];
125
+ }
126
+
127
+ function makeChainable(): Record<string, unknown> {
128
+ const chain: Record<string, unknown> = {
129
+ select: jest.fn((columns?: Record<string, unknown>) => {
130
+ selectCallCount++;
131
+ currentSelectColumns = columns;
132
+ return chain;
133
+ }),
134
+ from: jest.fn((table: Record<string, unknown>) => {
135
+ const tableDef = table._def as { tableName: string } | undefined;
136
+ currentFromTable = tableDef?.tableName ?? "unknown";
137
+ return chain;
138
+ }),
139
+ where: jest.fn(() => chain),
140
+ $dynamic: jest.fn(() => chain),
141
+ limit: jest.fn(() => chain),
142
+ offset: jest.fn(() => chain),
143
+ orderBy: jest.fn(() => chain),
144
+ innerJoin: jest.fn(() => chain),
145
+ // Make it thenable so `await query` resolves to results
146
+ then: (resolve: (val: unknown[]) => void) => {
147
+ resolve(resolveResults());
148
+ }
149
+ };
150
+ return chain;
151
+ }
152
+
153
+ return makeChainable() as unknown as jest.Mocked<NodePgDatabase>;
154
+ }
155
+
156
+ beforeEach(() => {
157
+ registry = new PostgresCollectionRegistry();
158
+
159
+ jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
160
+ if (path?.startsWith("posts")) return postsCollection;
161
+ if (path?.startsWith("authors")) return authorsCollection;
162
+ return undefined;
163
+ });
164
+
165
+ jest.spyOn(registry, "getTable").mockImplementation(tableName => {
166
+ if (tableName === "posts") return mockPostsTable as unknown as ReturnType<typeof registry.getTable>;
167
+ if (tableName === "authors") return mockAuthorsTable as unknown as ReturnType<typeof registry.getTable>;
168
+ return undefined;
169
+ });
170
+
171
+ jest.spyOn(registry, "getCollections").mockReturnValue([postsCollection, authorsCollection]);
172
+ });
173
+
174
+ afterEach(() => {
175
+ jest.restoreAllMocks();
176
+ });
177
+
178
+ it("should use exactly 2 SQL queries for owning relation batch fetch (not N+1)", async () => {
179
+ const mockPosts = generateMockPosts(NUM_POSTS);
180
+ const mockAuthors = generateMockAuthors(NUM_AUTHORS);
181
+ const db = createSpiedDb(mockPosts, mockAuthors);
182
+
183
+ const relationService = new RelationService(db, registry);
184
+ selectCallCount = 0;
185
+
186
+ const postIds = mockPosts.map(p => p.id);
187
+ const authorRelation = postsCollection.relations![0] as Relation;
188
+
189
+ const results = await relationService.batchFetchRelatedEntities(
190
+ "posts",
191
+ postIds,
192
+ "author",
193
+ authorRelation
194
+ );
195
+
196
+ // Exactly 2 queries:
197
+ // 1. SELECT id, author_id FROM posts WHERE id IN (...) — FK lookup
198
+ // 2. SELECT * FROM authors WHERE id IN (...) — target fetch
199
+ //
200
+ // The old broken code would have done either:
201
+ // - 1 wrong query (WHERE authors.id IN (post_ids) — wrong IDs)
202
+ // - Or N queries (one per entity)
203
+ expect(selectCallCount).toBe(2);
204
+
205
+ console.log(`[N+1 Regression] ${NUM_POSTS} posts: batchFetchRelatedEntities used ${selectCallCount} queries`);
206
+ });
207
+
208
+ it("should correctly map each post to its author via FK values", async () => {
209
+ const mockPosts = generateMockPosts(NUM_POSTS);
210
+ const mockAuthors = generateMockAuthors(NUM_AUTHORS);
211
+ const db = createSpiedDb(mockPosts, mockAuthors);
212
+
213
+ const relationService = new RelationService(db, registry);
214
+
215
+ const postIds = mockPosts.map(p => p.id);
216
+ const authorRelation = postsCollection.relations![0] as Relation;
217
+
218
+ const results = await relationService.batchFetchRelatedEntities(
219
+ "posts",
220
+ postIds,
221
+ "author",
222
+ authorRelation
223
+ );
224
+
225
+ // Every post should have a resolved author
226
+ for (const post of mockPosts) {
227
+ const authorEntity = results.get(String(post.id));
228
+ expect(authorEntity).toBeDefined();
229
+ expect(authorEntity!.path).toBe("authors");
230
+ // The author ID should match the FK value, not the post ID
231
+ expect(authorEntity!.id).toBe(String(post.author_id));
232
+ }
233
+ });
234
+
235
+ it("should handle null FK values gracefully (only 1 query for FK lookup)", async () => {
236
+ const nullFkPosts = Array.from({ length: 5 }, (_, i) => ({
237
+ id: i + 1,
238
+ title: `Orphan Post ${i + 1}`,
239
+ author_id: null as unknown as number
240
+ }));
241
+
242
+ const db = createSpiedDb(nullFkPosts as ReturnType<typeof generateMockPosts>, []);
243
+ const relationService = new RelationService(db, registry);
244
+ selectCallCount = 0;
245
+
246
+ const postIds = nullFkPosts.map(p => p.id);
247
+ const authorRelation = postsCollection.relations![0] as Relation;
248
+
249
+ const results = await relationService.batchFetchRelatedEntities(
250
+ "posts",
251
+ postIds,
252
+ "author",
253
+ authorRelation
254
+ );
255
+
256
+ // Only 1 query (FK lookup) then early-return since all FKs are null
257
+ expect(selectCallCount).toBe(1);
258
+ expect(results.size).toBe(0);
259
+ });
260
+
261
+ it("should deduplicate FK values when multiple entities share the same relation target", async () => {
262
+ // All 50 posts point to author 1
263
+ const sameAuthorPosts = Array.from({ length: NUM_POSTS }, (_, i) => ({
264
+ id: i + 1,
265
+ title: `Post ${i + 1}`,
266
+ author_id: 1
267
+ }));
268
+ const singleAuthor = [{ id: 1,
269
+ name: "Shared Author" }];
270
+
271
+ const db = createSpiedDb(sameAuthorPosts, singleAuthor);
272
+ const relationService = new RelationService(db, registry);
273
+ selectCallCount = 0;
274
+
275
+ const postIds = sameAuthorPosts.map(p => p.id);
276
+ const authorRelation = postsCollection.relations![0] as Relation;
277
+
278
+ const results = await relationService.batchFetchRelatedEntities(
279
+ "posts",
280
+ postIds,
281
+ "author",
282
+ authorRelation
283
+ );
284
+
285
+ // Still exactly 2 queries
286
+ expect(selectCallCount).toBe(2);
287
+
288
+ // All 50 posts should resolve to the same author
289
+ for (const post of sameAuthorPosts) {
290
+ const authorEntity = results.get(String(post.id));
291
+ expect(authorEntity).toBeDefined();
292
+ expect(authorEntity!.id).toBe("1");
293
+ expect(authorEntity!.path).toBe("authors");
294
+ }
295
+ });
296
+
297
+ it("should handle empty parent IDs array without any queries", async () => {
298
+ const db = createSpiedDb([], []);
299
+ const relationService = new RelationService(db, registry);
300
+ selectCallCount = 0;
301
+
302
+ const authorRelation = postsCollection.relations![0] as Relation;
303
+
304
+ const results = await relationService.batchFetchRelatedEntities(
305
+ "posts",
306
+ [],
307
+ "author",
308
+ authorRelation
309
+ );
310
+
311
+ expect(selectCallCount).toBe(0);
312
+ expect(results.size).toBe(0);
313
+ });
314
+ });