@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.0

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 (92) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/backup-service-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/connection-BuZ97wsr.js +250 -0
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
  13. package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
  14. package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
  15. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  16. package/dist/index.es.js +1231 -10031
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/auth-schema.d.ts +83 -144
  21. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  22. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  23. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  24. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  25. package/dist/schema/introspect-db-logic.d.ts +94 -5
  26. package/dist/schema/introspect-db-queries.d.ts +119 -0
  27. package/dist/schema/introspect-db-structure.d.ts +263 -0
  28. package/dist/schema/introspect-db-types.d.ts +11 -0
  29. package/dist/services/FetchService.d.ts +4 -1
  30. package/dist/services/RelationService.d.ts +24 -1
  31. package/dist/services/channel-bus/index.d.ts +1 -7
  32. package/dist/services/collection-helpers.d.ts +36 -2
  33. package/dist/services/dataService.d.ts +3 -1
  34. package/dist/services/row-pipeline.d.ts +1 -1
  35. package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
  36. package/dist/src-DlPBctw_.js.map +1 -0
  37. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  38. package/dist/src-DoU9yPqq.js.map +1 -0
  39. package/dist/utils/connection-string.d.ts +29 -0
  40. package/dist/utils/drizzle-conditions.d.ts +5 -4
  41. package/dist/utils/pg-error-utils.d.ts +35 -0
  42. package/dist/websocket-B2LsrINK.js +530 -0
  43. package/dist/websocket-B2LsrINK.js.map +1 -0
  44. package/package.json +14 -14
  45. package/src/PostgresAdapter.ts +21 -2
  46. package/src/PostgresBackendDriver.ts +4 -0
  47. package/src/PostgresBootstrapper.ts +192 -33
  48. package/src/auth/ensure-tables.ts +164 -9
  49. package/src/auth/services.ts +24 -2
  50. package/src/backup/backup-cli.ts +41 -2
  51. package/src/backup/backup-service.ts +38 -5
  52. package/src/backup/pg-tools.ts +96 -3
  53. package/src/cli-helpers.ts +70 -0
  54. package/src/cli.ts +44 -26
  55. package/src/collections/validate-relations.ts +15 -0
  56. package/src/connection.ts +73 -0
  57. package/src/data-transformer.ts +9 -3
  58. package/src/databasePoolManager.ts +5 -2
  59. package/src/schema/auth-schema.ts +30 -19
  60. package/src/schema/ensure-collection-policies.ts +105 -0
  61. package/src/schema/ensure-collection-tables.test.ts +105 -9
  62. package/src/schema/ensure-collection-tables.ts +220 -32
  63. package/src/schema/generate-drizzle-schema-logic.ts +42 -6
  64. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  65. package/src/schema/introspect-db-constraints.ts +385 -0
  66. package/src/schema/introspect-db-inference.ts +18 -8
  67. package/src/schema/introspect-db-logic.ts +385 -71
  68. package/src/schema/introspect-db-queries.ts +326 -0
  69. package/src/schema/introspect-db-structure.ts +670 -0
  70. package/src/schema/introspect-db-types.ts +56 -0
  71. package/src/schema/introspect-db.ts +37 -80
  72. package/src/schema/introspect-runtime.test.ts +56 -8
  73. package/src/schema/introspect-runtime.ts +31 -9
  74. package/src/security/policy-drift.test.ts +11 -3
  75. package/src/services/BranchService.ts +66 -28
  76. package/src/services/FetchService.ts +90 -14
  77. package/src/services/PersistService.ts +20 -6
  78. package/src/services/RelationService.ts +249 -48
  79. package/src/services/channel-bus/index.ts +0 -9
  80. package/src/services/collection-helpers.ts +69 -3
  81. package/src/services/dataService.ts +3 -1
  82. package/src/services/realtimeService.ts +3 -3
  83. package/src/services/row-pipeline.ts +1 -1
  84. package/src/utils/connection-string.ts +58 -0
  85. package/src/utils/drizzle-conditions.ts +31 -6
  86. package/src/utils/pg-error-utils.ts +65 -0
  87. package/src/websocket.ts +18 -9
  88. package/dist/chunk-DSJWtz9O.js +0 -40
  89. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  90. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  91. package/dist/src-BbFOPJ1S.js.map +0 -1
  92. 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 } from "@rebasepro/types";
2
- import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
- import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
3
+ import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
4
4
 
5
5
  // --- Helper Functions ---
6
6
 
@@ -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,13 +108,24 @@ 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
 
119
+ /**
120
+ * Single-quote escaping for a SQL string literal (PostgreSQL doubles the
121
+ * quote). Enum labels come straight from user-authored collection config, so a
122
+ * label like `it's` closes the literal early and the whole generated file stops
123
+ * parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
124
+ * because ensure-collection-tables already imports from this module — the
125
+ * reverse would be a cycle.
126
+ */
127
+ export const quoteSqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
128
+
93
129
  export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
94
130
  switch (prop.type) {
95
131
  case "string": {
@@ -103,11 +139,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
103
139
  if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
104
140
  return "UUID";
105
141
  }
142
+ // Width comes from `validation.max` when the property states one.
143
+ // It used to be a hardcoded 255 here and *absent* on the Drizzle
144
+ // path, so the same property produced a bounded column down one
145
+ // generator and an unbounded one down the other.
106
146
  if (stringProp.columnType === "char") {
107
- return "CHAR(255)";
147
+ return `CHAR(${resolveStringColumnLength(stringProp)})`;
108
148
  }
109
149
  if (stringProp.columnType === "varchar") {
110
- return "VARCHAR(255)";
150
+ return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
111
151
  }
112
152
  // `text` is the default. The two generators disagreed here before:
113
153
  // this one emitted VARCHAR(255) while the drizzle path emitted a bare
@@ -212,6 +252,13 @@ export const generatePostgresDdl = async (
212
252
  if (uniqueSchemas.length > 0) ddl += "\n";
213
253
 
214
254
  // 2. Generate Enums
255
+ //
256
+ // The enum type name is derived from table + column, so two collections
257
+ // mapped onto the same table — or two properties whose `columnName`
258
+ // resolves to the same column — land on the same name. `CREATE TYPE` has no
259
+ // IF NOT EXISTS, so emitting it twice aborts the whole file on the second
260
+ // statement. Dedupe by name, matching planCollectionSchemaEnsure.
261
+ const emittedEnums = new Set<string>();
215
262
  collections.forEach(collection => {
216
263
  const collectionTable = getTableName(collection);
217
264
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
@@ -223,8 +270,9 @@ export const generatePostgresDdl = async (
223
270
  String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
224
271
  )
225
272
  : Object.keys(prop.enum);
226
- if (values.length > 0) {
227
- ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
273
+ if (values.length > 0 && !emittedEnums.has(`${schema}.${enumDbName}`)) {
274
+ emittedEnums.add(`${schema}.${enumDbName}`);
275
+ ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});\n`;
228
276
  }
229
277
  }
230
278
  });
@@ -470,6 +518,321 @@ export const generatePostgresDdl = async (
470
518
  return ddl;
471
519
  };
472
520
 
521
+ /** The RLS statements one declared collection's table needs, ready to run. */
522
+ /**
523
+ * A foreign key, as both its parts and the statement that creates it.
524
+ *
525
+ * `ALTER TABLE … ADD CONSTRAINT` has no `IF NOT EXISTS`, so a caller applying
526
+ * these has to skip by name — hence the name is a field and not only a substring
527
+ * of the SQL.
528
+ */
529
+ export interface ForeignKeyPlan {
530
+ constraintName: string;
531
+ schema: string;
532
+ /** Bare table name, no schema prefix. */
533
+ table: string;
534
+ column: string;
535
+ targetSchema: string;
536
+ targetTable: string;
537
+ targetColumn: string;
538
+ sql: string;
539
+ }
540
+
541
+ /** A column a `relation` or `reference` property owns on its own table. */
542
+ export interface RelationalColumnPlan {
543
+ schema: string;
544
+ /** Bare table name, no schema prefix. */
545
+ table: string;
546
+ column: string;
547
+ /** Postgres type, exactly as the DDL generator declares it. */
548
+ type: string;
549
+ /** Absent when the target collection is not part of this bundle. */
550
+ foreignKey?: ForeignKeyPlan;
551
+ /**
552
+ * What this column would have been called before `generateForeignKeyName`
553
+ * learned to singularize — set only when the two differ and the column name
554
+ * is the derived default rather than one the author wrote.
555
+ *
556
+ * Carried so the boot-time ensure can notice a database provisioned under
557
+ * the old rule. It is never used to name anything.
558
+ */
559
+ legacyColumn?: string;
560
+ }
561
+
562
+ /** The table behind a many-to-many `through` relation. */
563
+ export interface JunctionTablePlan {
564
+ schema: string;
565
+ /** Bare table name, no schema prefix. */
566
+ table: string;
567
+ columns: { name: string; type: string; legacyName?: string }[];
568
+ /** Both endpoint columns plus the composite primary key. */
569
+ createTable: string;
570
+ foreignKeys: ForeignKeyPlan[];
571
+ }
572
+
573
+ const schemaOfCollection = (collection: CollectionConfig): string =>
574
+ isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
575
+
576
+ const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
577
+
578
+ const foreignKeyPlan = (
579
+ args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
580
+ ): ForeignKeyPlan => {
581
+ const constraintName = `${args.table}_${args.column}_fkey`;
582
+ const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
583
+ return {
584
+ constraintName,
585
+ schema: args.schema,
586
+ table: args.table,
587
+ column: args.column,
588
+ targetSchema: args.targetSchema,
589
+ targetTable: args.targetTable,
590
+ targetColumn: args.targetColumn,
591
+ sql:
592
+ `ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" ` +
593
+ `FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ` +
594
+ `("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
595
+ };
596
+ };
597
+
598
+ /**
599
+ * The FK columns the declared collections own — one entry per `relation`
600
+ * (`belongsTo` side) or `reference` property.
601
+ *
602
+ * Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
603
+ * create the same columns with the same names, types and constraints. Before
604
+ * this it skipped them outright, which was survivable only because `db push`
605
+ * always followed; on a managed tenant nothing follows, so a table arrived
606
+ * without the column its own collection reads and wrote 400 on every insert.
607
+ *
608
+ * A relation whose target is not in the bundle yields no column at all (the
609
+ * generator returns early on an unresolvable target); a `reference` whose target
610
+ * is unknown yields the column without a constraint. Both mirror the generator
611
+ * exactly — a divergence here is a schema fork between boot and `db push`.
612
+ */
613
+ export const planRelationalColumns = (collections: CollectionConfig[]): RelationalColumnPlan[] => {
614
+ const plans: RelationalColumnPlan[] = [];
615
+
616
+ for (const collection of collections) {
617
+ const tableName = getTableName(collection);
618
+ if (!tableName) continue;
619
+ const schema = schemaOfCollection(collection);
620
+ const table = bareTableName(tableName);
621
+
622
+ for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
623
+ const prop = rawProp as Property;
624
+
625
+ if (prop.type === "relation") {
626
+ const refProp = prop as RelationProperty;
627
+ const resolvedRelations = resolveCollectionRelations(collection);
628
+ const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
629
+ if (relInfo?.kind !== "belongsTo") continue;
630
+ // The relation and an explicit FK property can both be declared;
631
+ // the explicit one owns the column.
632
+ if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
633
+
634
+ let targetCollection: CollectionConfig;
635
+ try {
636
+ targetCollection = relInfo.target();
637
+ } catch {
638
+ continue;
639
+ }
640
+ if (!targetCollection) continue;
641
+
642
+ const required = prop.validation?.required;
643
+ // Only a *derived* column name can be affected by the change to
644
+ // `generateForeignKeyName`; one the author wrote is theirs.
645
+ const relationName = refProp.relation?.relationName ?? propName;
646
+ const legacyKey = legacyForeignKeyName(relationName);
647
+ const derived = relInfo.localKey === generateForeignKeyName(relationName);
648
+ plans.push({
649
+ schema,
650
+ table,
651
+ column: relInfo.localKey,
652
+ legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : undefined,
653
+ type: getSqlColumnType(propName, prop, collection, collections),
654
+ foreignKey: foreignKeyPlan({
655
+ schema,
656
+ table,
657
+ column: relInfo.localKey,
658
+ targetSchema: schemaOfCollection(targetCollection),
659
+ targetTable: bareTableName(getTableName(targetCollection)),
660
+ targetColumn: getPrimaryKeyName(targetCollection),
661
+ onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
662
+ onUpdate: relInfo.onUpdate
663
+ })
664
+ });
665
+ } else if (prop.type === "reference") {
666
+ const refProp = prop as ReferenceProperty;
667
+ const targetCollection = collections.find(
668
+ c => c.slug === refProp.path || getTableName(c) === refProp.path
669
+ );
670
+ const column = resolveColumnName(propName, prop);
671
+ const type = getSqlColumnType(propName, prop, collection, collections);
672
+ const required = prop.validation?.required;
673
+
674
+ plans.push({
675
+ schema,
676
+ table,
677
+ column,
678
+ type,
679
+ foreignKey: targetCollection
680
+ ? foreignKeyPlan({
681
+ schema,
682
+ table,
683
+ column,
684
+ targetSchema: schemaOfCollection(targetCollection),
685
+ targetTable: bareTableName(getTableName(targetCollection)),
686
+ targetColumn: getPrimaryKeyName(targetCollection),
687
+ onDelete: required ? "CASCADE" : "SET NULL"
688
+ })
689
+ : undefined
690
+ });
691
+ }
692
+ }
693
+ }
694
+
695
+ return plans;
696
+ };
697
+
698
+ /**
699
+ * The junction tables a bundle's many-to-many relations imply.
700
+ *
701
+ * Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
702
+ * comes from, so a table created here always has policies planned for it — a
703
+ * junction with row-level security left off is readable and writable by every
704
+ * signed-in user, which is why the two must ship together.
705
+ */
706
+ export const planJunctionTables = (collections: CollectionConfig[]): JunctionTablePlan[] => {
707
+ const plans: JunctionTablePlan[] = [];
708
+
709
+ for (const spec of resolveJunctionSpecs(collections).values()) {
710
+ const [source, target] = spec.endpoints;
711
+ // A junction column's default name is derived from the endpoint
712
+ // collection's slug — which is normally plural, so this is where the
713
+ // singularization change actually lands: a `categories` endpoint used
714
+ // to give `categorie_id` and now gives `category_id`. Recorded, not
715
+ // used, so boot-ensure can recognise a table built under the old rule.
716
+ const legacyFor = (endpoint: typeof source): string | undefined => {
717
+ const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
718
+ const legacy = legacyForeignKeyName(slug);
719
+ const derived = endpoint.junctionColumn === generateForeignKeyName(slug);
720
+ return derived && legacy !== endpoint.junctionColumn ? legacy : undefined;
721
+ };
722
+ const columns = [
723
+ { name: source.junctionColumn, type: junctionKeyType(source.collection), legacyName: legacyFor(source) },
724
+ { name: target.junctionColumn, type: junctionKeyType(target.collection), legacyName: legacyFor(target) }
725
+ ];
726
+ // Every declaring side agrees on the edge's lifetime; the first one wins,
727
+ // as it does in the generator's walk.
728
+ const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
729
+
730
+ plans.push({
731
+ schema: spec.schema,
732
+ table: spec.table,
733
+ columns,
734
+ createTable:
735
+ `CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` +
736
+ columns.map(c => `"${c.name}" ${c.type} NOT NULL`).join(", ") +
737
+ `, PRIMARY KEY (${columns.map(c => `"${c.name}"`).join(", ")}));`,
738
+ foreignKeys: [source, target].map((endpoint, i) =>
739
+ foreignKeyPlan({
740
+ schema: spec.schema,
741
+ table: spec.table,
742
+ column: columns[i].name,
743
+ targetSchema: schemaOfCollection(endpoint.collection),
744
+ targetTable: bareTableName(getTableName(endpoint.collection)),
745
+ targetColumn: getPrimaryKeyName(endpoint.collection),
746
+ onDelete
747
+ })
748
+ )
749
+ });
750
+ }
751
+
752
+ return plans;
753
+ };
754
+
755
+ export interface CollectionPolicyPlan {
756
+ /** The table's schema (e.g. `public`, `rebase`). */
757
+ schema: string;
758
+ /** The bare table name, no schema prefix. */
759
+ table: string;
760
+ /** `schema.table` — matches the keys `readExistingSchema` returns. */
761
+ qualified: string;
762
+ /** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
763
+ enableRls: string;
764
+ /** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
765
+ policyStatements: string[];
766
+ }
767
+
768
+ /**
769
+ * The per-table RLS plan for the *declared* collections, as executable
770
+ * statements — what the managed runtime applies at boot so a freshly
771
+ * provisioned tenant database serves data instead of 401ing every read.
772
+ *
773
+ * Mirrors {@link generatePostgresPoliciesDdl} exactly (same
774
+ * `generatePolicyStatements`, same enable-RLS, same effective rules, same
775
+ * derived junction rules), so boot and `db push` produce identical policies from
776
+ * identical collections.
777
+ *
778
+ * Junction tables are included, and have to be: boot creates them now
779
+ * ({@link planJunctionTables}), and a junction with RLS left off is readable and
780
+ * writable by every signed-in user. A junction whose table is still absent is
781
+ * skipped by the applier, not planned away here.
782
+ */
783
+ export const planCollectionPolicies = (collections: CollectionConfig[]): CollectionPolicyPlan[] => {
784
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
785
+ const plans: CollectionPolicyPlan[] = [];
786
+ const seen = new Set<string>();
787
+
788
+ for (const collection of collections) {
789
+ const tableName = getTableName(collection);
790
+ if (!tableName) continue;
791
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
792
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
793
+ const qualified = `${schema}.${baseTableName}`;
794
+ if (seen.has(qualified)) continue;
795
+ seen.add(qualified);
796
+
797
+ const policyStatements: string[] = [];
798
+ for (const rule of getEffectiveSecurityRules(collection)) {
799
+ policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
800
+ }
801
+
802
+ plans.push({
803
+ schema,
804
+ table: baseTableName,
805
+ qualified,
806
+ enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
807
+ policyStatements
808
+ });
809
+ }
810
+
811
+ // Junctions are derived from `through` relations rather than declared, so
812
+ // the walk above never sees them.
813
+ for (const spec of resolveJunctionSpecs(collections).values()) {
814
+ const qualified = `${spec.schema}.${spec.table}`;
815
+ if (seen.has(qualified)) continue;
816
+ seen.add(qualified);
817
+
818
+ const junctionCollection = getJunctionCollectionConfig(spec);
819
+ const policyStatements: string[] = [];
820
+ for (const rule of getJunctionSecurityRules(spec)) {
821
+ policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
822
+ }
823
+
824
+ plans.push({
825
+ schema: spec.schema,
826
+ table: spec.table,
827
+ qualified,
828
+ enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
829
+ policyStatements
830
+ });
831
+ }
832
+
833
+ return plans;
834
+ };
835
+
473
836
  export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
474
837
  let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
475
838