@rebasepro/server-postgres 0.16.1-canary.ge71347e → 0.17.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 (40) hide show
  1. package/dist/PostgresBackendDriver.d.ts +59 -5
  2. package/dist/{backup-service-BL5x6Fj5.js → backup-service-BtgHxfFm.js} +2 -1
  3. package/dist/{backup-service-BL5x6Fj5.js.map → backup-service-BtgHxfFm.js.map} +1 -1
  4. package/dist/cli-helpers.d.ts +41 -0
  5. package/dist/{ensure-collection-tables-C_Gr59le.js → collection-index-DxJBvVTH.js} +427 -1914
  6. package/dist/collection-index-DxJBvVTH.js.map +1 -0
  7. package/dist/{ensure-collection-policies-CMYAvFpM.js → ensure-collection-policies-DFpOl8SM.js} +3 -3
  8. package/dist/{ensure-collection-policies-CMYAvFpM.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
  9. package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
  10. package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
  11. package/dist/index.es.js +13 -7488
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{rls-enforcement-HLy7w5hL.js → rls-enforcement-CInuYj1-.js} +3 -3
  14. package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
  15. package/dist/schema/collection-index.d.ts +182 -0
  16. package/dist/schema/introspect-db-inference.d.ts +1 -1
  17. package/dist/schema/introspect-db-logic.d.ts +4 -4
  18. package/dist/schema/introspect-db-project.d.ts +2 -2
  19. package/dist/src-DiDgtX8P.js.map +1 -1
  20. package/dist/websocket-HcyLl1ZM.js +8188 -0
  21. package/dist/websocket-HcyLl1ZM.js.map +1 -0
  22. package/package.json +6 -6
  23. package/src/PostgresBackendDriver.ts +149 -57
  24. package/src/cli-helpers.ts +114 -0
  25. package/src/cli.ts +22 -0
  26. package/src/schema/collection-index.ts +427 -0
  27. package/src/schema/ensure-collection-tables.ts +21 -0
  28. package/src/schema/generate-postgres-ddl-logic.ts +17 -5
  29. package/src/schema/introspect-db-inference.ts +1 -1
  30. package/src/schema/introspect-db-logic.ts +4 -4
  31. package/src/schema/introspect-db-project.ts +2 -2
  32. package/src/schema/introspect-db.ts +2 -2
  33. package/src/services/realtimeService.ts +17 -4
  34. package/src/websocket.ts +12 -2
  35. package/dist/data_driver-ULAyJEi9.js +0 -193
  36. package/dist/data_driver-ULAyJEi9.js.map +0 -1
  37. package/dist/ensure-collection-tables-C_Gr59le.js.map +0 -1
  38. package/dist/rls-enforcement-HLy7w5hL.js.map +0 -1
  39. package/dist/websocket-D0YNv8hp.js +0 -651
  40. package/dist/websocket-D0YNv8hp.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.16.1-canary.ge71347e",
4
+ "version": "0.17.0",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.22.0",
49
49
  "ws": "^8.21.1",
50
- "@rebasepro/codegen": "0.16.1-canary.ge71347e",
51
- "@rebasepro/common": "0.16.1-canary.ge71347e",
52
- "@rebasepro/server": "0.16.1-canary.ge71347e",
53
- "@rebasepro/utils": "0.16.1-canary.ge71347e",
54
- "@rebasepro/types": "0.16.1-canary.ge71347e"
50
+ "@rebasepro/codegen": "0.17.0",
51
+ "@rebasepro/server": "0.17.0",
52
+ "@rebasepro/utils": "0.17.0",
53
+ "@rebasepro/types": "0.17.0",
54
+ "@rebasepro/common": "0.17.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
@@ -42,6 +42,67 @@ import { applyAuthContext } from "./security/rls-enforcement";
42
42
  import { generateSchemaCommit } from "./schema/generate-schema-commit";
43
43
  import { readSchemaFactsFor, type Queryable } from "./schema/ensure-collection-tables";
44
44
 
45
+ /**
46
+ * Has an operator opted out of database role switching entirely?
47
+ *
48
+ * `DISABLE_DB_ROLE_SWITCHING=true` is documented (README, the configuration
49
+ * page, the backend skill) as "run Studio SQL Editor queries as the connection
50
+ * owner", for deployments whose application roles have no database role behind
51
+ * them. It is the only sanctioned way a statement that named a role runs
52
+ * without it — every other route now refuses.
53
+ *
54
+ * Exact `"true"` on purpose, matching the check this replaced. `=1` and `=yes`
55
+ * silently do nothing, which `docs/audits/80-config-and-env.md` already records
56
+ * as a finding across the env surface; fixing it here alone would make this one
57
+ * variable disagree with the rest.
58
+ */
59
+ export function isRoleSwitchingOptedOut(): boolean {
60
+ return process.env.DISABLE_DB_ROLE_SWITCHING === "true";
61
+ }
62
+
63
+ /**
64
+ * The role a statement will actually have run as, given the role it asked for.
65
+ *
66
+ * For the audit log, which recorded `options.role` — the *requested* role — and
67
+ * so restated the caller's request as though it were the outcome. The two part
68
+ * company exactly when {@link isRoleSwitchingOptedOut} holds, because every
69
+ * other divergence is now an error rather than a quiet substitution.
70
+ */
71
+ export function effectiveSqlRole(requestedRole?: string): string {
72
+ if (!requestedRole) return CONNECTION_OWNER;
73
+ return isRoleSwitchingOptedOut() ? CONNECTION_OWNER : requestedRole;
74
+ }
75
+
76
+ /** How {@link effectiveSqlRole} names "whatever role the connection holds". */
77
+ export const CONNECTION_OWNER = "<connection owner>";
78
+
79
+ /**
80
+ * A statement named a database role the connection cannot assume.
81
+ *
82
+ * Its own type because the tempting recovery — run it anyway, as the owner — is
83
+ * the one thing that must not happen. Callers that catch this should report it,
84
+ * not retry unscoped.
85
+ */
86
+ export class RoleSwitchUnavailableError extends Error {
87
+ readonly code = "ROLE_SWITCH_UNAVAILABLE";
88
+ readonly role: string;
89
+ /** The underlying Postgres error, when the refusal came from a live attempt. */
90
+ readonly pgError?: unknown;
91
+
92
+ constructor(role: string, pgError?: unknown) {
93
+ super(
94
+ `Cannot execute SQL as role "${role}": this connection is not permitted to SET ROLE. ` +
95
+ `The statement was NOT executed — running it as the connection owner would return ` +
96
+ `owner-visible rows, which is a different question from the one that was asked. ` +
97
+ `Grant the connection user membership in "${role}", or set DISABLE_DB_ROLE_SWITCHING=true ` +
98
+ `to run SQL Editor queries as the connection owner.`
99
+ );
100
+ this.name = "RoleSwitchUnavailableError";
101
+ this.role = role;
102
+ this.pgError = pgError;
103
+ }
104
+ }
105
+
45
106
  export class PostgresBackendDriver implements DataDriver {
46
107
  key = "postgres";
47
108
  initialised = true;
@@ -55,12 +116,18 @@ export class PostgresBackendDriver implements DataDriver {
55
116
  public client?: RebaseClient;
56
117
 
57
118
  /**
58
- * Auto-set to `true` when a SET LOCAL ROLE fails with insufficient
59
- * privileges, so subsequent queries skip the doomed attempt.
60
- * Mirrors the static `DISABLE_DB_ROLE_SWITCHING` env var but is
61
- * learned at runtime.
119
+ * Auto-set to `true` once a `SET LOCAL ROLE` has failed with insufficient
120
+ * privileges, so later statements refuse without spending the round trip
121
+ * to be refused again.
122
+ *
123
+ * Deliberately NOT a mirror of `DISABLE_DB_ROLE_SWITCHING`, which it used
124
+ * to be described as. The env var is an operator saying "run these as the
125
+ * connection owner"; this flag is the database saying "I cannot give you
126
+ * the role you asked for". The first is a decision and permits the
127
+ * fallback, the second is a failure and must not — see the SECURITY note
128
+ * in {@link executeSql}.
62
129
  */
63
- private _roleSwitchingDisabled = false;
130
+ private _roleSwitchingUnavailable = false;
64
131
 
65
132
  /**
66
133
  * Restricted role that authenticated (user-context) requests run as (via
@@ -1257,6 +1324,27 @@ export class PostgresBackendDriver implements DataDriver {
1257
1324
  return this.poolManager.getDrizzle(databaseName);
1258
1325
  }
1259
1326
 
1327
+ /**
1328
+ * Build one statement, binding `$n` placeholders as real parameters.
1329
+ *
1330
+ * Shared by the role-switched path (inside a transaction) and the
1331
+ * unswitched one. They held byte-identical copies of this loop, which is
1332
+ * exactly the shape where a fix lands in one copy and not the other.
1333
+ */
1334
+ private buildStatement(sqlText: string, params?: unknown[]) {
1335
+ if (!params || params.length === 0) return drizzleSql.raw(sqlText);
1336
+ const parts = sqlText.split(/\$(\d+)/);
1337
+ const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
1338
+ for (let i = 0; i < parts.length; i++) {
1339
+ if (i % 2 === 0) {
1340
+ if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
1341
+ } else {
1342
+ chunks.push(drizzleSql.param(params[Number(parts[i]) - 1]));
1343
+ }
1344
+ }
1345
+ return drizzleSql.join(chunks, drizzleSql.raw(""));
1346
+ }
1347
+
1260
1348
  async executeSql(sqlText: string, options?: {
1261
1349
  database?: string,
1262
1350
  role?: string,
@@ -1269,77 +1357,81 @@ export class PostgresBackendDriver implements DataDriver {
1269
1357
  const targetDb = this.getTargetDb(options?.database);
1270
1358
 
1271
1359
  try {
1272
- // Determine if we actually need to switch roles.
1273
- // Skip SET LOCAL ROLE when the requested role matches the current session role,
1274
- // as it's a no-op that can fail on managed Postgres setups where the connection
1275
- // user doesn't have permission to SET ROLE.
1360
+ // Does this actually need a role switch?
1361
+ //
1362
+ // Asking for the role the session already runs as is a no-op, not a
1363
+ // downgrade the statement really does execute as the requested
1364
+ // role — so it stays allowed even where switching is unavailable.
1365
+ // That is the ordinary Studio path: the role picker defaults to
1366
+ // `current_user`.
1276
1367
  let needsRoleSwitch = false;
1277
- if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true" && !this._roleSwitchingDisabled) {
1368
+ if (options?.role) {
1278
1369
  try {
1279
1370
  const currentRoleResult = await targetDb.execute(drizzleSql.raw("SELECT current_user AS role"));
1280
1371
  const currentRole = (currentRoleResult.rows?.[0] as Record<string, unknown>)?.role as string | undefined;
1281
1372
  needsRoleSwitch = !!currentRole && currentRole !== options.role;
1282
1373
  } catch {
1283
- // If we can't determine the current role, attempt the switch anyway
1374
+ // Current role unknown. Assume a switch is needed rather
1375
+ // than assume the session already is the requested role:
1376
+ // attempting and refusing beats guessing in our own favour.
1284
1377
  needsRoleSwitch = true;
1285
1378
  }
1286
1379
  }
1287
1380
 
1288
1381
  if (needsRoleSwitch && options?.role) {
1289
- const safeRole = options.role.replace(/"/g, "\"\"");
1290
- try {
1291
- return await targetDb.transaction(async (tx) => {
1292
- await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
1293
- let result;
1294
- if (options?.params && options.params.length > 0) {
1295
- const parts = sqlText.split(/\$(\d+)/);
1296
- const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
1297
- for (let i = 0; i < parts.length; i++) {
1298
- if (i % 2 === 0) {
1299
- if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
1300
- } else {
1301
- chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
1302
- }
1303
- }
1304
- result = await tx.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
1305
- } else {
1306
- result = await tx.execute(drizzleSql.raw(sqlText));
1382
+ if (isRoleSwitchingOptedOut()) {
1383
+ // The one sanctioned way to run this unswitched, and a
1384
+ // decision somebody made rather than a failure: the env var
1385
+ // is documented (README, docs/getting-started/configuration)
1386
+ // as "run SQL Editor queries as the connection owner", for
1387
+ // deployments whose application roles have no database role
1388
+ // behind them. `effectiveSqlRole` reads the same switch, so
1389
+ // the audit log records the role that actually applied.
1390
+ logger.debug(
1391
+ `[PostgresBackendDriver] DISABLE_DB_ROLE_SWITCHING=true running as the ` +
1392
+ `connection owner rather than "${options.role}".`
1393
+ );
1394
+ } else if (this._roleSwitchingUnavailable) {
1395
+ // Already learned this connection cannot SET ROLE; refuse
1396
+ // without spending the round trip to be told again.
1397
+ throw new RoleSwitchUnavailableError(options.role);
1398
+ } else {
1399
+ const safeRole = options.role.replace(/"/g, "\"\"");
1400
+ try {
1401
+ return await targetDb.transaction(async (tx) => {
1402
+ await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
1403
+ const result = await tx.execute(this.buildStatement(sqlText, options?.params));
1404
+ return result.rows as Record<string, unknown>[];
1405
+ });
1406
+ } catch (roleError: unknown) {
1407
+ if (isRoleSwitchingPermissionError(roleError)) {
1408
+ // SECURITY: do NOT fall through and run this as the
1409
+ // owner.
1410
+ //
1411
+ // The caller asked for a *constrained* execution.
1412
+ // Owner rows are not a degraded answer to that
1413
+ // question, they are a confident wrong one: the only
1414
+ // reason to pass a role is to see what the database
1415
+ // looks like under RLS, and owner output makes a
1416
+ // protected table read as exposed. This used to warn
1417
+ // and continue — and latch, so a single failure
1418
+ // silently unscoped every later call in the process.
1419
+ //
1420
+ // `applyAuthContext` (the user request path) and
1421
+ // `scopeDataDriver` both fail closed. This is the
1422
+ // same question, so it gets the same answer.
1423
+ this._roleSwitchingUnavailable = true;
1424
+ throw new RoleSwitchUnavailableError(options.role, roleError);
1307
1425
  }
1308
- return result.rows as Record<string, unknown>[];
1309
- });
1310
- } catch (roleError: unknown) {
1311
- if (isRoleSwitchingPermissionError(roleError)) {
1312
- logger.warn(
1313
- `[PostgresBackendDriver] SET LOCAL ROLE "${safeRole}" failed — ` +
1314
- `the connection user lacks permission. Falling back to executing ` +
1315
- `without role switching. To suppress this warning, set ` +
1316
- `DISABLE_DB_ROLE_SWITCHING=true in your .env file.`
1317
- );
1318
- this._roleSwitchingDisabled = true;
1319
- // Fall through to execute without role switching below
1320
- } else {
1321
1426
  throw roleError;
1322
1427
  }
1323
1428
  }
1324
1429
  }
1325
1430
 
1326
- let result;
1327
- if (options?.params && options.params.length > 0) {
1328
- const parts = sqlText.split(/\$(\d+)/);
1329
- const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
1330
- for (let i = 0; i < parts.length; i++) {
1331
- if (i % 2 === 0) {
1332
- if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
1333
- } else {
1334
- chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
1335
- }
1336
- }
1337
- result = await targetDb.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
1338
- } else {
1339
- result = await targetDb.execute(drizzleSql.raw(sqlText));
1340
- }
1431
+ const result = await targetDb.execute(this.buildStatement(sqlText, options?.params));
1341
1432
  return result.rows as Record<string, unknown>[];
1342
1433
  } catch (error: unknown) {
1434
+ if (error instanceof RoleSwitchUnavailableError) throw error;
1343
1435
  const msg = error instanceof Error ? error.message : String(error);
1344
1436
  // Provide a user-friendly message for connection/auth errors
1345
1437
  if (msg.includes("pg_hba.conf") || msg.includes("no encryption") || msg.includes("connection refused")) {
@@ -6,6 +6,7 @@ import { createRequire } from "module";
6
6
  import readline from "readline";
7
7
  import { pathToFileURL } from "url";
8
8
  import chalk from "chalk";
9
+ import { isRebaseIndexName } from "./schema/collection-index";
9
10
  import { out, outWarn } from "./cli-output";
10
11
  import type { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
11
12
  import { moduleDir as __helpersDirname } from "./module-dir";
@@ -306,6 +307,119 @@ export async function seedDevDatabaseSearchHelpers(
306
307
  * catalogs. Separated from {@link getTableExcludes} so its failure mode can
307
308
  * be handled explicitly (fail closed) and so tests can inject a stub.
308
309
  */
310
+ /**
311
+ * Every index Postgres holds on a table Rebase manages, as
312
+ * `schema.table.index`.
313
+ *
314
+ * Constraint-backed indexes are left out: a `PRIMARY KEY` or a `UNIQUE` is a
315
+ * *constraint* to Atlas, diffed from the constraint declaration, and naming its
316
+ * index in an exclude would shield the constraint itself.
317
+ */
318
+ export async function queryExistingIndexes(databaseUrl: string): Promise<string[]> {
319
+ const { Client } = await import("pg");
320
+ const client = new Client({ connectionString: databaseUrl });
321
+ await client.connect();
322
+ try {
323
+ const res = await client.query(`
324
+ SELECT n.nspname || '.' || t.relname || '.' || i.relname AS full_name
325
+ FROM pg_index x
326
+ JOIN pg_class i ON i.oid = x.indexrelid
327
+ JOIN pg_class t ON t.oid = x.indrelid
328
+ JOIN pg_namespace n ON n.oid = t.relnamespace
329
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
330
+ AND t.relkind IN ('r', 'p')
331
+ AND NOT EXISTS (
332
+ SELECT 1 FROM pg_constraint c WHERE c.conindid = i.oid
333
+ );
334
+ `);
335
+ return res.rows.map((row: { full_name: string }) => row.full_name);
336
+ } finally {
337
+ await client.end();
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Glob patterns keeping Atlas away from indexes that are not Rebase's.
343
+ *
344
+ * The problem this solves is live and predates the `indexes:` block. `db push`
345
+ * is declarative, so an index on a managed table that is absent from
346
+ * `schema.sql` is drift — and Atlas plans `DROP INDEX` for it. Verified
347
+ * against atlas v1.2.3: create one by hand, re-run an unchanged push, and the
348
+ * plan is a bare drop. `DROP INDEX` is not in `DESTRUCTIVE_PATTERNS`, so the
349
+ * auto-approved apply took it without asking. Until now the only way to have
350
+ * an index at all was to write it by hand, which made that the *only* outcome.
351
+ *
352
+ * Ownership rather than a prompt, because once indexes are declarable a drop
353
+ * is usually correct: removing one from the config should remove it from the
354
+ * database, quietly. What must never be dropped is an index Rebase never
355
+ * created — a hand-written one, or one an introspected database arrived with.
356
+ * {@link isRebaseIndexName} is the test, the same arrangement
357
+ * `isGeneratedPolicyName` uses to let policy reconciliation drop only what it
358
+ * generated.
359
+ *
360
+ * The named-in-the-desired-state check comes first and is what makes removal
361
+ * work: a declared index that the author has just deleted still matches the
362
+ * name pattern, is no longer in the plan, and so is *not* excluded — Atlas
363
+ * drops it, as intended.
364
+ *
365
+ * Three-part `schema.table.index`, never two: a two-part pattern reads as a
366
+ * *table* named `<index>` in a *schema* named `<table>`, matches nothing, and
367
+ * reports no error — the trap already recorded for the search excludes.
368
+ */
369
+ export async function getForeignIndexExcludes(
370
+ databaseUrl: string,
371
+ collectionsPath: string,
372
+ deps: {
373
+ queryExistingIndexes?: (databaseUrl: string) => Promise<string[]>;
374
+ getManagedIndexNames?: (collectionsPath: string) => Promise<Set<string>>;
375
+ } = {}
376
+ ): Promise<string[]> {
377
+ const queryIndexes = deps.queryExistingIndexes ?? queryExistingIndexes;
378
+ const getManaged = deps.getManagedIndexNames ?? managedIndexNames;
379
+
380
+ const managed = await getManaged(collectionsPath);
381
+
382
+ let existing: string[];
383
+ try {
384
+ existing = await queryIndexes(databaseUrl);
385
+ } catch (err) {
386
+ // Fails CLOSED, like getTableExcludes: without the catalogue we cannot
387
+ // tell a foreign index from one of ours, and guessing wrong destroys
388
+ // an index somebody is relying on.
389
+ throw new ExcludeIntrospectionError(
390
+ `Failed to introspect the database for unmanaged indexes: ${err instanceof Error ? err.message : String(err)}`,
391
+ err
392
+ );
393
+ }
394
+
395
+ return existing.filter(full => {
396
+ const indexName = full.slice(full.lastIndexOf(".") + 1);
397
+ if (managed.has(indexName)) return false; // ours, and still declared
398
+ return !isRebaseIndexName(indexName); // ours, but no longer declared -> let it drop
399
+ });
400
+ }
401
+
402
+ /**
403
+ * The index names the current collections would create — search, vector and
404
+ * declared alike. Anything Atlas would emit is by definition not foreign.
405
+ */
406
+ async function managedIndexNames(collectionsPath: string): Promise<Set<string>> {
407
+ const collections = await loadCollectionsForCli(collectionsPath);
408
+ const { resolveColumnName, searchExcludePatterns } = await import("./schema/generate-postgres-ddl-logic");
409
+ const { buildCollectionIndexPlan } = await import("./schema/collection-index");
410
+
411
+ const names = new Set<string>(
412
+ buildCollectionIndexPlan(collections, resolveColumnName).map(spec => spec.indexName)
413
+ );
414
+ // Search objects are excluded from Atlas wholesale by their own patterns;
415
+ // listing them here too is harmless and keeps this the single answer to
416
+ // "is this index one of ours".
417
+ for (const pattern of searchExcludePatterns(collections)) {
418
+ names.add(pattern.slice(pattern.lastIndexOf(".") + 1));
419
+ }
420
+ return names;
421
+ }
422
+
309
423
  export async function queryExistingTables(databaseUrl: string): Promise<string[]> {
310
424
  const { Client } = await import("pg");
311
425
  const client = new Client({ connectionString: databaseUrl });
package/src/cli.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  readSearchDdl,
19
19
  seedDevDatabaseSearchHelpers,
20
20
  getTableExcludes,
21
+ getForeignIndexExcludes,
21
22
  ExcludeIntrospectionError,
22
23
  promptConfirm
23
24
  } from "./cli-helpers";
@@ -872,6 +873,27 @@ async function runAtlas(
872
873
  for (const exc of excludes) {
873
874
  atlasArgs.push("--exclude", exc);
874
875
  }
876
+
877
+ // And the indexes on those tables that Rebase did not create. Same
878
+ // fail-closed contract as the table list above, for the same reason: a
879
+ // partial answer here silently drops somebody's index.
880
+ let indexExcludes: string[];
881
+ try {
882
+ indexExcludes = await getForeignIndexExcludes(databaseUrl, collectionsPath);
883
+ } catch (err) {
884
+ if (err instanceof ExcludeIntrospectionError) {
885
+ outError(chalk.red("\n✗ Aborting push: could not determine which indexes to protect."));
886
+ outError(chalk.gray(` ${err.message}`));
887
+ outError(chalk.gray(" Refusing to apply — a partial exclude list could drop an index Rebase does not manage."));
888
+ const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
889
+ if (hint) outError(hint);
890
+ process.exit(1);
891
+ }
892
+ throw err;
893
+ }
894
+ for (const exc of indexExcludes) {
895
+ atlasArgs.push("--exclude", exc);
896
+ }
875
897
  }
876
898
 
877
899
  // Stream stdout live but tee stderr so we can inspect Atlas's error text