@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.
- package/dist/{backup-service-BtgHxfFm.js → backup-service-DCk7KhhL.js} +3 -3
- package/dist/{backup-service-BtgHxfFm.js.map → backup-service-DCk7KhhL.js.map} +1 -1
- package/dist/cli-helpers.d.ts +26 -14
- package/dist/{collection-index-DxJBvVTH.js → collection-index-BRUg10H5.js} +2 -2
- package/dist/{collection-index-DxJBvVTH.js.map → collection-index-BRUg10H5.js.map} +1 -1
- package/dist/{ensure-collection-policies-BHk4TRuf.js → ensure-collection-policies-UCqgv_8c.js} +4 -4
- package/dist/{ensure-collection-policies-BHk4TRuf.js.map → ensure-collection-policies-UCqgv_8c.js.map} +1 -1
- package/dist/{ensure-collection-tables-DMjOkeRy.js → ensure-collection-tables-DgVixhX3.js} +256 -38
- package/dist/ensure-collection-tables-DgVixhX3.js.map +1 -0
- package/dist/index.es.js +12 -12
- package/dist/{rls-bootstrap-sql-DNzaWd4C.js → rls-bootstrap-sql-B5C9LoJ6.js} +2 -2
- package/dist/{rls-bootstrap-sql-DNzaWd4C.js.map → rls-bootstrap-sql-B5C9LoJ6.js.map} +1 -1
- package/dist/{rls-enforcement-C1RJ1uI2.js → rls-enforcement-DvAbL9YJ.js} +3 -3
- package/dist/{rls-enforcement-C1RJ1uI2.js.map → rls-enforcement-DvAbL9YJ.js.map} +1 -1
- package/dist/schema/atlas-argv.d.ts +58 -0
- package/dist/schema/carved-out-migration.d.ts +65 -0
- package/dist/schema/ensure-collection-tables.d.ts +13 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +57 -5
- package/dist/schema/vector-index.d.ts +120 -0
- package/dist/{src-DiDgtX8P.js → src-DiB5RP2Z.js} +33 -3
- package/dist/{src-DiDgtX8P.js.map → src-DiB5RP2Z.js.map} +1 -1
- package/dist/{websocket-g1Ji7m4o.js → websocket-BZ4H5wUz.js} +19 -9
- package/dist/websocket-BZ4H5wUz.js.map +1 -0
- package/package.json +6 -6
- package/src/cli-helpers.ts +65 -34
- package/src/cli.ts +168 -71
- package/src/schema/atlas-argv.ts +94 -0
- package/src/schema/carved-out-migration.ts +404 -0
- package/src/schema/ensure-collection-tables.ts +59 -32
- package/src/schema/generate-postgres-ddl-logic.ts +147 -21
- package/src/schema/generate-postgres-ddl.ts +59 -6
- package/src/schema/generate-schema-commit.ts +31 -6
- package/src/schema/vector-index.ts +213 -0
- package/dist/ensure-collection-tables-DMjOkeRy.js.map +0 -1
- 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.
|
|
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.
|
|
51
|
-
"@rebasepro/
|
|
52
|
-
"@rebasepro/
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
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",
|
package/src/cli-helpers.ts
CHANGED
|
@@ -264,42 +264,70 @@ export async function getSearchExcludes(collectionsPath: string): Promise<string
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
/**
|
|
267
|
-
*
|
|
267
|
+
* The generated SQL for the project's `vector` properties, if it has any.
|
|
268
268
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
*
|
|
277
|
-
*
|
|
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
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
293
|
+
export async function applyVectorDdl(
|
|
294
|
+
databaseUrl: string,
|
|
295
|
+
drizzleDir: string = path.resolve(process.cwd(), "drizzle")
|
|
286
296
|
): Promise<void> {
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
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 } =
|
|
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
|
|
415
|
-
// listing them here too is harmless and keeps this the single
|
|
416
|
-
// "is this index one of ours".
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
163
|
-
// it is not shown them — so a migration
|
|
164
|
-
// fresh database would build every table
|
|
165
|
-
// that
|
|
166
|
-
// `ALTER TABLE ... ADD COLUMN`
|
|
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
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
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
|
|
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
|
|
287
|
-
//
|
|
288
|
-
// reference the table
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|