@rebasepro/server-postgres 0.10.1-canary.a54c057 → 0.10.1-canary.af2af91

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 (69) hide show
  1. package/dist/PostgresBootstrapper.d.ts +7 -3
  2. package/dist/auth/services.d.ts +43 -4
  3. package/dist/backup/backup-logic.d.ts +23 -0
  4. package/dist/backup/backup-service.d.ts +44 -2
  5. package/dist/backup/pg-tools.d.ts +41 -1
  6. package/dist/chunk-DSJWtz9O.js +40 -0
  7. package/dist/cli-helpers.d.ts +33 -1
  8. package/dist/ensure-collection-tables-BVvtkkRm.js +304 -0
  9. package/dist/ensure-collection-tables-BVvtkkRm.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +1854 -4745
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/schema/auth-schema.d.ts +170 -0
  14. package/dist/schema/destructive-sql.d.ts +49 -0
  15. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  16. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  17. package/dist/services/FetchService.d.ts +21 -8
  18. package/dist/services/PersistService.d.ts +12 -0
  19. package/dist/services/RelationService.d.ts +30 -0
  20. package/dist/services/cdc/CdcListener.d.ts +7 -14
  21. package/dist/services/cdc/junction-tables.d.ts +38 -0
  22. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  23. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  24. package/dist/services/channel-bus/index.d.ts +55 -0
  25. package/dist/services/channel-history.d.ts +11 -0
  26. package/dist/services/channel-presence.d.ts +66 -0
  27. package/dist/services/nested-path.d.ts +59 -0
  28. package/dist/services/pg-notify-listener.d.ts +47 -0
  29. package/dist/services/realtimeService.d.ts +133 -6
  30. package/dist/src-CBgtrPhJ.js +336 -0
  31. package/dist/src-CBgtrPhJ.js.map +1 -0
  32. package/dist/src-lcfUP4xg.js +4106 -0
  33. package/dist/src-lcfUP4xg.js.map +1 -0
  34. package/dist/utils/drizzle-conditions.d.ts +41 -0
  35. package/package.json +8 -9
  36. package/src/PostgresBootstrapper.ts +82 -4
  37. package/src/auth/ensure-tables.ts +70 -56
  38. package/src/auth/services.ts +186 -48
  39. package/src/backup/backup-cli.ts +60 -1
  40. package/src/backup/backup-cron.ts +24 -1
  41. package/src/backup/backup-logic.ts +62 -0
  42. package/src/backup/backup-service.ts +132 -13
  43. package/src/backup/pg-tools.ts +70 -2
  44. package/src/cli-helpers.ts +82 -27
  45. package/src/cli.ts +152 -6
  46. package/src/index.ts +4 -0
  47. package/src/schema/auth-schema.ts +41 -3
  48. package/src/schema/destructive-sql.ts +94 -0
  49. package/src/schema/doctor.ts +6 -6
  50. package/src/schema/ensure-collection-tables.test.ts +156 -0
  51. package/src/schema/ensure-collection-tables.ts +297 -0
  52. package/src/schema/generate-drizzle-schema-logic.ts +13 -9
  53. package/src/schema/generate-postgres-ddl-logic.ts +22 -15
  54. package/src/schema/introspect-db-inference.ts +13 -13
  55. package/src/schema/introspect-db-logic.ts +6 -6
  56. package/src/services/FetchService.ts +104 -114
  57. package/src/services/PersistService.ts +127 -85
  58. package/src/services/RelationService.ts +95 -6
  59. package/src/services/cdc/CdcListener.ts +27 -91
  60. package/src/services/cdc/junction-tables.ts +91 -0
  61. package/src/services/channel-bus/ChannelBus.ts +44 -0
  62. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  63. package/src/services/channel-bus/index.ts +123 -0
  64. package/src/services/channel-history.ts +35 -0
  65. package/src/services/channel-presence.ts +148 -0
  66. package/src/services/nested-path.ts +130 -0
  67. package/src/services/pg-notify-listener.ts +137 -0
  68. package/src/services/realtimeService.ts +443 -14
  69. package/src/utils/drizzle-conditions.ts +154 -0
@@ -1,4 +1,4 @@
1
- import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
@@ -25,24 +25,62 @@ export function createAuthSchema(usersSchemaName = "rebase") {
25
25
  isAnonymous: boolean("is_anonymous").default(false).notNull(),
26
26
  roles: text("roles").array().default([]).notNull(),
27
27
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
28
+ /**
29
+ * Sessions that began before this instant are dead, whatever tokens
30
+ * they still hold. Password resets and admin revocations stamp it.
31
+ *
32
+ * Deleting the user's refresh-token rows (which we also do) is not
33
+ * sufficient on its own: a request already in flight can insert a
34
+ * freshly rotated row microseconds after the delete and survive it.
35
+ * This timestamp cannot be outrun that way — it is checked against
36
+ * `refresh_tokens.session_started_at`, which rotation carries forward.
37
+ */
38
+ tokensValidAfter: timestamp("tokens_valid_after"),
28
39
  createdAt: timestamp("created_at").defaultNow().notNull(),
29
40
  updatedAt: timestamp("updated_at").defaultNow().notNull()
30
41
  });
31
42
 
32
43
 
33
44
  /**
34
- * Refresh tokens for long-lived sessions
45
+ * Refresh tokens for long-lived sessions.
46
+ *
47
+ * A row is one token, not one device. Every token minted from the same
48
+ * sign-in shares a `sessionId`, and rotation ADDS a row rather than
49
+ * replacing one: the superseded token stays on file, flagged `revoked`
50
+ * with a `rotatedAt` stamp. That record is what lets the refresh endpoint
51
+ * tell a client replaying a token it never got an answer for (a response
52
+ * lost to a redeploy, a second tab racing on boot) apart from a stranger
53
+ * presenting a token that was never issued. Deleting the old row on sight
54
+ * — the previous behaviour — made those two cases indistinguishable, and
55
+ * the legitimate one is overwhelmingly the common one.
56
+ *
57
+ * There is deliberately NO unique constraint on (uid, user_agent,
58
+ * ip_address). Keying a session on the IP meant one row per "device",
59
+ * so a second browser profile behind the same NAT silently evicted the
60
+ * first, and a phone changing networks orphaned a row on every hop.
61
+ * User agent and IP are descriptive metadata for the sessions list;
62
+ * `sessionId` is the identity.
35
63
  */
36
64
  const refreshTokens = tableCreator("refresh_tokens", {
37
65
  id: uuid("id").defaultRandom().primaryKey(),
38
66
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
67
+ sessionId: uuid("session_id").defaultRandom().notNull(),
39
68
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
40
69
  expiresAt: timestamp("expires_at").notNull(),
70
+ revoked: boolean("revoked").default(false).notNull(),
71
+ rotatedAt: timestamp("rotated_at"),
72
+ /**
73
+ * When the sign-in this token descends from happened — carried across
74
+ * every rotation, unlike `createdAt`. `users.tokensValidAfter` is
75
+ * compared against this, so a revocation cannot be outrun by a token
76
+ * that rotates immediately after it.
77
+ */
78
+ sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
41
79
  userAgent: varchar("user_agent", { length: 500 }),
42
80
  ipAddress: varchar("ip_address", { length: 45 }),
43
81
  createdAt: timestamp("created_at").defaultNow().notNull()
44
82
  }, (table) => ({
45
- uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress)
83
+ sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
46
84
  }));
47
85
 
48
86
  /**
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure helpers that classify an Atlas declarative-apply plan as destructive
3
+ * or not, and decide what `rebase db push` should do about it.
4
+ *
5
+ * `db push` runs `atlas schema apply` to make the live database match the
6
+ * generated `schema.sql`. Removing a collection field compiles to
7
+ * `DROP COLUMN`; renaming compiles to drop-then-add — either destroys data.
8
+ * We first run the apply with `--dry-run` to obtain the planned SQL, scan it
9
+ * here, and refuse to auto-approve anything destructive without an explicit
10
+ * opt-in.
11
+ *
12
+ * Everything in this file is side-effect free so it can be unit-tested
13
+ * without Atlas or a database.
14
+ */
15
+
16
+ /**
17
+ * SQL fragments that destroy data or data-bearing objects. Matched
18
+ * case-insensitively against each statement of the plan. `IF EXISTS` /
19
+ * whitespace variations are tolerated by the regexes below.
20
+ */
21
+ const DESTRUCTIVE_PATTERNS: { label: string; re: RegExp }[] = [
22
+ { label: "DROP TABLE", re: /\bDROP\s+TABLE\b/i },
23
+ { label: "DROP COLUMN", re: /\bDROP\s+COLUMN\b/i },
24
+ { label: "DROP SCHEMA", re: /\bDROP\s+SCHEMA\b/i },
25
+ { label: "DROP VIEW", re: /\bDROP\s+(MATERIALIZED\s+)?VIEW\b/i },
26
+ { label: "DROP TYPE", re: /\bDROP\s+TYPE\b/i },
27
+ { label: "TRUNCATE", re: /\bTRUNCATE\b/i }
28
+ ];
29
+
30
+ /**
31
+ * Split a SQL script into individual statements, dropping blank lines and
32
+ * `--` comment lines. Deliberately simple: Atlas emits one plain statement
33
+ * per `;`, without string literals that contain semicolons in a schema DDL
34
+ * plan, so a naive split is safe and keeps this dependency-free.
35
+ */
36
+ export function splitSqlStatements(sql: string): string[] {
37
+ // Strip full-line SQL comments so a commented-out DROP never trips the
38
+ // detector, then split on semicolons.
39
+ const withoutComments = sql
40
+ .split("\n")
41
+ .filter((line) => !line.trim().startsWith("--"))
42
+ .join("\n");
43
+ return withoutComments
44
+ .split(";")
45
+ .map((s) => s.trim())
46
+ .filter((s) => s.length > 0);
47
+ }
48
+
49
+ export interface DestructiveStatement {
50
+ /** The offending statement (trimmed, without the trailing `;`). */
51
+ statement: string;
52
+ /** Which destructive operation it was flagged for, e.g. "DROP COLUMN". */
53
+ kind: string;
54
+ }
55
+
56
+ /**
57
+ * Scan an Atlas plan (the SQL printed by `schema apply --dry-run`) and return
58
+ * the statements that would destroy data. An empty array means the plan is
59
+ * safe to auto-approve.
60
+ */
61
+ export function detectDestructiveStatements(planSql: string): DestructiveStatement[] {
62
+ const found: DestructiveStatement[] = [];
63
+ for (const statement of splitSqlStatements(planSql)) {
64
+ for (const { label, re } of DESTRUCTIVE_PATTERNS) {
65
+ if (re.test(statement)) {
66
+ found.push({ statement, kind: label });
67
+ break; // one label per statement is enough to flag it
68
+ }
69
+ }
70
+ }
71
+ return found;
72
+ }
73
+
74
+ export type PushDecision = "apply" | "confirm" | "refuse";
75
+
76
+ /**
77
+ * Decide how `db push` should proceed given the plan's destructiveness and
78
+ * the invocation context.
79
+ *
80
+ * - No destructive statements → `apply` (safe to auto-approve).
81
+ * - Destructive + `--allow-destructive` → `apply` (operator opted in).
82
+ * - Destructive + interactive TTY → `confirm` (prompt before applying).
83
+ * - Destructive + non-interactive → `refuse` (never silently drop data in
84
+ * CI / scripts / agents).
85
+ */
86
+ export function decidePushSafety(opts: {
87
+ destructiveCount: number;
88
+ allowDestructive: boolean;
89
+ interactive: boolean;
90
+ }): PushDecision {
91
+ if (opts.destructiveCount === 0) return "apply";
92
+ if (opts.allowDestructive) return "apply";
93
+ return opts.interactive ? "confirm" : "refuse";
94
+ }
@@ -62,12 +62,10 @@ export function getExpectedColumnType(prop: Property): string | null {
62
62
  if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
63
63
  if ("isId" in sp && sp.isId === "uuid") return "uuid";
64
64
  if (sp.columnType === "uuid") return "uuid";
65
- // A markdown/multiline string compiles to `text`, not varchar — the
66
- // generator treats those UI hints as column-type signals, so the
67
- // expectation here must too or every such column reads as drift.
68
- if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
69
65
  if (sp.columnType === "char") return "character";
70
- return "character varying";
66
+ if (sp.columnType === "varchar") return "character varying";
67
+ // `text` is the default — see generate-postgres-ddl-logic.
68
+ return "text";
71
69
  }
72
70
  case "number": {
73
71
  const np = prop as NumberProperty;
@@ -122,7 +120,9 @@ export function getExpectedColumnType(prop: Property): string | null {
122
120
  case "relation":
123
121
  return null; // FK columns are derived from the relation, not from the property
124
122
  case "reference":
125
- return "character varying"; // References default to varchar FK
123
+ // A reference FK follows the key it points at, and a string key is
124
+ // `text` — see generate-postgres-ddl-logic.
125
+ return "text";
126
126
  case "vector":
127
127
  return "USER-DEFINED";
128
128
  case "binary":
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import type { CollectionConfig } from "@rebasepro/types";
3
+ import {
4
+ planCollectionSchemaEnsure,
5
+ ensureCollectionTables,
6
+ type ExistingSchema,
7
+ type Queryable
8
+ } from "./ensure-collection-tables";
9
+
10
+ const posts = {
11
+ name: "Posts",
12
+ slug: "posts",
13
+ properties: {
14
+ id: { name: "ID", type: "string", isId: "uuid" },
15
+ title: { name: "Title", type: "string" },
16
+ views: { name: "Views", type: "number" },
17
+ status: {
18
+ name: "Status",
19
+ type: "string",
20
+ enum: [
21
+ { id: "draft", label: "Draft" },
22
+ { id: "published", label: "Published" }
23
+ ]
24
+ }
25
+ }
26
+ } as unknown as CollectionConfig;
27
+
28
+ const empty = (): ExistingSchema => ({ tables: new Map(), enums: new Set() });
29
+
30
+ const withTable = (key: string, columns: string[]): ExistingSchema => ({
31
+ tables: new Map([[key, new Set(columns)]]),
32
+ enums: new Set()
33
+ });
34
+
35
+ describe("planning an additive schema ensure", () => {
36
+ it("creates a missing table, its enum type, and its columns in that order", () => {
37
+ const plan = planCollectionSchemaEnsure([posts], empty());
38
+ const kinds = plan.actions.map(a => a.kind);
39
+
40
+ // The enum must exist before the column that references it, and the
41
+ // table before its own columns.
42
+ expect(kinds.indexOf("create-enum")).toBeLessThan(kinds.indexOf("create-table"));
43
+ expect(kinds.indexOf("create-table")).toBeLessThan(kinds.indexOf("add-column"));
44
+ expect(plan.statements.join("\n")).toMatch(/CREATE TABLE IF NOT EXISTS "public"\."posts"/);
45
+ });
46
+
47
+ it("adds only the columns an existing table is missing", () => {
48
+ const plan = planCollectionSchemaEnsure([posts], withTable("public.posts", ["id", "title"]));
49
+ const added = plan.actions.filter(a => a.kind === "add-column").map(a => a.target);
50
+
51
+ expect(added).toContain("public.posts.views");
52
+ expect(added).toContain("public.posts.status");
53
+ expect(added).not.toContain("public.posts.title");
54
+ expect(plan.actions.some(a => a.kind === "create-table")).toBe(false);
55
+ });
56
+
57
+ it("is a no-op against a database that is already current", () => {
58
+ const existing: ExistingSchema = {
59
+ tables: new Map([["public.posts", new Set(["id", "title", "views", "status"])]]),
60
+ enums: new Set(["public.posts_status"])
61
+ };
62
+ expect(planCollectionSchemaEnsure([posts], existing).actions).toEqual([]);
63
+ });
64
+
65
+ it("skips an enum type that already exists, since CREATE TYPE has no IF NOT EXISTS", () => {
66
+ const existing: ExistingSchema = { tables: new Map(), enums: new Set(["public.posts_status"]) };
67
+ const plan = planCollectionSchemaEnsure([posts], existing);
68
+ expect(plan.actions.some(a => a.kind === "create-enum")).toBe(false);
69
+ });
70
+
71
+ it("NEVER emits a destructive statement, whatever the database contains", () => {
72
+ // The core safety property. This runs unattended against customer data
73
+ // with nobody reading a diff, so a column the collections no longer
74
+ // mention must be left alone, not dropped.
75
+ const existing = withTable("public.posts", ["id", "title", "legacy_column", "another_old_one"]);
76
+ const sql = planCollectionSchemaEnsure([posts], existing).statements.join("\n");
77
+
78
+ expect(sql).not.toMatch(/\bDROP\b/i);
79
+ expect(sql).not.toMatch(/\bTRUNCATE\b/i);
80
+ expect(sql).not.toMatch(/ALTER COLUMN/i);
81
+ expect(sql).not.toMatch(/legacy_column/);
82
+ });
83
+
84
+ it("never adds a NOT NULL column, which an existing table with rows could not take", () => {
85
+ const sql = planCollectionSchemaEnsure([posts], withTable("public.posts", ["id"]))
86
+ .statements.join("\n");
87
+ expect(sql).not.toMatch(/NOT NULL/i);
88
+ });
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 }
96
+ }
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);
100
+ });
101
+ });
102
+
103
+ describe("applying the plan", () => {
104
+ function fakeClient(): { client: Queryable; executed: string[] } {
105
+ const executed: string[] = [];
106
+ const client: Queryable = {
107
+ async query<T>(sql: string): Promise<{ rows: T[] }> {
108
+ executed.push(sql);
109
+ return { rows: [] as T[] };
110
+ }
111
+ };
112
+ return { client, executed };
113
+ }
114
+
115
+ it("creates the schema, reads what exists, then applies", async () => {
116
+ const { client, executed } = fakeClient();
117
+ const plan = await ensureCollectionTables(client, [posts]);
118
+
119
+ expect(plan.actions.length).toBeGreaterThan(0);
120
+ expect(executed.some(s => s.includes("information_schema.columns"))).toBe(true);
121
+ expect(executed.some(s => s.includes("CREATE TABLE IF NOT EXISTS"))).toBe(true);
122
+ });
123
+
124
+ it("surfaces which statement failed rather than a bare driver error", async () => {
125
+ const client: Queryable = {
126
+ async query<T>(sql: string): Promise<{ rows: T[] }> {
127
+ if (sql.startsWith("CREATE TABLE")) throw new Error("permission denied");
128
+ return { rows: [] as T[] };
129
+ }
130
+ };
131
+ await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/permission denied/);
132
+ await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
133
+ });
134
+
135
+ it("does nothing when the database is already current", async () => {
136
+ const client: Queryable = {
137
+ async query<T>(sql: string): Promise<{ rows: T[] }> {
138
+ if (sql.includes("information_schema.columns")) {
139
+ return {
140
+ rows: ["id", "title", "views", "status"].map(c => ({
141
+ table_schema: "public",
142
+ table_name: "posts",
143
+ column_name: c
144
+ })) as unknown as T[]
145
+ };
146
+ }
147
+ if (sql.includes("pg_type")) {
148
+ return { rows: [{ schema: "public", name: "posts_status" }] as unknown as T[] };
149
+ }
150
+ return { rows: [] as T[] };
151
+ }
152
+ };
153
+ const plan = await ensureCollectionTables(client, [posts]);
154
+ expect(plan.actions).toEqual([]);
155
+ });
156
+ });
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Bringing a database up to date with a bundle's collections, additively.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * A managed runtime boots someone else's compiled project against a database it
7
+ * has never seen. Auth tables are ensured at boot already, but collection tables
8
+ * were not created by anything: the platform ran the app and every `/api/data/*`
9
+ * request answered 500 on a missing relation. `rebase db push` cannot help — it
10
+ * is an Atlas-driven CLI command, and the runtime image ships no CLI.
11
+ *
12
+ * ## Why additive-only, forever
13
+ *
14
+ * This runs unattended, against a database with customers' data in it, with no
15
+ * human reading a diff. So it may only ever do things that cannot lose data:
16
+ * create a missing table, add a missing column, create a missing enum type.
17
+ *
18
+ * It will **never** drop a table or a column, narrow a type, or alter a
19
+ * constraint. A removed field leaves its column behind; a renamed field looks
20
+ * like an addition and the old column stays. That is the correct trade for an
21
+ * automated path — the alternative is an unattended process that can silently
22
+ * destroy a column, which is precisely the failure `db push` was hardened
23
+ * against. Destructive changes stay a deliberate, human-reviewed migration.
24
+ *
25
+ * Because of that, this is safe to run on every boot, and re-running it is a
26
+ * no-op.
27
+ */
28
+ import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
29
+ import { getTableName } from "@rebasepro/common";
30
+ import {
31
+ getSqlColumnType,
32
+ resolveColumnName,
33
+ isIdProperty
34
+ } from "./generate-postgres-ddl-logic";
35
+
36
+ /**
37
+ * The subset of a database handle this needs: run a statement, get rows back.
38
+ *
39
+ * Deliberately parameterless. Everything here is DDL or catalogue reads keyed by
40
+ * schema name, and schema names are identifiers — they cannot be bound as
41
+ * parameters anyway. They are validated against {@link SAFE_IDENTIFIER} before
42
+ * they reach a statement, so a config that somehow carried a quote is refused
43
+ * rather than concatenated.
44
+ */
45
+ export interface Queryable {
46
+ query<T = unknown>(sql: string): Promise<{ rows: T[] }>;
47
+ }
48
+
49
+ /** Postgres identifiers this module is willing to interpolate. */
50
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
51
+
52
+ function assertSafeIdentifier(value: string, what: string): string {
53
+ if (!SAFE_IDENTIFIER.test(value)) {
54
+ throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ /** What the database currently has, as the planner needs it. */
60
+ export interface ExistingSchema {
61
+ /** `schema.table` → set of column names. */
62
+ tables: Map<string, Set<string>>;
63
+ /** `schema.typename` of every enum type that already exists. */
64
+ enums: Set<string>;
65
+ }
66
+
67
+ export interface EnsureAction {
68
+ kind: "create-enum" | "create-table" | "add-column";
69
+ /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
70
+ target: string;
71
+ sql: string;
72
+ }
73
+
74
+ export interface EnsurePlan {
75
+ actions: EnsureAction[];
76
+ /** Every statement, in dependency order. Empty when the schema is current. */
77
+ statements: string[];
78
+ }
79
+
80
+ function schemaOf(collection: CollectionConfig): string {
81
+ return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
82
+ }
83
+
84
+ function qualified(collection: CollectionConfig): string {
85
+ return `${schemaOf(collection)}.${getTableName(collection)}`;
86
+ }
87
+
88
+ /**
89
+ * Enum types a collection's properties require, as `schema.typename`.
90
+ *
91
+ * Named exactly as the DDL generator names them (`<table>_<column>`), because
92
+ * a column added here has to reference the same type the generator would have
93
+ * created — a second, differently-named type for the same field would be a
94
+ * silent schema fork.
95
+ */
96
+ function requiredEnums(collection: CollectionConfig): { name: string; values: string[] }[] {
97
+ const table = getTableName(collection);
98
+ const schema = schemaOf(collection);
99
+ const out: { name: string; values: string[] }[] = [];
100
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
101
+ const p = prop as Property;
102
+ if (!("enum" in p) || !p.enum) continue;
103
+ if (p.type !== "string" && p.type !== "number") continue;
104
+ const values = (p.enum as unknown[])
105
+ .map(entry =>
106
+ entry && typeof entry === "object" && "id" in (entry as Record<string, unknown>)
107
+ ? String((entry as Record<string, unknown>).id)
108
+ : String(entry)
109
+ )
110
+ .filter(v => v.length > 0);
111
+ if (values.length === 0) continue;
112
+ out.push({ name: `${schema}.${table}_${resolveColumnName(propName, p)}`, values });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Single-quote escaping for an enum label. */
118
+ function quoteLiteral(value: string): string {
119
+ return `'${value.replace(/'/g, "''")}'`;
120
+ }
121
+
122
+ /**
123
+ * Decide what to add. Pure — the caller supplies what exists and runs the result.
124
+ *
125
+ * Ordering matters and is deliberate: enum types before the tables and columns
126
+ * that reference them, tables before the columns added to other tables (a new
127
+ * table may be the target of a relation), and nothing is emitted twice.
128
+ */
129
+ export function planCollectionSchemaEnsure(
130
+ collections: CollectionConfig[],
131
+ existing: ExistingSchema
132
+ ): EnsurePlan {
133
+ const actions: EnsureAction[] = [];
134
+ const plannedEnums = new Set<string>();
135
+
136
+ // 1. Enum types. `CREATE TYPE` has no IF NOT EXISTS, so an existing type is
137
+ // skipped by name rather than guarded in SQL.
138
+ for (const collection of collections) {
139
+ for (const { name, values } of requiredEnums(collection)) {
140
+ if (existing.enums.has(name) || plannedEnums.has(name)) continue;
141
+ plannedEnums.add(name);
142
+ const [schema, typeName] = name.split(".");
143
+ actions.push({
144
+ kind: "create-enum",
145
+ target: name,
146
+ sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteLiteral).join(", ")});`
147
+ });
148
+ }
149
+ }
150
+
151
+ // 2. Missing tables. Only the identity column is created here; every other
152
+ // column is added by step 3, so a new table and an existing table that
153
+ // gained a field travel the exact same code path. One way to build a
154
+ // column means one way for it to be wrong.
155
+ const created = new Set<string>();
156
+ for (const collection of collections) {
157
+ const key = qualified(collection);
158
+ if (existing.tables.has(key) || created.has(key)) continue;
159
+ created.add(key);
160
+ const schema = schemaOf(collection);
161
+ const table = getTableName(collection);
162
+ const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) =>
163
+ isIdProperty(n, p as Property, collection)
164
+ );
165
+ const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1] as Property) : "id";
166
+ const idProp = idEntry?.[1] as Property | undefined;
167
+ let idDef: string;
168
+ if (idProp?.type === "number") {
169
+ idDef = `"${idName}" BIGSERIAL PRIMARY KEY`;
170
+ } else if (
171
+ idProp &&
172
+ idProp.type === "string" &&
173
+ (idProp as { isId?: unknown }).isId === "uuid"
174
+ ) {
175
+ idDef = `"${idName}" UUID PRIMARY KEY DEFAULT gen_random_uuid()`;
176
+ } else {
177
+ idDef = `"${idName}" TEXT PRIMARY KEY`;
178
+ }
179
+ actions.push({
180
+ kind: "create-table",
181
+ target: key,
182
+ sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${table}" (${idDef});`
183
+ });
184
+ }
185
+
186
+ // 3. Missing columns, on both brand-new and pre-existing tables.
187
+ for (const collection of collections) {
188
+ const key = qualified(collection);
189
+ const schema = schemaOf(collection);
190
+ const table = getTableName(collection);
191
+ const present = existing.tables.get(key) ?? new Set<string>();
192
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
193
+ const p = prop as Property;
194
+ 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.
199
+ 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
+ });
211
+ }
212
+ }
213
+
214
+ return { actions, statements: actions.map(a => a.sql) };
215
+ }
216
+
217
+ /** Read what the database has, for the schemas the collections live in. */
218
+ export async function readExistingSchema(
219
+ client: Queryable,
220
+ schemas: string[]
221
+ ): Promise<ExistingSchema> {
222
+ const tables = new Map<string, Set<string>>();
223
+ const enums = new Set<string>();
224
+ if (schemas.length === 0) return { tables, enums };
225
+
226
+ const inList = schemas
227
+ .map(schema => `'${assertSafeIdentifier(schema, "schema name")}'`)
228
+ .join(", ");
229
+
230
+ const { rows: columns } = await client.query<{
231
+ table_schema: string;
232
+ table_name: string;
233
+ column_name: string;
234
+ }>(
235
+ `SELECT table_schema, table_name, column_name
236
+ FROM information_schema.columns
237
+ WHERE table_schema IN (${inList})`
238
+ );
239
+ for (const row of columns) {
240
+ const key = `${row.table_schema}.${row.table_name}`;
241
+ if (!tables.has(key)) tables.set(key, new Set());
242
+ tables.get(key)!.add(row.column_name);
243
+ }
244
+
245
+ const { rows: enumRows } = await client.query<{ schema: string; name: string }>(
246
+ `SELECT n.nspname AS schema, t.typname AS name
247
+ FROM pg_type t
248
+ JOIN pg_namespace n ON t.typnamespace = n.oid
249
+ WHERE t.typtype = 'e' AND n.nspname IN (${inList})`
250
+ );
251
+ for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
252
+
253
+ return { tables, enums };
254
+ }
255
+
256
+ /**
257
+ * Bring the database up to date. Returns what it did.
258
+ *
259
+ * Each statement runs on its own rather than in one transaction: they are all
260
+ * independently safe and idempotent, and a single failure (an enum label that
261
+ * cannot be added, say) should not roll back the tables that were created fine.
262
+ * The error is surfaced with the statement that caused it.
263
+ */
264
+ export async function ensureCollectionTables(
265
+ client: Queryable,
266
+ collections: CollectionConfig[],
267
+ log?: (message: string) => void
268
+ ): Promise<EnsurePlan> {
269
+ const schemas = Array.from(new Set(collections.map(schemaOf)));
270
+ for (const schema of schemas) {
271
+ assertSafeIdentifier(schema, "schema name");
272
+ if (schema !== "public") {
273
+ await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
274
+ }
275
+ }
276
+
277
+ const existing = await readExistingSchema(client, schemas);
278
+ const plan = planCollectionSchemaEnsure(collections, existing);
279
+
280
+ if (plan.actions.length === 0) {
281
+ log?.("Schema is up to date; nothing to create.");
282
+ return plan;
283
+ }
284
+
285
+ for (const action of plan.actions) {
286
+ try {
287
+ await client.query(action.sql);
288
+ log?.(`${action.kind}: ${action.target}`);
289
+ } catch (err) {
290
+ throw new Error(
291
+ `Failed to ${action.kind} ${action.target}: ` +
292
+ `${err instanceof Error ? err.message : String(err)}\n ${action.sql}`
293
+ );
294
+ }
295
+ }
296
+ return plan;
297
+ }