@checkstack/backend 0.12.0 → 0.14.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.
- package/CHANGELOG.md +68 -0
- package/package.json +1 -1
- package/src/plugin-manager/plugin-loader.ts +55 -44
- package/src/plugin-manager.test.ts +44 -0
- package/src/utils/relocate-legacy-public-objects.test.ts +261 -0
- package/src/utils/relocate-legacy-public-objects.ts +206 -0
- package/src/utils/run-plugin-migrations.test.ts +193 -0
- package/src/utils/run-plugin-migrations.ts +136 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 79b3487: Relocate plugin objects stranded in `public` into their plugin schema, and run
|
|
8
|
+
migrations under a strict plugin-only `search_path`.
|
|
9
|
+
|
|
10
|
+
Some databases predate per-plugin schema isolation and have a plugin's tables
|
|
11
|
+
and enums sitting in `public` while the `__drizzle_migrations` ledger lives in
|
|
12
|
+
the plugin schema. Runtime kept working because the scoped-db `search_path`
|
|
13
|
+
falls back to `public`, but migrations did not: a new migration referencing a
|
|
14
|
+
pre-existing object (e.g. the `health_check_status` enum) failed at startup with
|
|
15
|
+
`type "health_check_status" does not exist`, crash-looping the pod. The previous
|
|
16
|
+
pinned-connection fix made this deterministic by reliably targeting the
|
|
17
|
+
(empty-of-that-object) plugin schema.
|
|
18
|
+
|
|
19
|
+
The loader now, before running a plugin's migrations, MOVES any of that plugin's
|
|
20
|
+
objects still in `public` into `plugin_<id>` with fully-qualified
|
|
21
|
+
`ALTER ... SET SCHEMA` statements (by-OID, so columns, foreign keys, enum
|
|
22
|
+
references, and owned sequences keep working). The relocation is idempotent
|
|
23
|
+
(only moves objects that are in `public` and not already in the plugin schema)
|
|
24
|
+
and is driven by the union of every Drizzle snapshot the plugin ships, so a
|
|
25
|
+
table an early migration created and a later one drops is moved first and its
|
|
26
|
+
unqualified `DROP TABLE` still resolves.
|
|
27
|
+
|
|
28
|
+
With the stragglers relocated, migrations run under a strict
|
|
29
|
+
`search_path = "plugin_<id>"` (no `public` fallback). Combined with creating the
|
|
30
|
+
schema before the `SET`, unqualified `CREATE TABLE` / `CREATE TYPE` can only ever
|
|
31
|
+
land in the plugin schema, never silently in `public`.
|
|
32
|
+
|
|
33
|
+
## 0.13.0
|
|
34
|
+
|
|
35
|
+
### Minor Changes
|
|
36
|
+
|
|
37
|
+
- af6bda7: Fix plugin migrations failing on upgrade with `type "..." does not exist`.
|
|
38
|
+
|
|
39
|
+
Plugin migrations are schema-agnostic and rely on `search_path` to resolve
|
|
40
|
+
unqualified names into the plugin's schema (e.g. `plugin_healthcheck`). The
|
|
41
|
+
loader set `search_path` at the session level on the shared admin pool and
|
|
42
|
+
then called Drizzle's `migrate()`. Because `migrate()` runs all pending
|
|
43
|
+
migrations inside its own transaction, a `pg.Pool` could service that
|
|
44
|
+
transaction on a different physical connection than the one the `SET` ran on,
|
|
45
|
+
so the migration SQL executed against `public` instead.
|
|
46
|
+
|
|
47
|
+
This was invisible on a fresh database (every object is created within that
|
|
48
|
+
one transaction, so unqualified references still resolve), but broke upgrades:
|
|
49
|
+
the healthcheck plugin's new `health_check_state_transitions` migration
|
|
50
|
+
references the pre-existing `health_check_status` enum, which an earlier
|
|
51
|
+
migration created in the plugin schema. On a different pooled connection that
|
|
52
|
+
enum is not on the `public` `search_path`, so startup failed with
|
|
53
|
+
`type "health_check_status" does not exist` and the pod crash-looped.
|
|
54
|
+
|
|
55
|
+
Migrations now run on a single pinned pool connection: the loader checks out
|
|
56
|
+
one dedicated client, sets `search_path` on it, and binds the migrator to that
|
|
57
|
+
same client, mirroring the connection-affinity pattern already used by the
|
|
58
|
+
advisory-lock service. Every migration statement now runs under the intended
|
|
59
|
+
schema.
|
|
60
|
+
|
|
61
|
+
Boot was also restructured into two passes over the topologically-sorted
|
|
62
|
+
plugins: pass 1 runs every plugin's migrations, pass 2 runs every plugin's
|
|
63
|
+
`init()`. Previously the two were interleaved per plugin, so an
|
|
64
|
+
already-initialized plugin's background work (queue consumers, sweepers,
|
|
65
|
+
reactive-entity/event wiring) could compete for pool connections while a later
|
|
66
|
+
plugin was still migrating. Running all migrations first keeps the pool quiet
|
|
67
|
+
during migrations and removes that race entirely. The pinned connection and the
|
|
68
|
+
two-pass ordering are each independently sufficient for the fix above; together
|
|
69
|
+
they make boot robust regardless of what else touches the pool.
|
|
70
|
+
|
|
3
71
|
## 0.12.0
|
|
4
72
|
|
|
5
73
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
2
|
-
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
1
|
import path from "node:path";
|
|
4
2
|
import fs from "node:fs";
|
|
5
3
|
import type { Hono } from "hono";
|
|
6
|
-
import { eq, and
|
|
4
|
+
import { eq, and } from "drizzle-orm";
|
|
7
5
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
8
6
|
import {
|
|
9
7
|
coreServices,
|
|
@@ -23,6 +21,8 @@ import { rootLogger } from "../logger";
|
|
|
23
21
|
import type { ServiceRegistry } from "../services/service-registry";
|
|
24
22
|
import { plugins } from "../schema";
|
|
25
23
|
import { stripPublicSchemaFromMigrations } from "../utils/strip-public-schema";
|
|
24
|
+
import { runPluginMigrations } from "../utils/run-plugin-migrations";
|
|
25
|
+
import { adminPool } from "../db";
|
|
26
26
|
import {
|
|
27
27
|
discoverLocalPlugins,
|
|
28
28
|
syncPluginsToDatabase,
|
|
@@ -309,6 +309,17 @@ export async function loadPlugins({
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
// Phase 2: Initialize Plugins (Topological Sort)
|
|
312
|
+
//
|
|
313
|
+
// Done in two passes over the topologically-sorted plugins:
|
|
314
|
+
// Pass 1 - run EVERY plugin's migrations.
|
|
315
|
+
// Pass 2 - resolve deps + run EVERY plugin's init().
|
|
316
|
+
// Splitting the passes guarantees no plugin's init() (which may start
|
|
317
|
+
// background DB work - queue consumers, sweepers, reactive-entity/event
|
|
318
|
+
// wiring) is running while another plugin is still migrating. That
|
|
319
|
+
// interleaving is what could divert a migration onto a pooled connection
|
|
320
|
+
// without the plugin's search_path. `runPluginMigrations` pins a connection
|
|
321
|
+
// too, so each measure is independently sufficient; together they make boot
|
|
322
|
+
// robust regardless of what touches the pool.
|
|
312
323
|
const logger = await deps.registry.get(coreServices.logger, {
|
|
313
324
|
pluginId: "core",
|
|
314
325
|
});
|
|
@@ -347,9 +358,9 @@ export async function loadPlugins({
|
|
|
347
358
|
// pre-existing behavior and is preferable to a multi-second hang.
|
|
348
359
|
deps.onApiRouteRegistered?.();
|
|
349
360
|
|
|
361
|
+
// Phase 2, pass 1: run every plugin's migrations BEFORE any plugin init.
|
|
350
362
|
for (const id of sortedIds) {
|
|
351
363
|
const p = pendingInits.find((x) => x.metadata.pluginId === id)!;
|
|
352
|
-
rootLogger.info(`🚀 Initializing ${p.metadata.pluginId}...`);
|
|
353
364
|
|
|
354
365
|
try {
|
|
355
366
|
/**
|
|
@@ -372,34 +383,32 @@ export async function loadPlugins({
|
|
|
372
383
|
* causing "relation does not exist" errors since the tables are actually in
|
|
373
384
|
* the plugin's schema (e.g., `plugin_maintenance.maintenances`).
|
|
374
385
|
*
|
|
375
|
-
* ##
|
|
386
|
+
* ## Why a pinned connection (not a session-level SET on the pool)
|
|
387
|
+
*
|
|
388
|
+
* The migration `search_path` MUST be set on the exact connection the
|
|
389
|
+
* migration statements run on. Setting it at the session level on the
|
|
390
|
+
* shared `adminPool` does not achieve that: `migrate()` runs all pending
|
|
391
|
+
* migrations inside its own transaction, which a `pg.Pool` may service on
|
|
392
|
+
* a *different* physical connection than the `SET` ran on. The migration
|
|
393
|
+
* SQL would then execute against `public`.
|
|
376
394
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* -
|
|
380
|
-
*
|
|
395
|
+
* `runPluginMigrations()` therefore checks out ONE dedicated client from
|
|
396
|
+
* the pool, sets `search_path` on it, and binds the migrator to that same
|
|
397
|
+
* client - the same connection-affinity pattern the advisory-lock service
|
|
398
|
+
* uses (see `advisory-lock.ts`). The bug this prevents is invisible on a
|
|
399
|
+
* fresh database (every object is created in one transaction, so
|
|
400
|
+
* unqualified references still resolve) but breaks UPGRADES: a new
|
|
401
|
+
* migration that references an enum an earlier migration created in the
|
|
402
|
+
* plugin schema fails with `type "..." does not exist`.
|
|
381
403
|
*
|
|
382
404
|
* ## Why This Doesn't Affect Runtime Queries
|
|
383
405
|
*
|
|
384
406
|
* After migrations complete, plugins receive their database via
|
|
385
407
|
* `createScopedDb()` which wraps every query in a transaction with
|
|
386
408
|
* `SET LOCAL search_path`. This ensures runtime queries always use the
|
|
387
|
-
* correct schema
|
|
388
|
-
*
|
|
389
|
-
* ## Potential Hazards
|
|
390
|
-
*
|
|
391
|
-
* 1. **Error During Migration**: If a migration fails, the search_path may
|
|
392
|
-
* remain set to that plugin's schema. The next plugin's migration would
|
|
393
|
-
* fail visibly (wrong schema), which is better than silent data corruption.
|
|
394
|
-
*
|
|
395
|
-
* 2. **Parallel Migration Execution**: This code assumes sequential plugin
|
|
396
|
-
* initialization (which is enforced by the topologically-sorted loop).
|
|
397
|
-
* If migrations ever run in parallel, search_path conflicts would occur.
|
|
398
|
-
*
|
|
399
|
-
* 3. **Connection Pool Pollution**: `SET` without `LOCAL` affects the entire
|
|
400
|
-
* session. However, we reset to `public` after each plugin's migrations,
|
|
401
|
-
* and runtime queries use `SET LOCAL` anyway, so this is safe.
|
|
409
|
+
* correct schema.
|
|
402
410
|
*
|
|
411
|
+
* @see runPluginMigrations in ../utils/run-plugin-migrations.ts
|
|
403
412
|
* @see createScopedDb in ../utils/scoped-db.ts for runtime query isolation
|
|
404
413
|
* @see getPluginSchemaName in @checkstack/drizzle-helper for schema naming
|
|
405
414
|
* =======================================================================
|
|
@@ -419,29 +428,13 @@ export async function loadPlugins({
|
|
|
419
428
|
` -> Running migrations for ${p.metadata.pluginId} from ${migrationsFolder}`,
|
|
420
429
|
);
|
|
421
430
|
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
sql.raw(`CREATE SCHEMA IF NOT EXISTS "${migrationsSchema}"`),
|
|
427
|
-
);
|
|
428
|
-
|
|
429
|
-
// Set search_path to plugin schema before running migrations.
|
|
430
|
-
// Uses session-level SET (not SET LOCAL) because migrate() may run
|
|
431
|
-
// multiple statements across transaction boundaries.
|
|
432
|
-
// No 'public' fallback: schema is guaranteed to exist from CREATE above.
|
|
433
|
-
await deps.db.execute(
|
|
434
|
-
sql.raw(`SET search_path = "${migrationsSchema}"`),
|
|
435
|
-
);
|
|
436
|
-
// Drizzle migrate() requires NodePgDatabase, cast from SafeDatabase
|
|
437
|
-
await migrate(deps.db as NodePgDatabase<Record<string, unknown>>, {
|
|
431
|
+
// Run on a single pinned connection so the search_path we set is the
|
|
432
|
+
// one the migration statements actually execute under.
|
|
433
|
+
await runPluginMigrations({
|
|
434
|
+
pool: adminPool,
|
|
438
435
|
migrationsFolder,
|
|
439
436
|
migrationsSchema,
|
|
440
437
|
});
|
|
441
|
-
|
|
442
|
-
// Reset search_path to public after migrations complete.
|
|
443
|
-
// This prevents search_path leaking into subsequent plugin migrations.
|
|
444
|
-
await deps.db.execute(sql.raw(`SET search_path = public`));
|
|
445
438
|
} catch (error) {
|
|
446
439
|
rootLogger.error(
|
|
447
440
|
`❌ Failed migration of plugin ${p.metadata.pluginId}:`,
|
|
@@ -456,7 +449,25 @@ export async function loadPlugins({
|
|
|
456
449
|
` -> No migrations found for ${p.metadata.pluginId} (skipping)`,
|
|
457
450
|
);
|
|
458
451
|
}
|
|
452
|
+
} catch (error) {
|
|
453
|
+
rootLogger.error(
|
|
454
|
+
`❌ Critical error loading plugin ${p.metadata.pluginId}:`,
|
|
455
|
+
error,
|
|
456
|
+
);
|
|
457
|
+
throw new Error(`Critical error loading plugin ${p.metadata.pluginId}`, {
|
|
458
|
+
cause: error,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Phase 2, pass 2: initialize plugins in topological order. Every plugin -
|
|
464
|
+
// and therefore every dependency - is fully migrated by now, so an init()
|
|
465
|
+
// can assume all plugin schemas exist.
|
|
466
|
+
for (const id of sortedIds) {
|
|
467
|
+
const p = pendingInits.find((x) => x.metadata.pluginId === id)!;
|
|
468
|
+
rootLogger.info(`🚀 Initializing ${p.metadata.pluginId}...`);
|
|
459
469
|
|
|
470
|
+
try {
|
|
460
471
|
// Resolve Dependencies
|
|
461
472
|
const resolvedDeps: Record<string, unknown> = {};
|
|
462
473
|
for (const [key, ref] of Object.entries(p.deps)) {
|
|
@@ -486,5 +486,49 @@ describe("PluginManager", () => {
|
|
|
486
486
|
|
|
487
487
|
expect(testBackendInit).toHaveBeenCalled();
|
|
488
488
|
});
|
|
489
|
+
|
|
490
|
+
it("initializes every plugin across the two-pass (migrate-all, then init-all) loop", async () => {
|
|
491
|
+
const mockRouter = {
|
|
492
|
+
route: mock(),
|
|
493
|
+
all: mock(),
|
|
494
|
+
newResponse: mock(),
|
|
495
|
+
} as never;
|
|
496
|
+
|
|
497
|
+
// Boot runs migrations for all plugins in pass 1, then inits in pass 2.
|
|
498
|
+
// Manual test plugins have no plugin path so pass 1 is a no-op for them;
|
|
499
|
+
// this guards that pass 2 still initializes EVERY plugin (the split loop
|
|
500
|
+
// doesn't drop any) and follows topological order.
|
|
501
|
+
const initOrder: string[] = [];
|
|
502
|
+
const makePlugin = (pluginId: string) =>
|
|
503
|
+
createBackendPlugin({
|
|
504
|
+
metadata: { pluginId },
|
|
505
|
+
register(env) {
|
|
506
|
+
env.registerInit({
|
|
507
|
+
deps: {},
|
|
508
|
+
init: async () => {
|
|
509
|
+
initOrder.push(pluginId);
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
pluginManager.registerService(
|
|
516
|
+
coreServices.queueManager,
|
|
517
|
+
createMockQueueManager(),
|
|
518
|
+
);
|
|
519
|
+
pluginManager.registerService(coreServices.logger, createMockLogger());
|
|
520
|
+
pluginManager.registerService(
|
|
521
|
+
coreServices.database,
|
|
522
|
+
createMockDb() as never,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
await pluginManager.loadPlugins(
|
|
526
|
+
mockRouter,
|
|
527
|
+
[makePlugin("plugin-a"), makePlugin("plugin-b"), makePlugin("plugin-c")],
|
|
528
|
+
{ skipDiscovery: true },
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
expect(initOrder).toEqual(["plugin-a", "plugin-b", "plugin-c"]);
|
|
532
|
+
});
|
|
489
533
|
});
|
|
490
534
|
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
readPluginOwnedObjects,
|
|
7
|
+
relocateLegacyPublicObjects,
|
|
8
|
+
type RelocationClient,
|
|
9
|
+
} from "./relocate-legacy-public-objects";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A fake client that answers catalog membership queries from in-memory schema
|
|
13
|
+
* maps and records the `ALTER ... SET SCHEMA` statements it is asked to run.
|
|
14
|
+
*/
|
|
15
|
+
function makeFakeClient(state: {
|
|
16
|
+
// schema -> relations present, with relkind
|
|
17
|
+
relations: Record<string, Record<string, string>>;
|
|
18
|
+
// schema -> type names present
|
|
19
|
+
types: Record<string, Set<string>>;
|
|
20
|
+
}) {
|
|
21
|
+
const altered: string[] = [];
|
|
22
|
+
const client: RelocationClient = {
|
|
23
|
+
async query<T = Record<string, unknown>>(
|
|
24
|
+
text: string,
|
|
25
|
+
values?: unknown[],
|
|
26
|
+
): Promise<{ rows: T[] }> {
|
|
27
|
+
if (text.startsWith("ALTER ")) {
|
|
28
|
+
altered.push(text);
|
|
29
|
+
return { rows: [] };
|
|
30
|
+
}
|
|
31
|
+
// Catalog lookups are parameterized: relations use pg_class, types pg_type.
|
|
32
|
+
if (text.includes("pg_class")) {
|
|
33
|
+
const wantsTarget = text.includes("n.nspname = $1");
|
|
34
|
+
const schema = wantsTarget ? (values?.[0] as string) : "public";
|
|
35
|
+
const names = (wantsTarget ? values?.[1] : values?.[0]) as string[];
|
|
36
|
+
const present = state.relations[schema] ?? {};
|
|
37
|
+
const rows = names
|
|
38
|
+
.filter((n) => n in present)
|
|
39
|
+
.map((n) => ({ relname: n, relkind: present[n] }));
|
|
40
|
+
return { rows: rows as T[] };
|
|
41
|
+
}
|
|
42
|
+
if (text.includes("pg_type")) {
|
|
43
|
+
const wantsTarget = text.includes("n.nspname = $1");
|
|
44
|
+
const schema = wantsTarget ? (values?.[0] as string) : "public";
|
|
45
|
+
const names = (wantsTarget ? values?.[1] : values?.[0]) as string[];
|
|
46
|
+
const present = state.types[schema] ?? new Set<string>();
|
|
47
|
+
const rows = names
|
|
48
|
+
.filter((n) => present.has(n))
|
|
49
|
+
.map((n) => ({ typname: n }));
|
|
50
|
+
return { rows: rows as T[] };
|
|
51
|
+
}
|
|
52
|
+
return { rows: [] };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return { client, altered };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("relocateLegacyPublicObjects", () => {
|
|
59
|
+
it("moves tables and enums that live in public into the plugin schema", async () => {
|
|
60
|
+
const { client, altered } = makeFakeClient({
|
|
61
|
+
relations: {
|
|
62
|
+
public: {
|
|
63
|
+
health_check_configurations: "r",
|
|
64
|
+
health_check_auto_incidents: "r",
|
|
65
|
+
},
|
|
66
|
+
plugin_healthcheck: {},
|
|
67
|
+
},
|
|
68
|
+
types: {
|
|
69
|
+
public: new Set(["health_check_status", "bucket_size"]),
|
|
70
|
+
plugin_healthcheck: new Set(),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const { moved } = await relocateLegacyPublicObjects({
|
|
75
|
+
client,
|
|
76
|
+
schema: "plugin_healthcheck",
|
|
77
|
+
owned: {
|
|
78
|
+
relations: [
|
|
79
|
+
"health_check_configurations",
|
|
80
|
+
"health_check_auto_incidents",
|
|
81
|
+
"health_check_state_transitions", // not in public yet -> skipped
|
|
82
|
+
],
|
|
83
|
+
types: ["health_check_status", "bucket_size"],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(altered).toEqual([
|
|
88
|
+
'ALTER TABLE public."health_check_configurations" SET SCHEMA "plugin_healthcheck"',
|
|
89
|
+
'ALTER TABLE public."health_check_auto_incidents" SET SCHEMA "plugin_healthcheck"',
|
|
90
|
+
'ALTER TYPE public."health_check_status" SET SCHEMA "plugin_healthcheck"',
|
|
91
|
+
'ALTER TYPE public."bucket_size" SET SCHEMA "plugin_healthcheck"',
|
|
92
|
+
]);
|
|
93
|
+
expect(moved).toContain("table health_check_configurations");
|
|
94
|
+
expect(moved).toContain("type health_check_status");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("is idempotent: skips objects already in the plugin schema or absent from public", async () => {
|
|
98
|
+
const { client, altered } = makeFakeClient({
|
|
99
|
+
relations: {
|
|
100
|
+
public: {},
|
|
101
|
+
plugin_healthcheck: { health_check_configurations: "r" },
|
|
102
|
+
},
|
|
103
|
+
types: {
|
|
104
|
+
public: new Set(),
|
|
105
|
+
plugin_healthcheck: new Set(["health_check_status"]),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { moved } = await relocateLegacyPublicObjects({
|
|
110
|
+
client,
|
|
111
|
+
schema: "plugin_healthcheck",
|
|
112
|
+
owned: {
|
|
113
|
+
relations: ["health_check_configurations"],
|
|
114
|
+
types: ["health_check_status"],
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(altered).toEqual([]);
|
|
119
|
+
expect(moved).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("skips objects present in BOTH public and the plugin schema (no collision ALTER)", async () => {
|
|
123
|
+
// A half-finished prior run (or a manual move) can leave an object in both
|
|
124
|
+
// schemas. Moving it again would error with "relation already exists in
|
|
125
|
+
// schema", so it must be skipped.
|
|
126
|
+
const { client, altered } = makeFakeClient({
|
|
127
|
+
relations: {
|
|
128
|
+
public: { health_check_runs: "r" },
|
|
129
|
+
plugin_healthcheck: { health_check_runs: "r" },
|
|
130
|
+
},
|
|
131
|
+
types: {
|
|
132
|
+
public: new Set(["health_check_status"]),
|
|
133
|
+
plugin_healthcheck: new Set(["health_check_status"]),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const { moved } = await relocateLegacyPublicObjects({
|
|
138
|
+
client,
|
|
139
|
+
schema: "plugin_healthcheck",
|
|
140
|
+
owned: {
|
|
141
|
+
relations: ["health_check_runs"],
|
|
142
|
+
types: ["health_check_status"],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(altered).toEqual([]);
|
|
147
|
+
expect(moved).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("uses the right ALTER keyword per object kind (view, matview, sequence)", async () => {
|
|
151
|
+
const { client, altered } = makeFakeClient({
|
|
152
|
+
relations: {
|
|
153
|
+
public: { my_view: "v", my_matview: "m", my_seq: "S", my_table: "r" },
|
|
154
|
+
plugin_x: {},
|
|
155
|
+
},
|
|
156
|
+
types: { public: new Set(), plugin_x: new Set() },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await relocateLegacyPublicObjects({
|
|
160
|
+
client,
|
|
161
|
+
schema: "plugin_x",
|
|
162
|
+
owned: {
|
|
163
|
+
relations: ["my_view", "my_matview", "my_seq", "my_table"],
|
|
164
|
+
types: [],
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(altered).toEqual([
|
|
169
|
+
'ALTER VIEW public."my_view" SET SCHEMA "plugin_x"',
|
|
170
|
+
'ALTER MATERIALIZED VIEW public."my_matview" SET SCHEMA "plugin_x"',
|
|
171
|
+
'ALTER SEQUENCE public."my_seq" SET SCHEMA "plugin_x"',
|
|
172
|
+
'ALTER TABLE public."my_table" SET SCHEMA "plugin_x"',
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("never relocates into the public schema", async () => {
|
|
177
|
+
const { client, altered } = makeFakeClient({
|
|
178
|
+
relations: { public: { foo: "r" } },
|
|
179
|
+
types: {},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const { moved } = await relocateLegacyPublicObjects({
|
|
183
|
+
client,
|
|
184
|
+
schema: "public",
|
|
185
|
+
owned: { relations: ["foo"], types: [] },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(altered).toEqual([]);
|
|
189
|
+
expect(moved).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("readPluginOwnedObjects", () => {
|
|
194
|
+
let dir: string;
|
|
195
|
+
|
|
196
|
+
beforeEach(() => {
|
|
197
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "owned-objects-"));
|
|
198
|
+
fs.mkdirSync(path.join(dir, "meta"), { recursive: true });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
afterEach(() => {
|
|
202
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const writeSnapshot = (name: string, body: unknown) =>
|
|
206
|
+
fs.writeFileSync(
|
|
207
|
+
path.join(dir, "meta", name),
|
|
208
|
+
JSON.stringify(body),
|
|
209
|
+
"utf8",
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
it("unions object names across ALL snapshots (incl. created-then-dropped)", () => {
|
|
213
|
+
// Early snapshot: a table that a later migration drops.
|
|
214
|
+
writeSnapshot("0000_snapshot.json", {
|
|
215
|
+
tables: {
|
|
216
|
+
"public.kept": { name: "kept", schema: "" },
|
|
217
|
+
"public.dropped_later": { name: "dropped_later", schema: "" },
|
|
218
|
+
},
|
|
219
|
+
enums: { "public.status": { name: "status", schema: "public" } },
|
|
220
|
+
});
|
|
221
|
+
// Latest snapshot: no longer lists the dropped table, adds a new one.
|
|
222
|
+
writeSnapshot("0001_snapshot.json", {
|
|
223
|
+
tables: {
|
|
224
|
+
"public.kept": { name: "kept", schema: "" },
|
|
225
|
+
"public.added": { name: "added", schema: "" },
|
|
226
|
+
},
|
|
227
|
+
enums: { "public.status": { name: "status", schema: "public" } },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const owned = readPluginOwnedObjects(dir);
|
|
231
|
+
|
|
232
|
+
expect(new Set(owned.relations)).toEqual(
|
|
233
|
+
new Set(["kept", "dropped_later", "added"]),
|
|
234
|
+
);
|
|
235
|
+
expect(owned.types).toEqual(["status"]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("ignores objects declared in an explicit non-default schema", () => {
|
|
239
|
+
writeSnapshot("0000_snapshot.json", {
|
|
240
|
+
tables: {
|
|
241
|
+
"public.in_public": { name: "in_public", schema: "" },
|
|
242
|
+
"other.elsewhere": { name: "elsewhere", schema: "other" },
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const owned = readPluginOwnedObjects(dir);
|
|
247
|
+
expect(owned.relations).toEqual(["in_public"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("returns empty when there is no meta folder", () => {
|
|
251
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "no-meta-"));
|
|
252
|
+
try {
|
|
253
|
+
expect(readPluginOwnedObjects(empty)).toEqual({
|
|
254
|
+
relations: [],
|
|
255
|
+
types: [],
|
|
256
|
+
});
|
|
257
|
+
} finally {
|
|
258
|
+
fs.rmSync(empty, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Relocate a plugin's objects that were left in `public` by pre-isolation
|
|
7
|
+
* deploys into the plugin's dedicated schema.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this exists
|
|
10
|
+
*
|
|
11
|
+
* Each plugin's objects are supposed to live in a dedicated schema
|
|
12
|
+
* (`plugin_<id>`). Deploys that predate schema isolation - or that ran
|
|
13
|
+
* migrations before the connection/search_path handling was correct - created
|
|
14
|
+
* the plugin's tables and enums in `public` instead, while the
|
|
15
|
+
* `__drizzle_migrations` ledger lived in the plugin schema. Runtime kept
|
|
16
|
+
* working only because the scoped-db search_path fell back to `public`.
|
|
17
|
+
*
|
|
18
|
+
* Rather than carry a permanent `public` fallback in the migration search_path
|
|
19
|
+
* (which risks new objects silently landing in `public`), the loader moves the
|
|
20
|
+
* stragglers into the plugin schema once, up front, with fully-qualified
|
|
21
|
+
* `ALTER ... SET SCHEMA` statements. After this runs, every object the plugin
|
|
22
|
+
* owns lives in `plugin_<id>`, so migrations can use a strict
|
|
23
|
+
* `search_path = "plugin_<id>"` and new objects always land in the right place.
|
|
24
|
+
*
|
|
25
|
+
* The move is by-OID, so columns, foreign keys, enum references, indexes, and
|
|
26
|
+
* owned sequences all keep working. It is idempotent: an object is moved only
|
|
27
|
+
* if it currently exists in `public` and does not already exist in the plugin
|
|
28
|
+
* schema, so fresh installs and already-migrated installs are no-ops.
|
|
29
|
+
*
|
|
30
|
+
* ## Which objects
|
|
31
|
+
*
|
|
32
|
+
* The owned-object set is the UNION of every Drizzle snapshot under the
|
|
33
|
+
* plugin's `drizzle/meta/`, not just the latest. A table that an early
|
|
34
|
+
* migration created and a later one drops (e.g. a since-removed table) still
|
|
35
|
+
* exists in `public` on an upgrading database when its `DROP` migration is
|
|
36
|
+
* pending; including it here means it gets moved into the plugin schema first,
|
|
37
|
+
* so the unqualified `DROP TABLE` resolves under the strict search_path.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Minimal pooled-client surface this helper needs (modelled on `pg.PoolClient`). */
|
|
41
|
+
export interface RelocationClient {
|
|
42
|
+
query<T = Record<string, unknown>>(
|
|
43
|
+
queryText: string,
|
|
44
|
+
values?: unknown[],
|
|
45
|
+
): Promise<{ rows: T[] }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PluginOwnedObjects {
|
|
49
|
+
/** Tables, views, and materialized views (everything in `pg_class`-land). */
|
|
50
|
+
relations: string[];
|
|
51
|
+
/** Enum / composite type names. */
|
|
52
|
+
types: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Snapshots carry far more than we read; accept and ignore the rest.
|
|
56
|
+
const objectEntrySchema = z
|
|
57
|
+
.object({ name: z.string(), schema: z.string().optional() })
|
|
58
|
+
.passthrough();
|
|
59
|
+
const snapshotSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
tables: z.record(z.string(), objectEntrySchema).optional(),
|
|
62
|
+
enums: z.record(z.string(), objectEntrySchema).optional(),
|
|
63
|
+
sequences: z.record(z.string(), objectEntrySchema).optional(),
|
|
64
|
+
views: z.record(z.string(), objectEntrySchema).optional(),
|
|
65
|
+
})
|
|
66
|
+
.passthrough();
|
|
67
|
+
|
|
68
|
+
/** An object belongs in the default/`public` namespace (i.e. is relocatable). */
|
|
69
|
+
function isDefaultSchema(schema: string | undefined): boolean {
|
|
70
|
+
return schema === undefined || schema === "" || schema === "public";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the union of all object names a plugin has ever declared, across every
|
|
75
|
+
* snapshot in its `drizzle/meta/` folder.
|
|
76
|
+
*/
|
|
77
|
+
export function readPluginOwnedObjects(
|
|
78
|
+
migrationsFolder: string,
|
|
79
|
+
): PluginOwnedObjects {
|
|
80
|
+
const metaDir = path.join(migrationsFolder, "meta");
|
|
81
|
+
if (!fs.existsSync(metaDir)) {
|
|
82
|
+
return { relations: [], types: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const relations = new Set<string>();
|
|
86
|
+
const types = new Set<string>();
|
|
87
|
+
|
|
88
|
+
const snapshotFiles = fs
|
|
89
|
+
.readdirSync(metaDir)
|
|
90
|
+
.filter((f) => f.endsWith("_snapshot.json"));
|
|
91
|
+
|
|
92
|
+
for (const file of snapshotFiles) {
|
|
93
|
+
const raw = JSON.parse(fs.readFileSync(path.join(metaDir, file), "utf8"));
|
|
94
|
+
const snapshot = snapshotSchema.parse(raw);
|
|
95
|
+
|
|
96
|
+
for (const entry of Object.values(snapshot.tables ?? {})) {
|
|
97
|
+
if (isDefaultSchema(entry.schema)) relations.add(entry.name);
|
|
98
|
+
}
|
|
99
|
+
for (const entry of Object.values(snapshot.views ?? {})) {
|
|
100
|
+
if (isDefaultSchema(entry.schema)) relations.add(entry.name);
|
|
101
|
+
}
|
|
102
|
+
for (const entry of Object.values(snapshot.sequences ?? {})) {
|
|
103
|
+
if (isDefaultSchema(entry.schema)) relations.add(entry.name);
|
|
104
|
+
}
|
|
105
|
+
for (const entry of Object.values(snapshot.enums ?? {})) {
|
|
106
|
+
if (isDefaultSchema(entry.schema)) types.add(entry.name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { relations: [...relations], types: [...types] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Double-quote and escape a Postgres identifier for safe interpolation. */
|
|
114
|
+
function quoteIdent(name: string): string {
|
|
115
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Map a `pg_class.relkind` to the right `ALTER ... SET SCHEMA` keyword. Kinds
|
|
120
|
+
* not in this map (indexes, toast tables, composite-type rows, etc.) are never
|
|
121
|
+
* relocated here.
|
|
122
|
+
*/
|
|
123
|
+
const ALTER_KEYWORD_BY_RELKIND: Record<string, string> = {
|
|
124
|
+
r: "TABLE", // ordinary table
|
|
125
|
+
p: "TABLE", // partitioned table
|
|
126
|
+
S: "SEQUENCE", // sequence
|
|
127
|
+
v: "VIEW", // view
|
|
128
|
+
m: "MATERIALIZED VIEW", // materialized view
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Move the plugin's `public`-resident objects into `schema`. Returns the list
|
|
133
|
+
* of moved objects (for logging). No-op when `schema` is `public`.
|
|
134
|
+
*/
|
|
135
|
+
export async function relocateLegacyPublicObjects({
|
|
136
|
+
client,
|
|
137
|
+
schema,
|
|
138
|
+
owned,
|
|
139
|
+
}: {
|
|
140
|
+
client: RelocationClient;
|
|
141
|
+
schema: string;
|
|
142
|
+
owned: PluginOwnedObjects;
|
|
143
|
+
}): Promise<{ moved: string[] }> {
|
|
144
|
+
const moved: string[] = [];
|
|
145
|
+
if (schema === "public") return { moved };
|
|
146
|
+
|
|
147
|
+
const target = quoteIdent(schema);
|
|
148
|
+
|
|
149
|
+
// --- Relations (tables / views / matviews / standalone sequences) ---
|
|
150
|
+
if (owned.relations.length > 0) {
|
|
151
|
+
const inPublic = await client.query<{ relname: string; relkind: string }>(
|
|
152
|
+
`SELECT c.relname, c.relkind
|
|
153
|
+
FROM pg_class c
|
|
154
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
155
|
+
WHERE n.nspname = 'public' AND c.relname = ANY($1::text[])`,
|
|
156
|
+
[owned.relations],
|
|
157
|
+
);
|
|
158
|
+
const inTarget = await client.query<{ relname: string }>(
|
|
159
|
+
`SELECT c.relname
|
|
160
|
+
FROM pg_class c
|
|
161
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
162
|
+
WHERE n.nspname = $1 AND c.relname = ANY($2::text[])`,
|
|
163
|
+
[schema, owned.relations],
|
|
164
|
+
);
|
|
165
|
+
const alreadyMoved = new Set(inTarget.rows.map((r) => r.relname));
|
|
166
|
+
|
|
167
|
+
for (const { relname, relkind } of inPublic.rows) {
|
|
168
|
+
if (alreadyMoved.has(relname)) continue;
|
|
169
|
+
const keyword = ALTER_KEYWORD_BY_RELKIND[relkind];
|
|
170
|
+
if (!keyword) continue;
|
|
171
|
+
await client.query(
|
|
172
|
+
`ALTER ${keyword} public.${quoteIdent(relname)} SET SCHEMA ${target}`,
|
|
173
|
+
);
|
|
174
|
+
moved.push(`${keyword.toLowerCase()} ${relname}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Types (enums / composite types) ---
|
|
179
|
+
if (owned.types.length > 0) {
|
|
180
|
+
const inPublic = await client.query<{ typname: string }>(
|
|
181
|
+
`SELECT t.typname
|
|
182
|
+
FROM pg_type t
|
|
183
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
184
|
+
WHERE n.nspname = 'public' AND t.typname = ANY($1::text[])`,
|
|
185
|
+
[owned.types],
|
|
186
|
+
);
|
|
187
|
+
const inTarget = await client.query<{ typname: string }>(
|
|
188
|
+
`SELECT t.typname
|
|
189
|
+
FROM pg_type t
|
|
190
|
+
JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
191
|
+
WHERE n.nspname = $1 AND t.typname = ANY($2::text[])`,
|
|
192
|
+
[schema, owned.types],
|
|
193
|
+
);
|
|
194
|
+
const alreadyMoved = new Set(inTarget.rows.map((r) => r.typname));
|
|
195
|
+
|
|
196
|
+
for (const { typname } of inPublic.rows) {
|
|
197
|
+
if (alreadyMoved.has(typname)) continue;
|
|
198
|
+
await client.query(
|
|
199
|
+
`ALTER TYPE public.${quoteIdent(typname)} SET SCHEMA ${target}`,
|
|
200
|
+
);
|
|
201
|
+
moved.push(`type ${typname}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { moved };
|
|
206
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import type { PoolClient } from "pg";
|
|
3
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
+
import { runPluginMigrations } from "./run-plugin-migrations";
|
|
5
|
+
|
|
6
|
+
type MigrationDb = NodePgDatabase<Record<string, unknown>>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A fake pooled client that records the SQL it runs and whether it was
|
|
10
|
+
* released, modelling the bits of `pg.PoolClient` the helper touches.
|
|
11
|
+
*/
|
|
12
|
+
function makeFakeClient() {
|
|
13
|
+
const queries: string[] = [];
|
|
14
|
+
let released = false;
|
|
15
|
+
const client = {
|
|
16
|
+
query: async (text: string) => {
|
|
17
|
+
queries.push(text);
|
|
18
|
+
return { rows: [] };
|
|
19
|
+
},
|
|
20
|
+
release: () => {
|
|
21
|
+
released = true;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
client: client as unknown as PoolClient,
|
|
26
|
+
queries,
|
|
27
|
+
isReleased: () => released,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A relocation step that does nothing (keeps tests off the filesystem/db). */
|
|
32
|
+
const noopRelocate = async () => {};
|
|
33
|
+
|
|
34
|
+
describe("runPluginMigrations", () => {
|
|
35
|
+
it("runs the migrator on a single pinned connection with a strict search_path set first", async () => {
|
|
36
|
+
const { client, queries, isReleased } = makeFakeClient();
|
|
37
|
+
|
|
38
|
+
let connectCount = 0;
|
|
39
|
+
const pool = {
|
|
40
|
+
connect: async () => {
|
|
41
|
+
connectCount++;
|
|
42
|
+
return client;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const fakeDb = { __fake: true } as unknown as MigrationDb;
|
|
47
|
+
let dbPassedToMigrate: unknown;
|
|
48
|
+
let clientPassedToFactory: PoolClient | undefined;
|
|
49
|
+
let queriesBeforeMigrate: string[] = [];
|
|
50
|
+
|
|
51
|
+
await runPluginMigrations({
|
|
52
|
+
pool,
|
|
53
|
+
migrationsFolder: "/plugins/healthcheck/drizzle",
|
|
54
|
+
migrationsSchema: "plugin_healthcheck",
|
|
55
|
+
relocateLegacyObjects: noopRelocate,
|
|
56
|
+
createMigrationDb: (c) => {
|
|
57
|
+
clientPassedToFactory = c;
|
|
58
|
+
return fakeDb;
|
|
59
|
+
},
|
|
60
|
+
migrate: async (db, config) => {
|
|
61
|
+
dbPassedToMigrate = db;
|
|
62
|
+
queriesBeforeMigrate = [...queries];
|
|
63
|
+
expect(config.migrationsFolder).toBe("/plugins/healthcheck/drizzle");
|
|
64
|
+
expect(config.migrationsSchema).toBe("plugin_healthcheck");
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Exactly ONE connection is checked out: the SET and the migration must
|
|
69
|
+
// share a physical connection, which was the whole bug.
|
|
70
|
+
expect(connectCount).toBe(1);
|
|
71
|
+
|
|
72
|
+
// The migrator runs against a Drizzle instance bound to that same pinned
|
|
73
|
+
// client.
|
|
74
|
+
expect(clientPassedToFactory).toBe(client);
|
|
75
|
+
expect(dbPassedToMigrate).toBe(fakeDb);
|
|
76
|
+
|
|
77
|
+
// The plugin schema is created BEFORE search_path points at it (so new
|
|
78
|
+
// objects can never fall through to `public`), and the search_path is
|
|
79
|
+
// STRICT - plugin schema only, no `public` fallback.
|
|
80
|
+
expect(queriesBeforeMigrate).toEqual([
|
|
81
|
+
'CREATE SCHEMA IF NOT EXISTS "plugin_healthcheck"',
|
|
82
|
+
'SET search_path = "plugin_healthcheck"',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
// Afterwards the connection is reset and returned to the pool.
|
|
86
|
+
expect(queries.at(-1)).toBe("SET search_path = public");
|
|
87
|
+
expect(isReleased()).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("relocates legacy public objects after creating the schema and before setting search_path / migrating", async () => {
|
|
91
|
+
const { client, queries } = makeFakeClient();
|
|
92
|
+
const pool = { connect: async () => client };
|
|
93
|
+
|
|
94
|
+
const events: string[] = [];
|
|
95
|
+
let schemaPassedToRelocate: string | undefined;
|
|
96
|
+
let folderPassedToRelocate: string | undefined;
|
|
97
|
+
let clientPassedToRelocate: PoolClient | undefined;
|
|
98
|
+
|
|
99
|
+
await runPluginMigrations({
|
|
100
|
+
pool,
|
|
101
|
+
migrationsFolder: "/plugins/healthcheck/drizzle",
|
|
102
|
+
migrationsSchema: "plugin_healthcheck",
|
|
103
|
+
relocateLegacyObjects: async ({ client: c, schema, migrationsFolder }) => {
|
|
104
|
+
clientPassedToRelocate = c;
|
|
105
|
+
schemaPassedToRelocate = schema;
|
|
106
|
+
folderPassedToRelocate = migrationsFolder;
|
|
107
|
+
events.push(`relocate@${queries.length}`);
|
|
108
|
+
},
|
|
109
|
+
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
110
|
+
migrate: async () => {
|
|
111
|
+
events.push(`migrate@${queries.length}`);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Relocation runs on the same pinned client, with the plugin schema and
|
|
116
|
+
// the plugin's own migrations folder.
|
|
117
|
+
expect(clientPassedToRelocate).toBe(client);
|
|
118
|
+
expect(schemaPassedToRelocate).toBe("plugin_healthcheck");
|
|
119
|
+
expect(folderPassedToRelocate).toBe("/plugins/healthcheck/drizzle");
|
|
120
|
+
|
|
121
|
+
// Ordering: CREATE SCHEMA, then relocate, then SET search_path, then migrate.
|
|
122
|
+
// After CREATE SCHEMA only (1 query) relocate runs; after the SET (2
|
|
123
|
+
// queries) migrate runs.
|
|
124
|
+
expect(events).toEqual(["relocate@1", "migrate@2"]);
|
|
125
|
+
expect(queries).toEqual([
|
|
126
|
+
'CREATE SCHEMA IF NOT EXISTS "plugin_healthcheck"',
|
|
127
|
+
'SET search_path = "plugin_healthcheck"',
|
|
128
|
+
"SET search_path = public",
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("uses a strict plugin-only search_path with no `public` fallback", async () => {
|
|
133
|
+
const { client, queries } = makeFakeClient();
|
|
134
|
+
const pool = { connect: async () => client };
|
|
135
|
+
|
|
136
|
+
await runPluginMigrations({
|
|
137
|
+
pool,
|
|
138
|
+
migrationsFolder: "/x",
|
|
139
|
+
migrationsSchema: "plugin_healthcheck",
|
|
140
|
+
relocateLegacyObjects: noopRelocate,
|
|
141
|
+
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
142
|
+
migrate: async () => {},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const setStatement = queries.find(
|
|
146
|
+
(q) => q.startsWith("SET search_path =") && q.includes("plugin_healthcheck"),
|
|
147
|
+
);
|
|
148
|
+
expect(setStatement).toBe('SET search_path = "plugin_healthcheck"');
|
|
149
|
+
expect(setStatement).not.toContain("public");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("resets search_path and releases the connection even when the migrator throws", async () => {
|
|
153
|
+
const { client, queries, isReleased } = makeFakeClient();
|
|
154
|
+
const pool = { connect: async () => client };
|
|
155
|
+
const boom = new Error("migration failed");
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
runPluginMigrations({
|
|
159
|
+
pool,
|
|
160
|
+
migrationsFolder: "/x",
|
|
161
|
+
migrationsSchema: "plugin_x",
|
|
162
|
+
relocateLegacyObjects: noopRelocate,
|
|
163
|
+
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
164
|
+
migrate: async () => {
|
|
165
|
+
throw boom;
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
).rejects.toThrow("migration failed");
|
|
169
|
+
|
|
170
|
+
expect(queries.at(-1)).toBe("SET search_path = public");
|
|
171
|
+
expect(isReleased()).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("never touches anything but connect() on the pool (no session SET on the shared pool)", async () => {
|
|
175
|
+
const { client } = makeFakeClient();
|
|
176
|
+
const pool = { connect: async () => client };
|
|
177
|
+
|
|
178
|
+
await runPluginMigrations({
|
|
179
|
+
pool,
|
|
180
|
+
migrationsFolder: "/x",
|
|
181
|
+
migrationsSchema: "plugin_x",
|
|
182
|
+
relocateLegacyObjects: noopRelocate,
|
|
183
|
+
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
184
|
+
migrate: async () => {},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// The pool surface the helper depends on is exactly `connect`; everything
|
|
188
|
+
// else (CREATE SCHEMA, relocation, SET search_path, the migration itself)
|
|
189
|
+
// happens on the checked-out client. If this contract ever widens, the
|
|
190
|
+
// regression that motivated the pinned connection could creep back in.
|
|
191
|
+
expect(Object.keys(pool)).toEqual(["connect"]);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { migrate as defaultMigrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
|
+
import type { Pool, PoolClient } from "pg";
|
|
4
|
+
import {
|
|
5
|
+
readPluginOwnedObjects,
|
|
6
|
+
relocateLegacyPublicObjects,
|
|
7
|
+
} from "./relocate-legacy-public-objects";
|
|
8
|
+
|
|
9
|
+
type MigrationDb = NodePgDatabase<Record<string, unknown>>;
|
|
10
|
+
|
|
11
|
+
export interface RunPluginMigrationsArgs {
|
|
12
|
+
/** Shared admin pool; the helper checks out ONE dedicated client from it. */
|
|
13
|
+
pool: Pick<Pool, "connect">;
|
|
14
|
+
/** Absolute path to the plugin's Drizzle migrations folder. */
|
|
15
|
+
migrationsFolder: string;
|
|
16
|
+
/**
|
|
17
|
+
* Postgres schema the plugin's objects live in (e.g. `plugin_healthcheck`).
|
|
18
|
+
* Also used by Drizzle for the per-plugin `__drizzle_migrations` table.
|
|
19
|
+
*/
|
|
20
|
+
migrationsSchema: string;
|
|
21
|
+
/**
|
|
22
|
+
* Builds the Drizzle instance the migrator runs against. Defaults to one
|
|
23
|
+
* bound to the pinned `client`. Injectable so tests can run without a real
|
|
24
|
+
* connection.
|
|
25
|
+
*/
|
|
26
|
+
createMigrationDb?: (client: PoolClient) => MigrationDb;
|
|
27
|
+
/** Drizzle's migrator. Injectable for tests. */
|
|
28
|
+
migrate?: (
|
|
29
|
+
db: MigrationDb,
|
|
30
|
+
config: { migrationsFolder: string; migrationsSchema: string },
|
|
31
|
+
) => Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Moves the plugin's objects that earlier deploys left in `public` into the
|
|
34
|
+
* plugin schema, before migrations run. Injectable for tests.
|
|
35
|
+
*/
|
|
36
|
+
relocateLegacyObjects?: (args: {
|
|
37
|
+
client: PoolClient;
|
|
38
|
+
schema: string;
|
|
39
|
+
migrationsFolder: string;
|
|
40
|
+
}) => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const defaultRelocateLegacyObjects = async ({
|
|
44
|
+
client,
|
|
45
|
+
schema,
|
|
46
|
+
migrationsFolder,
|
|
47
|
+
}: {
|
|
48
|
+
client: PoolClient;
|
|
49
|
+
schema: string;
|
|
50
|
+
migrationsFolder: string;
|
|
51
|
+
}): Promise<void> => {
|
|
52
|
+
const owned = readPluginOwnedObjects(migrationsFolder);
|
|
53
|
+
await relocateLegacyPublicObjects({ client, schema, owned });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run a plugin's Drizzle migrations on a SINGLE pinned pool connection.
|
|
58
|
+
*
|
|
59
|
+
* ## Strict, isolated search_path
|
|
60
|
+
*
|
|
61
|
+
* Plugin migrations are schema-agnostic: they reference the plugin's tables,
|
|
62
|
+
* types, and enums *unqualified* and rely on `search_path` to resolve them. We
|
|
63
|
+
* run them with a STRICT `search_path = "<plugin_schema>"` (no `public`
|
|
64
|
+
* fallback) so new objects can only ever land in the plugin schema, and a
|
|
65
|
+
* migration that references something not in that schema fails loudly instead
|
|
66
|
+
* of silently reading or writing `public`.
|
|
67
|
+
*
|
|
68
|
+
* The schema is created BEFORE the `SET`. That ordering matters: if the schema
|
|
69
|
+
* did not exist, an unqualified `CREATE TABLE` would have nowhere to go and
|
|
70
|
+
* Postgres would error - which is the safe outcome we want, not a silent
|
|
71
|
+
* fallthrough.
|
|
72
|
+
*
|
|
73
|
+
* ## Relocating legacy `public` objects first
|
|
74
|
+
*
|
|
75
|
+
* Some installs created the plugin's objects in `public` (pre-isolation
|
|
76
|
+
* deploys), which is exactly what a strict search_path would choke on. Before
|
|
77
|
+
* migrating, {@link relocateLegacyPublicObjects} moves any of the plugin's
|
|
78
|
+
* objects that are still in `public` into the plugin schema using
|
|
79
|
+
* fully-qualified `ALTER ... SET SCHEMA` (so it needs no search_path of its
|
|
80
|
+
* own). After it runs, everything the plugin owns lives in the plugin schema
|
|
81
|
+
* and the strict search_path resolves cleanly. It is idempotent, so fresh and
|
|
82
|
+
* already-migrated installs are no-ops.
|
|
83
|
+
*
|
|
84
|
+
* ## Why a pinned connection
|
|
85
|
+
*
|
|
86
|
+
* The `search_path` must be set on the exact connection the migration
|
|
87
|
+
* statements run on. Setting it at the *session* level on the shared pool does
|
|
88
|
+
* NOT guarantee that, for the same reason session-level advisory locks don't
|
|
89
|
+
* (see `advisory-lock.ts`): Drizzle's `migrate()` wraps all pending migrations
|
|
90
|
+
* in one transaction, and with a `pg.Pool` that transaction can check out a
|
|
91
|
+
* *different* physical connection than the one the `SET` ran on. Binding the
|
|
92
|
+
* migrator (and the relocation) to ONE pinned client guarantees every statement
|
|
93
|
+
* runs on the connection we prepared.
|
|
94
|
+
*/
|
|
95
|
+
export async function runPluginMigrations({
|
|
96
|
+
pool,
|
|
97
|
+
migrationsFolder,
|
|
98
|
+
migrationsSchema,
|
|
99
|
+
createMigrationDb = (client) => drizzle(client),
|
|
100
|
+
migrate = defaultMigrate,
|
|
101
|
+
relocateLegacyObjects = defaultRelocateLegacyObjects,
|
|
102
|
+
}: RunPluginMigrationsArgs): Promise<void> {
|
|
103
|
+
const client = await pool.connect();
|
|
104
|
+
try {
|
|
105
|
+
// Create the schema BEFORE anything points at it, so relocated and newly
|
|
106
|
+
// created objects have a home and never fall through to `public`.
|
|
107
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS "${migrationsSchema}"`);
|
|
108
|
+
|
|
109
|
+
// Move any of this plugin's objects that earlier deploys left in `public`
|
|
110
|
+
// into the plugin schema, so the strict search_path below resolves them.
|
|
111
|
+
await relocateLegacyObjects({
|
|
112
|
+
client,
|
|
113
|
+
schema: migrationsSchema,
|
|
114
|
+
migrationsFolder,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Strict search_path: plugin schema only, no `public` fallback.
|
|
118
|
+
await client.query(`SET search_path = "${migrationsSchema}"`);
|
|
119
|
+
|
|
120
|
+
await migrate(createMigrationDb(client), {
|
|
121
|
+
migrationsFolder,
|
|
122
|
+
migrationsSchema,
|
|
123
|
+
});
|
|
124
|
+
} finally {
|
|
125
|
+
// Reset before the client returns to the pool so the setting never leaks
|
|
126
|
+
// onto an unrelated query that later reuses this physical connection.
|
|
127
|
+
try {
|
|
128
|
+
await client.query("SET search_path = public");
|
|
129
|
+
} catch (resetError) {
|
|
130
|
+
// Best-effort: the release below still returns the connection. Reference
|
|
131
|
+
// the binding so lint doesn't flag an empty catch.
|
|
132
|
+
void resetError;
|
|
133
|
+
}
|
|
134
|
+
client.release();
|
|
135
|
+
}
|
|
136
|
+
}
|