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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index.es.js +1254 -591
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +1254 -591
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
  10. package/dist/server-postgresql/src/auth/services.d.ts +7 -3
  11. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
  12. package/dist/server-postgresql/src/connection.d.ts +34 -1
  13. package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
  14. package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
  15. package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
  16. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  17. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  18. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
  19. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  20. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
  21. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
  22. package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
  23. package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
  24. package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
  25. package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
  26. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
  27. package/dist/types/src/controllers/auth.d.ts +2 -0
  28. package/dist/types/src/controllers/client.d.ts +119 -7
  29. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  30. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  31. package/dist/types/src/controllers/data.d.ts +34 -7
  32. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  33. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  34. package/dist/types/src/controllers/email.d.ts +34 -0
  35. package/dist/types/src/controllers/index.d.ts +1 -0
  36. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  37. package/dist/types/src/controllers/navigation.d.ts +5 -5
  38. package/dist/types/src/controllers/registry.d.ts +6 -3
  39. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  40. package/dist/types/src/controllers/storage.d.ts +24 -26
  41. package/dist/types/src/rebase_context.d.ts +8 -4
  42. package/dist/types/src/types/backend.d.ts +4 -1
  43. package/dist/types/src/types/builders.d.ts +5 -4
  44. package/dist/types/src/types/chips.d.ts +1 -1
  45. package/dist/types/src/types/collections.d.ts +169 -125
  46. package/dist/types/src/types/cron.d.ts +102 -0
  47. package/dist/types/src/types/data_source.d.ts +1 -1
  48. package/dist/types/src/types/entity_actions.d.ts +8 -8
  49. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  50. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  51. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  52. package/dist/types/src/types/entity_views.d.ts +8 -8
  53. package/dist/types/src/types/export_import.d.ts +3 -3
  54. package/dist/types/src/types/index.d.ts +1 -0
  55. package/dist/types/src/types/plugins.d.ts +72 -18
  56. package/dist/types/src/types/properties.d.ts +118 -33
  57. package/dist/types/src/types/relations.d.ts +1 -1
  58. package/dist/types/src/types/slots.d.ts +30 -6
  59. package/dist/types/src/types/translations.d.ts +44 -0
  60. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  61. package/drizzle-test/0000_woozy_junta.sql +6 -0
  62. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  63. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  64. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  65. package/drizzle-test/meta/0000_snapshot.json +47 -0
  66. package/drizzle-test/meta/0001_snapshot.json +48 -0
  67. package/drizzle-test/meta/0002_snapshot.json +38 -0
  68. package/drizzle-test/meta/0003_snapshot.json +48 -0
  69. package/drizzle-test/meta/_journal.json +34 -0
  70. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  71. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  72. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  73. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  74. package/drizzle-test-out/meta/_journal.json +20 -0
  75. package/drizzle.test.config.ts +10 -0
  76. package/package.json +88 -89
  77. package/scratch.ts +41 -0
  78. package/src/PostgresBackendDriver.ts +63 -79
  79. package/src/PostgresBootstrapper.ts +7 -8
  80. package/src/auth/ensure-tables.ts +158 -86
  81. package/src/auth/services.ts +109 -50
  82. package/src/cli.ts +259 -16
  83. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  84. package/src/connection.ts +70 -48
  85. package/src/data-transformer.ts +155 -116
  86. package/src/databasePoolManager.ts +6 -5
  87. package/src/history/HistoryService.ts +3 -12
  88. package/src/interfaces.ts +3 -3
  89. package/src/schema/auth-schema.ts +26 -3
  90. package/src/schema/doctor-cli.ts +47 -0
  91. package/src/schema/doctor.ts +595 -0
  92. package/src/schema/generate-drizzle-schema-logic.ts +204 -57
  93. package/src/schema/generate-drizzle-schema.ts +6 -6
  94. package/src/schema/test-schema.ts +11 -0
  95. package/src/services/BranchService.ts +5 -5
  96. package/src/services/EntityFetchService.ts +317 -188
  97. package/src/services/EntityPersistService.ts +15 -17
  98. package/src/services/RelationService.ts +299 -37
  99. package/src/services/entity-helpers.ts +39 -13
  100. package/src/services/entityService.ts +11 -9
  101. package/src/services/realtimeService.ts +58 -29
  102. package/src/utils/drizzle-conditions.ts +25 -24
  103. package/src/websocket.ts +52 -21
  104. package/test/auth-services.test.ts +131 -39
  105. package/test/batch-many-to-many-regression.test.ts +573 -0
  106. package/test/branchService.test.ts +22 -12
  107. package/test/data-transformer-hardening.test.ts +417 -0
  108. package/test/data-transformer.test.ts +175 -0
  109. package/test/doctor.test.ts +182 -0
  110. package/test/entityService.errors.test.ts +31 -16
  111. package/test/entityService.relations.test.ts +155 -59
  112. package/test/entityService.subcollection-search.test.ts +107 -57
  113. package/test/entityService.test.ts +105 -47
  114. package/test/generate-drizzle-schema.test.ts +262 -69
  115. package/test/historyService.test.ts +31 -16
  116. package/test/n-plus-one-regression.test.ts +314 -0
  117. package/test/postgresDataDriver.test.ts +260 -168
  118. package/test/realtimeService.test.ts +70 -39
  119. package/test/relation-pipeline-gaps.test.ts +637 -0
  120. package/test/relations.test.ts +492 -39
  121. package/test-drizzle-bug.ts +18 -0
  122. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  123. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  124. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  125. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  126. package/test-drizzle-out/meta/_journal.json +20 -0
  127. package/test-drizzle-prompt.sh +2 -0
  128. package/test-policy-prompt.sh +3 -0
  129. package/test-programmatic.ts +30 -0
  130. package/test-programmatic2.ts +59 -0
  131. package/test-schema-no-policies.ts +12 -0
  132. package/test_drizzle_mock.js +2 -2
  133. package/test_find_changed.mjs +3 -1
  134. package/test_hash.js +14 -0
  135. package/tsconfig.json +1 -1
  136. package/vite.config.ts +5 -5
@@ -1,23 +1,32 @@
1
- import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty } from "@rebasepro/types";
1
+ import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/entity-helpers";
3
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations } from "@rebasepro/common";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
4
4
  import { toSnakeCase } from "@rebasepro/utils";
5
-
5
+ import { createHash } from "crypto";
6
6
  // --- Helper Functions ---
7
7
 
8
- const getPrimaryKeyProp = (collection: EntityCollection): { name: string, type: "string" | "number" } => {
8
+ const getPrimaryKeyProp = (collection: EntityCollection): { name: string, type: "string" | "number", isUuid: boolean } => {
9
9
  if (collection.properties) {
10
10
  const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
11
11
  if (idPropEntry) {
12
- return { name: idPropEntry[0], type: (idPropEntry[1] as Property).type === "number" ? "number" : "string" };
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 };
13
17
  }
14
18
  }
15
19
  // Fallback
16
20
  const idProp = collection.properties?.["id"] as Property | undefined;
17
21
  if (idProp?.type === "number") {
18
- return { name: "id", type: "number" };
22
+ return { name: "id",
23
+ type: "number",
24
+ isUuid: false };
19
25
  }
20
- return { name: "id", type: "string" };
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 };
21
30
  };
22
31
 
23
32
  const isNumericId = (collection: EntityCollection): boolean => {
@@ -36,7 +45,7 @@ const isIdProperty = (propName: string, prop: Property, collection: EntityCollec
36
45
  return !hasExplicitId && propName === "id";
37
46
  };
38
47
 
39
- const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCollection): string | null => {
48
+ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCollection, collections: EntityCollection[]): string | null => {
40
49
  const colName = toSnakeCase(propName);
41
50
  let columnDefinition: string;
42
51
 
@@ -56,13 +65,13 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
56
65
  columnDefinition = `varchar("${colName}")`;
57
66
  }
58
67
  if (isIdProperty(propName, prop, collection)) {
59
- columnDefinition += `.primaryKey()`;
68
+ columnDefinition += ".primaryKey()";
60
69
  }
61
70
  if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
62
71
  if (stringProp.isId === "uuid") {
63
- columnDefinition += `.defaultRandom()`;
72
+ columnDefinition += ".defaultRandom()";
64
73
  } else if (stringProp.isId === "cuid") {
65
- columnDefinition += `.default(sql\`cuid()\`)`;
74
+ columnDefinition += ".default(sql`cuid()`)";
66
75
  } else if (typeof stringProp.isId === "string") {
67
76
  const sqlContent = stringProp.isId.startsWith("sql`") && stringProp.isId.endsWith("`")
68
77
  ? stringProp.isId.substring(4, stringProp.isId.length - 1)
@@ -71,7 +80,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
71
80
  }
72
81
  }
73
82
  if (stringProp.validation?.unique) {
74
- columnDefinition += `.unique()`;
83
+ columnDefinition += ".unique()";
75
84
  }
76
85
  break;
77
86
  }
@@ -98,10 +107,10 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
98
107
  }
99
108
 
100
109
  if (isId) {
101
- columnDefinition += `.primaryKey()`;
110
+ columnDefinition += ".primaryKey()";
102
111
  }
103
112
  if (numProp.validation?.unique) {
104
- columnDefinition += `.unique()`;
113
+ columnDefinition += ".unique()";
105
114
  }
106
115
  break;
107
116
  }
@@ -109,7 +118,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
109
118
  columnDefinition = `boolean("${colName}")`;
110
119
  break;
111
120
  case "date": {
112
- const dateProp = prop as import("@rebasepro/types").DateProperty;
121
+ const dateProp = prop as DateProperty;
113
122
  if (dateProp.columnType === "date") {
114
123
  columnDefinition = `date("${colName}", { mode: 'string' })`;
115
124
  } else if (dateProp.columnType === "time") {
@@ -121,7 +130,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
121
130
  }
122
131
  case "map":
123
132
  case "array": {
124
- const arrayOrMapProp = prop as import("@rebasepro/types").ArrayProperty | import("@rebasepro/types").MapProperty;
133
+ const arrayOrMapProp = prop as ArrayProperty | MapProperty;
125
134
  if (arrayOrMapProp.columnType === "json") {
126
135
  columnDefinition = `json("${colName}")`;
127
136
  } else {
@@ -131,8 +140,8 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
131
140
  }
132
141
  case "relation": {
133
142
  const refProp = prop as RelationProperty;
134
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
135
- const relation = resolvedRelations[refProp.relationName ?? propName];
143
+ const resolvedRelations = resolveCollectionRelations(collection);
144
+ const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
136
145
 
137
146
  // Only owning one-to-one/many-to-one relations create a column here.
138
147
  if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
@@ -160,10 +169,11 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
160
169
 
161
170
  const fkColumnName = toSnakeCase(relation.localKey);
162
171
  const targetTableVar = getTableVarName(getTableName(targetCollection));
163
- const targetIdField = getPrimaryKeyName(targetCollection);
164
- const baseColumn = isNumericId(targetCollection) ? `integer(\"${fkColumnName}\")` : `varchar(\"${fkColumnName}\")`;
172
+ const pkProp = getPrimaryKeyProp(targetCollection);
173
+ const targetIdField = pkProp.name;
174
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`);
165
175
 
166
- const onUpdate = relation.onUpdate ? `onUpdate: \"${relation.onUpdate}\"` : "";
176
+ const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
167
177
  const required = prop.validation?.required;
168
178
  const onDeleteVal = relation.onDelete ?? (required ? "cascade" : "set null");
169
179
  const onDelete = `onDelete: \"${onDeleteVal}\"`;
@@ -179,6 +189,30 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
179
189
 
180
190
  return ` ${relation.localKey}: ${columnDef}`;
181
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
+ }
182
216
  default:
183
217
  return null;
184
218
  }
@@ -205,7 +239,7 @@ const resolveRawSql = (expression: string): string => {
205
239
  * Generates: `(<clause>) AND (string_to_array(auth.roles(), ',') && ARRAY['<role1>','<role2>'])`
206
240
  */
207
241
  const wrapWithRoleCheck = (clause: string, roles: string[]): string => {
208
- const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(',')}]`;
242
+ const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
209
243
  const roleCondition = `string_to_array(auth.roles(), ',') @> ${rolesArrayString}`;
210
244
  return `sql\`(${unwrapSql(clause)}) AND (${roleCondition})\``;
211
245
  };
@@ -226,7 +260,7 @@ const buildUsingClause = (rule: SecurityRule): string | null => {
226
260
  return resolveRawSql(rule.using);
227
261
  }
228
262
  if (rule.access === "public") {
229
- return `sql\`true\``;
263
+ return "sql`true`";
230
264
  }
231
265
  if (rule.ownerField) {
232
266
  return `sql\`\${table.${rule.ownerField}} = auth.uid()\``;
@@ -246,6 +280,24 @@ const buildWithCheckClause = (rule: SecurityRule): string | null => {
246
280
  return buildUsingClause(rule);
247
281
  };
248
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
+
249
301
  /**
250
302
  * Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
251
303
  *
@@ -262,11 +314,13 @@ const generatePolicyCode = (tableName: string, rule: SecurityRule, index: number
262
314
  ? rule.operations
263
315
  : [rule.operation ?? "all"];
264
316
 
317
+ const ruleHash = getPolicyNameHash(rule);
318
+
265
319
  // Generate one pgPolicy per operation
266
320
  return ops.map((op, opIdx) => {
267
321
  const policyName = rule.name
268
322
  ? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
269
- : `${tableName}_${op}_policy_${index}${ops.length > 1 ? `_${opIdx}` : ""}`;
323
+ : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
270
324
 
271
325
  return generateSinglePolicyCode(tableName, rule, op, policyName);
272
326
  }).join("");
@@ -277,7 +331,7 @@ const generatePolicyCode = (tableName: string, rule: SecurityRule, index: number
277
331
  */
278
332
  const generateSinglePolicyCode = (tableName: string, rule: SecurityRule, operation: SecurityOperation, policyName: string): string => {
279
333
  const mode = rule.mode ?? "permissive";
280
- const roles = rule.roles;
334
+ const roles = rule.roles ? [...rule.roles].sort() : undefined;
281
335
 
282
336
  // Determine which clauses this operation needs:
283
337
  // SELECT, DELETE → USING only
@@ -296,30 +350,30 @@ const generateSinglePolicyCode = (tableName: string, rule: SecurityRule, operati
296
350
  usingClause = wrapWithRoleCheck(usingClause, roles);
297
351
  } else if (needsUsing) {
298
352
  // Roles-only rule (e.g. { operation: "select", roles: ["admin"] })
299
- const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(',')}]`;
353
+ const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
300
354
  usingClause = `sql\`string_to_array(auth.roles(), ',') @> ${rolesArrayString}\``;
301
355
  }
302
356
  if (withCheckClause) {
303
357
  withCheckClause = wrapWithRoleCheck(withCheckClause, roles);
304
358
  } else if (needsWithCheck) {
305
- const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(',')}]`;
359
+ const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
306
360
  withCheckClause = `sql\`string_to_array(auth.roles(), ',') @> ${rolesArrayString}\``;
307
361
  }
308
362
  }
309
363
 
310
364
  // Fallback: if we still have no clauses, deny all (safety net)
311
365
  if (!usingClause && needsUsing) {
312
- usingClause = `sql\`false\``;
366
+ usingClause = "sql`false`";
313
367
  }
314
368
  if (!withCheckClause && needsWithCheck) {
315
- withCheckClause = `sql\`false\``;
369
+ withCheckClause = "sql`false`";
316
370
  }
317
371
 
318
372
  // Build the policy options object
319
373
  const parts: string[] = [];
320
374
  parts.push(`as: "${mode}"`);
321
375
  parts.push(`for: "${operation}"`);
322
- const toRoles = rule.pgRoles ?? ["public"];
376
+ const toRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
323
377
  parts.push(`to: [${toRoles.map(r => `"${r}"`).join(", ")}]`);
324
378
  if (usingClause) parts.push(`using: ${usingClause}`);
325
379
  if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
@@ -327,8 +381,84 @@ const generateSinglePolicyCode = (tableName: string, rule: SecurityRule, operati
327
381
  return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
328
382
  };
329
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
+
330
460
  // --- Main Schema Generation Logic ---
331
- export const generateSchema = async (collections: EntityCollection[]): Promise<string> => {
461
+ export const generateSchema = async (collections: EntityCollection[], stripPolicies = false): Promise<string> => {
332
462
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
333
463
 
334
464
  const hasUuid = collections.some(c =>
@@ -347,7 +477,7 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
347
477
  const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
348
478
  if (hasUuid) pgCoreImports.push("uuid");
349
479
  schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
350
- schemaContent += `import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n`;
480
+ schemaContent += "import { relations as drizzleRelations, sql } from 'drizzle-orm';\n\n";
351
481
 
352
482
  const exportedTableVars: string[] = [];
353
483
  const exportedEnumVars: string[] = [];
@@ -386,7 +516,7 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
386
516
  allTablesToGenerate.set(tableName, { collection });
387
517
  }
388
518
 
389
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
519
+ const resolvedRelations = resolveCollectionRelations(collection);
390
520
  for (const relation of Object.values(resolvedRelations)) {
391
521
  if (relation.through) { // Standard M2M junction table
392
522
  const junctionTableName = relation.through.table;
@@ -424,22 +554,22 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
424
554
  const onDelete = relation.onDelete ?? "cascade";
425
555
  const refOptions = `{ onDelete: \"${onDelete}\" }`;
426
556
 
427
- const sourceColType = isNumericId(sourceCollection) ? "integer" : "varchar";
428
- const targetColType = isNumericId(targetCollection) ? "integer" : "varchar";
557
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar");
558
+ const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar");
429
559
  const sourceId = getPrimaryKeyName(sourceCollection);
430
560
  const targetId = getPrimaryKeyName(targetCollection);
431
561
 
432
562
  schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
433
563
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${toSnakeCase(sourceColumn)}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
434
564
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${toSnakeCase(targetColumn)}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
435
- schemaContent += `}, (table) => ({\n`;
565
+ schemaContent += "}, (table) => ({\n";
436
566
  schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
437
- schemaContent += `}));\n\n`;
567
+ schemaContent += "}));\n\n";
438
568
  } else if (!isJunction) {
439
569
  schemaContent += `export const ${tableVarName} = pgTable(\"${tableName}\", {\n`;
440
570
  const columns = new Set<string>();
441
571
  Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
442
- const columnString = getDrizzleColumn(propName, prop as Property, collection);
572
+ const columnString = getDrizzleColumn(propName, prop as Property, collection, collections);
443
573
  if (columnString) columns.add(columnString);
444
574
  });
445
575
 
@@ -447,13 +577,13 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
447
577
  // We should generate a basic id column if one was completely omitted.
448
578
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
449
579
  if (!hasIdColumn) {
450
- columns.add(` id: varchar(\"id\").primaryKey()`);
580
+ columns.add(" id: varchar(\"id\").primaryKey()");
451
581
  }
452
582
 
453
583
  schemaContent += `${Array.from(columns).join(",\n")}`;
454
584
 
455
- const securityRules = (collection as import("@rebasepro/types").PostgresCollection<Record<string, unknown>, import("@rebasepro/types").User>).securityRules;
456
- if (securityRules && securityRules.length > 0) {
585
+ const securityRules = isPostgresCollection(collection) ? collection.securityRules : undefined;
586
+ if (!stripPolicies && securityRules && securityRules.length > 0) {
457
587
  schemaContent += "\n}, (table) => ([\n";
458
588
  securityRules.forEach((rule: SecurityRule, idx: number) => {
459
589
  schemaContent += generatePolicyCode(tableName, rule, idx);
@@ -500,7 +630,7 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
500
630
  // inverse many() on the target table.
501
631
  let inverseRelationName: string | null = null;
502
632
  try {
503
- const targetRelations = resolveCollectionRelations(targetCollection as import("@rebasepro/types").PostgresCollection<any, any>);
633
+ const targetRelations = resolveCollectionRelations(targetCollection);
504
634
  for (const [, targetRel] of Object.entries(targetRelations)) {
505
635
  if (targetRel.direction === "inverse" &&
506
636
  targetRel.cardinality === "many" &&
@@ -514,37 +644,54 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
514
644
  }
515
645
 
516
646
  // Source side one(): pairs with owning table's many(junctionTable, { relationName })
517
- tableRelations.push(` ${relation.through.sourceColumn}: one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
647
+ tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
518
648
 
519
649
  // Target side one(): pairs with inverse table's many(junctionTable, { relationName })
520
650
  const targetRelName = inverseRelationName ?? owningRelationName;
521
- tableRelations.push(` ${relation.through.targetColumn}: one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: \"${targetRelName}\"\n })`);
651
+ tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: \"${targetRelName}\"\n })`);
522
652
  }
523
653
  } else {
524
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
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>();
525
660
  for (const [relationKey, rel] of Object.entries(resolvedRelations)) {
526
661
  try {
527
662
  const target = rel.target();
528
663
  const targetTableVar = getTableVarName(getTableName(target));
529
- const relationName = rel.relationName ?? relationKey;
530
664
 
531
- // Determine the correct relation name for Drizzle
532
- // For inverse relations, we should use the current relation's name, not the inverse name
533
- const drizzleRelationName = relationName;
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);
534
681
 
535
682
  if (rel.cardinality === "one") {
536
683
  if (rel.direction === "owning" && rel.localKey) {
537
- tableRelations.push(` ${relationKey}: one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
684
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
538
685
  } else if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
539
686
  const sourceIdField = getPrimaryKeyName(collection);
540
- tableRelations.push(` ${relationKey}: one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${rel.foreignKeyOnTarget}],\n relationName: \"${drizzleRelationName}\"\n })`);
687
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${rel.foreignKeyOnTarget}],\n relationName: \"${drizzleRelationName}\"\n })`);
541
688
  } else if (rel.direction === "inverse" && !rel.foreignKeyOnTarget) {
542
689
  // Handle inverse one-to-one relations where the FK is on the target table
543
690
  // but foreignKeyOnTarget is not explicitly specified
544
691
  // In this case, we need to find the corresponding owning relation on the target
545
692
  try {
546
693
  const targetCollection = rel.target();
547
- const targetResolvedRelations = resolveCollectionRelations(targetCollection as import("@rebasepro/types").PostgresCollection<any, any>);
694
+ const targetResolvedRelations = resolveCollectionRelations(targetCollection);
548
695
 
549
696
  // Find the owning relation on the target that points back to this collection
550
697
  const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
@@ -555,7 +702,7 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
555
702
 
556
703
  if (correspondingRelation && correspondingRelation.localKey) {
557
704
  const sourceIdField = getPrimaryKeyName(collection);
558
- tableRelations.push(` ${relationKey}: one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${correspondingRelation.localKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
705
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${sourceIdField}],\n references: [${targetTableVar}.${correspondingRelation.localKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
559
706
  }
560
707
  } catch (e) {
561
708
  console.warn(`Could not resolve inverse one-to-one relation '${relationKey}':`, e);
@@ -564,16 +711,16 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
564
711
  } else if (rel.cardinality === "many") {
565
712
  if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
566
713
  // One-to-many inverse relation
567
- tableRelations.push(` ${relationKey}: many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
714
+ tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
568
715
  } else if (rel.through) {
569
716
  // Many-to-many owning relation with explicit junction table
570
717
  const junctionTableVar = getTableVarName(rel.through.table);
571
- tableRelations.push(` ${relationKey}: many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
718
+ tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
572
719
  } else if (rel.direction === "inverse" && rel.inverseRelationName) {
573
720
  // Many-to-many inverse relation - find the corresponding owning relation's junction table
574
721
  try {
575
722
  const targetCollection = rel.target();
576
- const targetResolvedRelations = resolveCollectionRelations(targetCollection as import("@rebasepro/types").PostgresCollection<any, any>);
723
+ const targetResolvedRelations = resolveCollectionRelations(targetCollection);
577
724
 
578
725
  // Find the corresponding owning many-to-many relation on the target
579
726
  const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
@@ -585,7 +732,7 @@ export const generateSchema = async (collections: EntityCollection[]): Promise<s
585
732
 
586
733
  if (correspondingRelation && correspondingRelation.through) {
587
734
  const junctionTableVar = getTableVarName(correspondingRelation.through.table);
588
- tableRelations.push(` ${relationKey}: many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
735
+ tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
589
736
  } else {
590
737
  console.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
591
738
  }
@@ -58,15 +58,15 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
58
58
  if (stats.isDirectory()) {
59
59
  const files = fs.readdirSync(resolvedPath);
60
60
  for (const file of files) {
61
- if ((file.endsWith('.ts') || file.endsWith('.js')) &&
62
- !file.includes('.test.') &&
63
- !file.endsWith('.d.ts') &&
64
- file !== 'index.ts' && file !== 'index.js') {
61
+ if ((file.endsWith(".ts") || file.endsWith(".js")) &&
62
+ !file.includes(".test.") &&
63
+ !file.endsWith(".d.ts") &&
64
+ file !== "index.ts" && file !== "index.js") {
65
65
 
66
66
  const filePath = path.join(resolvedPath, file);
67
67
  try {
68
68
  const fileUrl = pathToFileURL(filePath).href;
69
- const dynamicImport = new Function('url', 'return import(url)');
69
+ const dynamicImport = new Function("url", "return import(url)");
70
70
  const module = await dynamicImport(fileUrl);
71
71
  if (module && module.default) {
72
72
  collections.push(module.default);
@@ -79,7 +79,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
79
79
  }
80
80
  } else {
81
81
  const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
82
- const dynamicImport = new Function('url', 'return import(url)');
82
+ const dynamicImport = new Function("url", "return import(url)");
83
83
  const imported = await dynamicImport(fileUrl);
84
84
  collections = imported.backendCollections || imported.collections;
85
85
  }
@@ -0,0 +1,11 @@
1
+ import { pgTable, text, pgPolicy } from "drizzle-orm/pg-core";
2
+ import { sql } from "drizzle-orm";
3
+
4
+ export const testTable = pgTable("test", {
5
+ id: text("id").primaryKey()
6
+ }, (t) => [
7
+ pgPolicy("renamed_policy", { as: "permissive",
8
+ to: "public",
9
+ for: "select",
10
+ using: sql`true` })
11
+ ]);
@@ -103,7 +103,7 @@ export class BranchService {
103
103
  if (msg.includes("being accessed by other users")) {
104
104
  throw new Error(
105
105
  `Cannot create branch: the source database "${sourceDb}" has active connections. ` +
106
- `Close other clients or connections and try again.`
106
+ "Close other clients or connections and try again."
107
107
  );
108
108
  }
109
109
  throw err;
@@ -119,7 +119,7 @@ export class BranchService {
119
119
  return {
120
120
  name: sanitizedName,
121
121
  parentDatabase: sourceDb,
122
- createdAt: now,
122
+ createdAt: now
123
123
  };
124
124
  }
125
125
 
@@ -156,7 +156,7 @@ export class BranchService {
156
156
  if (msg.includes("being accessed by other users")) {
157
157
  throw new Error(
158
158
  `Cannot delete branch "${sanitizedName}": the database has active connections. ` +
159
- `Close other clients and try again.`
159
+ "Close other clients and try again."
160
160
  );
161
161
  }
162
162
  throw err;
@@ -188,7 +188,7 @@ export class BranchService {
188
188
  name: row.name as string,
189
189
  parentDatabase: row.parent_db as string,
190
190
  createdAt: new Date(row.created_at as string),
191
- sizeBytes: row.size_bytes != null ? Number(row.size_bytes) : undefined,
191
+ sizeBytes: row.size_bytes != null ? Number(row.size_bytes) : undefined
192
192
  }));
193
193
  }
194
194
 
@@ -231,7 +231,7 @@ export class BranchService {
231
231
  name: row.name as string,
232
232
  parentDatabase: row.parent_db as string,
233
233
  createdAt: new Date(row.created_at as string),
234
- sizeBytes,
234
+ sizeBytes
235
235
  };
236
236
  }
237
237
  }