@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
@@ -54,6 +54,25 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
54
54
  return key ? table[key] : undefined;
55
55
  }
56
56
 
57
+ /**
58
+ * The single definition of what an email address looks like in storage.
59
+ *
60
+ * Reads have always folded case; writes did not, and normalising was left to
61
+ * each caller. That asymmetry is only ever one forgotten `.toLowerCase()` away
62
+ * from a row no lookup can find — the account exists, every sign-in path
63
+ * reports no such user, and the byte-exact UNIQUE on the column does not stop a
64
+ * duplicate differing only in case. Applied on both sides here so the guarantee
65
+ * belongs to the repository rather than to its callers' discipline; the
66
+ * `lower(email)` unique index added in `ensureAuthTablesExist` is the database
67
+ * half of the same rule.
68
+ *
69
+ * Whitespace goes too: a trailing space survives the fold and reproduces the
70
+ * problem exactly.
71
+ */
72
+ export function normalizeEmail<T>(email: T): T | string {
73
+ return typeof email === "string" ? email.trim().toLowerCase() : email;
74
+ }
75
+
57
76
  /**
58
77
  * PostgreSQL implementation of UserRepository.
59
78
  * Handles all user-related database operations using Drizzle ORM.
@@ -184,7 +203,7 @@ export class UserService implements UserRepository {
184
203
  const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
185
204
 
186
205
  if ("id" in data) payload[idKey] = data.id;
187
- if ("email" in data) payload[emailKey] = data.email;
206
+ if ("email" in data) payload[emailKey] = normalizeEmail(data.email);
188
207
  if ("passwordHash" in data) payload[passwordHashKey] = data.passwordHash;
189
208
  if ("displayName" in data) payload[displayNameKey] = data.displayName;
190
209
  if ("photoUrl" in data) payload[photoUrlKey] = data.photoUrl;
@@ -244,7 +263,7 @@ export class UserService implements UserRepository {
244
263
  async getUserByEmail(email: string): Promise<UserData | null> {
245
264
  const emailCol = getColumn(this.usersTable, "email");
246
265
  if (!emailCol) return null;
247
- const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, email.toLowerCase()));
266
+ const [row] = await this.db.select().from(this.usersTable).where(eq(emailCol, normalizeEmail(email)));
248
267
  return row ? this.mapRowToUser(row as Record<string, unknown>) : null;
249
268
  }
250
269
 
@@ -1,8 +1,19 @@
1
- import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
5
5
  * Factory function to dynamically create the auth tables bound to the specified schema names.
6
+ *
7
+ * This module builds queries; it does not create tables. `ensureAuthTablesExist`
8
+ * owns the DDL, which makes everything here a *claim* about a database it cannot
9
+ * enforce — and the claims drifted. Every column below was declared
10
+ * `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
11
+ * `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
12
+ * `token_hash` as varchar(255). None of it was true of any database this
13
+ * framework ever provisioned. Harmless at runtime — drizzle does not enforce a
14
+ * length client-side, so the widths only ever misled the next reader — but a
15
+ * schema module that describes columns that do not exist is worse than no
16
+ * schema module. They are `text` here now because they are TEXT there.
6
17
  */
7
18
  export function createAuthSchema(usersSchemaName = "rebase") {
8
19
  const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
@@ -15,12 +26,12 @@ export function createAuthSchema(usersSchemaName = "rebase") {
15
26
  */
16
27
  const users = usersTableCreator("users", {
17
28
  id: uuid("id").defaultRandom().primaryKey(),
18
- email: varchar("email", { length: 255 }).notNull().unique(),
19
- passwordHash: varchar("password_hash", { length: 255 }), // NULL for OAuth-only users
20
- displayName: varchar("display_name", { length: 255 }),
21
- photoUrl: varchar("photo_url", { length: 500 }),
29
+ email: text("email").notNull().unique(),
30
+ passwordHash: text("password_hash"), // NULL for OAuth-only users
31
+ displayName: text("display_name"),
32
+ photoUrl: text("photo_url"),
22
33
  emailVerified: boolean("email_verified").default(false).notNull(),
23
- emailVerificationToken: varchar("email_verification_token", { length: 255 }),
34
+ emailVerificationToken: text("email_verification_token"),
24
35
  emailVerificationSentAt: timestamp("email_verification_sent_at"),
25
36
  isAnonymous: boolean("is_anonymous").default(false).notNull(),
26
37
  roles: text("roles").array().default([]).notNull(),
@@ -65,7 +76,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
65
76
  id: uuid("id").defaultRandom().primaryKey(),
66
77
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
67
78
  sessionId: uuid("session_id").defaultRandom().notNull(),
68
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
79
+ tokenHash: text("token_hash").notNull().unique(),
69
80
  expiresAt: timestamp("expires_at").notNull(),
70
81
  revoked: boolean("revoked").default(false).notNull(),
71
82
  rotatedAt: timestamp("rotated_at"),
@@ -76,8 +87,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
76
87
  * that rotates immediately after it.
77
88
  */
78
89
  sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
79
- userAgent: varchar("user_agent", { length: 500 }),
80
- ipAddress: varchar("ip_address", { length: 45 }),
90
+ userAgent: text("user_agent"),
91
+ ipAddress: text("ip_address"),
81
92
  createdAt: timestamp("created_at").defaultNow().notNull()
82
93
  }, (table) => ({
83
94
  sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
@@ -89,7 +100,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
89
100
  const passwordResetTokens = tableCreator("password_reset_tokens", {
90
101
  id: uuid("id").defaultRandom().primaryKey(),
91
102
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
92
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
103
+ tokenHash: text("token_hash").notNull().unique(),
93
104
  expiresAt: timestamp("expires_at").notNull(),
94
105
  usedAt: timestamp("used_at"),
95
106
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -99,7 +110,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
99
110
  * App config - key/value store for custom settings
100
111
  */
101
112
  const appConfig = tableCreator("app_config", {
102
- key: varchar("key", { length: 100 }).primaryKey(),
113
+ key: text("key").primaryKey(),
103
114
  value: jsonb("value").notNull(),
104
115
  updatedAt: timestamp("updated_at").defaultNow().notNull()
105
116
  });
@@ -110,8 +121,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
110
121
  const userIdentities = tableCreator("user_identities", {
111
122
  id: uuid("id").defaultRandom().primaryKey(),
112
123
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
113
- provider: varchar("provider", { length: 50 }).notNull(), // e.g. 'google', 'linkedin'
114
- providerId: varchar("provider_id", { length: 255 }).notNull(),
124
+ provider: text("provider").notNull(), // e.g. 'google', 'linkedin'
125
+ providerId: text("provider_id").notNull(),
115
126
  profileData: jsonb("profile_data"),
116
127
  createdAt: timestamp("created_at").defaultNow().notNull(),
117
128
  updatedAt: timestamp("updated_at").defaultNow().notNull()
@@ -125,9 +136,9 @@ export function createAuthSchema(usersSchemaName = "rebase") {
125
136
  const mfaFactors = tableCreator("mfa_factors", {
126
137
  id: uuid("id").defaultRandom().primaryKey(),
127
138
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
128
- factorType: varchar("factor_type", { length: 20 }).notNull(), // 'totp'
129
- secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
130
- friendlyName: varchar("friendly_name", { length: 255 }),
139
+ factorType: text("factor_type").notNull(), // 'totp'
140
+ secretEncrypted: text("secret_encrypted").notNull(),
141
+ friendlyName: text("friendly_name"),
131
142
  verified: boolean("verified").default(false).notNull(),
132
143
  createdAt: timestamp("created_at").defaultNow().notNull(),
133
144
  updatedAt: timestamp("updated_at").defaultNow().notNull()
@@ -141,7 +152,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
141
152
  factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
142
153
  createdAt: timestamp("created_at").defaultNow().notNull(),
143
154
  verifiedAt: timestamp("verified_at"),
144
- ipAddress: varchar("ip_address", { length: 45 }),
155
+ ipAddress: text("ip_address"),
145
156
  expiresAt: timestamp("expires_at").notNull()
146
157
  });
147
158
 
@@ -151,7 +162,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
151
162
  const recoveryCodes = tableCreator("recovery_codes", {
152
163
  id: uuid("id").defaultRandom().primaryKey(),
153
164
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
154
- codeHash: varchar("code_hash", { length: 255 }).notNull(),
165
+ codeHash: text("code_hash").notNull(),
155
166
  usedAt: timestamp("used_at"),
156
167
  createdAt: timestamp("created_at").defaultNow().notNull()
157
168
  });
@@ -162,7 +173,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
162
173
  const magicLinkTokens = tableCreator("magic_link_tokens", {
163
174
  id: uuid("id").defaultRandom().primaryKey(),
164
175
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
165
- tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
176
+ tokenHash: text("token_hash").notNull().unique(),
166
177
  expiresAt: timestamp("expires_at").notNull(),
167
178
  usedAt: timestamp("used_at"),
168
179
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Applying a bundle's RLS policies to a database at boot, idempotently.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * {@link ensureCollectionTables} creates the collection *tables* a managed
7
+ * runtime boots against, but a table with row-level security disabled and no
8
+ * policies is not servable: authenticated requests run as the restricted
9
+ * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
10
+ * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
11
+ * is denied. The policies live in the collections' `securityRules`; nothing at
12
+ * boot applied them. `rebase db push` does — but it drives Atlas against a
13
+ * local `DATABASE_URL`, and a managed tenant's database is reachable only from
14
+ * inside the cluster, by the runtime that is already connected to it. So the
15
+ * runtime is the only thing that *can* apply them, and this is where it does.
16
+ *
17
+ * ## Why this is safe to run on every boot
18
+ *
19
+ * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
20
+ * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
21
+ * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
22
+ * and replaces; it never drops data. (It does not *reconcile* — a policy a
23
+ * previous push left behind under an old name is not removed here; that stays a
24
+ * `db push` / `db migrate` concern, alongside destructive schema changes.)
25
+ *
26
+ * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
27
+ * table whose policies could not be applied fails **closed** (denies) rather
28
+ * than leaking rows. One collection's policy failing (e.g. a rule that
29
+ * references a table a real migration has not created yet) must not crash-loop
30
+ * the whole deployment and take the other collections' working routes down with
31
+ * it. Failures are reported loudly and per-table so the operator can see
32
+ * exactly which collection is not yet servable and why.
33
+ */
34
+ import { type CollectionConfig } from "@rebasepro/types";
35
+ import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
36
+ import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
37
+
38
+ export interface PolicyEnsureResult {
39
+ /** `CREATE POLICY` statements that ran successfully. */
40
+ policiesApplied: number;
41
+ /** Tables that had RLS enabled. */
42
+ tablesSecured: number;
43
+ /** Declared tables absent from the database — left to a real migration. */
44
+ skipped: { table: string; reason: string }[];
45
+ /** Tables whose RLS could not be fully applied (fail closed). */
46
+ failures: { table: string; error: string }[];
47
+ }
48
+
49
+ const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
50
+
51
+ /**
52
+ * Bring the declared collections' RLS policies up to date. Returns what it did.
53
+ *
54
+ * Only tables that already exist are touched: the boot-time table creator runs
55
+ * first, so anything still missing is a table this additive path is not allowed
56
+ * to create (a junction, or a relation left to a migration). Enabling RLS on a
57
+ * non-existent table would error, so those are recorded as skipped, not failed.
58
+ */
59
+ export async function ensureCollectionPolicies(
60
+ client: Queryable,
61
+ collections: CollectionConfig[],
62
+ log?: (message: string) => void
63
+ ): Promise<PolicyEnsureResult> {
64
+ const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
65
+
66
+ const plans = planCollectionPolicies(collections);
67
+ if (plans.length === 0) return result;
68
+
69
+ const schemas = Array.from(new Set(plans.map(p => p.schema)));
70
+ const existing = await readExistingSchema(client, schemas);
71
+
72
+ for (const plan of plans) {
73
+ if (!existing.tables.has(plan.qualified)) {
74
+ result.skipped.push({
75
+ table: plan.qualified,
76
+ reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
77
+ });
78
+ continue;
79
+ }
80
+
81
+ try {
82
+ // Enable first: if a later policy statement fails, the table is left
83
+ // locked (deny-all for the user role) rather than open.
84
+ await client.query(plan.enableRls);
85
+ result.tablesSecured++;
86
+
87
+ let created = 0;
88
+ for (const statement of plan.policyStatements) {
89
+ await client.query(statement);
90
+ if (isCreatePolicy(statement)) {
91
+ result.policiesApplied++;
92
+ created++;
93
+ }
94
+ }
95
+ log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
96
+ } catch (err) {
97
+ result.failures.push({
98
+ table: plan.qualified,
99
+ error: err instanceof Error ? err.message : String(err)
100
+ });
101
+ }
102
+ }
103
+
104
+ return result;
105
+ }
@@ -87,16 +87,112 @@ describe("planning an additive schema ensure", () => {
87
87
  expect(sql).not.toMatch(/NOT NULL/i);
88
88
  });
89
89
 
90
- it("leaves relation columns to a real migration rather than adding them without their key", () => {
91
- const withRelation = {
92
- ...posts,
93
- properties: {
94
- ...(posts as unknown as { properties: Record<string, unknown> }).properties,
95
- author: { name: "Author", type: "reference", target: () => posts }
90
+ // ── Relations ────────────────────────────────────────────────────────────
91
+ //
92
+ // These columns were once skipped outright, on the reasoning that a bare
93
+ // column without its foreign key would disagree with what `db push` later
94
+ // generated. On a managed tenant nothing pushes afterwards, so the table
95
+ // arrived without the column its own collection reads: every insert 400ed
96
+ // with `column "author_id" does not exist`. The answer is to emit the key
97
+ // too, from the same planner `db push` uses.
98
+
99
+ const authors = {
100
+ name: "Authors",
101
+ slug: "authors",
102
+ properties: {
103
+ id: { name: "ID", type: "string", isId: "uuid" },
104
+ name: { name: "Name", type: "string" }
105
+ }
106
+ } as unknown as CollectionConfig;
107
+
108
+ const postsWithAuthor = {
109
+ ...posts,
110
+ properties: {
111
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
112
+ author: { name: "Author", type: "reference", path: "authors" }
113
+ }
114
+ } as unknown as CollectionConfig;
115
+
116
+ const tags = {
117
+ name: "Tags",
118
+ slug: "tags",
119
+ properties: {
120
+ id: { name: "ID", type: "number", isId: "increment" },
121
+ name: { name: "Name", type: "string" }
122
+ }
123
+ } as unknown as CollectionConfig;
124
+
125
+ const postsWithTags = {
126
+ ...posts,
127
+ properties: {
128
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
129
+ tags: {
130
+ name: "Tags",
131
+ type: "relation",
132
+ relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
96
133
  }
97
- } as unknown as CollectionConfig;
98
- const plan = planCollectionSchemaEnsure([withRelation], withTable("public.posts", ["id"]));
99
- expect(plan.actions.some(a => a.target.endsWith(".author"))).toBe(false);
134
+ }
135
+ } as unknown as CollectionConfig;
136
+
137
+ it("adds a reference column, and the foreign key that makes it one", () => {
138
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
139
+
140
+ expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
141
+ const fk = plan.actions.find(a => a.kind === "add-constraint");
142
+ expect(fk?.target).toBe("public.posts.posts_author_fkey");
143
+ expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
144
+ });
145
+
146
+ it("orders every constraint after the columns and tables it depends on", () => {
147
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
148
+ const kinds = plan.actions.map(a => a.kind);
149
+ expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
150
+ expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
151
+ });
152
+
153
+ it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
154
+ const existing: ExistingSchema = {
155
+ tables: new Map([
156
+ ["public.posts", new Set(["id", "author"])],
157
+ ["public.authors", new Set(["id", "name"])]
158
+ ]),
159
+ enums: new Set(["public.posts_status"]),
160
+ constraints: new Set(["public.posts.posts_author_fkey"])
161
+ };
162
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
163
+ expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
164
+ });
165
+
166
+ it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
167
+ const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
168
+ const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
169
+
170
+ expect(junction).toBeDefined();
171
+ // The endpoint key types have to match the primary keys they reference:
172
+ // posts is a uuid, tags an auto-increment integer.
173
+ expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
174
+ expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
175
+ expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
176
+
177
+ const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
178
+ expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
179
+ expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
180
+ });
181
+
182
+ it("leaves an existing junction table alone", () => {
183
+ const existing: ExistingSchema = {
184
+ tables: new Map([
185
+ ["public.posts", new Set(["id", "title", "views", "status"])],
186
+ ["public.tags", new Set(["id", "name"])],
187
+ ["public.posts_tags", new Set(["post_id", "tag_id"])]
188
+ ]),
189
+ enums: new Set(["public.posts_status"]),
190
+ constraints: new Set([
191
+ "public.posts_tags.posts_tags_post_id_fkey",
192
+ "public.posts_tags.posts_tags_tag_id_fkey"
193
+ ])
194
+ };
195
+ expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
100
196
  });
101
197
  });
102
198
 
@@ -30,7 +30,9 @@ import { getTableName } from "@rebasepro/common";
30
30
  import {
31
31
  getSqlColumnType,
32
32
  resolveColumnName,
33
- isIdProperty
33
+ isIdProperty,
34
+ planRelationalColumns,
35
+ planJunctionTables
34
36
  } from "./generate-postgres-ddl-logic";
35
37
 
36
38
  /**
@@ -62,10 +64,18 @@ export interface ExistingSchema {
62
64
  tables: Map<string, Set<string>>;
63
65
  /** `schema.typename` of every enum type that already exists. */
64
66
  enums: Set<string>;
67
+ /**
68
+ * `schema.table.constraint` of every constraint that already exists.
69
+ *
70
+ * Optional so a caller that only cares about tables can still build one by
71
+ * hand; absent is read as "none known", which at worst re-attempts a
72
+ * constraint that then fails harmlessly as a duplicate.
73
+ */
74
+ constraints?: Set<string>;
65
75
  }
66
76
 
67
77
  export interface EnsureAction {
68
- kind: "create-enum" | "create-table" | "add-column";
78
+ kind: "create-enum" | "create-table" | "add-column" | "add-constraint";
69
79
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
70
80
  target: string;
71
81
  sql: string;
@@ -77,6 +87,18 @@ export interface EnsurePlan {
77
87
  statements: string[];
78
88
  }
79
89
 
90
+ export interface EnsureOutcome extends EnsurePlan {
91
+ /**
92
+ * Constraints that could not be added — always non-fatal.
93
+ *
94
+ * A foreign key can only fail on data that already violates it, and the
95
+ * column it would police exists either way, so the collection still serves.
96
+ * Refusing to boot over one would turn a pre-existing data problem into an
97
+ * outage. Reported loudly instead.
98
+ */
99
+ failures: { target: string; error: string }[];
100
+ }
101
+
80
102
  function schemaOf(collection: CollectionConfig): string {
81
103
  return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
82
104
  }
@@ -183,34 +205,98 @@ export function planCollectionSchemaEnsure(
183
205
  });
184
206
  }
185
207
 
208
+ // 2b. Junction tables behind many-to-many relations. No collection declares
209
+ // them, so the walk above never sees them — and until they existed, an
210
+ // m2m write had nowhere to land and the junction's derived RLS had
211
+ // nothing to attach to.
212
+ const junctions = planJunctionTables(collections);
213
+ for (const junction of junctions) {
214
+ const key = `${junction.schema}.${junction.table}`;
215
+ if (existing.tables.has(key) || created.has(key)) continue;
216
+ created.add(key);
217
+ actions.push({ kind: "create-table", target: key, sql: junction.createTable });
218
+ }
219
+
186
220
  // 3. Missing columns, on both brand-new and pre-existing tables.
221
+ const addColumn = (
222
+ key: string,
223
+ schema: string,
224
+ table: string,
225
+ column: string,
226
+ type: string
227
+ ): void => {
228
+ const present = existing.tables.get(key);
229
+ if (present?.has(column)) return;
230
+ actions.push({
231
+ kind: "add-column",
232
+ target: `${key}.${column}`,
233
+ // Never NOT NULL: an existing table with rows cannot take a
234
+ // non-null column without a default, and inventing one would be
235
+ // guessing at the customer's data.
236
+ sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
237
+ });
238
+ };
239
+
187
240
  for (const collection of collections) {
188
241
  const key = qualified(collection);
189
242
  const schema = schemaOf(collection);
190
243
  const table = getTableName(collection);
191
- const present = existing.tables.get(key) ?? new Set<string>();
192
244
  for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
193
245
  const p = prop as Property;
194
246
  if (isIdProperty(propName, p, collection)) continue;
195
- // A relation's own column is emitted by the DDL generator with a
196
- // foreign key; adding a bare column here would create the column
197
- // without the constraint and make the generator's later output
198
- // disagree with the database. Left to a real migration.
247
+ // Relation and reference columns are planned from the shared
248
+ // relational planner below, which derives the column name, type and
249
+ // foreign key the same way `db push` does. Deriving them here as
250
+ // plain columns is what once produced a column with no constraint.
199
251
  if (p.type === "reference" || p.type === "relation") continue;
200
- const column = resolveColumnName(propName, p);
201
- if (present.has(column)) continue;
202
- const type = getSqlColumnType(propName, p, collection, collections);
203
- actions.push({
204
- kind: "add-column",
205
- target: `${key}.${column}`,
206
- // Never NOT NULL: an existing table with rows cannot take a
207
- // non-null column without a default, and inventing one would be
208
- // guessing at the customer's data.
209
- sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
210
- });
252
+ addColumn(key, schema, table, resolveColumnName(propName, p), getSqlColumnType(propName, p, collection, collections));
253
+ }
254
+ }
255
+
256
+ // 3b. A junction that already existed, but is short a column. One created
257
+ // above already carries both — unlike a collection table, whose CREATE
258
+ // declares only the identity column so re-listing them would log two
259
+ // no-op statements and inflate the count of changes applied.
260
+ for (const junction of junctions) {
261
+ const key = `${junction.schema}.${junction.table}`;
262
+ if (created.has(key)) continue;
263
+ for (const column of junction.columns) {
264
+ addColumn(key, junction.schema, junction.table, column.name, column.type);
211
265
  }
212
266
  }
213
267
 
268
+ // 3c. The columns relation and reference properties own.
269
+ for (const relational of planRelationalColumns(collections)) {
270
+ addColumn(
271
+ `${relational.schema}.${relational.table}`,
272
+ relational.schema,
273
+ relational.table,
274
+ relational.column,
275
+ relational.type
276
+ );
277
+ }
278
+
279
+ // 4. Foreign keys, last: the tables and columns on both ends have to exist
280
+ // first, and a constraint is the one thing here that can fail on data
281
+ // rather than on schema, so nothing else depends on it.
282
+ const knownConstraints = existing.constraints ?? new Set<string>();
283
+ const plannedConstraints = new Set<string>();
284
+ const foreignKeys = [
285
+ ...planRelationalColumns(collections).map(r => r.foreignKey),
286
+ ...junctions.flatMap(j => j.foreignKeys)
287
+ ];
288
+ for (const fk of foreignKeys) {
289
+ if (!fk) continue;
290
+ const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
291
+ if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
292
+ plannedConstraints.add(name);
293
+ actions.push({
294
+ kind: "add-constraint",
295
+ target: `${fk.schema}.${fk.table}.${fk.constraintName}`,
296
+ sql: fk.sql
297
+ });
298
+ }
299
+
214
300
  return { actions, statements: actions.map(a => a.sql) };
215
301
  }
216
302
 
@@ -250,7 +336,23 @@ export async function readExistingSchema(
250
336
  );
251
337
  for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
252
338
 
253
- return { tables, enums };
339
+ // `ADD CONSTRAINT` has no IF NOT EXISTS, so an existing foreign key is
340
+ // skipped by name rather than guarded in SQL.
341
+ const constraints = new Set<string>();
342
+ const { rows: constraintRows } = await client.query<{
343
+ schema: string;
344
+ table: string;
345
+ name: string;
346
+ }>(
347
+ `SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
348
+ FROM pg_constraint con
349
+ JOIN pg_class c ON con.conrelid = c.oid
350
+ JOIN pg_namespace n ON c.relnamespace = n.oid
351
+ WHERE n.nspname IN (${inList})`
352
+ );
353
+ for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
354
+
355
+ return { tables, enums, constraints };
254
356
  }
255
357
 
256
358
  /**
@@ -265,8 +367,14 @@ export async function ensureCollectionTables(
265
367
  client: Queryable,
266
368
  collections: CollectionConfig[],
267
369
  log?: (message: string) => void
268
- ): Promise<EnsurePlan> {
269
- const schemas = Array.from(new Set(collections.map(schemaOf)));
370
+ ): Promise<EnsureOutcome> {
371
+ // Junctions live alongside the collections that declare them, so their
372
+ // schema has to be read too — otherwise an existing junction reads as
373
+ // missing and its constraints as unplanned.
374
+ const schemas = Array.from(new Set([
375
+ ...collections.map(schemaOf),
376
+ ...planJunctionTables(collections).map(j => j.schema)
377
+ ]));
270
378
  for (const schema of schemas) {
271
379
  assertSafeIdentifier(schema, "schema name");
272
380
  if (schema !== "public") {
@@ -276,10 +384,11 @@ export async function ensureCollectionTables(
276
384
 
277
385
  const existing = await readExistingSchema(client, schemas);
278
386
  const plan = planCollectionSchemaEnsure(collections, existing);
387
+ const failures: { target: string; error: string }[] = [];
279
388
 
280
389
  if (plan.actions.length === 0) {
281
390
  log?.("Schema is up to date; nothing to create.");
282
- return plan;
391
+ return { ...plan, failures };
283
392
  }
284
393
 
285
394
  for (const action of plan.actions) {
@@ -287,11 +396,19 @@ export async function ensureCollectionTables(
287
396
  await client.query(action.sql);
288
397
  log?.(`${action.kind}: ${action.target}`);
289
398
  } catch (err) {
399
+ const message = err instanceof Error ? err.message : String(err);
400
+ // A foreign key is the only action that can fail on the customer's
401
+ // data rather than on the schema. The column it polices is already
402
+ // there, so the collection serves either way — record it and carry
403
+ // on rather than crash-looping the deployment.
404
+ if (action.kind === "add-constraint") {
405
+ failures.push({ target: action.target, error: message });
406
+ continue;
407
+ }
290
408
  throw new Error(
291
- `Failed to ${action.kind} ${action.target}: ` +
292
- `${err instanceof Error ? err.message : String(err)}\n ${action.sql}`
409
+ `Failed to ${action.kind} ${action.target}: ${message}\n ${action.sql}`
293
410
  );
294
411
  }
295
412
  }
296
- return plan;
413
+ return { ...plan, failures };
297
414
  }