@checkstack/backend 0.13.0 → 0.15.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 +87 -0
- package/package.json +1 -1
- package/src/db.ts +109 -2
- package/src/plugin-manager/core-services.ts +9 -6
- 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 +75 -6
- package/src/utils/run-plugin-migrations.ts +69 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a57f7db: fix(backend): give advisory locks a dedicated connection pool to prevent pool-starvation deadlock
|
|
8
|
+
|
|
9
|
+
Both the session-lock service and `withXactLock` HOLD a Postgres connection for
|
|
10
|
+
the lock's whole lifetime while the gated work runs on a _different_ connection.
|
|
11
|
+
Both lock and work were drawing from the single shared `adminPool` (which, with
|
|
12
|
+
no explicit config, defaulted to `max: 10` and `connectionTimeoutMillis: 0` -
|
|
13
|
+
wait forever). Under concurrency >= pool size, every slot became a lock-holding
|
|
14
|
+
connection waiting for a work connection that could never free up: a permanent
|
|
15
|
+
deadlock. It surfaced as all connections stuck `idle in transaction` on
|
|
16
|
+
`pg_advisory_xact_lock` and every API request hanging into an upstream 502,
|
|
17
|
+
only after the server had been running long enough to hit that concurrency
|
|
18
|
+
(e.g. a burst of health-check evaluations or incident dedups).
|
|
19
|
+
|
|
20
|
+
Advisory locks now run on a dedicated `lockPool`, separate from `adminPool`, so
|
|
21
|
+
the acquire graph is acyclic (`lockPool -> adminPool`, never back) and the
|
|
22
|
+
deadlock class is impossible. `AdvisoryLockService` gains a pooled
|
|
23
|
+
`withXactLock({ key, fn })` method (lock on the lock pool, work on the admin
|
|
24
|
+
pool); healthcheck's per-system serializer, incident's dedup-create, and the
|
|
25
|
+
automation single-mode concurrency lock now use it. The deadlock-prone
|
|
26
|
+
standalone `withXactLock({ db, ... })` helper is REMOVED.
|
|
27
|
+
|
|
28
|
+
Both pools are explicitly configured with `connectionTimeoutMillis` so any
|
|
29
|
+
future exhaustion fails fast and self-heals instead of hanging, and both get a
|
|
30
|
+
pool-level `error` handler (an idle pooled client whose backend dies otherwise
|
|
31
|
+
crashes the pod). The lock pool additionally sets
|
|
32
|
+
`idle_in_transaction_session_timeout` and `lock_timeout` so a stalled critical
|
|
33
|
+
section is reaped server-side (auto-releasing the lock) rather than stranding a
|
|
34
|
+
key forever. The advisory-lock service also now removes its per-client error
|
|
35
|
+
listener on release (it previously leaked one listener per acquisition on each
|
|
36
|
+
reused pooled connection - an unbounded `MaxListenersExceeded` leak).
|
|
37
|
+
|
|
38
|
+
New env vars (all optional): `DATABASE_POOL_MAX` (default 20),
|
|
39
|
+
`DATABASE_LOCK_POOL_MAX` (default 10), `DATABASE_POOL_CONNECTION_TIMEOUT_MS`
|
|
40
|
+
(default 10000), `DATABASE_POOL_IDLE_TIMEOUT_MS` (default 30000),
|
|
41
|
+
`DATABASE_LOCK_IDLE_TX_TIMEOUT_MS` (default 30000), `DATABASE_LOCK_TIMEOUT_MS`
|
|
42
|
+
(default 30000). Size pools off
|
|
43
|
+
`N_pods * (DATABASE_POOL_MAX + DATABASE_LOCK_POOL_MAX) <= max_connections`.
|
|
44
|
+
|
|
45
|
+
BREAKING CHANGE: the standalone `withXactLock({ db, key, fn })` export is
|
|
46
|
+
removed - use `coreServices.advisoryLock.withXactLock({ key, fn })` instead.
|
|
47
|
+
`IncidentService`'s constructor now requires an `AdvisoryLockService` as its
|
|
48
|
+
second argument, and the healthcheck `createHealthEntitySerializer` /
|
|
49
|
+
`executeHealthCheckJob` / `setupHealthCheckWorker` helpers take `advisoryLock`
|
|
50
|
+
instead of `db` for the serializer.
|
|
51
|
+
|
|
52
|
+
### Patch Changes
|
|
53
|
+
|
|
54
|
+
- Updated dependencies [a57f7db]
|
|
55
|
+
- @checkstack/backend-api@0.20.0
|
|
56
|
+
- @checkstack/cache-api@0.3.8
|
|
57
|
+
- @checkstack/queue-api@0.3.8
|
|
58
|
+
- @checkstack/signal-backend@0.2.12
|
|
59
|
+
|
|
60
|
+
## 0.14.0
|
|
61
|
+
|
|
62
|
+
### Minor Changes
|
|
63
|
+
|
|
64
|
+
- 79b3487: Relocate plugin objects stranded in `public` into their plugin schema, and run
|
|
65
|
+
migrations under a strict plugin-only `search_path`.
|
|
66
|
+
|
|
67
|
+
Some databases predate per-plugin schema isolation and have a plugin's tables
|
|
68
|
+
and enums sitting in `public` while the `__drizzle_migrations` ledger lives in
|
|
69
|
+
the plugin schema. Runtime kept working because the scoped-db `search_path`
|
|
70
|
+
falls back to `public`, but migrations did not: a new migration referencing a
|
|
71
|
+
pre-existing object (e.g. the `health_check_status` enum) failed at startup with
|
|
72
|
+
`type "health_check_status" does not exist`, crash-looping the pod. The previous
|
|
73
|
+
pinned-connection fix made this deterministic by reliably targeting the
|
|
74
|
+
(empty-of-that-object) plugin schema.
|
|
75
|
+
|
|
76
|
+
The loader now, before running a plugin's migrations, MOVES any of that plugin's
|
|
77
|
+
objects still in `public` into `plugin_<id>` with fully-qualified
|
|
78
|
+
`ALTER ... SET SCHEMA` statements (by-OID, so columns, foreign keys, enum
|
|
79
|
+
references, and owned sequences keep working). The relocation is idempotent
|
|
80
|
+
(only moves objects that are in `public` and not already in the plugin schema)
|
|
81
|
+
and is driven by the union of every Drizzle snapshot the plugin ships, so a
|
|
82
|
+
table an early migration created and a later one drops is moved first and its
|
|
83
|
+
unqualified `DROP TABLE` still resolves.
|
|
84
|
+
|
|
85
|
+
With the stragglers relocated, migrations run under a strict
|
|
86
|
+
`search_path = "plugin_<id>"` (no `public` fallback). Combined with creating the
|
|
87
|
+
schema before the `SET`, unqualified `CREATE TABLE` / `CREATE TYPE` can only ever
|
|
88
|
+
land in the plugin schema, never silently in `public`.
|
|
89
|
+
|
|
3
90
|
## 0.13.0
|
|
4
91
|
|
|
5
92
|
### Minor Changes
|
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
-
import { Pool } from "pg";
|
|
2
|
+
import { Pool, type PoolConfig } from "pg";
|
|
3
3
|
import * as schema from "./schema";
|
|
4
|
+
import { rootLogger } from "./logger";
|
|
4
5
|
|
|
5
6
|
// Basic connection string sometimes fails with Bun + pg + docker SASL
|
|
6
7
|
// parsing manually or relying on pg to pick up ENV variables if we don't pass anything
|
|
@@ -13,8 +14,114 @@ if (!connectionString) {
|
|
|
13
14
|
throw new Error("DATABASE_URL is not defined");
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
/** Parse a positive-integer env var, falling back to `fallback` when unset/invalid. */
|
|
18
|
+
function intFromEnv(name: string, fallback: number): number {
|
|
19
|
+
const raw = process.env[name];
|
|
20
|
+
if (raw === undefined || raw === "") return fallback;
|
|
21
|
+
const parsed = Number.parseInt(raw, 10);
|
|
22
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* ## Connection budget (read this before bumping the defaults)
|
|
27
|
+
*
|
|
28
|
+
* The platform runs as N horizontally-scaled pods sharing ONE Postgres. The
|
|
29
|
+
* server-wide ceiling is `max_connections`, so the real budget is:
|
|
30
|
+
*
|
|
31
|
+
* N_pods * (adminPool.max + lockPool.max) <= max_connections - headroom
|
|
32
|
+
*
|
|
33
|
+
* Size the pools off that budget (via the env vars below), NOT off the number
|
|
34
|
+
* of plugins: connections are never pinned per-plugin. The scoped-db proxy sets
|
|
35
|
+
* `SET LOCAL search_path` per transaction on a borrowed-then-returned
|
|
36
|
+
* connection, so a connection is occupied only for one transaction (or one
|
|
37
|
+
* held advisory lock), never for a plugin's lifetime.
|
|
38
|
+
*
|
|
39
|
+
* ## Why two pools
|
|
40
|
+
*
|
|
41
|
+
* Session/transaction advisory locks (see `advisory-lock.ts`) HOLD a connection
|
|
42
|
+
* for the lock's whole lifetime while the locked work runs on a *different*
|
|
43
|
+
* connection. If both came from one pool, then under concurrency >= pool size
|
|
44
|
+
* every slot becomes a lock-holding connection waiting for a work connection
|
|
45
|
+
* that can never free up - a self-inflicted deadlock (observed: 10 connections
|
|
46
|
+
* all `idle in transaction` on `pg_advisory_xact_lock`). Giving advisory locks
|
|
47
|
+
* their own `lockPool` makes the acquire graph acyclic (lockPool -> adminPool,
|
|
48
|
+
* never back), so that deadlock class is impossible.
|
|
49
|
+
*
|
|
50
|
+
* `connectionTimeoutMillis` is the universal safety net: a pg Pool defaults to
|
|
51
|
+
* waiting FOREVER for a free connection, which turns any future exhaustion into
|
|
52
|
+
* a permanent hang (and an upstream 502). With a finite timeout, an exhausted
|
|
53
|
+
* acquire throws, its holder unwinds and releases, and the pool self-heals.
|
|
54
|
+
*/
|
|
55
|
+
const COMMON_POOL_CONFIG = {
|
|
17
56
|
connectionString,
|
|
57
|
+
connectionTimeoutMillis: intFromEnv(
|
|
58
|
+
"DATABASE_POOL_CONNECTION_TIMEOUT_MS",
|
|
59
|
+
10_000,
|
|
60
|
+
),
|
|
61
|
+
idleTimeoutMillis: intFromEnv("DATABASE_POOL_IDLE_TIMEOUT_MS", 30_000),
|
|
62
|
+
} satisfies PoolConfig;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The main pool: serves every plugin query and all API request work. Never used
|
|
66
|
+
* to hold an advisory lock open (that is `lockPool`'s job).
|
|
67
|
+
*/
|
|
68
|
+
export const adminPool = new Pool({
|
|
69
|
+
...COMMON_POOL_CONFIG,
|
|
70
|
+
max: intFromEnv("DATABASE_POOL_MAX", 20),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Dedicated pool for advisory locks ONLY (the session-lock service and
|
|
75
|
+
* `withXactLock`). Kept separate from `adminPool` so a held lock connection
|
|
76
|
+
* and the work it gates draw from different pools - see the deadlock note
|
|
77
|
+
* above. Sized for peak concurrent held locks INCLUDING nesting: one logical
|
|
78
|
+
* operation can hold up to two locks at once (an automation run lock wrapping
|
|
79
|
+
* an `incident.dedupe-open-for-system` lock), so this needs headroom above the
|
|
80
|
+
* raw concurrent-operation count.
|
|
81
|
+
*
|
|
82
|
+
* ## Stall backstops (server-enforced, can't be skipped by a hung process)
|
|
83
|
+
*
|
|
84
|
+
* Advisory locks lock no rows or tables - only other callers of the SAME key
|
|
85
|
+
* block. But a critical section whose `fn` hangs (e.g. an unbounded await)
|
|
86
|
+
* would hold its key + this connection open indefinitely, blocking same-key
|
|
87
|
+
* callers. Two connection-level timeouts bound that, set ONLY on this pool
|
|
88
|
+
* (where every transaction is a short lock critical section, so the limits are
|
|
89
|
+
* safe; the admin pool runs arbitrary plugin work and must NOT inherit them):
|
|
90
|
+
*
|
|
91
|
+
* - `idle_in_transaction_session_timeout`: the lock transaction sits "idle in
|
|
92
|
+
* transaction" for the whole time `fn` runs (it only issued BEGIN + the
|
|
93
|
+
* lock). If `fn` hangs past this, Postgres ABORTS the session, which
|
|
94
|
+
* auto-releases the advisory lock and frees the connection - so a stall
|
|
95
|
+
* self-heals instead of stranding the lock forever.
|
|
96
|
+
* - `lock_timeout`: a caller BLOCKED waiting to acquire a contended/stalled
|
|
97
|
+
* key aborts after this (verified to apply to `pg_advisory_xact_lock`),
|
|
98
|
+
* surfacing as a retryable error rather than an indefinite block that also
|
|
99
|
+
* ties up a lock-pool connection.
|
|
100
|
+
*
|
|
101
|
+
* Both default high enough that a healthy short critical section never trips
|
|
102
|
+
* them; tune via env if your critical sections are unusually long.
|
|
103
|
+
*/
|
|
104
|
+
export const lockPool = new Pool({
|
|
105
|
+
...COMMON_POOL_CONFIG,
|
|
106
|
+
max: intFromEnv("DATABASE_LOCK_POOL_MAX", 10),
|
|
107
|
+
idle_in_transaction_session_timeout: intFromEnv(
|
|
108
|
+
"DATABASE_LOCK_IDLE_TX_TIMEOUT_MS",
|
|
109
|
+
30_000,
|
|
110
|
+
),
|
|
111
|
+
lock_timeout: intFromEnv("DATABASE_LOCK_TIMEOUT_MS", 30_000),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// A pg Pool emits 'error' on behalf of IDLE clients whose backend dies (admin
|
|
115
|
+
// termination, failover, network drop). With no listener, that 'error' is
|
|
116
|
+
// unhandled and CRASHES the process. Log and swallow: the pool discards the
|
|
117
|
+
// dead client and hands out a fresh one on the next checkout. (Checked-out
|
|
118
|
+
// clients are covered separately - the scoped-db transaction wrapper and the
|
|
119
|
+
// advisory-lock service attach their own per-client handlers while held.)
|
|
120
|
+
adminPool.on("error", (error) => {
|
|
121
|
+
rootLogger.warn("adminPool idle client error (recovered)", error);
|
|
122
|
+
});
|
|
123
|
+
lockPool.on("error", (error) => {
|
|
124
|
+
rootLogger.warn("lockPool idle client error (recovered)", error);
|
|
18
125
|
});
|
|
19
126
|
|
|
20
127
|
export const db = drizzle({ client: adminPool, schema });
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { AuthApi } from "@checkstack/auth-common";
|
|
15
15
|
import type { ServiceRegistry } from "../services/service-registry";
|
|
16
16
|
import { rootLogger } from "../logger";
|
|
17
|
-
import { db } from "../db";
|
|
17
|
+
import { db, lockPool } from "../db";
|
|
18
18
|
import { jwtService } from "../services/jwt";
|
|
19
19
|
import {
|
|
20
20
|
CoreHealthCheckRegistry,
|
|
@@ -98,11 +98,14 @@ export function registerCoreServices({
|
|
|
98
98
|
return createScopedDb(db, assignedSchema);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
// 1b. Advisory Lock Factory (server-global, backed by the
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
101
|
+
// 1b. Advisory Lock Factory (server-global, backed by the DEDICATED
|
|
102
|
+
// `lockPool`, NOT `adminPool`). Both session locks (`tryAcquire`) and the
|
|
103
|
+
// transaction-scoped `withXactLock` HOLD a connection for the lock's whole
|
|
104
|
+
// lifetime while the locked work runs on `adminPool`. Drawing the lock
|
|
105
|
+
// connection from a separate pool keeps the acquire graph acyclic
|
|
106
|
+
// (lockPool -> adminPool, never back), so a held lock can never starve the
|
|
107
|
+
// work pool into the `idle in transaction` deadlock. See `db.ts`.
|
|
108
|
+
const advisoryLockService = createAdvisoryLockService(lockPool);
|
|
106
109
|
registry.registerFactory(
|
|
107
110
|
coreServices.advisoryLock,
|
|
108
111
|
() => advisoryLockService,
|
|
@@ -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
|
+
}
|
|
@@ -28,8 +28,11 @@ function makeFakeClient() {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** A relocation step that does nothing (keeps tests off the filesystem/db). */
|
|
32
|
+
const noopRelocate = async () => {};
|
|
33
|
+
|
|
31
34
|
describe("runPluginMigrations", () => {
|
|
32
|
-
it("runs the migrator on a single pinned connection with search_path set first", async () => {
|
|
35
|
+
it("runs the migrator on a single pinned connection with a strict search_path set first", async () => {
|
|
33
36
|
const { client, queries, isReleased } = makeFakeClient();
|
|
34
37
|
|
|
35
38
|
let connectCount = 0;
|
|
@@ -49,6 +52,7 @@ describe("runPluginMigrations", () => {
|
|
|
49
52
|
pool,
|
|
50
53
|
migrationsFolder: "/plugins/healthcheck/drizzle",
|
|
51
54
|
migrationsSchema: "plugin_healthcheck",
|
|
55
|
+
relocateLegacyObjects: noopRelocate,
|
|
52
56
|
createMigrationDb: (c) => {
|
|
53
57
|
clientPassedToFactory = c;
|
|
54
58
|
return fakeDb;
|
|
@@ -70,8 +74,9 @@ describe("runPluginMigrations", () => {
|
|
|
70
74
|
expect(clientPassedToFactory).toBe(client);
|
|
71
75
|
expect(dbPassedToMigrate).toBe(fakeDb);
|
|
72
76
|
|
|
73
|
-
//
|
|
74
|
-
// the
|
|
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.
|
|
75
80
|
expect(queriesBeforeMigrate).toEqual([
|
|
76
81
|
'CREATE SCHEMA IF NOT EXISTS "plugin_healthcheck"',
|
|
77
82
|
'SET search_path = "plugin_healthcheck"',
|
|
@@ -82,6 +87,68 @@ describe("runPluginMigrations", () => {
|
|
|
82
87
|
expect(isReleased()).toBe(true);
|
|
83
88
|
});
|
|
84
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
|
+
|
|
85
152
|
it("resets search_path and releases the connection even when the migrator throws", async () => {
|
|
86
153
|
const { client, queries, isReleased } = makeFakeClient();
|
|
87
154
|
const pool = { connect: async () => client };
|
|
@@ -92,6 +159,7 @@ describe("runPluginMigrations", () => {
|
|
|
92
159
|
pool,
|
|
93
160
|
migrationsFolder: "/x",
|
|
94
161
|
migrationsSchema: "plugin_x",
|
|
162
|
+
relocateLegacyObjects: noopRelocate,
|
|
95
163
|
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
96
164
|
migrate: async () => {
|
|
97
165
|
throw boom;
|
|
@@ -111,14 +179,15 @@ describe("runPluginMigrations", () => {
|
|
|
111
179
|
pool,
|
|
112
180
|
migrationsFolder: "/x",
|
|
113
181
|
migrationsSchema: "plugin_x",
|
|
182
|
+
relocateLegacyObjects: noopRelocate,
|
|
114
183
|
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
115
184
|
migrate: async () => {},
|
|
116
185
|
});
|
|
117
186
|
|
|
118
187
|
// The pool surface the helper depends on is exactly `connect`; everything
|
|
119
|
-
// else (CREATE SCHEMA, SET search_path, the migration itself)
|
|
120
|
-
// the checked-out client. If this contract ever widens, the
|
|
121
|
-
// that motivated the pinned connection could creep back in.
|
|
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.
|
|
122
191
|
expect(Object.keys(pool)).toEqual(["connect"]);
|
|
123
192
|
});
|
|
124
193
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
2
|
import { migrate as defaultMigrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
3
|
import type { Pool, PoolClient } from "pg";
|
|
4
|
+
import {
|
|
5
|
+
readPluginOwnedObjects,
|
|
6
|
+
relocateLegacyPublicObjects,
|
|
7
|
+
} from "./relocate-legacy-public-objects";
|
|
4
8
|
|
|
5
9
|
type MigrationDb = NodePgDatabase<Record<string, unknown>>;
|
|
6
10
|
|
|
@@ -25,34 +29,68 @@ export interface RunPluginMigrationsArgs {
|
|
|
25
29
|
db: MigrationDb,
|
|
26
30
|
config: { migrationsFolder: string; migrationsSchema: string },
|
|
27
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>;
|
|
28
41
|
}
|
|
29
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
|
+
|
|
30
56
|
/**
|
|
31
57
|
* Run a plugin's Drizzle migrations on a SINGLE pinned pool connection.
|
|
32
58
|
*
|
|
33
|
-
* ##
|
|
59
|
+
* ## Strict, isolated search_path
|
|
34
60
|
*
|
|
35
61
|
* Plugin migrations are schema-agnostic: they reference the plugin's tables,
|
|
36
|
-
* types, and enums *unqualified* and rely on `search_path` to resolve them
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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.
|
|
39
72
|
*
|
|
40
|
-
*
|
|
41
|
-
* same reason session-level advisory locks don't (see `advisory-lock.ts`):
|
|
42
|
-
* Drizzle's `migrate()` wraps all pending migrations in one transaction, and
|
|
43
|
-
* with a `pg.Pool` that transaction checks out a *different* physical
|
|
44
|
-
* connection than the one the `SET` ran on. The migration statements then
|
|
45
|
-
* execute with the default `public` search_path.
|
|
73
|
+
* ## Relocating legacy `public` objects first
|
|
46
74
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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.
|
|
53
83
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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.
|
|
56
94
|
*/
|
|
57
95
|
export async function runPluginMigrations({
|
|
58
96
|
pool,
|
|
@@ -60,13 +98,23 @@ export async function runPluginMigrations({
|
|
|
60
98
|
migrationsSchema,
|
|
61
99
|
createMigrationDb = (client) => drizzle(client),
|
|
62
100
|
migrate = defaultMigrate,
|
|
101
|
+
relocateLegacyObjects = defaultRelocateLegacyObjects,
|
|
63
102
|
}: RunPluginMigrationsArgs): Promise<void> {
|
|
64
103
|
const client = await pool.connect();
|
|
65
104
|
try {
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// would recreate the very bug this helper exists to prevent.
|
|
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`.
|
|
69
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.
|
|
70
118
|
await client.query(`SET search_path = "${migrationsSchema}"`);
|
|
71
119
|
|
|
72
120
|
await migrate(createMigrationDb(client), {
|