@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g35be8cb

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 (41) hide show
  1. package/dist/auth/services.d.ts +16 -0
  2. package/dist/backup-service-DH9kPg-E.js +8866 -0
  3. package/dist/backup-service-DH9kPg-E.js.map +1 -0
  4. package/dist/connection-B5Wndbr1.js +196 -0
  5. package/dist/connection-B5Wndbr1.js.map +1 -0
  6. package/dist/ensure-collection-policies-Vl3Cv1Q9.js +57 -0
  7. package/dist/ensure-collection-policies-Vl3Cv1Q9.js.map +1 -0
  8. package/dist/ensure-collection-tables-DsDsNl6o.js +590 -0
  9. package/dist/ensure-collection-tables-DsDsNl6o.js.map +1 -0
  10. package/dist/index.es.js +452 -9609
  11. package/dist/index.es.js.map +1 -1
  12. package/dist/schema/auth-schema.d.ts +83 -144
  13. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  14. package/dist/schema/ensure-collection-tables.d.ts +24 -2
  15. package/dist/schema/generate-postgres-ddl-logic.d.ts +116 -1
  16. package/dist/{src-BbFOPJ1S.js → src-DihrDFuP.js} +160 -150
  17. package/dist/src-DihrDFuP.js.map +1 -0
  18. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  19. package/dist/src-DoU9yPqq.js.map +1 -0
  20. package/dist/utils/pg-error-utils.d.ts +19 -0
  21. package/dist/websocket-BKcGvILX.js +528 -0
  22. package/dist/websocket-BKcGvILX.js.map +1 -0
  23. package/package.json +14 -14
  24. package/src/PostgresAdapter.ts +14 -0
  25. package/src/PostgresBootstrapper.ts +111 -13
  26. package/src/auth/ensure-tables.ts +164 -9
  27. package/src/auth/services.ts +21 -2
  28. package/src/schema/auth-schema.ts +30 -19
  29. package/src/schema/ensure-collection-policies.ts +105 -0
  30. package/src/schema/ensure-collection-tables.test.ts +105 -9
  31. package/src/schema/ensure-collection-tables.ts +142 -25
  32. package/src/schema/generate-drizzle-schema-logic.ts +7 -3
  33. package/src/schema/generate-postgres-ddl-logic.ts +335 -16
  34. package/src/schema/introspect-runtime.test.ts +56 -8
  35. package/src/schema/introspect-runtime.ts +31 -9
  36. package/src/utils/pg-error-utils.ts +46 -0
  37. package/dist/chunk-DSJWtz9O.js +0 -40
  38. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  39. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  40. package/dist/src-BbFOPJ1S.js.map +0 -1
  41. package/dist/src-Zqwaw3P5.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
4
4
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
5
5
  import { logger } from "@rebasepro/server";
6
6
  // --- Helper Functions ---
@@ -98,9 +98,13 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
98
98
  } else if (stringProp.columnType === "uuid") {
99
99
  columnDefinition = `uuid("${colName}")`;
100
100
  } else if (stringProp.columnType === "char") {
101
- columnDefinition = `char("${colName}")`;
101
+ columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
102
102
  } else if (stringProp.columnType === "varchar") {
103
- columnDefinition = `varchar("${colName}")`;
103
+ // The length is not optional decoration: `varchar("col")` with
104
+ // no length is an UNBOUNDED varchar in Postgres, which is what
105
+ // this emitted while the DDL generator emitted VARCHAR(255) for
106
+ // the very same property.
107
+ columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
104
108
  } else {
105
109
  // `text` is the default, and the only length-unbounded choice.
106
110
  // Ask for `varchar` explicitly if you want the length constraint.
@@ -1,5 +1,5 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo } from "@rebasepro/types";
2
- import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
3
3
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
4
4
 
5
5
  // --- Helper Functions ---
@@ -11,7 +11,7 @@ export const resolveColumnName = (propName: string, prop?: Property | null): str
11
11
  return toSnakeCase(propName);
12
12
  };
13
13
 
14
- const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
14
+ export const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
15
15
  if (collection.properties) {
16
16
  const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as unknown as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
17
17
  if (idPropEntry) {
@@ -28,14 +28,18 @@ const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type:
28
28
  return { name: "id", type: "string", isUuid: isUuid ?? false };
29
29
  };
30
30
 
31
- const isNumericId = (collection: CollectionConfig): boolean => {
31
+ export const isNumericId = (collection: CollectionConfig): boolean => {
32
32
  return getPrimaryKeyProp(collection).type === "number";
33
33
  };
34
34
 
35
- const getPrimaryKeyName = (collection: CollectionConfig): string => {
35
+ export const getPrimaryKeyName = (collection: CollectionConfig): string => {
36
36
  return getPrimaryKeyProp(collection).name;
37
37
  };
38
38
 
39
+ /** The column type a junction holds for one endpoint's primary key. */
40
+ const junctionKeyType = (collection: CollectionConfig): string =>
41
+ isNumericId(collection) ? "INTEGER" : (getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT");
42
+
39
43
  export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
40
44
  if ("isId" in prop && Boolean(prop.isId)) return true;
41
45
  const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
@@ -45,7 +49,28 @@ export const isIdProperty = (propName: string, prop: Property, collection: Colle
45
49
 
46
50
  type ResolveCollection = (slug: string) => CollectionConfig | undefined;
47
51
 
48
- const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string => {
52
+ /**
53
+ * Render statements produced by {@link generatePolicyStatements} back into the
54
+ * exact string the DDL/policies files have always carried: each statement on
55
+ * its own line, terminated by a newline. Keeping the string form derived from
56
+ * the statement array means the two can never drift — the boot-time applier and
57
+ * the generated `policies.sql` emit the same SQL, from the same source.
58
+ */
59
+ const statementsToDdl = (statements: string[]): string => statements.map(s => `${s}\n`).join("");
60
+
61
+ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string =>
62
+ statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
63
+
64
+ /**
65
+ * The individual SQL statements a single security rule compiles to: a
66
+ * `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
67
+ * statement (terminated by `;`, no trailing newline).
68
+ *
69
+ * This is the primitive the boot-time RLS applier runs one statement at a time
70
+ * (the runtime's DB handle speaks the extended query protocol, which forbids
71
+ * multiple commands in one execute), while `db push` writes the joined string.
72
+ */
73
+ export const generatePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string[] => {
49
74
  const tableName = getTableName(collection);
50
75
  const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
51
76
  ? rule.operations
@@ -53,12 +78,12 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
53
78
 
54
79
  const policyNames = getPolicyNamesForRule(rule, tableName);
55
80
 
56
- return ops.map((op, opIdx) => {
57
- return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
58
- }).join("");
81
+ return ops.flatMap((op, opIdx) => {
82
+ return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
83
+ });
59
84
  };
60
85
 
61
- const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string => {
86
+ const generateSinglePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string[] => {
62
87
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
63
88
  const tableName = getTableName(collection);
64
89
  const mode = (rule.mode ?? "permissive").toUpperCase();
@@ -83,11 +108,12 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
83
108
  withCheckClause = "false";
84
109
  }
85
110
 
86
- let ddl = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";\n`;
87
- ddl += `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
88
- if (usingClause) ddl += ` USING (${usingClause})`;
89
- if (withCheckClause) ddl += ` WITH CHECK (${withCheckClause})`;
90
- return `${ddl};\n`;
111
+ const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
112
+ let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
113
+ if (usingClause) create += ` USING (${usingClause})`;
114
+ if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
115
+ create += ";";
116
+ return [drop, create];
91
117
  };
92
118
 
93
119
  export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
@@ -103,11 +129,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
103
129
  if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
104
130
  return "UUID";
105
131
  }
132
+ // Width comes from `validation.max` when the property states one.
133
+ // It used to be a hardcoded 255 here and *absent* on the Drizzle
134
+ // path, so the same property produced a bounded column down one
135
+ // generator and an unbounded one down the other.
106
136
  if (stringProp.columnType === "char") {
107
- return "CHAR(255)";
137
+ return `CHAR(${resolveStringColumnLength(stringProp)})`;
108
138
  }
109
139
  if (stringProp.columnType === "varchar") {
110
- return "VARCHAR(255)";
140
+ return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
111
141
  }
112
142
  // `text` is the default. The two generators disagreed here before:
113
143
  // this one emitted VARCHAR(255) while the drizzle path emitted a bare
@@ -470,6 +500,295 @@ export const generatePostgresDdl = async (
470
500
  return ddl;
471
501
  };
472
502
 
503
+ /** The RLS statements one declared collection's table needs, ready to run. */
504
+ /**
505
+ * A foreign key, as both its parts and the statement that creates it.
506
+ *
507
+ * `ALTER TABLE … ADD CONSTRAINT` has no `IF NOT EXISTS`, so a caller applying
508
+ * these has to skip by name — hence the name is a field and not only a substring
509
+ * of the SQL.
510
+ */
511
+ export interface ForeignKeyPlan {
512
+ constraintName: string;
513
+ schema: string;
514
+ /** Bare table name, no schema prefix. */
515
+ table: string;
516
+ column: string;
517
+ targetSchema: string;
518
+ targetTable: string;
519
+ targetColumn: string;
520
+ sql: string;
521
+ }
522
+
523
+ /** A column a `relation` or `reference` property owns on its own table. */
524
+ export interface RelationalColumnPlan {
525
+ schema: string;
526
+ /** Bare table name, no schema prefix. */
527
+ table: string;
528
+ column: string;
529
+ /** Postgres type, exactly as the DDL generator declares it. */
530
+ type: string;
531
+ /** Absent when the target collection is not part of this bundle. */
532
+ foreignKey?: ForeignKeyPlan;
533
+ }
534
+
535
+ /** The table behind a many-to-many `through` relation. */
536
+ export interface JunctionTablePlan {
537
+ schema: string;
538
+ /** Bare table name, no schema prefix. */
539
+ table: string;
540
+ columns: { name: string; type: string }[];
541
+ /** Both endpoint columns plus the composite primary key. */
542
+ createTable: string;
543
+ foreignKeys: ForeignKeyPlan[];
544
+ }
545
+
546
+ const schemaOfCollection = (collection: CollectionConfig): string =>
547
+ isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
548
+
549
+ const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
550
+
551
+ const foreignKeyPlan = (
552
+ args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
553
+ ): ForeignKeyPlan => {
554
+ const constraintName = `${args.table}_${args.column}_fkey`;
555
+ const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
556
+ return {
557
+ constraintName,
558
+ schema: args.schema,
559
+ table: args.table,
560
+ column: args.column,
561
+ targetSchema: args.targetSchema,
562
+ targetTable: args.targetTable,
563
+ targetColumn: args.targetColumn,
564
+ sql:
565
+ `ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" ` +
566
+ `FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ` +
567
+ `("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
568
+ };
569
+ };
570
+
571
+ /**
572
+ * The FK columns the declared collections own — one entry per `relation`
573
+ * (`belongsTo` side) or `reference` property.
574
+ *
575
+ * Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
576
+ * create the same columns with the same names, types and constraints. Before
577
+ * this it skipped them outright, which was survivable only because `db push`
578
+ * always followed; on a managed tenant nothing follows, so a table arrived
579
+ * without the column its own collection reads and wrote 400 on every insert.
580
+ *
581
+ * A relation whose target is not in the bundle yields no column at all (the
582
+ * generator returns early on an unresolvable target); a `reference` whose target
583
+ * is unknown yields the column without a constraint. Both mirror the generator
584
+ * exactly — a divergence here is a schema fork between boot and `db push`.
585
+ */
586
+ export const planRelationalColumns = (collections: CollectionConfig[]): RelationalColumnPlan[] => {
587
+ const plans: RelationalColumnPlan[] = [];
588
+
589
+ for (const collection of collections) {
590
+ const tableName = getTableName(collection);
591
+ if (!tableName) continue;
592
+ const schema = schemaOfCollection(collection);
593
+ const table = bareTableName(tableName);
594
+
595
+ for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
596
+ const prop = rawProp as Property;
597
+
598
+ if (prop.type === "relation") {
599
+ const refProp = prop as RelationProperty;
600
+ const resolvedRelations = resolveCollectionRelations(collection);
601
+ const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
602
+ if (relInfo?.kind !== "belongsTo") continue;
603
+ // The relation and an explicit FK property can both be declared;
604
+ // the explicit one owns the column.
605
+ if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
606
+
607
+ let targetCollection: CollectionConfig;
608
+ try {
609
+ targetCollection = relInfo.target();
610
+ } catch {
611
+ continue;
612
+ }
613
+ if (!targetCollection) continue;
614
+
615
+ const required = prop.validation?.required;
616
+ plans.push({
617
+ schema,
618
+ table,
619
+ column: relInfo.localKey,
620
+ type: getSqlColumnType(propName, prop, collection, collections),
621
+ foreignKey: foreignKeyPlan({
622
+ schema,
623
+ table,
624
+ column: relInfo.localKey,
625
+ targetSchema: schemaOfCollection(targetCollection),
626
+ targetTable: bareTableName(getTableName(targetCollection)),
627
+ targetColumn: getPrimaryKeyName(targetCollection),
628
+ onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
629
+ onUpdate: relInfo.onUpdate
630
+ })
631
+ });
632
+ } else if (prop.type === "reference") {
633
+ const refProp = prop as ReferenceProperty;
634
+ const targetCollection = collections.find(
635
+ c => c.slug === refProp.path || getTableName(c) === refProp.path
636
+ );
637
+ const column = resolveColumnName(propName, prop);
638
+ const type = getSqlColumnType(propName, prop, collection, collections);
639
+ const required = prop.validation?.required;
640
+
641
+ plans.push({
642
+ schema,
643
+ table,
644
+ column,
645
+ type,
646
+ foreignKey: targetCollection
647
+ ? foreignKeyPlan({
648
+ schema,
649
+ table,
650
+ column,
651
+ targetSchema: schemaOfCollection(targetCollection),
652
+ targetTable: bareTableName(getTableName(targetCollection)),
653
+ targetColumn: getPrimaryKeyName(targetCollection),
654
+ onDelete: required ? "CASCADE" : "SET NULL"
655
+ })
656
+ : undefined
657
+ });
658
+ }
659
+ }
660
+ }
661
+
662
+ return plans;
663
+ };
664
+
665
+ /**
666
+ * The junction tables a bundle's many-to-many relations imply.
667
+ *
668
+ * Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
669
+ * comes from, so a table created here always has policies planned for it — a
670
+ * junction with row-level security left off is readable and writable by every
671
+ * signed-in user, which is why the two must ship together.
672
+ */
673
+ export const planJunctionTables = (collections: CollectionConfig[]): JunctionTablePlan[] => {
674
+ const plans: JunctionTablePlan[] = [];
675
+
676
+ for (const spec of resolveJunctionSpecs(collections).values()) {
677
+ const [source, target] = spec.endpoints;
678
+ const columns = [
679
+ { name: source.junctionColumn, type: junctionKeyType(source.collection) },
680
+ { name: target.junctionColumn, type: junctionKeyType(target.collection) }
681
+ ];
682
+ // Every declaring side agrees on the edge's lifetime; the first one wins,
683
+ // as it does in the generator's walk.
684
+ const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
685
+
686
+ plans.push({
687
+ schema: spec.schema,
688
+ table: spec.table,
689
+ columns,
690
+ createTable:
691
+ `CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` +
692
+ columns.map(c => `"${c.name}" ${c.type} NOT NULL`).join(", ") +
693
+ `, PRIMARY KEY (${columns.map(c => `"${c.name}"`).join(", ")}));`,
694
+ foreignKeys: [source, target].map((endpoint, i) =>
695
+ foreignKeyPlan({
696
+ schema: spec.schema,
697
+ table: spec.table,
698
+ column: columns[i].name,
699
+ targetSchema: schemaOfCollection(endpoint.collection),
700
+ targetTable: bareTableName(getTableName(endpoint.collection)),
701
+ targetColumn: getPrimaryKeyName(endpoint.collection),
702
+ onDelete
703
+ })
704
+ )
705
+ });
706
+ }
707
+
708
+ return plans;
709
+ };
710
+
711
+ export interface CollectionPolicyPlan {
712
+ /** The table's schema (e.g. `public`, `rebase`). */
713
+ schema: string;
714
+ /** The bare table name, no schema prefix. */
715
+ table: string;
716
+ /** `schema.table` — matches the keys `readExistingSchema` returns. */
717
+ qualified: string;
718
+ /** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
719
+ enableRls: string;
720
+ /** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
721
+ policyStatements: string[];
722
+ }
723
+
724
+ /**
725
+ * The per-table RLS plan for the *declared* collections, as executable
726
+ * statements — what the managed runtime applies at boot so a freshly
727
+ * provisioned tenant database serves data instead of 401ing every read.
728
+ *
729
+ * Mirrors {@link generatePostgresPoliciesDdl} exactly (same
730
+ * `generatePolicyStatements`, same enable-RLS, same effective rules, same
731
+ * derived junction rules), so boot and `db push` produce identical policies from
732
+ * identical collections.
733
+ *
734
+ * Junction tables are included, and have to be: boot creates them now
735
+ * ({@link planJunctionTables}), and a junction with RLS left off is readable and
736
+ * writable by every signed-in user. A junction whose table is still absent is
737
+ * skipped by the applier, not planned away here.
738
+ */
739
+ export const planCollectionPolicies = (collections: CollectionConfig[]): CollectionPolicyPlan[] => {
740
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
741
+ const plans: CollectionPolicyPlan[] = [];
742
+ const seen = new Set<string>();
743
+
744
+ for (const collection of collections) {
745
+ const tableName = getTableName(collection);
746
+ if (!tableName) continue;
747
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
748
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
749
+ const qualified = `${schema}.${baseTableName}`;
750
+ if (seen.has(qualified)) continue;
751
+ seen.add(qualified);
752
+
753
+ const policyStatements: string[] = [];
754
+ for (const rule of getEffectiveSecurityRules(collection)) {
755
+ policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
756
+ }
757
+
758
+ plans.push({
759
+ schema,
760
+ table: baseTableName,
761
+ qualified,
762
+ enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
763
+ policyStatements
764
+ });
765
+ }
766
+
767
+ // Junctions are derived from `through` relations rather than declared, so
768
+ // the walk above never sees them.
769
+ for (const spec of resolveJunctionSpecs(collections).values()) {
770
+ const qualified = `${spec.schema}.${spec.table}`;
771
+ if (seen.has(qualified)) continue;
772
+ seen.add(qualified);
773
+
774
+ const junctionCollection = getJunctionCollectionConfig(spec);
775
+ const policyStatements: string[] = [];
776
+ for (const rule of getJunctionSecurityRules(spec)) {
777
+ policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
778
+ }
779
+
780
+ plans.push({
781
+ schema: spec.schema,
782
+ table: spec.table,
783
+ qualified,
784
+ enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
785
+ policyStatements
786
+ });
787
+ }
788
+
789
+ return plans;
790
+ };
791
+
473
792
  export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
474
793
  let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
475
794
 
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from "@jest/globals";
2
+ import { resolveCollectionRelations } from "@rebasepro/common";
3
+ import type { CollectionConfig } from "@rebasepro/types";
2
4
 
3
5
  import { buildCollectionsFromSchema, introspectSchema, readRlsStatus, IntrospectedSchema, Queryable } from "./introspect-runtime";
4
6
  import { buildTablesMap, identifyJoinTables, TableColumn, ForeignKeyRow, PrimaryKeyRow } from "./introspect-db-logic";
@@ -121,14 +123,60 @@ describe("buildCollectionsFromSchema", () => {
121
123
  const props = posts.properties as any;
122
124
 
123
125
  expect(props.author_id).toBeUndefined();
124
- expect(props.author).toEqual({
125
- name: "Author",
126
- type: "relation",
127
- target: "authors",
128
- cardinality: "one",
129
- direction: "owning",
130
- localKey: "author_id"
131
- });
126
+ expect(props.author.name).toBe("Author");
127
+ expect(props.author.type).toBe("relation");
128
+ // The descriptor is nested under `relation`, carries `kind`, and its
129
+ // `target` is a thunk — see the resolvability test below for why none of
130
+ // those three are stylistic.
131
+ expect(props.author.relation).toMatchObject({ kind: "belongsTo", localKey: "author_id" });
132
+ expect(typeof props.author.relation.target).toBe("function");
133
+ expect(props.author.relation.target().slug).toBe("authors");
134
+ });
135
+
136
+ /**
137
+ * The shape has to be one the resolver actually reads.
138
+ *
139
+ * This path emitted `cardinality` / `direction` / a bare-string `target`
140
+ * flat on the property — the shape `Relation` stopped accepting, and the
141
+ * same drift `introspect-emits-valid-relations.test.ts` caught in the
142
+ * generated-file path and this one was missed by. Nothing threw:
143
+ * `resolveCollectionRelations` reads `property.relation`, found none, and
144
+ * reported a collection with no relations at all.
145
+ *
146
+ * The visible cost was writes. `assertKnownWriteFields` learns an owning
147
+ * relation's FK column from the *resolved* relation's `localKey`, and the FK
148
+ * column is deliberately absent from `properties` ("surfaces as a
149
+ * relation"), so with nothing resolving, `POST /api/data/orders` with
150
+ * `product_id` came back 400 `has no field 'product_id'` — the column was
151
+ * simultaneously the only way to set the relation and not a known field.
152
+ *
153
+ * Asserting the literal object is what let it drift, so this asserts
154
+ * through the resolver instead: a future reshape has to keep it resolvable,
155
+ * not merely keep the keys someone once wrote down.
156
+ */
157
+ it("emits a relation the resolver can read, so the fk column is writable", () => {
158
+ const schema = schemaOf(
159
+ [
160
+ column({ table_name: "products", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
161
+ column({ table_name: "orders", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
162
+ column({ table_name: "orders", column_name: "product_id", data_type: "integer", udt_name: "int4" })
163
+ ],
164
+ [
165
+ { table_name: "products", column_name: "id" },
166
+ { table_name: "orders", column_name: "id" }
167
+ ],
168
+ [{ table_name: "orders", column_name: "product_id", foreign_table_name: "products", foreign_column_name: "id" }]
169
+ );
170
+
171
+ const orders = buildCollectionsFromSchema(schema, "public").find((c) => c.slug === "orders")!;
172
+ const resolved = resolveCollectionRelations(orders as unknown as CollectionConfig);
173
+
174
+ expect(Object.keys(resolved)).toEqual(["product"]);
175
+ expect(resolved.product.kind).toBe("belongsTo");
176
+ // `localKey` is the field `assertKnownWriteFields` adds to the known set,
177
+ // which is what makes `product_id` writable. The write itself is covered
178
+ // end-to-end by `scripts/smoke-baas.ts` against a real database.
179
+ expect((resolved.product as { localKey: string }).localKey).toBe("product_id");
132
180
  });
133
181
 
134
182
  it("skips join tables — they are an edge between collections, not a collection", () => {
@@ -207,13 +207,30 @@ function buildProperties(
207
207
  }
208
208
 
209
209
  /**
210
- * Owning relations, derived from this table's foreign keys. Mirrors the shape
211
- * `generateCollectionFile` emits, except `target` uses the slug string form
212
- * rather than a thunk to a module import.
210
+ * Owning relations, derived from this table's foreign keys the same shape
211
+ * `generateCollectionFile` writes into a collection file: a `relation` property
212
+ * whose nested descriptor carries `kind`, a `target` thunk and the `localKey`.
213
+ *
214
+ * The shape is load-bearing, not cosmetic. `resolveCollectionRelations` reads
215
+ * relations from `property.relation` and `resolveRelation` requires `target` to
216
+ * be a thunk; this used to emit `target`/`cardinality`/`localKey` flat on the
217
+ * property with the slug as a bare string, which satisfies neither. Nothing
218
+ * threw — the resolver simply skipped every such property and reported that the
219
+ * collection had no relations. So an introspected BaaS collection had its FK
220
+ * columns removed from `properties` (they "surface as relations") and then no
221
+ * resolvable relation to surface as, which is why writing the FK column
222
+ * directly came back as `has no field 'product_id'`: `assertKnownWriteFields`
223
+ * learns that column from the resolved relation's `localKey`.
224
+ *
225
+ * The thunk closes over the collections being built in this same pass rather
226
+ * than importing a module, which is what a runtime introspection has instead of
227
+ * generated files. It is called lazily, after the map is fully populated, so a
228
+ * table may reference one introspected later.
213
229
  */
214
230
  function buildRelations(
215
231
  meta: TableMeta,
216
- slugByTable: Map<string, string>
232
+ slugByTable: Map<string, string>,
233
+ collectionBySlug: Map<string, PostgresCollectionConfig>
217
234
  ): Record<string, Record<string, unknown>> {
218
235
  const relations: Record<string, Record<string, unknown>> = {};
219
236
 
@@ -233,10 +250,11 @@ function buildRelations(
233
250
  relations[key] = {
234
251
  name: humanize(key),
235
252
  type: "relation",
236
- target: targetSlug,
237
- cardinality: "one",
238
- direction: "owning",
239
- localKey: fk.column_name
253
+ relation: {
254
+ kind: "belongsTo",
255
+ target: () => collectionBySlug.get(targetSlug),
256
+ localKey: fk.column_name
257
+ }
240
258
  };
241
259
  }
242
260
 
@@ -259,6 +277,9 @@ export function buildCollectionsFromSchema(
259
277
  }
260
278
 
261
279
  const collections: PostgresCollectionConfig[] = [];
280
+ // Filled as we go; the relation thunks read it lazily, so a table may point
281
+ // at one that has not been built yet at the moment its relation is created.
282
+ const collectionBySlug = new Map<string, PostgresCollectionConfig>();
262
283
 
263
284
  for (const [tableName, meta] of tablesMap) {
264
285
  if (joinTables.has(tableName)) continue;
@@ -273,11 +294,12 @@ export function buildCollectionsFromSchema(
273
294
  icon: getIconForTable(tableName),
274
295
  properties: {
275
296
  ...buildProperties(meta, enumMap),
276
- ...buildRelations(meta, slugByTable)
297
+ ...buildRelations(meta, slugByTable, collectionBySlug)
277
298
  }
278
299
  } as unknown as PostgresCollectionConfig;
279
300
 
280
301
  collections.push(collection);
302
+ collectionBySlug.set(tableName, collection);
281
303
  }
282
304
 
283
305
  return collections;
@@ -97,6 +97,52 @@ export function extractCauseMessage(error: unknown): string | null {
97
97
  return null;
98
98
  }
99
99
 
100
+ /**
101
+ * Codes that mean "this connection will never work as configured".
102
+ *
103
+ * A wrong password or a database that does not exist is a settled fact about
104
+ * the connection string, not a transient fault — retrying produces the same
105
+ * answer forever.
106
+ */
107
+ const UNRECOVERABLE_CONNECT_CODES = new Set([
108
+ "28P01", // invalid_password
109
+ "28000", // invalid_authorization_specification
110
+ "3D000", // invalid_catalog_name — the database does not exist
111
+ "42501" // insufficient_privilege
112
+ ]);
113
+
114
+ export interface ConnectFailure {
115
+ /** True when retrying cannot help: the connection string itself is wrong. */
116
+ fatal: boolean;
117
+ /** The deepest message available — the Postgres one where there is one. */
118
+ reason: string;
119
+ /** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */
120
+ code?: string;
121
+ }
122
+
123
+ /**
124
+ * Describe a failed connection attempt in terms a developer can act on.
125
+ *
126
+ * The error a caller catches is Drizzle's wrapper: its message is
127
+ * `Failed query: SELECT 1` and its stack runs through drizzle internals, while
128
+ * the sentence that says what is actually wrong — "password authentication
129
+ * failed for user …", "database … does not exist" — sits in `.cause`. Logging
130
+ * the wrapper, as the bootstrapper used to, tells a developer with a typo in
131
+ * their `DATABASE_URL` nothing at all.
132
+ */
133
+ export function classifyConnectFailure(error: unknown): ConnectFailure {
134
+ const pgError = extractPgError(error);
135
+ const reason =
136
+ pgError?.message ??
137
+ extractCauseMessage(error) ??
138
+ (error instanceof Error ? error.message : String(error));
139
+ return {
140
+ fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
141
+ reason,
142
+ code: pgError?.code
143
+ };
144
+ }
145
+
100
146
  /**
101
147
  * Detect whether an error is specifically a role-switching permission failure
102
148
  * (e.g. "permission denied to set role" or "must be member of role"),