@rebasepro/server-postgres 0.17.1 → 0.17.2-canary.g4d21a53

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 (35) hide show
  1. package/dist/{backup-service-BtgHxfFm.js → backup-service-DCk7KhhL.js} +3 -3
  2. package/dist/{backup-service-BtgHxfFm.js.map → backup-service-DCk7KhhL.js.map} +1 -1
  3. package/dist/cli-helpers.d.ts +26 -14
  4. package/dist/{collection-index-DxJBvVTH.js → collection-index-BRUg10H5.js} +2 -2
  5. package/dist/{collection-index-DxJBvVTH.js.map → collection-index-BRUg10H5.js.map} +1 -1
  6. package/dist/{ensure-collection-policies-BHk4TRuf.js → ensure-collection-policies-UCqgv_8c.js} +4 -4
  7. package/dist/{ensure-collection-policies-BHk4TRuf.js.map → ensure-collection-policies-UCqgv_8c.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-DMjOkeRy.js → ensure-collection-tables-DgVixhX3.js} +256 -38
  9. package/dist/ensure-collection-tables-DgVixhX3.js.map +1 -0
  10. package/dist/index.es.js +12 -12
  11. package/dist/{rls-bootstrap-sql-DNzaWd4C.js → rls-bootstrap-sql-B5C9LoJ6.js} +2 -2
  12. package/dist/{rls-bootstrap-sql-DNzaWd4C.js.map → rls-bootstrap-sql-B5C9LoJ6.js.map} +1 -1
  13. package/dist/{rls-enforcement-C1RJ1uI2.js → rls-enforcement-DvAbL9YJ.js} +3 -3
  14. package/dist/{rls-enforcement-C1RJ1uI2.js.map → rls-enforcement-DvAbL9YJ.js.map} +1 -1
  15. package/dist/schema/atlas-argv.d.ts +58 -0
  16. package/dist/schema/carved-out-migration.d.ts +65 -0
  17. package/dist/schema/ensure-collection-tables.d.ts +13 -1
  18. package/dist/schema/generate-postgres-ddl-logic.d.ts +57 -5
  19. package/dist/schema/vector-index.d.ts +120 -0
  20. package/dist/{src-DiDgtX8P.js → src-DiB5RP2Z.js} +33 -3
  21. package/dist/{src-DiDgtX8P.js.map → src-DiB5RP2Z.js.map} +1 -1
  22. package/dist/{websocket-g1Ji7m4o.js → websocket-BZ4H5wUz.js} +19 -9
  23. package/dist/websocket-BZ4H5wUz.js.map +1 -0
  24. package/package.json +6 -6
  25. package/src/cli-helpers.ts +65 -34
  26. package/src/cli.ts +168 -71
  27. package/src/schema/atlas-argv.ts +94 -0
  28. package/src/schema/carved-out-migration.ts +404 -0
  29. package/src/schema/ensure-collection-tables.ts +59 -32
  30. package/src/schema/generate-postgres-ddl-logic.ts +147 -21
  31. package/src/schema/generate-postgres-ddl.ts +59 -6
  32. package/src/schema/generate-schema-commit.ts +31 -6
  33. package/src/schema/vector-index.ts +213 -0
  34. package/dist/ensure-collection-tables-DMjOkeRy.js.map +0 -1
  35. package/dist/websocket-g1Ji7m4o.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.17.1",
4
+ "version": "0.17.2-canary.g4d21a53",
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.17.1",
51
- "@rebasepro/utils": "0.17.1",
52
- "@rebasepro/types": "0.17.1",
53
- "@rebasepro/server": "0.17.1",
54
- "@rebasepro/common": "0.17.1"
50
+ "@rebasepro/codegen": "0.17.2-canary.g4d21a53",
51
+ "@rebasepro/common": "0.17.2-canary.g4d21a53",
52
+ "@rebasepro/server": "0.17.2-canary.g4d21a53",
53
+ "@rebasepro/types": "0.17.2-canary.g4d21a53",
54
+ "@rebasepro/utils": "0.17.2-canary.g4d21a53"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
@@ -264,42 +264,70 @@ export async function getSearchExcludes(collectionsPath: string): Promise<string
264
264
  }
265
265
 
266
266
  /**
267
- * Give the dev database the search helper functions before Atlas plans.
267
+ * The generated SQL for the project's `vector` properties, if it has any.
268
268
  *
269
- * Excluding the search column keeps Atlas from *diffing* it, but not from
270
- * materialising the inspected schema — column and all — in the dev database to
271
- * analyse the plan against. That replay is where a push against an
272
- * already-searchable database died with `function public.rebase_search_text
273
- * (jsonb) does not exist`: the column came across, the function it calls did
274
- * not, because Atlas will not carry a function at all.
269
+ * @param drizzleDir directory holding the generated SQL. Defaults to `drizzle`
270
+ * under the working directory.
271
+ */
272
+ export function readVectorDdl(drizzleDir: string = path.resolve(process.cwd(), "drizzle")): string {
273
+ const vectorFile = path.join(drizzleDir, "vector.sql");
274
+ if (!fs.existsSync(vectorFile)) return "";
275
+ return fs.readFileSync(vectorFile, "utf-8").trim();
276
+ }
277
+
278
+ /**
279
+ * Install pgvector and bring the vector columns and their ANN indexes up to
280
+ * date.
275
281
  *
276
- * Only the extensions and functions, never the tables: the dev database holds
277
- * whatever Atlas puts there and nothing of ours.
282
+ * Runs *after* Atlas, like {@link applySearchDdl} and for the same reason: the
283
+ * statements are `ALTER TABLE ADD COLUMN`, so the table has to exist. Atlas
284
+ * is told to ignore these objects entirely ({@link getVectorExcludes}) — it
285
+ * cannot manage a type its dev database is structurally incapable of having,
286
+ * and left to itself it would plan a `DROP COLUMN` for every one.
278
287
  *
279
- * Best-effort by design. Failing here would block a push for a project whose
280
- * collections merely failed to import, and if the functions really are needed
281
- * and really are missing, Atlas says so a moment later in its own words.
288
+ * A no-op when no collection declares a `vector` property. A failure is *not*
289
+ * swallowed: a push that reported success while the embedding column never
290
+ * appeared is how a project ends up with vector search configured, no error
291
+ * anywhere, and no results.
282
292
  */
283
- export async function seedDevDatabaseSearchHelpers(
284
- devDatabaseUrl: string,
285
- collectionsPath: string
293
+ export async function applyVectorDdl(
294
+ databaseUrl: string,
295
+ drizzleDir: string = path.resolve(process.cwd(), "drizzle")
286
296
  ): Promise<void> {
287
- try {
288
- const { searchPrerequisiteStatements } = await import("./schema/generate-postgres-ddl-logic");
289
- const statements = searchPrerequisiteStatements(await loadCollectionsForCli(collectionsPath));
290
- if (statements.length === 0) return;
297
+ const sql = readVectorDdl(drizzleDir);
298
+ if (!sql) return;
291
299
 
292
- const { Client } = await import("pg");
293
- const client = new Client({ connectionString: devDatabaseUrl });
294
- await client.connect();
295
- try {
296
- await client.query(statements.join("\n"));
297
- } finally {
298
- await client.end();
299
- }
300
- } catch {
301
- // See above: not worth failing a push over.
300
+ const { vectorExtensionHint } = await import("./schema/vector-index");
301
+ const { Client } = await import("pg");
302
+ const client = new Client({ connectionString: databaseUrl });
303
+ await client.connect();
304
+ try {
305
+ await client.query(sql);
306
+ } catch (err) {
307
+ // The boot ensure explains this failure and the push has to as well —
308
+ // it is the same missing extension, and a push is the *more* likely
309
+ // place to meet it. Without this the developer gets a bare
310
+ // `type "vector" does not exist`, which names nothing they can act on
311
+ // and reads like a broken database rather than a line of config.
312
+ const message = err instanceof Error ? err.message : String(err);
313
+ const hint = vectorExtensionHint(message);
314
+ if (!hint) throw err;
315
+ throw new Error(`${message}${hint}`, { cause: err });
316
+ } finally {
317
+ await client.end();
302
318
  }
319
+ out(chalk.gray(" ✓ Applied vector columns and ANN indexes"));
320
+ }
321
+
322
+ /**
323
+ * Glob patterns keeping Atlas away from the vector objects.
324
+ *
325
+ * Returns an empty list — and so changes nothing — for a project with no
326
+ * `vector` property, which is every project that has not opted in.
327
+ */
328
+ export async function getVectorExcludes(collectionsPath: string): Promise<string[]> {
329
+ const { vectorExcludePatterns } = await import("./schema/generate-postgres-ddl-logic");
330
+ return vectorExcludePatterns(await loadCollectionsForCli(collectionsPath));
303
331
  }
304
332
 
305
333
  /**
@@ -405,16 +433,19 @@ export async function getForeignIndexExcludes(
405
433
  */
406
434
  async function managedIndexNames(collectionsPath: string): Promise<Set<string>> {
407
435
  const collections = await loadCollectionsForCli(collectionsPath);
408
- const { resolveColumnName, searchExcludePatterns } = await import("./schema/generate-postgres-ddl-logic");
436
+ const { resolveColumnName, searchExcludePatterns, vectorExcludePatterns } =
437
+ await import("./schema/generate-postgres-ddl-logic");
409
438
  const { buildCollectionIndexPlan } = await import("./schema/collection-index");
410
439
 
411
440
  const names = new Set<string>(
412
441
  buildCollectionIndexPlan(collections, resolveColumnName).map(spec => spec.indexName)
413
442
  );
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)) {
443
+ // Search and vector objects are excluded from Atlas wholesale by their own
444
+ // patterns; listing them here too is harmless and keeps this the single
445
+ // answer to "is this index one of ours". The doc comment above claimed
446
+ // vector was here long before it was — so an ANN index answered that
447
+ // question with "somebody else's".
448
+ for (const pattern of [...searchExcludePatterns(collections), ...vectorExcludePatterns(collections)]) {
418
449
  names.add(pattern.slice(pattern.lastIndexOf(".") + 1));
419
450
  }
420
451
  return names;
package/src/cli.ts CHANGED
@@ -16,7 +16,9 @@ import {
16
16
  applySearchDdl,
17
17
  getSearchExcludes,
18
18
  readSearchDdl,
19
- seedDevDatabaseSearchHelpers,
19
+ applyVectorDdl,
20
+ getVectorExcludes,
21
+ readVectorDdl,
20
22
  getTableExcludes,
21
23
  getForeignIndexExcludes,
22
24
  ExcludeIntrospectionError,
@@ -26,6 +28,8 @@ import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
26
28
  import { forLibpq } from "./utils/connection-string";
27
29
  import { dropLegacyAuthSchema, RLS_BOOTSTRAP_SQL } from "./schema/rls-bootstrap-sql";
28
30
  import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
31
+ import { stripCarvedOutStatements } from "./schema/carved-out-migration";
32
+ import { acceptsExcludeFlag, buildAtlasArgs } from "./schema/atlas-argv";
29
33
 
30
34
  const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
31
35
 
@@ -90,6 +94,82 @@ function listMigrationFiles(): string[] {
90
94
  return fs.readdirSync(dir).filter(f => f.endsWith(".sql")).sort();
91
95
  }
92
96
 
97
+ /**
98
+ * Take Atlas's carve-outs back out of the migration `migrate diff` just wrote.
99
+ *
100
+ * The apply path hands Atlas `--exclude` and it never sees the search or vector
101
+ * columns. The diff path has no such flag — `atlas migrate diff` rejects
102
+ * `--exclude` outright, and an `atlas.hcl` `env` block's `exclude` is accepted
103
+ * and then ignored — so Atlas replays the migration directory (which builds
104
+ * both columns, because that DDL is appended to migrations), does not find them
105
+ * in `schema.sql`, and plans `DROP COLUMN`. Applied, that would take search and
106
+ * the embeddings away from every database the migration reaches.
107
+ *
108
+ * Returns whether a migration Atlas just wrote is still there afterwards: a
109
+ * file whose only content was the spurious drop is deleted, because an empty
110
+ * migration is worse than none — it takes a slot in the revision history and
111
+ * the caller would append the carved-out DDL and the policies to it.
112
+ *
113
+ * @returns true when a new migration survives and may be appended to.
114
+ */
115
+ async function stripCarvedOutFromNewestMigration(collectionsPath: string): Promise<boolean> {
116
+ // Both carve-outs, for one reason: they are the same problem. Search is out
117
+ // of Atlas's sight because its free tier will not parse a function; vector
118
+ // is out because Atlas resolves the desired state in a dev database that
119
+ // can never have pgvector. What follows from that is identical — the diff
120
+ // replays a migration directory holding objects `schema.sql` omits, and
121
+ // plans a drop for every one.
122
+ const patterns = [
123
+ ...await getSearchExcludes(collectionsPath),
124
+ ...await getVectorExcludes(collectionsPath)
125
+ ];
126
+ if (patterns.length === 0) return true;
127
+
128
+ const newest = listMigrationFiles().at(-1);
129
+ if (!newest) return false;
130
+ const file = path.resolve(process.cwd(), "drizzle", "migrations", newest);
131
+
132
+ const result = stripCarvedOutStatements(fs.readFileSync(file, "utf-8"), patterns);
133
+
134
+ // Fail CLOSED, and loudly. A drop this could not rewrite stays in the file
135
+ // exactly as Atlas wrote it, and the file is one somebody applies next week
136
+ // — by which time the column is gone and nothing connects it to this run.
137
+ // The destructive gate does not cover it either: that reads the *push*
138
+ // plan. So `db generate` stops here rather than hand back a migration that
139
+ // destroys a column the developer never asked to lose.
140
+ if (result.unhandled.length > 0) {
141
+ outError(chalk.red(
142
+ `\n ✗ ${newest} drops an object Rebase manages outside Atlas, in a form the CLI`
143
+ ));
144
+ outError(chalk.red(" could not remove:"));
145
+ for (const statement of result.unhandled) {
146
+ outError(chalk.gray(` ${statement.replace(/\s+/g, " ")}`));
147
+ }
148
+ outError(chalk.gray(
149
+ " Left as Atlas wrote it. Delete those statements by hand before running\n" +
150
+ " `rebase db migrate` — and please report them: rewriting this is meant to\n" +
151
+ " be automatic."
152
+ ));
153
+ process.exit(1);
154
+ }
155
+
156
+ if (result.removed.length === 0) return true;
157
+
158
+ if (result.empty) {
159
+ fs.rmSync(file);
160
+ out(chalk.gray(
161
+ ` ✓ Dropped ${newest} — it planned nothing but the removal of columns\n` +
162
+ " Atlas cannot see and Rebase applies itself."
163
+ ));
164
+ } else {
165
+ fs.writeFileSync(file, result.sql, "utf-8");
166
+ out(chalk.gray(` ✓ Kept Rebase's own columns out of ${newest} (${result.removed.length} statement(s))`));
167
+ }
168
+
169
+ await runAtlas("migrate", ["hash", "--dir", "file://drizzle/migrations"], collectionsPath);
170
+ return !result.empty;
171
+ }
172
+
93
173
  async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
94
174
  const VALID_ACTIONS = ["push", "generate", "migrate", "branch", "backup", "restore", "backups"];
95
175
  if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
@@ -97,6 +177,20 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
97
177
  process.exit(1);
98
178
  }
99
179
 
180
+ // A second line of defence, and not a redundant one. `rebase db` answers
181
+ // `--help` before it ever spawns this file, but this file is also its own
182
+ // CLI — the driver is spawned directly by `resolvePluginCliScript`, and is
183
+ // executable on its own — so a `--help` reaching here means nobody upstream
184
+ // caught it. Only `branch` had a case for it; `push`, `migrate` and
185
+ // `restore` all took `--help` as an ordinary flag and did the work.
186
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
187
+ out("");
188
+ out(chalk.bold(` rebase db ${subcommand}`));
189
+ out(chalk.gray(" Run `rebase db --help` for the full page — this is the driver's own entry point."));
190
+ out("");
191
+ return;
192
+ }
193
+
100
194
  if (subcommand === "branch") {
101
195
  await branchCommand(rawArgs);
102
196
  return;
@@ -138,12 +232,27 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
138
232
  const migrationName = argsList._[0] || "migration";
139
233
  const migrationsBefore = listMigrationFiles();
140
234
  await runAtlas("migrate", ["diff", migrationName, "--dir", "file://drizzle/migrations", "--to", "file://drizzle/schema.sql"], collectionsPath);
141
- const wroteNewMigration = listMigrationFiles().length > migrationsBefore.length;
235
+ // The diff cannot be told to ignore the carved-out objects the way the
236
+ // apply can, so the drop it plans for them is taken back out of the
237
+ // file it just wrote. A migration that was nothing else is deleted.
238
+ let wroteNewMigration = listMigrationFiles().length > migrationsBefore.length;
239
+ if (wroteNewMigration) {
240
+ wroteNewMigration = await stripCarvedOutFromNewestMigration(collectionsPath);
241
+ }
142
242
 
143
- // Post-process the newest migration file
243
+ // Post-process the migration Atlas just wrote.
244
+ //
245
+ // `wroteNewMigration` gates the whole block, and that gate is the point.
246
+ // "The newest migration file" is only Atlas's when Atlas wrote one;
247
+ // otherwise it is a migration that has already run in production, and
248
+ // appending to it changes a hash Atlas has recorded while the appended
249
+ // SQL never runs anywhere. The search half already said so and checked;
250
+ // the `CREATE SCHEMA` rewrite and the policy append did not, so every
251
+ // `db generate` that found nothing to diff still grew the last
252
+ // migration by another copy of the policies.
144
253
  try {
145
254
  const migrationsDir = path.resolve(process.cwd(), "drizzle", "migrations");
146
- if (fs.existsSync(migrationsDir)) {
255
+ if (fs.existsSync(migrationsDir) && wroteNewMigration) {
147
256
  const files = fs.readdirSync(migrationsDir);
148
257
  const sqlFiles = files
149
258
  .filter(f => f.endsWith(".sql"))
@@ -159,31 +268,27 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
159
268
  /CREATE SCHEMA (?!IF NOT EXISTS)("[^"]+");/g,
160
269
  "CREATE SCHEMA IF NOT EXISTS $1;"
161
270
  );
162
- // Atlas never writes the search objects into a migration —
163
- // it is not shown them — so a migration replayed against a
164
- // fresh database would build every table except the part
165
- // that makes search work. Appending, because these are
166
- // `ALTER TABLE ... ADD COLUMN` over the tables the
167
- // migration just created.
271
+ // Atlas never writes the search or vector objects into a
272
+ // migration — it is not shown them — so a migration
273
+ // replayed against a fresh database would build every table
274
+ // except the parts that make search and vector search work.
275
+ // Appending, because these are `ALTER TABLE ... ADD COLUMN`
276
+ // over the tables the migration just created.
168
277
  //
169
- // Only into a migration Atlas just wrote. The file picked
170
- // here is simply the newest one, which when there was no
171
- // diff is a migration that has already run in production:
172
- // editing it changes a hash Atlas has recorded, and the
173
- // appended SQL would never be applied anywhere.
278
+ // Vector first: `vector.sql` may open with
279
+ // `CREATE EXTENSION vector`, so prerequisites lead, which is
280
+ // the order a reader of the migration expects. Nothing in
281
+ // search depends on it either way.
282
+ const vectorContent = readVectorDdl();
283
+ if (vectorContent) {
284
+ migrationContent = `${migrationContent}\n\n${vectorContent}`;
285
+ out(chalk.gray(" ✓ Appended vector DDL to the migration"));
286
+ }
287
+
174
288
  const searchContent = readSearchDdl();
175
- if (searchContent && wroteNewMigration) {
289
+ if (searchContent) {
176
290
  migrationContent = `${migrationContent}\n\n${searchContent}`;
177
291
  out(chalk.gray(" ✓ Appended search DDL to the migration"));
178
- } else if (searchContent) {
179
- // Reachable whenever a `search` block is the *only*
180
- // thing that changed: Atlas cannot see one, so it finds
181
- // nothing to diff and writes no file.
182
- out(chalk.gray(
183
- " ℹ Search DDL not written to a migration — this change produced none.\n" +
184
- " It is applied by `rebase db push`, and at boot by the schema ensure.\n" +
185
- " For a migration-only deployment, add drizzle/search.sql to a migration by hand."
186
- ));
187
292
  }
188
293
 
189
294
  fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
@@ -197,7 +302,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
197
302
  const policiesContent = fs.readFileSync(policiesFile, "utf-8");
198
303
  fs.appendFileSync(newestMigrationFile, "\n\n" + RLS_BOOTSTRAP_SQL + "\n" + policiesContent);
199
304
  out(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
200
-
305
+
201
306
  // Re-hash the migration directory
202
307
  out(chalk.gray(" Re-hashing migration files..."));
203
308
  await runAtlas("migrate", ["hash", "--dir", "file://drizzle/migrations"], collectionsPath);
@@ -209,6 +314,20 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
209
314
  outWarn(chalk.yellow(` ⚠️ Failed to append policies or re-hash migration: ${err instanceof Error ? err.message : String(err)}`));
210
315
  }
211
316
 
317
+ if (!wroteNewMigration) {
318
+ // Reachable whenever the only thing that changed is something Atlas
319
+ // cannot see — a `search` block, a `vector` property, or a
320
+ // `securityRules` edit. There is no new file to carry it, and the
321
+ // previous migration must not be reopened: it has already run
322
+ // wherever this project is deployed.
323
+ out(chalk.gray(
324
+ " ℹ No migration written — nothing in this change is visible to Atlas.\n" +
325
+ " Search, vector and RLS DDL are applied by `rebase db push`, and at boot by\n" +
326
+ " the schema ensure. For a migration-only deployment, add drizzle/search.sql,\n" +
327
+ " drizzle/vector.sql and drizzle/policies.sql to a migration by hand."
328
+ ));
329
+ }
330
+
212
331
  out("");
213
332
  out(` You can now run ${chalk.bold.green("rebase db migrate")} to apply the migrations to your database.`);
214
333
  out("");
@@ -283,9 +402,10 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
283
402
 
284
403
  if (databaseUrl) {
285
404
  await ensureAuthTables(databaseUrl, collectionsPath);
286
- // After the tables exist and before the policies: the search
287
- // column is `ALTER TABLE ... ADD COLUMN`, and a policy may
288
- // reference the table it is added to.
405
+ // After the tables exist and before the policies: the vector and
406
+ // search columns are `ALTER TABLE ... ADD COLUMN`, and a policy
407
+ // may reference the table they are added to.
408
+ await applyVectorDdl(databaseUrl);
289
409
  await applySearchDdl(databaseUrl);
290
410
  await applyPolicies(databaseUrl);
291
411
  await reconcilePolicies(databaseUrl, collectionsPath);
@@ -812,9 +932,6 @@ async function runAtlas(
812
932
 
813
933
  const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
814
934
  await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
815
- if (collectionsPath) {
816
- await seedDevDatabaseSearchHelpers(devDatabaseUrl, collectionsPath);
817
- }
818
935
 
819
936
  // Atlas speaks libpq, which rejects the `sslmode=no-verify` that
820
937
  // node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
@@ -822,43 +939,28 @@ async function runAtlas(
822
939
  const atlasUrl = forLibpq(databaseUrl);
823
940
  const atlasDevUrl = forLibpq(devDatabaseUrl);
824
941
 
825
- const atlasArgs = [domain, ...args];
826
-
827
- if (domain === "schema") {
828
- if (args.includes("apply")) {
829
- atlasArgs.push("--url", atlasUrl, "--dev-url", atlasDevUrl);
830
- } else if (args.includes("clean") || args.includes("inspect")) {
831
- atlasArgs.push("--url", atlasUrl);
832
- }
833
- } else if (domain === "migrate") {
834
- if (args.includes("diff")) {
835
- atlasArgs.push("--dev-url", atlasDevUrl);
836
- } else if (args.includes("apply") || args.includes("status")) {
837
- atlasArgs.push("--url", atlasUrl, "--revisions-schema", "rebase");
838
- if (args.includes("apply")) {
839
- atlasArgs.push("--allow-dirty");
840
- }
841
- }
842
- }
843
-
844
- // Search objects are Rebase's, not Atlas's — on both paths. `schema apply`
845
- // would drop them as absent from the desired state; `migrate diff` would
846
- // write that same drop into the next migration file, where it would sit
847
- // waiting to be applied later.
848
- if (collectionsPath && (args.includes("apply") || args.includes("diff"))) {
849
- for (const exc of await getSearchExcludes(collectionsPath)) {
850
- atlasArgs.push("--exclude", exc);
851
- }
852
- }
942
+ // Everything Atlas must keep its hands off, in one list.
943
+ //
944
+ // Gathered only for the invocations that accept the flag — which is
945
+ // `schema apply` and nothing else. `atlas migrate diff` and `migrate apply`
946
+ // both reject `--exclude` outright with `unknown flag`, and passing it there
947
+ // took `rebase db generate` and `rebase db migrate` down for every project
948
+ // declaring a `search` block; the diff keeps the same objects out of the
949
+ // migration afterwards instead, with `stripCarvedOutStatements`. See
950
+ // `acceptsExcludeFlag`.
951
+ const excludes: string[] = [];
952
+ if (collectionsPath && acceptsExcludeFlag(domain, args)) {
953
+ // Search and vector objects are Rebase's, not Atlas's: the desired state
954
+ // does not carry them, so an apply left to itself would drop them.
955
+ excludes.push(...await getSearchExcludes(collectionsPath));
956
+ excludes.push(...await getVectorExcludes(collectionsPath));
853
957
 
854
- if (domain === "schema" && args.includes("apply") && collectionsPath) {
855
958
  // Fail CLOSED: the exclude list is the only thing shielding
856
959
  // non-collection tables from the auto-approved apply. If we can't
857
960
  // introspect the database to build it, abort rather than proceed with
858
961
  // a partial list that would let Atlas drop unmanaged tables.
859
- let excludes: string[];
860
962
  try {
861
- excludes = await getTableExcludes(databaseUrl, collectionsPath);
963
+ excludes.push(...await getTableExcludes(databaseUrl, collectionsPath));
862
964
  } catch (err) {
863
965
  if (err instanceof ExcludeIntrospectionError) {
864
966
  outError(chalk.red("\n✗ Aborting push: could not determine which tables to protect."));
@@ -870,16 +972,12 @@ async function runAtlas(
870
972
  }
871
973
  throw err;
872
974
  }
873
- for (const exc of excludes) {
874
- atlasArgs.push("--exclude", exc);
875
- }
876
975
 
877
976
  // And the indexes on those tables that Rebase did not create. Same
878
977
  // fail-closed contract as the table list above, for the same reason: a
879
978
  // partial answer here silently drops somebody's index.
880
- let indexExcludes: string[];
881
979
  try {
882
- indexExcludes = await getForeignIndexExcludes(databaseUrl, collectionsPath);
980
+ excludes.push(...await getForeignIndexExcludes(databaseUrl, collectionsPath));
883
981
  } catch (err) {
884
982
  if (err instanceof ExcludeIntrospectionError) {
885
983
  outError(chalk.red("\n✗ Aborting push: could not determine which indexes to protect."));
@@ -891,11 +989,10 @@ async function runAtlas(
891
989
  }
892
990
  throw err;
893
991
  }
894
- for (const exc of indexExcludes) {
895
- atlasArgs.push("--exclude", exc);
896
- }
897
992
  }
898
993
 
994
+ const atlasArgs = buildAtlasArgs({ domain, args, url: atlasUrl, devUrl: atlasDevUrl, excludes });
995
+
899
996
  // Stream stdout live but tee stderr so we can inspect Atlas's error text
900
997
  // for known, actionable failure modes (e.g. a dependency-drop that leaves
901
998
  // the schema half-applied) after the process exits. When capturing (used
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The argv `runAtlas` hands the Atlas binary, as a pure function.
3
+ *
4
+ * Split out of `cli.ts` because the flags an Atlas subcommand accepts are not
5
+ * uniform and getting one wrong is fatal rather than degraded: Atlas rejects an
6
+ * unknown flag before doing any work, so a flag on the wrong subcommand takes
7
+ * the whole command down.
8
+ *
9
+ * `--exclude` is the one that bit. Of the six invocations here, exactly one
10
+ * accepts it. The guard this replaces was `args.includes("apply") ||
11
+ * args.includes("diff")`, and reading it as a *subcommand* test is the trap:
12
+ * `migrate apply` passes `args.includes("apply")` just as `schema apply` does.
13
+ * So the flag went onto three invocations, two of which reject it, and both
14
+ * `rebase db generate` and `rebase db migrate` exited 1 with `unknown flag:
15
+ * --exclude` for every project declaring a `search` block.
16
+ *
17
+ * The domain is half the identity of an Atlas subcommand, and a guard that
18
+ * looks only at `args` cannot tell `schema apply` from `migrate apply`.
19
+ *
20
+ * Nothing here touches a database or the filesystem, so the whole matrix is
21
+ * unit-tested in `atlas-argv.test.ts`.
22
+ */
23
+
24
+ export interface AtlasInvocation {
25
+ /** `schema` or `migrate`. */
26
+ domain: string;
27
+ /** The subcommand and its own flags, e.g. `["diff", "--dir", "file://…"]`. */
28
+ args: string[];
29
+ /** The target database, already rewritten for libpq. */
30
+ url: string;
31
+ /** The database Atlas plans against, already rewritten for libpq. */
32
+ devUrl: string;
33
+ /**
34
+ * Everything the caller resolved for `--exclude`, in the order it should
35
+ * appear. Dropped entirely when the subcommand does not accept the flag —
36
+ * see {@link acceptsExcludeFlag}.
37
+ */
38
+ excludes?: string[];
39
+ }
40
+
41
+ /**
42
+ * Does this Atlas invocation accept `--exclude`?
43
+ *
44
+ * Only `atlas schema apply` does, on the pinned 1.2.3. Measured against the
45
+ * binary: `migrate diff` and `migrate apply` both answer `unknown flag:
46
+ * --exclude`, and neither lists it in `--help` (`migrate apply` offers
47
+ * `--url/--dir/--format/--revisions-schema/--dry-run/--lock-name/--lock-timeout/`
48
+ * `--skip-lock/--baseline/--to-version/--tx-mode/--exec-order/--allow-dirty`).
49
+ * Hence `domain === "schema"` and not just the subcommand.
50
+ *
51
+ * The `exclude` attribute of an `atlas.hcl` `env` block is not a way round it
52
+ * either: measured, it is accepted and then ignored on the diff path, which is
53
+ * the silent-no-match shape that costs the most time to notice.
54
+ *
55
+ * So the diff keeps the carved-out objects out of the migration afterwards
56
+ * instead, with `stripCarvedOutStatements`.
57
+ */
58
+ export function acceptsExcludeFlag(domain: string, args: string[]): boolean {
59
+ return domain === "schema" && args.includes("apply");
60
+ }
61
+
62
+ /** Assemble the full argv, connection flags and all. */
63
+ export function buildAtlasArgs(invocation: AtlasInvocation): string[] {
64
+ const { domain, args, url, devUrl, excludes = [] } = invocation;
65
+ const argv = [domain, ...args];
66
+
67
+ if (domain === "schema") {
68
+ if (args.includes("apply")) {
69
+ argv.push("--url", url, "--dev-url", devUrl);
70
+ } else if (args.includes("clean") || args.includes("inspect")) {
71
+ argv.push("--url", url);
72
+ }
73
+ } else if (domain === "migrate") {
74
+ if (args.includes("diff")) {
75
+ argv.push("--dev-url", devUrl);
76
+ } else if (args.includes("apply") || args.includes("status")) {
77
+ argv.push("--url", url, "--revisions-schema", "rebase");
78
+ if (args.includes("apply")) {
79
+ argv.push("--allow-dirty");
80
+ }
81
+ }
82
+ }
83
+
84
+ // Second line of defence, deliberately not a caller's responsibility: a
85
+ // future caller that resolves excludes for the diff path gets them dropped
86
+ // here rather than a command that will not run.
87
+ if (acceptsExcludeFlag(domain, args)) {
88
+ for (const exclude of excludes) {
89
+ argv.push("--exclude", exclude);
90
+ }
91
+ }
92
+
93
+ return argv;
94
+ }