@rebasepro/server-postgres 0.9.1-canary.1d2d8b5 → 0.9.1-canary.5809e39

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 (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +25 -2
  2. package/dist/PostgresBootstrapper.d.ts +10 -0
  3. package/dist/auth/services.d.ts +16 -0
  4. package/dist/collections/buildRegistry.d.ts +27 -0
  5. package/dist/connection.d.ts +21 -0
  6. package/dist/data-transformer.d.ts +9 -2
  7. package/dist/index.es.js +1329 -548
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/doctor.d.ts +1 -1
  10. package/dist/services/FetchService.d.ts +4 -24
  11. package/dist/services/PersistService.d.ts +9 -1
  12. package/dist/services/RelationService.d.ts +34 -1
  13. package/dist/services/collection-helpers.d.ts +79 -14
  14. package/dist/services/dataService.d.ts +3 -1
  15. package/dist/services/index.d.ts +1 -1
  16. package/dist/services/realtimeService.d.ts +7 -0
  17. package/dist/services/row-pipeline.d.ts +63 -0
  18. package/package.json +10 -8
  19. package/src/PostgresBackendDriver.ts +127 -13
  20. package/src/PostgresBootstrapper.ts +62 -25
  21. package/src/auth/ensure-tables.ts +73 -11
  22. package/src/auth/services.ts +49 -19
  23. package/src/collections/buildRegistry.ts +59 -0
  24. package/src/connection.ts +61 -1
  25. package/src/data-transformer.ts +11 -9
  26. package/src/databasePoolManager.ts +2 -0
  27. package/src/schema/doctor.ts +45 -20
  28. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  29. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  30. package/src/schema/introspect-db.ts +19 -2
  31. package/src/services/BranchService.ts +42 -10
  32. package/src/services/FetchService.ts +65 -270
  33. package/src/services/PersistService.ts +62 -9
  34. package/src/services/RelationService.ts +153 -94
  35. package/src/services/collection-helpers.ts +164 -47
  36. package/src/services/dataService.ts +3 -2
  37. package/src/services/index.ts +1 -0
  38. package/src/services/realtimeService.ts +40 -19
  39. package/src/services/row-pipeline.ts +239 -0
  40. package/src/utils/drizzle-conditions.ts +13 -0
  41. package/src/websocket.ts +4 -1
  42. package/dist/schema/auth-default-policies.d.ts +0 -10
  43. package/src/schema/auth-default-policies.ts +0 -132
@@ -37,7 +37,7 @@ export type IssueSeverity = "error" | "warning" | "info";
37
37
 
38
38
  export interface DoctorIssue {
39
39
  severity: IssueSeverity;
40
- category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale";
40
+ category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated";
41
41
  table?: string;
42
42
  column?: string;
43
43
  expected?: string;
@@ -61,19 +61,29 @@ export function getExpectedColumnType(prop: Property): string | null {
61
61
  const sp = prop as StringProperty;
62
62
  if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
63
63
  if ("isId" in sp && sp.isId === "uuid") return "uuid";
64
- if (sp.columnType === "text") return "text";
64
+ if (sp.columnType === "uuid") return "uuid";
65
+ // A markdown/multiline string compiles to `text`, not varchar — the
66
+ // generator treats those UI hints as column-type signals, so the
67
+ // expectation here must too or every such column reads as drift.
68
+ if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
65
69
  if (sp.columnType === "char") return "character";
66
70
  return "character varying";
67
71
  }
68
72
  case "number": {
69
73
  const np = prop as NumberProperty;
70
- if (np.columnType === "double precision") return "double precision";
71
- if (np.columnType === "real") return "real";
72
- if (np.columnType === "bigint") return "bigint";
73
- if (np.columnType === "serial") return "integer"; // serial is integer under the hood
74
- if (np.columnType === "bigserial") return "bigint";
75
- if (np.columnType === "integer") return "integer";
76
- if (np.columnType === "numeric") return "numeric";
74
+ if (np.columnType) {
75
+ // The generator passes any columnType straight through to drizzle,
76
+ // so mirror that rather than enumerating a subset (which reported
77
+ // drift for anything unlisted, e.g. smallint). Serial types are
78
+ // integers with a sequence default; information_schema reports the
79
+ // underlying width.
80
+ const serialWidths: Record<string, string> = {
81
+ serial: "integer",
82
+ bigserial: "bigint",
83
+ smallserial: "smallint"
84
+ };
85
+ return serialWidths[np.columnType] ?? np.columnType;
86
+ }
77
87
  if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
78
88
  return "numeric";
79
89
  }
@@ -201,15 +211,18 @@ export async function checkCollectionsVsSdk(
201
211
  ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
202
212
  const issues: DoctorIssue[] = [];
203
213
 
204
- // Check if SDK file exists
214
+ // The typed SDK is opt-in — nothing in a scaffolded project imports it until
215
+ // you choose to. A project that never generated one isn't drifting, so report
216
+ // it as information rather than a warning; otherwise `doctor` can never come
217
+ // back clean on a fresh project and users learn to ignore its output.
205
218
  if (!fs.existsSync(sdkFilePath)) {
206
219
  issues.push({
207
- severity: "warning",
208
- category: "sdk_stale",
209
- message: `Generated SDK typedefs file does not exist at "${sdkFilePath}".`,
210
- fix: "Run `rebase generate-sdk`"
220
+ severity: "info",
221
+ category: "sdk_not_generated",
222
+ message: "Typed SDK not generated (optional).",
223
+ fix: "Run `rebase generate-sdk` if you want typed collection access"
211
224
  });
212
- return { passed: false,
225
+ return { passed: true,
213
226
  issues };
214
227
  }
215
228
 
@@ -631,11 +644,15 @@ export function renderReport(report: DoctorReport): void {
631
644
  }
632
645
 
633
646
  function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
634
- if (passed) {
647
+ const errorCount = issues.filter((i) => i.severity === "error").length;
648
+ const warnCount = issues.filter((i) => i.severity === "warning").length;
649
+ const infoIssues = issues.filter((i) => i.severity === "info");
650
+
651
+ // Informational notes don't make a phase unhealthy, so key the header off
652
+ // real problems rather than `passed` alone.
653
+ if (errorCount === 0 && warnCount === 0) {
635
654
  logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
636
655
  } else {
637
- const errorCount = issues.filter((i) => i.severity === "error").length;
638
- const warnCount = issues.filter((i) => i.severity === "warning").length;
639
656
  const parts: string[] = [];
640
657
  if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
641
658
  if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
@@ -643,7 +660,14 @@ function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): voi
643
660
  }
644
661
  logger.info("");
645
662
 
646
- for (const issue of issues) {
663
+ // Notes render as a quiet one-liner, not a full drift box.
664
+ for (const issue of infoIssues) {
665
+ const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
666
+ logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
667
+ logger.info("");
668
+ }
669
+
670
+ for (const issue of issues.filter((i) => i.severity !== "info")) {
647
671
  const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
648
672
  const categoryLabel = formatCategory(issue.category);
649
673
  logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
@@ -674,7 +698,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
674
698
  missing_enum: "Missing Enum",
675
699
  enum_value_mismatch: "Enum Value Mismatch",
676
700
  missing_foreign_key: "Missing Foreign Key",
677
- sdk_stale: "Stale SDK Types"
701
+ sdk_stale: "Stale SDK Types",
702
+ sdk_not_generated: "SDK Types Not Generated"
678
703
  };
679
704
  return labels[cat];
680
705
  }
@@ -1,10 +1,8 @@
1
1
  import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
2
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";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
4
+ import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
6
5
  import { logger } from "@rebasepro/server";
7
- import { getEffectiveSecurityRules } from "./auth-default-policies";
8
6
  // --- Helper Functions ---
9
7
 
10
8
  /**
@@ -315,22 +313,6 @@ const wrapSql = (clause: string): string => `sql\`${clause}\``;
315
313
  /**
316
314
  * Generates a deterministic hash based on the rule configuration.
317
315
  */
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
316
 
335
317
  /**
336
318
  * Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
@@ -351,15 +333,11 @@ const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, in
351
333
  ? rule.operations
352
334
  : [rule.operation ?? "all"];
353
335
 
354
- const ruleHash = getPolicyNameHash(rule);
336
+ const policyNames = getPolicyNamesForRule(rule, tableName);
355
337
 
356
338
  // Generate one pgPolicy per operation
357
339
  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);
340
+ return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
363
341
  }).join("");
364
342
  };
365
343
 
@@ -567,6 +545,10 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
567
545
  });
568
546
  schemaContent += "\n";
569
547
 
548
+ // Junction policy derivation needs every declaring side of each junction,
549
+ // not just the first relation that reached it in the walk below.
550
+ const junctionSpecs = resolveJunctionSpecs(collections);
551
+
570
552
  // 2. Identify all tables (collections and junction tables only)
571
553
  for (const collection of collections) {
572
554
  const tableName = getTableName(collection);
@@ -623,9 +605,22 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
623
605
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
624
606
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
625
607
  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";
608
+ schemaContent += "}, (table) => ([\n";
609
+ schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
610
+
611
+ // Junctions are generated tables like any other: locked by default,
612
+ // with derived policies (reads follow the endpoints, writes follow
613
+ // the declaring side's update rules). RLS is enabled regardless of
614
+ // policy stripping — a bare junction must default-deny, not fail open.
615
+ const junctionSpec = junctionSpecs.get(baseTableName);
616
+ if (!stripPolicies && junctionSpec) {
617
+ const junctionCollection = getJunctionCollectionConfig(junctionSpec);
618
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
619
+ getJunctionSecurityRules(junctionSpec).forEach((rule: SecurityRule, idx: number) => {
620
+ schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
621
+ });
622
+ }
623
+ schemaContent += "])).enableRLS();\n\n";
629
624
  } else if (!isJunction) {
630
625
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : undefined;
631
626
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -1,8 +1,6 @@
1
1
  import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
2
- import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
3
- import { toSnakeCase } from "@rebasepro/utils";
4
- import { createHash } from "crypto";
5
- import { getEffectiveSecurityRules } from "./auth-default-policies";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
+ import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
6
4
 
7
5
  // --- Helper Functions ---
8
6
 
@@ -44,22 +42,6 @@ const isIdProperty = (propName: string, prop: Property, collection: CollectionCo
44
42
  return !hasExplicitId && propName === "id";
45
43
  };
46
44
 
47
- const getPolicyNameHash = (rule: SecurityRule): string => {
48
- const data = JSON.stringify({
49
- a: rule.access,
50
- m: rule.mode,
51
- op: rule.operation,
52
- ops: rule.operations?.slice().sort(),
53
- own: rule.ownerField,
54
- rol: rule.roles?.slice().sort(),
55
- pg: rule.pgRoles?.slice().sort(),
56
- u: rule.using,
57
- w: rule.withCheck,
58
- c: rule.condition,
59
- ch: rule.check
60
- });
61
- return createHash("sha1").update(data).digest("hex").substring(0, 7);
62
- };
63
45
 
64
46
  type ResolveCollection = (slug: string) => CollectionConfig | undefined;
65
47
 
@@ -69,14 +51,10 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
69
51
  ? rule.operations
70
52
  : [rule.operation ?? "all"];
71
53
 
72
- const ruleHash = getPolicyNameHash(rule);
54
+ const policyNames = getPolicyNamesForRule(rule, tableName);
73
55
 
74
56
  return ops.map((op, opIdx) => {
75
- const policyName = rule.name
76
- ? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
77
- : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
78
-
79
- return generateSinglePolicyDdl(collection, rule, op, policyName, resolveCollection);
57
+ return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
80
58
  }).join("");
81
59
  };
82
60
 
@@ -248,6 +226,10 @@ export const generatePostgresDdl = async (
248
226
  });
249
227
  if (ddl.endsWith(";\n")) ddl += "\n";
250
228
 
229
+ // Junction policy derivation needs every declaring side of each junction,
230
+ // not just the first relation that reached it in the walk below.
231
+ const junctionSpecs = resolveJunctionSpecs(collections);
232
+
251
233
  const allTablesToGenerate = new Map<string, {
252
234
  collection: CollectionConfig,
253
235
  isJunction?: boolean,
@@ -283,6 +265,11 @@ export const generatePostgresDdl = async (
283
265
 
284
266
  // 3. Generate tables
285
267
  const fkStatements: string[] = [];
268
+ // Policies are emitted after every CREATE TABLE, like the FK constraints:
269
+ // a policy may reference other tables (a junction's derived policies always
270
+ // reference both endpoints; `policy.existsIn` references a join table), and
271
+ // CREATE POLICY validates those relations at creation time.
272
+ const policyStatements: string[] = [];
286
273
  for (const [tableName, {
287
274
  collection,
288
275
  isJunction,
@@ -315,6 +302,25 @@ export const generatePostgresDdl = async (
315
302
 
316
303
  fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
317
304
  fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
305
+
306
+ if (options.includePolicies) {
307
+ // Junction tables are generated tables like any other: locked by
308
+ // default, with derived policies — reads follow the endpoints'
309
+ // visibility, writes follow the declaring side's update rules.
310
+ // Without this they were the one kind of generated table with no
311
+ // RLS at all, readable and writable by every signed-in user.
312
+ ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
313
+ ddl += `\n`;
314
+
315
+ const spec = junctionSpecs.get(baseTableName);
316
+ if (spec) {
317
+ const junctionCollection = getJunctionCollectionConfig(spec);
318
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
319
+ getJunctionSecurityRules(spec).forEach((rule: SecurityRule) => {
320
+ policyStatements.push(generatePolicyDdl(junctionCollection, rule, resolveCollection));
321
+ });
322
+ }
323
+ }
318
324
  } else if (!isJunction) {
319
325
  ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
320
326
  const columns: string[] = [];
@@ -436,9 +442,8 @@ export const generatePostgresDdl = async (
436
442
  if (securityRules.length > 0) {
437
443
  const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
438
444
  securityRules.forEach((rule: SecurityRule) => {
439
- ddl += generatePolicyDdl(collection, rule, resolveCollection);
445
+ policyStatements.push(generatePolicyDdl(collection, rule, resolveCollection));
440
446
  });
441
- ddl += "\n";
442
447
  }
443
448
  }
444
449
  }
@@ -449,6 +454,12 @@ export const generatePostgresDdl = async (
449
454
  ddl += fkStatements.join("\n") + "\n\n";
450
455
  }
451
456
 
457
+ if (policyStatements.length > 0) {
458
+ ddl += "-- Row Level Security Policies\n";
459
+ ddl += policyStatements.join("");
460
+ ddl += "\n";
461
+ }
462
+
452
463
  return ddl;
453
464
  };
454
465
 
@@ -478,13 +489,50 @@ export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): st
478
489
  const securityRules = getEffectiveSecurityRules(collection);
479
490
  if (securityRules.length > 0) {
480
491
  const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
492
+ const injectedNames = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
493
+
481
494
  securityRules.forEach((rule: SecurityRule) => {
495
+ // Say which policies the author did not write. They are permissive,
496
+ // so they OR with the declared rules and widen the final ACL beyond
497
+ // what `securityRules` reads like — and re-appear after any manual
498
+ // DROP, because a push asserts the declared state.
499
+ if (rule.name && injectedNames.has(rule.name)) {
500
+ ddl += `-- Injected by Rebase (not from this collection's securityRules).\n`;
501
+ ddl += `-- Set \`disableDefaultPolicies: true\` on "${collection.slug}" to drop these and own its RLS outright.\n`;
502
+ }
482
503
  ddl += generatePolicyDdl(collection, rule, resolveCollection);
483
504
  });
484
505
  ddl += "\n";
485
506
  }
486
507
  }
487
508
 
509
+ // Junction tables are generated from `through` relations, not declared as
510
+ // collections, so the walk above never sees them. They get the same
511
+ // treatment as any generated table: locked by default, with derived
512
+ // policies — reads follow the endpoints, writes follow the declaring
513
+ // side's update rules.
514
+ const junctionSpecs = resolveJunctionSpecs(collections);
515
+ for (const spec of junctionSpecs.values()) {
516
+ ddl += `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;\n`;
517
+ ddl += `\n`;
518
+
519
+ const junctionRules = getJunctionSecurityRules(spec);
520
+ if (junctionRules.length === 0) continue;
521
+
522
+ const junctionCollection = getJunctionCollectionConfig(spec);
523
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
524
+ const declaringSlugs = spec.declaringSides.map(s => s.collection.slug).join('", "');
525
+
526
+ ddl += `-- Derived by Rebase for the junction "${spec.table}" (no collection declares it).\n`;
527
+ ddl += `-- Reads require both endpoint rows to be visible; writes follow the update\n`;
528
+ ddl += `-- rules of "${declaringSlugs}". Set \`disableDefaultPolicies: true\` on the\n`;
529
+ ddl += `-- declaring collection(s) to drop these and police the junction yourself.\n`;
530
+ junctionRules.forEach((rule: SecurityRule) => {
531
+ ddl += generatePolicyDdl(junctionCollection, rule, resolveCollection);
532
+ });
533
+ ddl += "\n";
534
+ }
535
+
488
536
  return ddl;
489
537
  };
490
538
 
@@ -30,6 +30,7 @@ async function main() {
30
30
  "--force": Boolean,
31
31
  "--schema": String,
32
32
  "--data-inference": Boolean,
33
+ "--no-data-inference": Boolean,
33
34
  "-o": "--output",
34
35
  "-c": "--collections",
35
36
  "-f": "--force"
@@ -166,8 +167,14 @@ async function main() {
166
167
  logger.info(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
167
168
 
168
169
  let runDataInference = false;
169
- if (args["--data-inference"] !== undefined) {
170
+ if (args["--no-data-inference"]) {
171
+ runDataInference = false;
172
+ } else if (args["--data-inference"] !== undefined) {
170
173
  runDataInference = args["--data-inference"];
174
+ } else if (!process.stdin.isTTY) {
175
+ // No terminal to answer the question below (scaffolding scripts, CI,
176
+ // `rebase init --introspect`) — asking would hang forever.
177
+ logger.info(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
171
178
  } else {
172
179
  const rl = readline.createInterface({
173
180
  input: process.stdin,
@@ -236,7 +243,17 @@ async function main() {
236
243
  const merged = mergeIndexContent(existing, generatedFiles);
237
244
  fs.writeFileSync(indexPath, merged, "utf-8");
238
245
  } else {
239
- const indexContent = generateIndexContent(generatedFiles);
246
+ // --force replaces collections derived from the database, but the
247
+ // directory can also hold hand-written ones with no table in the
248
+ // introspected schema (the auth users collection lives in
249
+ // "rebase"). The backend discovers the whole directory, so an
250
+ // index listing only introspected tables would silently drop them
251
+ // from the admin UI while the API still served them.
252
+ const siblings = fs.readdirSync(outDir)
253
+ .filter(f => f.endsWith(".ts") && f !== "index.ts")
254
+ .map(f => f.replace(/\.ts$/, ""));
255
+ const allFiles = [...new Set([...generatedFiles, ...siblings])];
256
+ const indexContent = generateIndexContent(allFiles);
240
257
  fs.writeFileSync(indexPath, indexContent, "utf-8");
241
258
  }
242
259
  logger.info(chalk.green(` ✓ ${indexPath}`));
@@ -11,10 +11,45 @@ import { sql } from "drizzle-orm";
11
11
  import { BranchInfo } from "@rebasepro/types";
12
12
  import { DrizzleClient } from "../interfaces";
13
13
  import { DatabasePoolManager } from "../databasePoolManager";
14
+ import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
14
15
 
15
16
  /** Internal prefix applied to branch database names to avoid collisions. */
16
17
  const BRANCH_DB_PREFIX = "rb_";
17
18
 
19
+ /** `duplicate_database` — the target database name is already taken. */
20
+ const PG_DUPLICATE_DATABASE = "42P04";
21
+
22
+ /** `object_in_use` — the database still has connections attached. */
23
+ const PG_OBJECT_IN_USE = "55006";
24
+
25
+ /**
26
+ * Describe a failed branch DDL statement in terms a user can act on.
27
+ *
28
+ * Drizzle reports failures as `Failed query: <sql> params:` and hides the real
29
+ * PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
30
+ * the actual problem. Match on the PG error code instead — it survives wrapping
31
+ * and, unlike the message text, is not locale-dependent.
32
+ */
33
+ function describeBranchDdlError(err: unknown, fallbackContext: string): Error {
34
+ const pgError = extractPgError(err);
35
+
36
+ if (pgError?.code === PG_DUPLICATE_DATABASE) {
37
+ return new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
38
+ }
39
+ if (pgError?.code === PG_OBJECT_IN_USE) {
40
+ return new Error(
41
+ `Cannot complete the operation: the database "${fallbackContext}" has active connections. ` +
42
+ "Close other clients or connections and try again."
43
+ );
44
+ }
45
+
46
+ // Unknown failure: surface the real PG message rather than the Drizzle
47
+ // wrapper, which would otherwise show the raw SQL and no reason at all.
48
+ const detail = pgError?.message ?? extractCauseMessage(err);
49
+ if (detail) return new Error(detail);
50
+ return err instanceof Error ? err : new Error(String(err));
51
+ }
52
+
18
53
  /** Fully-qualified metadata table in the rebase schema. */
19
54
  const BRANCHES_TABLE = "rebase.branches";
20
55
 
@@ -109,18 +144,15 @@ export class BranchService {
109
144
  sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`)
110
145
  );
111
146
  } catch (err) {
112
- const msg = err instanceof Error ? err.message : String(err);
113
- if (msg.includes("already exists")) {
114
- throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
115
- }
116
- // If template fails due to active connections, provide a helpful error
117
- if (msg.includes("being accessed by other users")) {
147
+ const pgError = extractPgError(err);
148
+ if (pgError?.code === PG_OBJECT_IN_USE) {
149
+ // The template not the new database is the one still in use.
118
150
  throw new Error(
119
151
  `Cannot create branch: the source database "${sourceDb}" has active connections. ` +
120
152
  "Close other clients or connections and try again."
121
153
  );
122
154
  }
123
- throw err;
155
+ throw describeBranchDdlError(err, dbName);
124
156
  }
125
157
 
126
158
  // Record metadata in the default database
@@ -166,14 +198,14 @@ export class BranchService {
166
198
  try {
167
199
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
168
200
  } catch (err) {
169
- const msg = err instanceof Error ? err.message : String(err);
170
- if (msg.includes("being accessed by other users")) {
201
+ const pgError = extractPgError(err);
202
+ if (pgError?.code === PG_OBJECT_IN_USE) {
171
203
  throw new Error(
172
204
  `Cannot delete branch "${sanitizedName}": the database has active connections. ` +
173
205
  "Close other clients and try again."
174
206
  );
175
207
  }
176
- throw err;
208
+ throw describeBranchDdlError(err, dbName);
177
209
  }
178
210
 
179
211
  // Remove metadata