@rebasepro/server-postgres 0.10.0 → 0.10.1-canary.14e53ae

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 (53) 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-CNlIONzj.js +304 -0
  9. package/dist/ensure-collection-tables-CNlIONzj.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +1472 -4640
  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/cdc/CdcListener.d.ts +7 -14
  18. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  19. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  20. package/dist/services/channel-bus/index.d.ts +55 -0
  21. package/dist/services/channel-history.d.ts +11 -0
  22. package/dist/services/channel-presence.d.ts +66 -0
  23. package/dist/services/pg-notify-listener.d.ts +47 -0
  24. package/dist/services/realtimeService.d.ts +114 -6
  25. package/dist/src-B0v4IKaI.js +329 -0
  26. package/dist/src-B0v4IKaI.js.map +1 -0
  27. package/dist/src-DmsRg8MR.js +4056 -0
  28. package/dist/src-DmsRg8MR.js.map +1 -0
  29. package/package.json +6 -6
  30. package/src/PostgresBootstrapper.ts +72 -3
  31. package/src/auth/ensure-tables.ts +91 -3
  32. package/src/auth/services.ts +186 -48
  33. package/src/backup/backup-cli.ts +60 -1
  34. package/src/backup/backup-cron.ts +24 -1
  35. package/src/backup/backup-logic.ts +62 -0
  36. package/src/backup/backup-service.ts +132 -13
  37. package/src/backup/pg-tools.ts +70 -2
  38. package/src/cli-helpers.ts +82 -27
  39. package/src/cli.ts +152 -6
  40. package/src/index.ts +4 -0
  41. package/src/schema/auth-schema.ts +41 -3
  42. package/src/schema/destructive-sql.ts +94 -0
  43. package/src/schema/ensure-collection-tables.test.ts +156 -0
  44. package/src/schema/ensure-collection-tables.ts +297 -0
  45. package/src/schema/generate-postgres-ddl-logic.ts +3 -3
  46. package/src/services/cdc/CdcListener.ts +27 -91
  47. package/src/services/channel-bus/ChannelBus.ts +44 -0
  48. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  49. package/src/services/channel-bus/index.ts +123 -0
  50. package/src/services/channel-history.ts +35 -0
  51. package/src/services/channel-presence.ts +148 -0
  52. package/src/services/pg-notify-listener.ts +137 -0
  53. package/src/services/realtimeService.ts +383 -14
@@ -143,36 +143,91 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
143
143
  }
144
144
  }
145
145
 
146
- export async function getTableExcludes(databaseUrl: string, collectionsPath: string): Promise<string[]> {
147
- const includes = await getTableIncludes(collectionsPath);
148
- const excludes: string[] = ["atlas_schema_revisions.*", "auth.*"];
149
-
146
+ /**
147
+ * Query the live database for every user table/view outside the system
148
+ * catalogs. Separated from {@link getTableExcludes} so its failure mode can
149
+ * be handled explicitly (fail closed) and so tests can inject a stub.
150
+ */
151
+ export async function queryExistingTables(databaseUrl: string): Promise<string[]> {
152
+ const { Client } = await import("pg");
153
+ const client = new Client({ connectionString: databaseUrl });
154
+ await client.connect();
150
155
  try {
151
- const { Client } = await import("pg");
152
- const client = new Client({ connectionString: databaseUrl });
153
- await client.connect();
154
- try {
155
- const res = await client.query(`
156
- SELECT table_schema || '.' || table_name AS full_name
157
- FROM information_schema.tables
158
- WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
159
- AND table_type IN ('BASE TABLE', 'VIEW');
160
- `);
161
-
162
- const existingTables = res.rows.map((row: { full_name: string }) => row.full_name);
163
-
164
- for (const table of existingTables) {
165
- if (!includes.includes(table)) {
166
- excludes.push(table);
167
- }
168
- }
169
- } finally {
170
- await client.end();
171
- }
156
+ const res = await client.query(`
157
+ SELECT table_schema || '.' || table_name AS full_name
158
+ FROM information_schema.tables
159
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
160
+ AND table_type IN ('BASE TABLE', 'VIEW');
161
+ `);
162
+ return res.rows.map((row: { full_name: string }) => row.full_name);
163
+ } finally {
164
+ await client.end();
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Raised when the exclude list could not be built. `db push` MUST abort on
170
+ * this rather than continue: the exclude list is the only thing shielding
171
+ * non-collection (user/system) tables from the auto-approved declarative
172
+ * apply. A partial list — the old fail-open behaviour — meant a transient
173
+ * introspection hiccup dropped every table not present in `schema.sql`.
174
+ */
175
+ export class ExcludeIntrospectionError extends Error {
176
+ constructor(message: string, readonly cause?: unknown) {
177
+ super(message);
178
+ this.name = "ExcludeIntrospectionError";
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Build the `--exclude` list that protects tables Rebase doesn't manage from
184
+ * the declarative apply. Anything not backing a collection (or its M2M
185
+ * junctions) is excluded so Atlas never drops it.
186
+ *
187
+ * Fails **closed**: if the database can't be introspected we cannot know
188
+ * which tables to protect, so we throw {@link ExcludeIntrospectionError}
189
+ * instead of returning a near-empty list and letting the caller drop
190
+ * everything.
191
+ *
192
+ * `deps` is injectable for tests; production uses the real pg-backed queries.
193
+ */
194
+ export async function getTableExcludes(
195
+ databaseUrl: string,
196
+ collectionsPath: string,
197
+ deps: {
198
+ queryExistingTables?: (databaseUrl: string) => Promise<string[]>;
199
+ getIncludes?: (collectionsPath: string) => Promise<string[]>;
200
+ } = {}
201
+ ): Promise<string[]> {
202
+ const getIncludes = deps.getIncludes ?? getTableIncludes;
203
+ const queryTables = deps.queryExistingTables ?? queryExistingTables;
204
+
205
+ const includes = await getIncludes(collectionsPath);
206
+ // Framework-owned schemas Rebase manages itself — the declarative apply
207
+ // must never touch them. `auth` holds the helper functions; `rebase` holds
208
+ // the auth tables (users, refresh_tokens, …) and Atlas's own revision
209
+ // table. Excluding both the schema object AND its contents keeps Atlas
210
+ // from planning a `DROP SCHEMA … CASCADE` — which on a live database would
211
+ // take the user/auth tables with it. `atlas_schema_revisions.*` is kept
212
+ // for backwards-compatibility with any external revision schema.
213
+ const excludes: string[] = ["atlas_schema_revisions.*", "auth", "auth.*", "rebase", "rebase.*"];
214
+
215
+ let existingTables: string[];
216
+ try {
217
+ existingTables = await queryTables(databaseUrl);
172
218
  } catch (err) {
173
- logger.warn(chalk.yellow(` ⚠️ Failed to query database for unmapped tables: ${err instanceof Error ? err.message : String(err)}`));
219
+ throw new ExcludeIntrospectionError(
220
+ `Failed to introspect the database for unmapped tables: ${err instanceof Error ? err.message : String(err)}`,
221
+ err
222
+ );
174
223
  }
175
-
224
+
225
+ for (const table of existingTables) {
226
+ if (!includes.includes(table)) {
227
+ excludes.push(table);
228
+ }
229
+ }
230
+
176
231
  return excludes;
177
232
  }
178
233
 
package/src/cli.ts CHANGED
@@ -10,10 +10,13 @@ import {
10
10
  getTableIncludes,
11
11
  getDevDatabaseUrl,
12
12
  ensureDevDatabaseExists,
13
- getTableExcludes
13
+ getTableExcludes,
14
+ ExcludeIntrospectionError
14
15
  } from "./cli-helpers";
15
16
  import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
16
17
  import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
18
+ import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
19
+ import readline from "readline";
17
20
 
18
21
  const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
19
22
 
@@ -87,7 +90,10 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
87
90
  const argsList = arg(
88
91
  {
89
92
  "--collections": String,
90
- "-c": "--collections"
93
+ "--allow-destructive": Boolean,
94
+ "--yes": Boolean,
95
+ "-c": "--collections",
96
+ "-y": "--yes"
91
97
  },
92
98
  {
93
99
  argv: rawArgs.slice(2),
@@ -171,10 +177,60 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
171
177
  if (databaseUrl) {
172
178
  await ensureAuthSchemaAndFunctions(databaseUrl);
173
179
  }
180
+
181
+ // Preview the plan before touching data. `atlas schema apply` with
182
+ // --auto-approve will silently DROP COLUMN on a field removal and
183
+ // drop+add on a rename, so we first dry-run to obtain the planned
184
+ // SQL and gate anything destructive.
185
+ const plan = await runAtlas(
186
+ "schema",
187
+ ["apply", "--to", "file://drizzle/schema.sql", "--dry-run"],
188
+ collectionsPath,
189
+ { captureStdout: true }
190
+ );
191
+ const destructive = detectDestructiveStatements(plan);
192
+ const allowDestructive = argsList["--allow-destructive"] === true || argsList["--yes"] === true;
193
+ const decision = decidePushSafety({
194
+ destructiveCount: destructive.length,
195
+ allowDestructive,
196
+ interactive: process.stdin.isTTY === true
197
+ });
198
+
199
+ if (destructive.length > 0) {
200
+ logger.warn(chalk.yellow(` ⚠️ This push includes ${destructive.length} destructive change(s) that will DESTROY data:`));
201
+ logger.warn("");
202
+ for (const d of destructive) {
203
+ logger.warn(chalk.red(` ${d.kind}: `) + chalk.gray(d.statement.replace(/\s+/g, " ")));
204
+ }
205
+ logger.warn("");
206
+ logger.warn(chalk.yellow(" Full planned changes:"));
207
+ logger.warn(chalk.gray(plan.trim().split("\n").map((l) => ` ${l}`).join("\n")));
208
+ logger.warn("");
209
+
210
+ if (decision === "refuse") {
211
+ logger.error(chalk.red(" ✗ Aborting: destructive changes require confirmation."));
212
+ logger.error(chalk.gray(" Re-run interactively, or pass --allow-destructive to proceed. Back up first: rebase db backup"));
213
+ process.exit(1);
214
+ }
215
+ if (decision === "confirm") {
216
+ const confirmed = await promptConfirm(
217
+ chalk.yellow(" Type 'yes' to apply these destructive changes (this cannot be undone): ")
218
+ );
219
+ if (!confirmed) {
220
+ logger.info(chalk.gray(" Aborted. No changes were made."));
221
+ process.exit(1);
222
+ }
223
+ } else {
224
+ // decision === "apply" via --allow-destructive
225
+ logger.warn(chalk.yellow(" Proceeding because --allow-destructive was passed."));
226
+ }
227
+ }
228
+
174
229
  await runAtlas("schema", ["apply", "--to", "file://drizzle/schema.sql", "--auto-approve"], collectionsPath);
175
230
  logger.info("");
176
231
 
177
232
  if (databaseUrl) {
233
+ await ensureAuthTables(databaseUrl, collectionsPath);
178
234
  await applyPolicies(databaseUrl);
179
235
  await reconcilePolicies(databaseUrl, collectionsPath);
180
236
  await ensureRlsUserRole(databaseUrl);
@@ -218,6 +274,43 @@ async function ensureAuthSchemaAndFunctions(databaseUrl: string): Promise<void>
218
274
  }
219
275
  }
220
276
 
277
+ /**
278
+ * Create the framework auth tables (e.g. `rebase.users`) that the generated RLS
279
+ * policies reference, before `applyPolicies` runs. Mirrors what the server does
280
+ * at boot (`PostgresBootstrapper.initializeAuth` → `ensureAuthTablesExist`).
281
+ *
282
+ * Without this, `rebase db push` against a database that has never booted the
283
+ * server fails while applying policies with `relation "rebase.users" does not
284
+ * exist` — the documented first-run does `db push` *before* the first `dev`.
285
+ * `ensureAuthTablesExist` is idempotent (CREATE TABLE IF NOT EXISTS), so it is
286
+ * safe to run on every push and harmless once the server has also created them.
287
+ */
288
+ async function ensureAuthTables(databaseUrl: string, collectionsPath: string): Promise<void> {
289
+ try {
290
+ const { drizzle } = await import("drizzle-orm/node-postgres");
291
+ const { ensureAuthTablesExist } = await import("./auth/ensure-tables");
292
+ const { loadCollections } = await import("./schema/doctor");
293
+ const { Client } = await import("pg");
294
+
295
+ const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
296
+ // The auth collection is flagged with `auth: true` or `auth: { enabled: true }`.
297
+ const authCollection = collections.find((c) => {
298
+ const a = c.auth;
299
+ return a === true || (typeof a === "object" && a !== null && a.enabled === true);
300
+ });
301
+
302
+ const client = new Client({ connectionString: databaseUrl });
303
+ await client.connect();
304
+ try {
305
+ await ensureAuthTablesExist(drizzle(client), authCollection);
306
+ } finally {
307
+ await client.end();
308
+ }
309
+ } catch (err) {
310
+ logger.warn(chalk.yellow(` ⚠️ Failed to ensure framework auth tables: ${err instanceof Error ? err.message : String(err)}`));
311
+ }
312
+ }
313
+
221
314
  /**
222
315
  * Provision the restricted `rebase_user` role right after schema changes land,
223
316
  * so grants cover freshly created tables (default privileges cover future
@@ -517,7 +610,29 @@ function timeAgo(date: Date): string {
517
610
 
518
611
 
519
612
 
520
- async function runAtlas(domain: "schema" | "migrate", args: string[], collectionsPath?: string): Promise<void> {
613
+ /**
614
+ * Ask a yes/no question on an interactive terminal. Non-interactive shells
615
+ * (CI, pipes, agents) can't answer, so this must only be reached after
616
+ * {@link decidePushSafety} has already ruled that interactive confirmation is
617
+ * possible.
618
+ */
619
+ async function promptConfirm(question: string): Promise<boolean> {
620
+ if (!process.stdin.isTTY) return false;
621
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
622
+ try {
623
+ const answer: string = await new Promise((resolve) => rl.question(question, resolve));
624
+ return /^y(es)?$/i.test(answer.trim());
625
+ } finally {
626
+ rl.close();
627
+ }
628
+ }
629
+
630
+ async function runAtlas(
631
+ domain: "schema" | "migrate",
632
+ args: string[],
633
+ collectionsPath?: string,
634
+ opts: { captureStdout?: boolean } = {}
635
+ ): Promise<string> {
521
636
  const atlasBin = resolveLocalBin("atlas");
522
637
  if (!atlasBin) {
523
638
  logger.error(chalk.red("✗ Could not find atlas binary."));
@@ -587,7 +702,24 @@ async function runAtlas(domain: "schema" | "migrate", args: string[], collection
587
702
  }
588
703
 
589
704
  if (domain === "schema" && args.includes("apply") && collectionsPath) {
590
- const excludes = await getTableExcludes(databaseUrl, collectionsPath);
705
+ // Fail CLOSED: the exclude list is the only thing shielding
706
+ // non-collection tables from the auto-approved apply. If we can't
707
+ // introspect the database to build it, abort rather than proceed with
708
+ // a partial list that would let Atlas drop unmanaged tables.
709
+ let excludes: string[];
710
+ try {
711
+ excludes = await getTableExcludes(databaseUrl, collectionsPath);
712
+ } catch (err) {
713
+ if (err instanceof ExcludeIntrospectionError) {
714
+ logger.error(chalk.red("\n✗ Aborting push: could not determine which tables to protect."));
715
+ logger.error(chalk.gray(` ${err.message}`));
716
+ logger.error(chalk.gray(" Refusing to apply — a partial exclude list could drop tables Rebase does not manage."));
717
+ const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
718
+ if (hint) logger.error(hint);
719
+ process.exit(1);
720
+ }
721
+ throw err;
722
+ }
591
723
  for (const exc of excludes) {
592
724
  atlasArgs.push("--exclude", exc);
593
725
  }
@@ -595,13 +727,20 @@ async function runAtlas(domain: "schema" | "migrate", args: string[], collection
595
727
 
596
728
  // Stream stdout live but tee stderr so we can inspect Atlas's error text
597
729
  // for known, actionable failure modes (e.g. a dependency-drop that leaves
598
- // the schema half-applied) after the process exits.
730
+ // the schema half-applied) after the process exits. When capturing (used
731
+ // for the destructive-change dry-run), pipe stdout and collect it instead.
599
732
  const subprocess = execa(atlasBin, atlasArgs, {
600
733
  cwd: process.cwd(),
601
- stdout: "inherit",
734
+ stdout: opts.captureStdout ? "pipe" : "inherit",
602
735
  stderr: "pipe",
603
736
  env
604
737
  });
738
+ let stdoutText = "";
739
+ if (opts.captureStdout) {
740
+ subprocess.stdout?.on("data", (chunk: Buffer) => {
741
+ stdoutText += chunk.toString();
742
+ });
743
+ }
605
744
  let stderrText = "";
606
745
  subprocess.stderr?.on("data", (chunk: Buffer) => {
607
746
  const text = chunk.toString();
@@ -619,6 +758,13 @@ async function runAtlas(domain: "schema" | "migrate", args: string[], collection
619
758
  }
620
759
  process.exit(1);
621
760
  }
761
+ // Atlas prints the plan to stdout, but fall back to stderr so the
762
+ // destructive-change detector never misses a plan on a version/build that
763
+ // routes it differently.
764
+ if (opts.captureStdout) {
765
+ return stdoutText.trim().length > 0 ? stdoutText : stderrText;
766
+ }
767
+ return "";
622
768
  }
623
769
 
624
770
  async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
package/src/index.ts CHANGED
@@ -7,6 +7,10 @@ export * from "./schema/generate-drizzle-schema-logic";
7
7
  export * from "./schema/generate-drizzle-schema";
8
8
  export * from "./utils/drizzle-conditions";
9
9
  export * from "./services/realtimeService";
10
+ // The channel bus is a public extension point: a transport published as its own
11
+ // package implements `ChannelBus` (declared in @rebasepro/types and re-exported
12
+ // here) and is passed to `realtime.bus`. Without this export it would be one.
13
+ export * from "./services/channel-bus";
10
14
  export * from "./websocket";
11
15
  export * from "./collections/PostgresCollectionRegistry";
12
16
  export * from "./services/BranchService";
@@ -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
+ }