@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,850 @@
1
+ import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
2
+ import { getPrimaryKeys } from "../services/collection-helpers";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
4
+ import { toSnakeCase } from "@rebasepro/utils";
5
+ import { createHash } from "crypto";
6
+ import { logger } from "@rebasepro/server";
7
+ import { getEffectiveSecurityRules } from "./auth-default-policies";
8
+ // --- Helper Functions ---
9
+
10
+ /**
11
+ * Resolve the SQL column name for a property.
12
+ * Uses the explicit `columnName` when set (e.g. from introspection),
13
+ * falling back to `toSnakeCase(propName)` for manually-authored collections.
14
+ */
15
+ const resolveColumnName = (propName: string, prop?: Property | null): string => {
16
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") {
17
+ return prop.columnName;
18
+ }
19
+ return toSnakeCase(propName);
20
+ };
21
+
22
+ const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
23
+ if (collection.properties) {
24
+ const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as unknown as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
25
+ if (idPropEntry) {
26
+ const prop = idPropEntry[1] as unknown as Property;
27
+ const isUuid = prop.type === "string" && "isId" in prop && (prop as unknown as StringProperty).isId === "uuid";
28
+ return { name: idPropEntry[0],
29
+ type: prop.type === "number" ? "number" : "string",
30
+ isUuid };
31
+ }
32
+ }
33
+ // Fallback
34
+ const idProp = collection.properties?.["id"] as unknown as Property | undefined;
35
+ if (idProp?.type === "number") {
36
+ return { name: "id",
37
+ type: "number",
38
+ isUuid: false };
39
+ }
40
+ const isUuid = idProp?.type === "string" && "isId" in idProp && (idProp as unknown as StringProperty).isId === "uuid";
41
+ return { name: "id",
42
+ type: "string",
43
+ isUuid: isUuid ?? false };
44
+ };
45
+
46
+ /**
47
+ * Given a raw DB column name (e.g. "client_id"), find the Drizzle property key
48
+ * on the collection that maps to that column. A property matches if:
49
+ * (a) it has an explicit `columnName` equal to the given column, OR
50
+ * (b) its snake_case form equals the given column.
51
+ *
52
+ * Returns the property key (the Drizzle object key) if found, or the original
53
+ * column name as a fallback.
54
+ */
55
+ const resolvePropertyKeyForColumn = (collection: CollectionConfig, column: string): string => {
56
+ if (!collection.properties) return column;
57
+ for (const [propKey, prop] of Object.entries(collection.properties)) {
58
+ const p = prop as Property;
59
+ // Explicit columnName match
60
+ if ("columnName" in p && typeof (p as unknown as Record<string, unknown>).columnName === "string") {
61
+ if ((p as unknown as Record<string, unknown>).columnName === column) return propKey;
62
+ }
63
+ // Convention match: snake_case(propKey) === column
64
+ if (toSnakeCase(propKey) === column) return propKey;
65
+ // Exact match (propKey is already the column name)
66
+ if (propKey === column) return propKey;
67
+ }
68
+ return column;
69
+ };
70
+
71
+ const isNumericId = (collection: CollectionConfig): boolean => {
72
+ return getPrimaryKeyProp(collection).type === "number";
73
+ };
74
+
75
+ const getPrimaryKeyName = (collection: CollectionConfig): string => {
76
+ return getPrimaryKeyProp(collection).name;
77
+ };
78
+
79
+ const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
80
+ if ("isId" in prop && Boolean(prop.isId)) return true;
81
+
82
+ // We only fallback to "id" if NO property is explicitly marked with `isId: true` or a generator string
83
+ const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
84
+ return !hasExplicitId && propName === "id";
85
+ };
86
+
87
+ const getDrizzleColumn = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string | null => {
88
+
89
+ const colName = resolveColumnName(propName, prop);
90
+ let columnDefinition: string;
91
+
92
+ switch (prop.type) {
93
+ case "string": {
94
+ const stringProp = prop as unknown as StringProperty;
95
+ if (stringProp.enum) {
96
+ const enumName = getEnumVarName(getTableName(collection), propName);
97
+ columnDefinition = `${enumName}("${colName}")`;
98
+ } else if ("isId" in stringProp && stringProp.isId === "uuid") {
99
+ columnDefinition = `uuid("${colName}")`;
100
+ } else if (stringProp.columnType === "uuid") {
101
+ columnDefinition = `uuid("${colName}")`;
102
+ } else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
103
+ columnDefinition = `text("${colName}")`;
104
+ } else if (stringProp.columnType === "char") {
105
+ columnDefinition = `char("${colName}")`;
106
+ } else {
107
+ columnDefinition = `varchar("${colName}")`;
108
+ }
109
+ if (isIdProperty(propName, prop, collection)) {
110
+ columnDefinition += ".primaryKey()";
111
+ }
112
+ if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
113
+ if (stringProp.isId === "uuid") {
114
+ columnDefinition += ".defaultRandom()";
115
+ } else if (stringProp.isId === "cuid") {
116
+ columnDefinition += ".default(sql`cuid()`)";
117
+ } else if (typeof stringProp.isId === "string") {
118
+ const sqlContent = stringProp.isId.startsWith("sql`") && stringProp.isId.endsWith("`")
119
+ ? stringProp.isId.substring(4, stringProp.isId.length - 1)
120
+ : stringProp.isId;
121
+ columnDefinition += `.default(sql\`${sqlContent}\`)`;
122
+ }
123
+ }
124
+ if (stringProp.validation?.unique) {
125
+ columnDefinition += ".unique()";
126
+ }
127
+ break;
128
+ }
129
+ case "number": {
130
+ const numProp = prop as unknown as NumberProperty;
131
+ const isId = isIdProperty(propName, prop, collection);
132
+
133
+ let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
134
+ if (numProp.columnType) {
135
+ if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
136
+ else baseType = `${numProp.columnType}("${colName}")`;
137
+ }
138
+
139
+ if ("isId" in numProp && numProp.isId === "increment") {
140
+ columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
141
+ } else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
142
+ columnDefinition = baseType;
143
+ const sqlContent = numProp.isId.startsWith("sql`") && numProp.isId.endsWith("`")
144
+ ? numProp.isId.substring(4, numProp.isId.length - 1)
145
+ : numProp.isId;
146
+ columnDefinition += `.default(sql\`${sqlContent}\`)`;
147
+ } else {
148
+ columnDefinition = baseType;
149
+ }
150
+
151
+ if (isId) {
152
+ columnDefinition += ".primaryKey()";
153
+ }
154
+ if (numProp.validation?.unique) {
155
+ columnDefinition += ".unique()";
156
+ }
157
+ break;
158
+ }
159
+ case "boolean":
160
+ columnDefinition = `boolean("${colName}")`;
161
+ break;
162
+ case "date": {
163
+ const dateProp = prop as DateProperty;
164
+ if (dateProp.columnType === "date") {
165
+ columnDefinition = `date("${colName}", { mode: 'string' })`;
166
+ } else if (dateProp.columnType === "time") {
167
+ columnDefinition = `time("${colName}")`;
168
+ } else {
169
+ columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
170
+ }
171
+ // autoValue: database-level default for initial value on INSERT
172
+ if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
173
+ columnDefinition += ".default(sql`now()`)";
174
+ }
175
+ break;
176
+ }
177
+ case "map": {
178
+ const mapProp = prop as MapProperty;
179
+ if (mapProp.columnType === "json") {
180
+ columnDefinition = `json("${colName}")`;
181
+ } else {
182
+ columnDefinition = `jsonb("${colName}")`;
183
+ }
184
+ break;
185
+ }
186
+ case "array": {
187
+ const arrayProp = prop as ArrayProperty;
188
+ let colType = arrayProp.columnType;
189
+ if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
190
+ const ofProp = arrayProp.of as Property;
191
+ if (ofProp.type === "string") {
192
+ colType = "text[]";
193
+ } else if (ofProp.type === "number") {
194
+ colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
195
+ } else if (ofProp.type === "boolean") {
196
+ colType = "boolean[]";
197
+ }
198
+ }
199
+
200
+ if (colType === "json") {
201
+ columnDefinition = `json("${colName}")`;
202
+ } else if (colType === "text[]") {
203
+ columnDefinition = `text("${colName}").array()`;
204
+ } else if (colType === "integer[]") {
205
+ columnDefinition = `integer("${colName}").array()`;
206
+ } else if (colType === "boolean[]") {
207
+ columnDefinition = `boolean("${colName}").array()`;
208
+ } else if (colType === "numeric[]") {
209
+ columnDefinition = `numeric("${colName}").array()`;
210
+ } else {
211
+ columnDefinition = `jsonb("${colName}")`;
212
+ }
213
+ break;
214
+ }
215
+ case "vector": {
216
+ const vp = prop as VectorProperty;
217
+ columnDefinition = `vector("${colName}", { dimensions: ${vp.dimensions} })`;
218
+ break;
219
+ }
220
+ case "binary": {
221
+ columnDefinition = `customType({ dataType() { return 'bytea'; } })("${colName}")`;
222
+ break;
223
+ }
224
+ case "relation": {
225
+ const refProp = prop as RelationProperty;
226
+ const resolvedRelations = resolveCollectionRelations(collection);
227
+ const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
228
+
229
+ // Only owning one-to-one/many-to-one relations create a column here.
230
+ if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
231
+ return null;
232
+ }
233
+
234
+ // The localKey property is the source of truth for the FK column name.
235
+ if (!relation.localKey) {
236
+ logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
237
+ return null;
238
+ }
239
+
240
+ // If the localKey property is defined elsewhere in the properties, it will be handled there.
241
+ // This logic is for when the relation property itself defines the FK.
242
+ if (collection.properties[relation.localKey] && propName !== relation.localKey) {
243
+ return null;
244
+ }
245
+
246
+ let targetCollection: CollectionConfig;
247
+ try {
248
+ targetCollection = relation.target();
249
+ } catch {
250
+ return null; // Cannot resolve target
251
+ }
252
+
253
+ const fkColumnName = relation.localKey;
254
+ const targetTableVar = getTableVarName(getTableName(targetCollection));
255
+ const pkProp = getPrimaryKeyProp(targetCollection);
256
+ const targetIdField = pkProp.name;
257
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`);
258
+
259
+ const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
260
+ const required = prop.validation?.required;
261
+ const onDeleteVal = relation.onDelete ?? (required ? "cascade" : "set null");
262
+ const onDelete = `onDelete: \"${onDeleteVal}\"`;
263
+
264
+ const refOptionsParts = [onUpdate, onDelete].filter(Boolean);
265
+ const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
266
+
267
+ let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
268
+
269
+ if (required) {
270
+ columnDef += ".notNull()";
271
+ }
272
+
273
+ return ` ${relation.localKey}: ${columnDef}`;
274
+ }
275
+ case "reference": {
276
+ const refProp = prop as ReferenceProperty;
277
+ const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
278
+ if (!targetCollection) {
279
+ columnDefinition = `varchar("${colName}")`;
280
+ break;
281
+ }
282
+
283
+ const pkProp = getPrimaryKeyProp(targetCollection);
284
+ const targetTableVar = getTableVarName(getTableName(targetCollection));
285
+ const targetIdField = pkProp.name;
286
+ const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`);
287
+
288
+ const required = prop.validation?.required;
289
+ const onDelete = required ? "cascade" : "set null";
290
+ const refOptions = `{ onDelete: "${onDelete}" }`;
291
+
292
+ columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${refOptions})`;
293
+ if (required) {
294
+ columnDefinition += ".notNull()";
295
+ }
296
+ // Skip the standard notNull() handling below because we did it here with references
297
+ return ` ${propName}: ${columnDefinition}`;
298
+ }
299
+ default:
300
+ return null;
301
+ }
302
+
303
+ if (prop.validation?.required) {
304
+ columnDefinition += ".notNull()";
305
+ }
306
+
307
+ return ` ${propName}: ${columnDefinition}`;
308
+ };
309
+
310
+ /**
311
+ * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
312
+ */
313
+ const wrapSql = (clause: string): string => `sql\`${clause}\``;
314
+
315
+ /**
316
+ * Generates a deterministic hash based on the rule configuration.
317
+ */
318
+ const getPolicyNameHash = (rule: SecurityRule): string => {
319
+ const data = JSON.stringify({
320
+ a: rule.access,
321
+ m: rule.mode,
322
+ op: rule.operation,
323
+ ops: rule.operations?.slice().sort(),
324
+ own: rule.ownerField,
325
+ rol: rule.roles?.slice().sort(),
326
+ pg: rule.pgRoles?.slice().sort(),
327
+ u: rule.using,
328
+ w: rule.withCheck,
329
+ c: rule.condition,
330
+ ch: rule.check
331
+ });
332
+ return createHash("sha1").update(data).digest("hex").substring(0, 7);
333
+ };
334
+
335
+ /**
336
+ * Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
337
+ *
338
+ * Supports the full spectrum:
339
+ * - Convenience shortcuts: ownerField, access, roles
340
+ * - Raw SQL: using, withCheck
341
+ * - Mode: permissive (default) or restrictive
342
+ * - operations[] array: generates one policy per operation
343
+ * - Combinations: roles + ownerField, roles + raw SQL, etc.
344
+ */
345
+ type ResolveCollection = (slug: string) => CollectionConfig | undefined;
346
+
347
+ const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, index: number, resolveCollection: ResolveCollection): string => {
348
+ const tableName = getTableName(collection);
349
+ // Resolve operations: operations[] takes precedence over operation (singular)
350
+ const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
351
+ ? rule.operations
352
+ : [rule.operation ?? "all"];
353
+
354
+ const ruleHash = getPolicyNameHash(rule);
355
+
356
+ // Generate one pgPolicy per operation
357
+ return ops.map((op, opIdx) => {
358
+ const policyName = rule.name
359
+ ? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
360
+ : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
361
+
362
+ return generateSinglePolicyCode(collection, rule, op, policyName, resolveCollection);
363
+ }).join("");
364
+ };
365
+
366
+ /**
367
+ * Generates a single pgPolicy() call for one specific operation.
368
+ */
369
+ const generateSinglePolicyCode = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string => {
370
+ const mode = rule.mode ?? "permissive";
371
+
372
+ // Determine which clauses this operation needs:
373
+ // SELECT, DELETE → USING only
374
+ // INSERT → WITH CHECK only
375
+ // UPDATE, ALL → both USING and WITH CHECK
376
+ const needsUsing = operation !== "insert";
377
+ const needsWithCheck = operation !== "select" && operation !== "delete";
378
+
379
+ // Desugar the rule (access / ownerField / roles / structured condition / raw
380
+ // SQL) into the shared PolicyExpression model, then compile to SQL — the same
381
+ // normalization the DDL generator and the client-side evaluator use.
382
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
383
+
384
+ let usingClause = needsUsing && usingExpr ? wrapSql(policyToPostgres(usingExpr, collection, { resolveCollection })) : null;
385
+ let withCheckClause = needsWithCheck && withCheckExpr ? wrapSql(policyToPostgres(withCheckExpr, collection, { resolveCollection })) : null;
386
+
387
+ // Fallback: if we still have no clauses, deny all (safety net)
388
+ if (!usingClause && needsUsing) {
389
+ usingClause = "sql`false`";
390
+ }
391
+ if (!withCheckClause && needsWithCheck) {
392
+ withCheckClause = "sql`false`";
393
+ }
394
+
395
+ // Build the policy options object
396
+ const parts: string[] = [];
397
+ parts.push(`as: "${mode}"`);
398
+ parts.push(`for: "${operation}"`);
399
+ const toRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
400
+ parts.push(`to: [${toRoles.map(r => `"${r}"`).join(", ")}]`);
401
+ if (usingClause) parts.push(`using: ${usingClause}`);
402
+ if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
403
+
404
+ return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
405
+ };
406
+
407
+ /**
408
+ * Computes a deterministic shared relation name for Drizzle.
409
+ *
410
+ * Drizzle requires both sides of a relation (owning + inverse) to use the
411
+ * exact same `relationName` string so it can pair them. Each collection
412
+ * definition may use a different local `relationName`, so we need a canonical
413
+ * form that both sides can independently compute.
414
+ *
415
+ * Strategy: `{owningTable}_{foreignKey}`
416
+ * - owning side → `{thisTable}_{localKey}` e.g. "jobs_company_id"
417
+ * - inverse side → `{targetTable}_{foreignKeyOnTarget}` e.g. "jobs_company_id"
418
+ *
419
+ * For M2M with junction tables the owning relation name is already shared via
420
+ * the junction table wiring, so we keep it as-is.
421
+ *
422
+ * Falls back to the local relation name when the counterpart can't be resolved.
423
+ */
424
+ const computeSharedRelationName = (
425
+ rel: Relation,
426
+ sourceCollection: CollectionConfig,
427
+ _collections: CollectionConfig[]
428
+ ): string => {
429
+ const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
430
+
431
+ // --- owning one (belongs-to) ---
432
+ if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
433
+ // Normalise the localKey to the actual Drizzle property name so that
434
+ // the owning side produces the same relation name as the inverse side
435
+ // (which resolves foreignKeyOnTarget via the same helper).
436
+ const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
437
+ return `${getTableName(sourceCollection)}_${normalisedKey}`;
438
+ }
439
+
440
+ // --- inverse many (one-to-many has-many) ---
441
+ if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
442
+ // The owning table is the *target*, the FK column is foreignKeyOnTarget.
443
+ // Resolve to the Drizzle property key on the target so it matches the
444
+ // owning side's normalised localKey.
445
+ try {
446
+ const targetCollection = rel.target();
447
+ const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
448
+ return `${getTableName(targetCollection)}_${normalisedFK}`;
449
+ } catch {
450
+ return fallback;
451
+ }
452
+ }
453
+
454
+ // --- inverse one (one-to-one inverse) ---
455
+ if (rel.direction === "inverse" && rel.cardinality === "one") {
456
+ if (rel.foreignKeyOnTarget) {
457
+ // FK lives on the target table — resolve to Drizzle property key
458
+ try {
459
+ const targetCollection = rel.target();
460
+ const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
461
+ return `${getTableName(targetCollection)}_${normalisedFK}`;
462
+ } catch {
463
+ return fallback;
464
+ }
465
+ }
466
+ // No explicit foreignKeyOnTarget — try to find the corresponding owning relation
467
+ try {
468
+ const targetCollection = rel.target();
469
+ const targetResolvedRelations = resolveCollectionRelations(targetCollection);
470
+ const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
471
+ targetRel.direction === "owning" &&
472
+ targetRel.cardinality === "one" &&
473
+ targetRel.localKey &&
474
+ targetRel.target().slug === sourceCollection.slug
475
+ );
476
+ if (correspondingRelation && correspondingRelation.localKey) {
477
+ return `${getTableName(targetCollection)}_${correspondingRelation.localKey}`;
478
+ }
479
+ } catch {
480
+ // ignore
481
+ }
482
+ return fallback;
483
+ }
484
+
485
+ // --- M2M owning (through) — keep local name (already shared via junction wiring) ---
486
+ // --- M2M inverse — keep local name ---
487
+ // --- joinPath — not emitted as Drizzle relations ---
488
+ return fallback;
489
+ };
490
+
491
+ // --- Main Schema Generation Logic ---
492
+ export const generateSchema = async (collections: CollectionConfig[], stripPolicies = false): Promise<string> => {
493
+ let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
494
+
495
+
496
+ const hasUuid = collections.some(c =>
497
+ c.properties && Object.values(c.properties).some(
498
+ (p: Property) => p.type === "string" && ((p as unknown as Record<string, unknown>).autoValue === "uuid" || (p as unknown as Record<string, unknown>).isId === "uuid")
499
+ )
500
+ );
501
+
502
+ const hasVector = collections.some(c =>
503
+ c.properties && Object.values(c.properties).some(
504
+ (p: Property) => p.type === "vector"
505
+ )
506
+ );
507
+
508
+ const hasBinary = collections.some(c =>
509
+ c.properties && Object.values(c.properties).some(
510
+ (p: Property) => p.type === "binary"
511
+ )
512
+ );
513
+
514
+ // Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
515
+ const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
516
+ if (hasUuid) pgCoreImports.push("uuid");
517
+ if (hasVector) pgCoreImports.push("vector");
518
+ if (hasBinary) pgCoreImports.push("customType");
519
+
520
+ const uniqueSchemas = Array.from(new Set(
521
+ collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
522
+ ));
523
+ if (uniqueSchemas.length > 0) {
524
+ pgCoreImports.push("pgSchema");
525
+ }
526
+
527
+ schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
528
+ schemaContent += "import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n";
529
+
530
+ uniqueSchemas.forEach(schema => {
531
+ schemaContent += `export const ${schema}Schema = pgSchema("${schema}");\n`;
532
+ });
533
+ if (uniqueSchemas.length > 0) {
534
+ schemaContent += "\n";
535
+ }
536
+
537
+ const exportedTableVars: string[] = [];
538
+ const exportedEnumVars: string[] = [];
539
+ const exportedRelationVars: string[] = [];
540
+
541
+ const allTablesToGenerate = new Map<string, {
542
+ collection: CollectionConfig,
543
+ isJunction?: boolean,
544
+ relation?: Relation,
545
+ sourceCollection?: CollectionConfig
546
+ }>();
547
+
548
+ // 1. Generate Enums
549
+ collections.forEach(collection => {
550
+ const collectionPath = getTableName(collection);
551
+ Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
552
+
553
+ if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
554
+ const enumVarName = getEnumVarName(collectionPath, propName);
555
+ const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
556
+ const values = Array.isArray(prop.enum)
557
+ ? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
558
+ String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
559
+ )
560
+ : Object.keys(prop.enum);
561
+ if (values.length > 0) {
562
+ schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map(v => `'${v}'`).join(", ")}]);\n`;
563
+ if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
564
+ }
565
+ }
566
+ });
567
+ });
568
+ schemaContent += "\n";
569
+
570
+ // 2. Identify all tables (collections and junction tables only)
571
+ for (const collection of collections) {
572
+ const tableName = getTableName(collection);
573
+ if (tableName) {
574
+ allTablesToGenerate.set(tableName, { collection });
575
+ }
576
+
577
+ const resolvedRelations = resolveCollectionRelations(collection);
578
+ for (const relation of Object.values(resolvedRelations)) {
579
+ if (relation.through) { // Standard M2M junction table
580
+ const junctionTableName = relation.through.table;
581
+ if (!allTablesToGenerate.has(junctionTableName)) {
582
+ allTablesToGenerate.set(junctionTableName, {
583
+ collection: {
584
+ table: junctionTableName,
585
+ properties: {}
586
+ } as CollectionConfig,
587
+ isJunction: true,
588
+ relation: relation,
589
+ sourceCollection: collection
590
+ });
591
+ }
592
+ }
593
+ // joinPath relations use existing user-controlled tables - no generation needed
594
+ }
595
+ }
596
+
597
+ // 3. Generate pgTable definitions for all unique tables
598
+ for (const [tableName, {
599
+ collection,
600
+ isJunction,
601
+ relation,
602
+ sourceCollection
603
+ }] of allTablesToGenerate.entries()) {
604
+ const tableVarName = getTableVarName(tableName);
605
+ if (isJunction && relation && sourceCollection && relation.through) {
606
+ const targetCollection = relation.target();
607
+ const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : undefined) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : undefined);
608
+ const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
609
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
610
+ const {
611
+ sourceColumn,
612
+ targetColumn
613
+ } = relation.through;
614
+
615
+ const onDelete = relation.onDelete ?? "cascade";
616
+ const refOptions = `{ onDelete: \"${onDelete}\" }`;
617
+
618
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar");
619
+ const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar");
620
+ const sourceId = getPrimaryKeyName(sourceCollection);
621
+ const targetId = getPrimaryKeyName(targetCollection);
622
+
623
+ schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
624
+ schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
625
+ schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
626
+ schemaContent += "}, (table) => ({\n";
627
+ schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
628
+ schemaContent += "}));\n\n";
629
+ } else if (!isJunction) {
630
+ const schema = isPostgresCollectionConfig(collection) ? collection.schema : undefined;
631
+ const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
632
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
633
+ schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
634
+ const columns = new Set<string>();
635
+ Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
636
+ const columnString = getDrizzleColumn(propName, prop as Property, collection, collections);
637
+ if (columnString) columns.add(columnString);
638
+
639
+ });
640
+
641
+ // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
642
+ // We should generate a basic id column if one was completely omitted.
643
+ const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
644
+ if (!hasIdColumn) {
645
+ columns.add(" id: varchar(\"id\").primaryKey()");
646
+ }
647
+
648
+ schemaContent += `${Array.from(columns).join(",\n")}`;
649
+
650
+ const securityRules = getEffectiveSecurityRules(collection);
651
+ if (!stripPolicies && securityRules.length > 0) {
652
+ schemaContent += "\n}, (table) => ([\n";
653
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
654
+ securityRules.forEach((rule: SecurityRule, idx: number) => {
655
+ schemaContent += generatePolicyCode(collection, rule, idx, resolveCollection);
656
+ });
657
+ schemaContent += "])).enableRLS();\n\n";
658
+ } else {
659
+ // No explicit policies — RLS enabled with deny-all default (Postgres denies
660
+ // everything when RLS is on and no permissive policies exist).
661
+ schemaContent += "\n}).enableRLS();\n\n";
662
+ }
663
+ }
664
+ if (!exportedTableVars.includes(tableVarName)) exportedTableVars.push(tableVarName);
665
+ }
666
+
667
+ // 4. Generate Drizzle Relations
668
+ for (const [tableName, {
669
+ collection,
670
+ isJunction
671
+ }] of allTablesToGenerate.entries()) {
672
+ const tableVarName = getTableVarName(tableName);
673
+ const tableRelations: string[] = [];
674
+
675
+ if (isJunction) {
676
+ const relationInfo = Array.from(allTablesToGenerate.values()).find(v => v.isJunction && getTableName(v.collection) === tableName);
677
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
678
+ const {
679
+ relation,
680
+ sourceCollection
681
+ } = relationInfo;
682
+ const targetCollection = relation.target();
683
+ const sourceTableVar = getTableVarName(getTableName(sourceCollection));
684
+ const targetTableVar = getTableVarName(getTableName(targetCollection));
685
+ const sourceId = getPrimaryKeyName(sourceCollection);
686
+ const targetId = getPrimaryKeyName(targetCollection);
687
+
688
+ if (!relation?.through)
689
+ throw new Error("Internal, the relation should have a through property. Relations passed to this script should sanitized first with sanitizeRelation().");
690
+
691
+ // The owning relation's name — used on the source side of the junction
692
+ const owningRelationName = relation.relationName ?? toSnakeCase(getTableName(targetCollection));
693
+
694
+ // Find the inverse relation name on the target collection (if any)
695
+ // This is needed so the junction's target-side one() can pair with the
696
+ // inverse many() on the target table.
697
+ let inverseRelationName: string | null = null;
698
+ try {
699
+ const targetRelations = resolveCollectionRelations(targetCollection);
700
+ for (const [, targetRel] of Object.entries(targetRelations)) {
701
+ if (targetRel.direction === "inverse" &&
702
+ targetRel.cardinality === "many" &&
703
+ targetRel.inverseRelationName === owningRelationName) {
704
+ inverseRelationName = targetRel.relationName ?? null;
705
+ break;
706
+ }
707
+ }
708
+ } catch {
709
+ // ignore — inverse side may not exist
710
+ }
711
+
712
+ // Source side one(): pairs with owning table's many(junctionTable, { relationName })
713
+ tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
714
+
715
+ // Target side one(): pairs with inverse table's many(junctionTable, { relationName })
716
+ // Always emit a relationName to avoid collisions with the source-side's owningRelationName.
717
+ // When no inverse relation exists on the target collection, synthesize a unique name.
718
+ const targetRelationName = inverseRelationName
719
+ ? inverseRelationName
720
+ : `${tableName}_${relation.through.targetColumn}`;
721
+ tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: "${targetRelationName}"\n })`);
722
+ }
723
+ } else {
724
+ const resolvedRelations = resolveCollectionRelations(collection);
725
+ // Defensive safety net: track emitted `drizzleRelationName` values
726
+ // to prevent duplicate one()/many() entries in the generated schema.
727
+ // The root deduplication happens inside resolveCollectionRelations,
728
+ // but this guards against any future regressions in that utility.
729
+ const emittedRelationNames = new Set<string>();
730
+ for (const [relationKey, rel] of Object.entries(resolvedRelations)) {
731
+ try {
732
+ const target = rel.target();
733
+ const targetTableVar = getTableVarName(getTableName(target));
734
+
735
+ // Compute a deterministic shared relationName for Drizzle.
736
+ // Both sides of an owning/inverse pair MUST share the same
737
+ // relationName, otherwise Drizzle cannot pair them.
738
+ //
739
+ // Strategy: use "{ownerTable}_{foreignKey}" which is
740
+ // computable from either side:
741
+ // - owning side: {thisTable}_{localKey}
742
+ // - inverse side: {targetTable}_{foreignKeyOnTarget}
743
+ const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
744
+
745
+ // Skip if we've already emitted a relation with this drizzleRelationName
746
+ // for this table — prevents duplicate definitions when
747
+ // resolveCollectionRelations returns alias entries for the same FK.
748
+ const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
749
+ if (emittedRelationNames.has(deduplicationKey)) continue;
750
+ emittedRelationNames.add(deduplicationKey);
751
+
752
+ if (rel.cardinality === "one") {
753
+ if (rel.direction === "owning" && rel.localKey) {
754
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
755
+ } else if (rel.direction === "inverse") {
756
+ // Inverse one-to-one: the FK lives on the TARGET table, not here.
757
+ // Drizzle pairs inverse relations via `relationName` alone — specifying
758
+ // `fields`/`references` on the inverse side is invalid and causes
759
+ // `normalizeRelation` to crash with "Cannot read properties of
760
+ // undefined (reading 'referencedTable')".
761
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
762
+ }
763
+ } else if (rel.cardinality === "many") {
764
+ if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
765
+ // One-to-many inverse relation
766
+ tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
767
+ } else if (rel.through) {
768
+ // Many-to-many owning relation with explicit junction table
769
+ const junctionTableVar = getTableVarName(rel.through.table);
770
+ tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
771
+ } else if (rel.direction === "inverse" && rel.inverseRelationName) {
772
+ // Many-to-many inverse relation - find the corresponding owning relation's junction table
773
+ try {
774
+ const targetCollection = rel.target();
775
+ const targetResolvedRelations = resolveCollectionRelations(targetCollection);
776
+
777
+ // Find the corresponding owning many-to-many relation on the target
778
+ const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
779
+ targetRel.direction === "owning" &&
780
+ targetRel.cardinality === "many" &&
781
+ targetRel.through &&
782
+ targetRel.relationName === rel.inverseRelationName
783
+ );
784
+
785
+ if (correspondingRelation && correspondingRelation.through) {
786
+ const junctionTableVar = getTableVarName(correspondingRelation.through.table);
787
+ tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
788
+ } else {
789
+ logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
790
+ }
791
+ } catch (e) {
792
+ logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
793
+ }
794
+ }
795
+ // joinPath relations don't generate Drizzle relations - they use existing user tables
796
+ }
797
+ } catch (e) {
798
+ logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
799
+ }
800
+ }
801
+
802
+ // Synthesize missing reciprocal relations
803
+ for (const otherCollection of collections) {
804
+ if (otherCollection.slug === collection.slug) continue;
805
+
806
+ const otherRelations = resolveCollectionRelations(otherCollection);
807
+ for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
808
+ if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
809
+ try {
810
+ const otherTarget = otherRel.target();
811
+ if (otherTarget.slug === collection.slug) {
812
+ const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
813
+ const deduplicationKey = `${drizzleRelationName}::owning`;
814
+
815
+ if (!emittedRelationNames.has(deduplicationKey)) {
816
+ const otherTableVar = getTableVarName(getTableName(otherCollection));
817
+ // Resolve foreignKeyOnTarget to the Drizzle property key
818
+ // on THIS collection (the owning table). The raw FK column
819
+ // name (e.g. "client_id") may differ from the property key
820
+ // (e.g. "clientId") when `columnName` is set.
821
+ const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
822
+ const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
823
+ tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
824
+ emittedRelationNames.add(deduplicationKey);
825
+ }
826
+ }
827
+ } catch (e) {
828
+ // ignore
829
+ }
830
+ }
831
+ }
832
+ }
833
+ }
834
+
835
+ if (tableRelations.length > 0) {
836
+ const relVarName = `${tableVarName}Relations`;
837
+ schemaContent += `export const ${relVarName} = drizzleRelations(${tableVarName}, ({ one, many }) => ({\n${tableRelations.join(",\n")}\n}));\n\n`;
838
+ if (!exportedRelationVars.includes(relVarName)) exportedRelationVars.push(relVarName);
839
+ }
840
+ }
841
+
842
+ // <<< ADDED: Final aggregated exports block
843
+ const tablesExport = `export const tables = { ${exportedTableVars.join(", ")} };\n`;
844
+ const enumsExport = `export const enums = { ${exportedEnumVars.join(", ")} };\n`;
845
+ const relationsExport = `export const relations = { ${exportedRelationVars.join(", ")} };\n\n`;
846
+ schemaContent += tablesExport + enumsExport + relationsExport;
847
+
848
+ return schemaContent;
849
+ };
850
+