@rebasepro/server-postgresql 0.0.1-canary.09e5ec5

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 (196) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +58 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +22 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +11298 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +11306 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +192 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +40 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
  43. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  45. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  46. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  47. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
  48. package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
  49. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  51. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
  52. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  53. package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
  54. package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
  55. package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
  56. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  57. package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
  58. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  59. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  60. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  61. package/dist/types/src/controllers/auth.d.ts +119 -0
  62. package/dist/types/src/controllers/client.d.ts +170 -0
  63. package/dist/types/src/controllers/collection_registry.d.ts +45 -0
  64. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  65. package/dist/types/src/controllers/data.d.ts +168 -0
  66. package/dist/types/src/controllers/data_driver.d.ts +160 -0
  67. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  68. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  69. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  70. package/dist/types/src/controllers/email.d.ts +34 -0
  71. package/dist/types/src/controllers/index.d.ts +18 -0
  72. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  73. package/dist/types/src/controllers/navigation.d.ts +213 -0
  74. package/dist/types/src/controllers/registry.d.ts +54 -0
  75. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  76. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  77. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  78. package/dist/types/src/controllers/storage.d.ts +171 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/rebase_context.d.ts +105 -0
  81. package/dist/types/src/types/backend.d.ts +536 -0
  82. package/dist/types/src/types/builders.d.ts +15 -0
  83. package/dist/types/src/types/chips.d.ts +5 -0
  84. package/dist/types/src/types/collections.d.ts +856 -0
  85. package/dist/types/src/types/cron.d.ts +102 -0
  86. package/dist/types/src/types/data_source.d.ts +64 -0
  87. package/dist/types/src/types/entities.d.ts +145 -0
  88. package/dist/types/src/types/entity_actions.d.ts +98 -0
  89. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  90. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  91. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  92. package/dist/types/src/types/entity_views.d.ts +61 -0
  93. package/dist/types/src/types/export_import.d.ts +21 -0
  94. package/dist/types/src/types/index.d.ts +23 -0
  95. package/dist/types/src/types/locales.d.ts +4 -0
  96. package/dist/types/src/types/modify_collections.d.ts +5 -0
  97. package/dist/types/src/types/plugins.d.ts +279 -0
  98. package/dist/types/src/types/properties.d.ts +1176 -0
  99. package/dist/types/src/types/property_config.d.ts +70 -0
  100. package/dist/types/src/types/relations.d.ts +336 -0
  101. package/dist/types/src/types/slots.d.ts +252 -0
  102. package/dist/types/src/types/translations.d.ts +870 -0
  103. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  104. package/dist/types/src/types/websockets.d.ts +78 -0
  105. package/dist/types/src/users/index.d.ts +2 -0
  106. package/dist/types/src/users/roles.d.ts +22 -0
  107. package/dist/types/src/users/user.d.ts +46 -0
  108. package/drizzle-test/0000_woozy_junta.sql +6 -0
  109. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  110. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  111. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  112. package/drizzle-test/meta/0000_snapshot.json +47 -0
  113. package/drizzle-test/meta/0001_snapshot.json +48 -0
  114. package/drizzle-test/meta/0002_snapshot.json +38 -0
  115. package/drizzle-test/meta/0003_snapshot.json +48 -0
  116. package/drizzle-test/meta/_journal.json +34 -0
  117. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  118. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  119. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  120. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  121. package/drizzle-test-out/meta/_journal.json +20 -0
  122. package/drizzle.test.config.ts +10 -0
  123. package/jest-all.log +3128 -0
  124. package/jest.log +49 -0
  125. package/package.json +92 -0
  126. package/scratch.ts +41 -0
  127. package/src/PostgresBackendDriver.ts +1008 -0
  128. package/src/PostgresBootstrapper.ts +231 -0
  129. package/src/auth/ensure-tables.ts +381 -0
  130. package/src/auth/services.ts +799 -0
  131. package/src/cli.ts +648 -0
  132. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  133. package/src/connection.ts +84 -0
  134. package/src/data-transformer.ts +608 -0
  135. package/src/databasePoolManager.ts +85 -0
  136. package/src/history/HistoryService.ts +248 -0
  137. package/src/history/ensure-history-table.ts +45 -0
  138. package/src/index.ts +13 -0
  139. package/src/interfaces.ts +60 -0
  140. package/src/schema/auth-schema.ts +169 -0
  141. package/src/schema/doctor-cli.ts +47 -0
  142. package/src/schema/doctor.ts +595 -0
  143. package/src/schema/generate-drizzle-schema-logic.ts +765 -0
  144. package/src/schema/generate-drizzle-schema.ts +151 -0
  145. package/src/schema/introspect-db-logic.ts +542 -0
  146. package/src/schema/introspect-db.ts +211 -0
  147. package/src/schema/test-schema.ts +11 -0
  148. package/src/services/BranchService.ts +237 -0
  149. package/src/services/EntityFetchService.ts +1576 -0
  150. package/src/services/EntityPersistService.ts +349 -0
  151. package/src/services/RelationService.ts +1274 -0
  152. package/src/services/entity-helpers.ts +147 -0
  153. package/src/services/entityService.ts +211 -0
  154. package/src/services/index.ts +13 -0
  155. package/src/services/realtimeService.ts +1034 -0
  156. package/src/utils/drizzle-conditions.ts +1000 -0
  157. package/src/websocket.ts +518 -0
  158. package/test/auth-services.test.ts +661 -0
  159. package/test/batch-many-to-many-regression.test.ts +573 -0
  160. package/test/branchService.test.ts +367 -0
  161. package/test/data-transformer-hardening.test.ts +417 -0
  162. package/test/data-transformer.test.ts +175 -0
  163. package/test/doctor.test.ts +182 -0
  164. package/test/drizzle-conditions.test.ts +895 -0
  165. package/test/entityService.errors.test.ts +367 -0
  166. package/test/entityService.relations.test.ts +1008 -0
  167. package/test/entityService.subcollection-search.test.ts +566 -0
  168. package/test/entityService.test.ts +1035 -0
  169. package/test/generate-drizzle-schema.test.ts +988 -0
  170. package/test/historyService.test.ts +141 -0
  171. package/test/introspect-db-generation.test.ts +436 -0
  172. package/test/introspect-db-utils.test.ts +389 -0
  173. package/test/n-plus-one-regression.test.ts +314 -0
  174. package/test/postgresDataDriver.test.ts +648 -0
  175. package/test/realtimeService.test.ts +307 -0
  176. package/test/relation-pipeline-gaps.test.ts +637 -0
  177. package/test/relations.test.ts +1115 -0
  178. package/test/unmapped-tables-safety.test.ts +345 -0
  179. package/test-drizzle-bug.ts +18 -0
  180. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  181. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  182. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  183. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  184. package/test-drizzle-out/meta/_journal.json +20 -0
  185. package/test-drizzle-prompt.sh +2 -0
  186. package/test-policy-prompt.sh +3 -0
  187. package/test-programmatic.ts +30 -0
  188. package/test-programmatic2.ts +59 -0
  189. package/test-schema-no-policies.ts +12 -0
  190. package/test_drizzle_mock.js +3 -0
  191. package/test_find_changed.mjs +32 -0
  192. package/test_hash.js +14 -0
  193. package/test_output.txt +3145 -0
  194. package/tsconfig.json +49 -0
  195. package/tsconfig.prod.json +20 -0
  196. package/vite.config.ts +82 -0
@@ -0,0 +1,765 @@
1
+ import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty } from "@rebasepro/types";
2
+ import { getPrimaryKeys } from "../services/entity-helpers";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
4
+ import { toSnakeCase } from "@rebasepro/utils";
5
+ import { createHash } from "crypto";
6
+ // --- Helper Functions ---
7
+
8
+ const getPrimaryKeyProp = (collection: EntityCollection): { name: string, type: "string" | "number", isUuid: boolean } => {
9
+ if (collection.properties) {
10
+ const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
11
+ if (idPropEntry) {
12
+ const prop = idPropEntry[1] as Property;
13
+ const isUuid = prop.type === "string" && "isId" in prop && (prop as StringProperty).isId === "uuid";
14
+ return { name: idPropEntry[0],
15
+ type: prop.type === "number" ? "number" : "string",
16
+ isUuid };
17
+ }
18
+ }
19
+ // Fallback
20
+ const idProp = collection.properties?.["id"] as Property | undefined;
21
+ if (idProp?.type === "number") {
22
+ return { name: "id",
23
+ type: "number",
24
+ isUuid: false };
25
+ }
26
+ const isUuid = idProp?.type === "string" && "isId" in idProp && (idProp as StringProperty).isId === "uuid";
27
+ return { name: "id",
28
+ type: "string",
29
+ isUuid: isUuid ?? false };
30
+ };
31
+
32
+ const isNumericId = (collection: EntityCollection): boolean => {
33
+ return getPrimaryKeyProp(collection).type === "number";
34
+ };
35
+
36
+ const getPrimaryKeyName = (collection: EntityCollection): string => {
37
+ return getPrimaryKeyProp(collection).name;
38
+ };
39
+
40
+ const isIdProperty = (propName: string, prop: Property, collection: EntityCollection): boolean => {
41
+ if ("isId" in prop && Boolean(prop.isId)) return true;
42
+
43
+ // We only fallback to "id" if NO property is explicitly marked with `isId: true` or a generator string
44
+ const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as object) && Boolean((p as unknown as Record<string, unknown>).isId));
45
+ return !hasExplicitId && propName === "id";
46
+ };
47
+
48
+ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCollection, collections: EntityCollection[]): string | null => {
49
+ const colName = toSnakeCase(propName);
50
+ let columnDefinition: string;
51
+
52
+ switch (prop.type) {
53
+ case "string": {
54
+ const stringProp = prop as StringProperty;
55
+ if (stringProp.enum) {
56
+ const enumName = getEnumVarName(getTableName(collection), propName);
57
+ columnDefinition = `${enumName}("${colName}")`;
58
+ } else if ("isId" in stringProp && stringProp.isId === "uuid") {
59
+ columnDefinition = `uuid("${colName}")`;
60
+ } else if (stringProp.columnType === "text") {
61
+ columnDefinition = `text("${colName}")`;
62
+ } else if (stringProp.columnType === "char") {
63
+ columnDefinition = `char("${colName}")`;
64
+ } else {
65
+ columnDefinition = `varchar("${colName}")`;
66
+ }
67
+ if (isIdProperty(propName, prop, collection)) {
68
+ columnDefinition += ".primaryKey()";
69
+ }
70
+ if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
71
+ if (stringProp.isId === "uuid") {
72
+ columnDefinition += ".defaultRandom()";
73
+ } else if (stringProp.isId === "cuid") {
74
+ columnDefinition += ".default(sql`cuid()`)";
75
+ } else if (typeof stringProp.isId === "string") {
76
+ const sqlContent = stringProp.isId.startsWith("sql`") && stringProp.isId.endsWith("`")
77
+ ? stringProp.isId.substring(4, stringProp.isId.length - 1)
78
+ : stringProp.isId;
79
+ columnDefinition += `.default(sql\`${sqlContent}\`)`;
80
+ }
81
+ }
82
+ if (stringProp.validation?.unique) {
83
+ columnDefinition += ".unique()";
84
+ }
85
+ break;
86
+ }
87
+ case "number": {
88
+ const numProp = prop as NumberProperty;
89
+ const isId = isIdProperty(propName, prop, collection);
90
+
91
+ let baseType = (numProp.validation?.integer || isId) ? `integer("${colName}")` : `numeric("${colName}")`;
92
+ if (numProp.columnType) {
93
+ if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
94
+ else baseType = `${numProp.columnType}("${colName}")`;
95
+ }
96
+
97
+ if ("isId" in numProp && numProp.isId === "increment") {
98
+ columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
99
+ } else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
100
+ columnDefinition = baseType;
101
+ const sqlContent = numProp.isId.startsWith("sql`") && numProp.isId.endsWith("`")
102
+ ? numProp.isId.substring(4, numProp.isId.length - 1)
103
+ : numProp.isId;
104
+ columnDefinition += `.default(sql\`${sqlContent}\`)`;
105
+ } else {
106
+ columnDefinition = baseType;
107
+ }
108
+
109
+ if (isId) {
110
+ columnDefinition += ".primaryKey()";
111
+ }
112
+ if (numProp.validation?.unique) {
113
+ columnDefinition += ".unique()";
114
+ }
115
+ break;
116
+ }
117
+ case "boolean":
118
+ columnDefinition = `boolean("${colName}")`;
119
+ break;
120
+ case "date": {
121
+ const dateProp = prop as DateProperty;
122
+ if (dateProp.columnType === "date") {
123
+ columnDefinition = `date("${colName}", { mode: 'string' })`;
124
+ } else if (dateProp.columnType === "time") {
125
+ columnDefinition = `time("${colName}")`;
126
+ } else {
127
+ columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
128
+ }
129
+ break;
130
+ }
131
+ case "map":
132
+ case "array": {
133
+ const arrayOrMapProp = prop as ArrayProperty | MapProperty;
134
+ if (arrayOrMapProp.columnType === "json") {
135
+ columnDefinition = `json("${colName}")`;
136
+ } else {
137
+ columnDefinition = `jsonb("${colName}")`;
138
+ }
139
+ break;
140
+ }
141
+ case "relation": {
142
+ const refProp = prop as RelationProperty;
143
+ const resolvedRelations = resolveCollectionRelations(collection);
144
+ const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
145
+
146
+ // Only owning one-to-one/many-to-one relations create a column here.
147
+ if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
148
+ return null;
149
+ }
150
+
151
+ // The localKey property is the source of truth for the FK column name.
152
+ if (!relation.localKey) {
153
+ console.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
154
+ return null;
155
+ }
156
+
157
+ // If the localKey property is defined elsewhere in the properties, it will be handled there.
158
+ // This logic is for when the relation property itself defines the FK.
159
+ if (collection.properties[relation.localKey] && propName !== relation.localKey) {
160
+ return null;
161
+ }
162
+
163
+ let targetCollection: EntityCollection;
164
+ try {
165
+ targetCollection = relation.target();
166
+ } catch {
167
+ return null; // Cannot resolve target
168
+ }
169
+
170
+ const fkColumnName = toSnakeCase(relation.localKey);
171
+ const targetTableVar = getTableVarName(getTableName(targetCollection));
172
+ const pkProp = getPrimaryKeyProp(targetCollection);
173
+ const targetIdField = pkProp.name;
174
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`);
175
+
176
+ const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
177
+ const required = prop.validation?.required;
178
+ const onDeleteVal = relation.onDelete ?? (required ? "cascade" : "set null");
179
+ const onDelete = `onDelete: \"${onDeleteVal}\"`;
180
+
181
+ const refOptionsParts = [onUpdate, onDelete].filter(Boolean);
182
+ const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
183
+
184
+ let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
185
+
186
+ if (required) {
187
+ columnDef += ".notNull()";
188
+ }
189
+
190
+ return ` ${relation.localKey}: ${columnDef}`;
191
+ }
192
+ case "reference": {
193
+ const refProp = prop as ReferenceProperty;
194
+ const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
195
+ if (!targetCollection) {
196
+ columnDefinition = `varchar("${colName}")`;
197
+ break;
198
+ }
199
+
200
+ const pkProp = getPrimaryKeyProp(targetCollection);
201
+ const targetTableVar = getTableVarName(getTableName(targetCollection));
202
+ const targetIdField = pkProp.name;
203
+ const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`);
204
+
205
+ const required = prop.validation?.required;
206
+ const onDelete = required ? "cascade" : "set null";
207
+ const refOptions = `{ onDelete: "${onDelete}" }`;
208
+
209
+ columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${refOptions})`;
210
+ if (required) {
211
+ columnDefinition += ".notNull()";
212
+ }
213
+ // Skip the standard notNull() handling below because we did it here with references
214
+ return ` ${propName}: ${columnDefinition}`;
215
+ }
216
+ default:
217
+ return null;
218
+ }
219
+
220
+ if (prop.validation?.required) {
221
+ columnDefinition += ".notNull()";
222
+ }
223
+
224
+ return ` ${propName}: ${columnDefinition}`;
225
+ };
226
+
227
+ /**
228
+ * Resolves a raw SQL string, replacing `{column_name}` with `${table.column_name}`.
229
+ * The result is wrapped in a Drizzle sql`` template literal.
230
+ */
231
+ const resolveRawSql = (expression: string): string => {
232
+ // Replace {column_name} with ${table.column_name}
233
+ const resolved = expression.replace(/\{(\w+)\}/g, (_, col) => `\${table.${col}}`);
234
+ return `sql\`${resolved}\``;
235
+ };
236
+
237
+ /**
238
+ * Wraps a SQL clause with a role check using AND.
239
+ * Generates: `(<clause>) AND (string_to_array(auth.roles(), ',') && ARRAY['<role1>','<role2>'])`
240
+ */
241
+ const wrapWithRoleCheck = (clause: string, roles: string[]): string => {
242
+ const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
243
+ const roleCondition = `string_to_array(auth.roles(), ',') @> ${rolesArrayString}`;
244
+ return `sql\`(${unwrapSql(clause)}) AND (${roleCondition})\``;
245
+ };
246
+
247
+ /**
248
+ * Extracts the inner expression from a `sql\`...\`` wrapper.
249
+ */
250
+ const unwrapSql = (sqlExpr: string): string => {
251
+ const match = sqlExpr.match(/^sql`(.*)`$/s);
252
+ return match ? match[1] : sqlExpr;
253
+ };
254
+
255
+ /**
256
+ * Builds the USING clause for a policy based on shortcuts or raw SQL.
257
+ */
258
+ const buildUsingClause = (rule: SecurityRule): string | null => {
259
+ if (rule.using) {
260
+ return resolveRawSql(rule.using);
261
+ }
262
+ if (rule.access === "public") {
263
+ return "sql`true`";
264
+ }
265
+ if (rule.ownerField) {
266
+ return `sql\`\${table.${rule.ownerField}} = auth.uid()\``;
267
+ }
268
+ return null;
269
+ };
270
+
271
+ /**
272
+ * Builds the WITH CHECK clause for a policy based on shortcuts or raw SQL.
273
+ * Falls back to the USING clause if not explicitly provided.
274
+ */
275
+ const buildWithCheckClause = (rule: SecurityRule): string | null => {
276
+ if (rule.withCheck) {
277
+ return resolveRawSql(rule.withCheck);
278
+ }
279
+ // For insert/update/all, fall back to using clause if withCheck not specified
280
+ return buildUsingClause(rule);
281
+ };
282
+
283
+ /**
284
+ * Generates a deterministic hash based on the rule configuration.
285
+ */
286
+ const getPolicyNameHash = (rule: SecurityRule): string => {
287
+ const data = JSON.stringify({
288
+ a: rule.access,
289
+ m: rule.mode,
290
+ op: rule.operation,
291
+ ops: rule.operations?.slice().sort(),
292
+ own: rule.ownerField,
293
+ rol: rule.roles?.slice().sort(),
294
+ pg: rule.pgRoles?.slice().sort(),
295
+ u: rule.using,
296
+ w: rule.withCheck
297
+ });
298
+ return createHash("sha1").update(data).digest("hex").substring(0, 7);
299
+ };
300
+
301
+ /**
302
+ * Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
303
+ *
304
+ * Supports the full spectrum:
305
+ * - Convenience shortcuts: ownerField, access, roles
306
+ * - Raw SQL: using, withCheck
307
+ * - Mode: permissive (default) or restrictive
308
+ * - operations[] array: generates one policy per operation
309
+ * - Combinations: roles + ownerField, roles + raw SQL, etc.
310
+ */
311
+ const generatePolicyCode = (tableName: string, rule: SecurityRule, index: number): string => {
312
+ // Resolve operations: operations[] takes precedence over operation (singular)
313
+ const ops: SecurityOperation[] = rule.operations && rule.operations.length > 0
314
+ ? rule.operations
315
+ : [rule.operation ?? "all"];
316
+
317
+ const ruleHash = getPolicyNameHash(rule);
318
+
319
+ // Generate one pgPolicy per operation
320
+ return ops.map((op, opIdx) => {
321
+ const policyName = rule.name
322
+ ? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
323
+ : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
324
+
325
+ return generateSinglePolicyCode(tableName, rule, op, policyName);
326
+ }).join("");
327
+ };
328
+
329
+ /**
330
+ * Generates a single pgPolicy() call for one specific operation.
331
+ */
332
+ const generateSinglePolicyCode = (tableName: string, rule: SecurityRule, operation: SecurityOperation, policyName: string): string => {
333
+ const mode = rule.mode ?? "permissive";
334
+ const roles = rule.roles ? [...rule.roles].sort() : undefined;
335
+
336
+ // Determine which clauses this operation needs:
337
+ // SELECT, DELETE → USING only
338
+ // INSERT → WITH CHECK only
339
+ // UPDATE, ALL → both USING and WITH CHECK
340
+ const needsUsing = operation !== "insert";
341
+ const needsWithCheck = operation !== "select" && operation !== "delete";
342
+
343
+ let usingClause = needsUsing ? buildUsingClause(rule) : null;
344
+ let withCheckClause = needsWithCheck ? buildWithCheckClause(rule) : null;
345
+
346
+ // If roles are specified, wrap existing clauses with role check,
347
+ // or generate a roles-only clause.
348
+ if (roles && roles.length > 0) {
349
+ if (usingClause) {
350
+ usingClause = wrapWithRoleCheck(usingClause, roles);
351
+ } else if (needsUsing) {
352
+ // Roles-only rule (e.g. { operation: "select", roles: ["admin"] })
353
+ const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
354
+ usingClause = `sql\`string_to_array(auth.roles(), ',') @> ${rolesArrayString}\``;
355
+ }
356
+ if (withCheckClause) {
357
+ withCheckClause = wrapWithRoleCheck(withCheckClause, roles);
358
+ } else if (needsWithCheck) {
359
+ const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
360
+ withCheckClause = `sql\`string_to_array(auth.roles(), ',') @> ${rolesArrayString}\``;
361
+ }
362
+ }
363
+
364
+ // Fallback: if we still have no clauses, deny all (safety net)
365
+ if (!usingClause && needsUsing) {
366
+ usingClause = "sql`false`";
367
+ }
368
+ if (!withCheckClause && needsWithCheck) {
369
+ withCheckClause = "sql`false`";
370
+ }
371
+
372
+ // Build the policy options object
373
+ const parts: string[] = [];
374
+ parts.push(`as: "${mode}"`);
375
+ parts.push(`for: "${operation}"`);
376
+ const toRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
377
+ parts.push(`to: [${toRoles.map(r => `"${r}"`).join(", ")}]`);
378
+ if (usingClause) parts.push(`using: ${usingClause}`);
379
+ if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
380
+
381
+ return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
382
+ };
383
+
384
+ /**
385
+ * Computes a deterministic shared relation name for Drizzle.
386
+ *
387
+ * Drizzle requires both sides of a relation (owning + inverse) to use the
388
+ * exact same `relationName` string so it can pair them. Each collection
389
+ * definition may use a different local `relationName`, so we need a canonical
390
+ * form that both sides can independently compute.
391
+ *
392
+ * Strategy: `{owningTable}_{foreignKey}`
393
+ * - owning side → `{thisTable}_{localKey}` e.g. "jobs_company_id"
394
+ * - inverse side → `{targetTable}_{foreignKeyOnTarget}` e.g. "jobs_company_id"
395
+ *
396
+ * For M2M with junction tables the owning relation name is already shared via
397
+ * the junction table wiring, so we keep it as-is.
398
+ *
399
+ * Falls back to the local relation name when the counterpart can't be resolved.
400
+ */
401
+ const computeSharedRelationName = (
402
+ rel: Relation,
403
+ sourceCollection: EntityCollection,
404
+ _collections: EntityCollection[]
405
+ ): string => {
406
+ const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
407
+
408
+ // --- owning one (belongs-to) ---
409
+ if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
410
+ return `${getTableName(sourceCollection)}_${rel.localKey}`;
411
+ }
412
+
413
+ // --- inverse many (one-to-many has-many) ---
414
+ if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
415
+ // The owning table is the *target*, the FK column is foreignKeyOnTarget
416
+ try {
417
+ const targetCollection = rel.target();
418
+ return `${getTableName(targetCollection)}_${rel.foreignKeyOnTarget}`;
419
+ } catch {
420
+ return fallback;
421
+ }
422
+ }
423
+
424
+ // --- inverse one (one-to-one inverse) ---
425
+ if (rel.direction === "inverse" && rel.cardinality === "one") {
426
+ if (rel.foreignKeyOnTarget) {
427
+ // FK lives on the target table
428
+ try {
429
+ const targetCollection = rel.target();
430
+ return `${getTableName(targetCollection)}_${rel.foreignKeyOnTarget}`;
431
+ } catch {
432
+ return fallback;
433
+ }
434
+ }
435
+ // No explicit foreignKeyOnTarget — try to find the corresponding owning relation
436
+ try {
437
+ const targetCollection = rel.target();
438
+ const targetResolvedRelations = resolveCollectionRelations(targetCollection);
439
+ const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
440
+ targetRel.direction === "owning" &&
441
+ targetRel.cardinality === "one" &&
442
+ targetRel.localKey &&
443
+ targetRel.target().slug === sourceCollection.slug
444
+ );
445
+ if (correspondingRelation && correspondingRelation.localKey) {
446
+ return `${getTableName(targetCollection)}_${correspondingRelation.localKey}`;
447
+ }
448
+ } catch {
449
+ // ignore
450
+ }
451
+ return fallback;
452
+ }
453
+
454
+ // --- M2M owning (through) — keep local name (already shared via junction wiring) ---
455
+ // --- M2M inverse — keep local name ---
456
+ // --- joinPath — not emitted as Drizzle relations ---
457
+ return fallback;
458
+ };
459
+
460
+ // --- Main Schema Generation Logic ---
461
+ export const generateSchema = async (collections: EntityCollection[], stripPolicies = false): Promise<string> => {
462
+ let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
463
+
464
+ const hasUuid = collections.some(c =>
465
+ c.properties && Object.values(c.properties).some(
466
+ (p: Property) => p.type === "string" && ((p as unknown as Record<string, unknown>).autoValue === "uuid" || (p as unknown as Record<string, unknown>).isId === "uuid")
467
+ )
468
+ );
469
+
470
+ const hasJson = collections.some(c =>
471
+ c.properties && Object.values(c.properties).some(
472
+ (p: Property) => (p.type === "map" || p.type === "array") && (p as unknown as Record<string, unknown>).columnType === "json"
473
+ )
474
+ );
475
+
476
+ // Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
477
+ const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
478
+ if (hasUuid) pgCoreImports.push("uuid");
479
+ schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
480
+ schemaContent += "import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n";
481
+
482
+ const exportedTableVars: string[] = [];
483
+ const exportedEnumVars: string[] = [];
484
+ const exportedRelationVars: string[] = [];
485
+
486
+ const allTablesToGenerate = new Map<string, {
487
+ collection: EntityCollection,
488
+ isJunction?: boolean,
489
+ relation?: Relation,
490
+ sourceCollection?: EntityCollection
491
+ }>();
492
+
493
+ // 1. Generate Enums
494
+ collections.forEach(collection => {
495
+ const collectionPath = getTableName(collection);
496
+ Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
497
+ if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
498
+ const enumVarName = getEnumVarName(collectionPath, propName);
499
+ const enumDbName = `${collectionPath}_${toSnakeCase(propName)}`;
500
+ const values = Array.isArray(prop.enum)
501
+ ? prop.enum.map(v => String(v.id ?? v))
502
+ : Object.keys(prop.enum);
503
+ if (values.length > 0) {
504
+ schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map(v => `'${v}'`).join(", ")}]);\n`;
505
+ if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
506
+ }
507
+ }
508
+ });
509
+ });
510
+ schemaContent += "\n";
511
+
512
+ // 2. Identify all tables (collections and junction tables only)
513
+ for (const collection of collections) {
514
+ const tableName = getTableName(collection);
515
+ if (tableName) {
516
+ allTablesToGenerate.set(tableName, { collection });
517
+ }
518
+
519
+ const resolvedRelations = resolveCollectionRelations(collection);
520
+ for (const relation of Object.values(resolvedRelations)) {
521
+ if (relation.through) { // Standard M2M junction table
522
+ const junctionTableName = relation.through.table;
523
+ if (!allTablesToGenerate.has(junctionTableName)) {
524
+ allTablesToGenerate.set(junctionTableName, {
525
+ collection: {
526
+ table: junctionTableName,
527
+ properties: {}
528
+ } as EntityCollection,
529
+ isJunction: true,
530
+ relation: relation,
531
+ sourceCollection: collection
532
+ });
533
+ }
534
+ }
535
+ // joinPath relations use existing user-controlled tables - no generation needed
536
+ }
537
+ }
538
+
539
+ // 3. Generate pgTable definitions for all unique tables
540
+ for (const [tableName, {
541
+ collection,
542
+ isJunction,
543
+ relation,
544
+ sourceCollection
545
+ }] of allTablesToGenerate.entries()) {
546
+ const tableVarName = getTableVarName(tableName);
547
+ if (isJunction && relation && sourceCollection && relation.through) {
548
+ const targetCollection = relation.target();
549
+ const {
550
+ sourceColumn,
551
+ targetColumn
552
+ } = relation.through;
553
+
554
+ const onDelete = relation.onDelete ?? "cascade";
555
+ const refOptions = `{ onDelete: \"${onDelete}\" }`;
556
+
557
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar");
558
+ const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar");
559
+ const sourceId = getPrimaryKeyName(sourceCollection);
560
+ const targetId = getPrimaryKeyName(targetCollection);
561
+
562
+ schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
563
+ schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${toSnakeCase(sourceColumn)}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
564
+ schemaContent += ` ${targetColumn}: ${targetColType}(\"${toSnakeCase(targetColumn)}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
565
+ schemaContent += "}, (table) => ({\n";
566
+ schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
567
+ schemaContent += "}));\n\n";
568
+ } else if (!isJunction) {
569
+ schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
570
+ const columns = new Set<string>();
571
+ Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
572
+ const columnString = getDrizzleColumn(propName, prop as Property, collection, collections);
573
+ if (columnString) columns.add(columnString);
574
+ });
575
+
576
+ // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
577
+ // We should generate a basic id column if one was completely omitted.
578
+ const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
579
+ if (!hasIdColumn) {
580
+ columns.add(" id: varchar(\"id\").primaryKey()");
581
+ }
582
+
583
+ schemaContent += `${Array.from(columns).join(",\n")}`;
584
+
585
+ const securityRules = isPostgresCollection(collection) ? collection.securityRules : undefined;
586
+ if (!stripPolicies && securityRules && securityRules.length > 0) {
587
+ schemaContent += "\n}, (table) => ([\n";
588
+ securityRules.forEach((rule: SecurityRule, idx: number) => {
589
+ schemaContent += generatePolicyCode(tableName, rule, idx);
590
+ });
591
+ schemaContent += "])).enableRLS();\n\n";
592
+ } else {
593
+ // No explicit policies — RLS enabled with deny-all default (Postgres denies
594
+ // everything when RLS is on and no permissive policies exist).
595
+ schemaContent += "\n}).enableRLS();\n\n";
596
+ }
597
+ }
598
+ if (!exportedTableVars.includes(tableVarName)) exportedTableVars.push(tableVarName);
599
+ }
600
+
601
+ // 4. Generate Drizzle Relations
602
+ for (const [tableName, {
603
+ collection,
604
+ isJunction
605
+ }] of allTablesToGenerate.entries()) {
606
+ const tableVarName = getTableVarName(tableName);
607
+ const tableRelations: string[] = [];
608
+
609
+ if (isJunction) {
610
+ const relationInfo = Array.from(allTablesToGenerate.values()).find(v => v.isJunction && getTableName(v.collection) === tableName);
611
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
612
+ const {
613
+ relation,
614
+ sourceCollection
615
+ } = relationInfo;
616
+ const targetCollection = relation.target();
617
+ const sourceTableVar = getTableVarName(getTableName(sourceCollection));
618
+ const targetTableVar = getTableVarName(getTableName(targetCollection));
619
+ const sourceId = getPrimaryKeyName(sourceCollection);
620
+ const targetId = getPrimaryKeyName(targetCollection);
621
+
622
+ if (!relation?.through)
623
+ throw new Error("Internal, the relation should have a through property. Relations passed to this script should sanitized first with sanitizeRelation().");
624
+
625
+ // The owning relation's name — used on the source side of the junction
626
+ const owningRelationName = relation.relationName ?? toSnakeCase(getTableName(targetCollection));
627
+
628
+ // Find the inverse relation name on the target collection (if any)
629
+ // This is needed so the junction's target-side one() can pair with the
630
+ // inverse many() on the target table.
631
+ let inverseRelationName: string | null = null;
632
+ try {
633
+ const targetRelations = resolveCollectionRelations(targetCollection);
634
+ for (const [, targetRel] of Object.entries(targetRelations)) {
635
+ if (targetRel.direction === "inverse" &&
636
+ targetRel.cardinality === "many" &&
637
+ targetRel.inverseRelationName === owningRelationName) {
638
+ inverseRelationName = targetRel.relationName ?? null;
639
+ break;
640
+ }
641
+ }
642
+ } catch {
643
+ // ignore — inverse side may not exist
644
+ }
645
+
646
+ // Source side one(): pairs with owning table's many(junctionTable, { relationName })
647
+ tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
648
+
649
+ // Target side one(): pairs with inverse table's many(junctionTable, { relationName })
650
+ const targetRelName = inverseRelationName ?? owningRelationName;
651
+ tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: \"${targetRelName}\"\n })`);
652
+ }
653
+ } else {
654
+ const resolvedRelations = resolveCollectionRelations(collection);
655
+ // Defensive safety net: track emitted `drizzleRelationName` values
656
+ // to prevent duplicate one()/many() entries in the generated schema.
657
+ // The root deduplication happens inside resolveCollectionRelations,
658
+ // but this guards against any future regressions in that utility.
659
+ const emittedRelationNames = new Set<string>();
660
+ for (const [relationKey, rel] of Object.entries(resolvedRelations)) {
661
+ try {
662
+ const target = rel.target();
663
+ const targetTableVar = getTableVarName(getTableName(target));
664
+
665
+ // Compute a deterministic shared relationName for Drizzle.
666
+ // Both sides of an owning/inverse pair MUST share the same
667
+ // relationName, otherwise Drizzle cannot pair them.
668
+ //
669
+ // Strategy: use "{ownerTable}_{foreignKey}" which is
670
+ // computable from either side:
671
+ // - owning side: {thisTable}_{localKey}
672
+ // - inverse side: {targetTable}_{foreignKeyOnTarget}
673
+ const drizzleRelationName = computeSharedRelationName(rel, collection, collections);
674
+
675
+ // Skip if we've already emitted a relation with this drizzleRelationName
676
+ // for this table — prevents duplicate definitions when
677
+ // resolveCollectionRelations returns alias entries for the same FK.
678
+ const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
679
+ if (emittedRelationNames.has(deduplicationKey)) continue;
680
+ emittedRelationNames.add(deduplicationKey);
681
+
682
+ if (rel.cardinality === "one") {
683
+ if (rel.direction === "owning" && rel.localKey) {
684
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
685
+ } else if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
686
+ const sourceIdField = getPrimaryKeyName(collection);
687
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${rel.foreignKeyOnTarget}],\n relationName: \"${drizzleRelationName}\"\n })`);
688
+ } else if (rel.direction === "inverse" && !rel.foreignKeyOnTarget) {
689
+ // Handle inverse one-to-one relations where the FK is on the target table
690
+ // but foreignKeyOnTarget is not explicitly specified
691
+ // In this case, we need to find the corresponding owning relation on the target
692
+ try {
693
+ const targetCollection = rel.target();
694
+ const targetResolvedRelations = resolveCollectionRelations(targetCollection);
695
+
696
+ // Find the owning relation on the target that points back to this collection
697
+ const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
698
+ targetRel.direction === "owning" &&
699
+ targetRel.cardinality === "one" &&
700
+ targetRel.target().slug === collection.slug
701
+ );
702
+
703
+ if (correspondingRelation && correspondingRelation.localKey) {
704
+ const sourceIdField = getPrimaryKeyName(collection);
705
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${correspondingRelation.localKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
706
+ }
707
+ } catch (e) {
708
+ console.warn(`Could not resolve inverse one-to-one relation '${relationKey}':`, e);
709
+ }
710
+ }
711
+ } else if (rel.cardinality === "many") {
712
+ if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
713
+ // One-to-many inverse relation
714
+ tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
715
+ } else if (rel.through) {
716
+ // Many-to-many owning relation with explicit junction table
717
+ const junctionTableVar = getTableVarName(rel.through.table);
718
+ tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
719
+ } else if (rel.direction === "inverse" && rel.inverseRelationName) {
720
+ // Many-to-many inverse relation - find the corresponding owning relation's junction table
721
+ try {
722
+ const targetCollection = rel.target();
723
+ const targetResolvedRelations = resolveCollectionRelations(targetCollection);
724
+
725
+ // Find the corresponding owning many-to-many relation on the target
726
+ const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
727
+ targetRel.direction === "owning" &&
728
+ targetRel.cardinality === "many" &&
729
+ targetRel.through &&
730
+ targetRel.relationName === rel.inverseRelationName
731
+ );
732
+
733
+ if (correspondingRelation && correspondingRelation.through) {
734
+ const junctionTableVar = getTableVarName(correspondingRelation.through.table);
735
+ tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
736
+ } else {
737
+ console.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
738
+ }
739
+ } catch (e) {
740
+ console.warn(`Could not resolve inverse many-to-many relation '${relationKey}':`, e);
741
+ }
742
+ }
743
+ // joinPath relations don't generate Drizzle relations - they use existing user tables
744
+ }
745
+ } catch (e) {
746
+ console.warn(`Could not generate relation ${relationKey} for ${collection.name}:`, e);
747
+ }
748
+ }
749
+ }
750
+
751
+ if (tableRelations.length > 0) {
752
+ const relVarName = `${tableVarName}Relations`;
753
+ schemaContent += `export const ${relVarName} = drizzleRelations(${tableVarName}, ({ one, many }) => ({\n${tableRelations.join(",\n")}\n}));\n\n`;
754
+ if (!exportedRelationVars.includes(relVarName)) exportedRelationVars.push(relVarName);
755
+ }
756
+ }
757
+
758
+ // <<< ADDED: Final aggregated exports block
759
+ const tablesExport = `export const tables = { ${exportedTableVars.join(", ")} };\n`;
760
+ const enumsExport = `export const enums = { ${exportedEnumVars.join(", ")} };\n`;
761
+ const relationsExport = `export const relations = { ${exportedRelationVars.join(", ")} };\n\n`;
762
+ schemaContent += tablesExport + enumsExport + relationsExport;
763
+
764
+ return schemaContent;
765
+ };