@checkstack/backend 0.11.0 → 0.13.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 +72 -0
- package/package.json +12 -12
- package/src/plugin-manager/core-services.ts +11 -0
- package/src/plugin-manager/plugin-loader.getservice.test.ts +108 -0
- package/src/plugin-manager/plugin-loader.ts +63 -44
- package/src/plugin-manager.test.ts +44 -0
- package/src/plugin-manager.ts +6 -0
- package/src/rpc-rest-compat.test.ts +67 -58
- package/src/test-preload.ts +22 -4
- package/src/utils/run-plugin-migrations.test.ts +124 -0
- package/src/utils/run-plugin-migrations.ts +88 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- af6bda7: Fix plugin migrations failing on upgrade with `type "..." does not exist`.
|
|
8
|
+
|
|
9
|
+
Plugin migrations are schema-agnostic and rely on `search_path` to resolve
|
|
10
|
+
unqualified names into the plugin's schema (e.g. `plugin_healthcheck`). The
|
|
11
|
+
loader set `search_path` at the session level on the shared admin pool and
|
|
12
|
+
then called Drizzle's `migrate()`. Because `migrate()` runs all pending
|
|
13
|
+
migrations inside its own transaction, a `pg.Pool` could service that
|
|
14
|
+
transaction on a different physical connection than the one the `SET` ran on,
|
|
15
|
+
so the migration SQL executed against `public` instead.
|
|
16
|
+
|
|
17
|
+
This was invisible on a fresh database (every object is created within that
|
|
18
|
+
one transaction, so unqualified references still resolve), but broke upgrades:
|
|
19
|
+
the healthcheck plugin's new `health_check_state_transitions` migration
|
|
20
|
+
references the pre-existing `health_check_status` enum, which an earlier
|
|
21
|
+
migration created in the plugin schema. On a different pooled connection that
|
|
22
|
+
enum is not on the `public` `search_path`, so startup failed with
|
|
23
|
+
`type "health_check_status" does not exist` and the pod crash-looped.
|
|
24
|
+
|
|
25
|
+
Migrations now run on a single pinned pool connection: the loader checks out
|
|
26
|
+
one dedicated client, sets `search_path` on it, and binds the migrator to that
|
|
27
|
+
same client, mirroring the connection-affinity pattern already used by the
|
|
28
|
+
advisory-lock service. Every migration statement now runs under the intended
|
|
29
|
+
schema.
|
|
30
|
+
|
|
31
|
+
Boot was also restructured into two passes over the topologically-sorted
|
|
32
|
+
plugins: pass 1 runs every plugin's migrations, pass 2 runs every plugin's
|
|
33
|
+
`init()`. Previously the two were interleaved per plugin, so an
|
|
34
|
+
already-initialized plugin's background work (queue consumers, sweepers,
|
|
35
|
+
reactive-entity/event wiring) could compete for pool connections while a later
|
|
36
|
+
plugin was still migrating. Running all migrations first keeps the pool quiet
|
|
37
|
+
during migrations and removes that race entirely. The pinned connection and the
|
|
38
|
+
two-pass ordering are each independently sufficient for the fix above; together
|
|
39
|
+
they make boot robust regardless of what else touches the pool.
|
|
40
|
+
|
|
41
|
+
## 0.12.0
|
|
42
|
+
|
|
43
|
+
### Minor Changes
|
|
44
|
+
|
|
45
|
+
- 270ef29: Fix automation provider actions and `secretEnv` script actions throwing in production.
|
|
46
|
+
|
|
47
|
+
The automation dispatch engine resolved provider-action dependencies (the integration connection store, the secret resolver) through a `getService` that was a throwing stub, so Jira / Teams / Webex actions and `secretEnv` script actions threw at execute time in production. The whole dispatch test suite stubbed `getService`, so the break was invisible.
|
|
48
|
+
|
|
49
|
+
Root cause: the plugin `env` exposed `registerService` but no resolver, so the dispatch path (the only context that resolves arbitrary cross-plugin refs outside an RPC handler) had nothing real to call.
|
|
50
|
+
|
|
51
|
+
Changes:
|
|
52
|
+
|
|
53
|
+
- `@checkstack/backend-api`: add `getService<S>(ref: ServiceRef<S>): Promise<S>` to the plugin `env` (`BackendPluginRegistry`). It resolves a service registered by any plugin through the real `ServiceRegistry` using the calling plugin's identity, and throws a clear error if the ref is not registered (never silently `undefined`). **NEW PLUGIN-AUTHOR CONTRACT**: `env.getService` is now available to resolve arbitrary cross-plugin service refs at init / afterPluginsReady time.
|
|
54
|
+
- `@checkstack/backend`: implement `env.getService` in both the plugin loader and the runtime single-plugin registration path, backed by `ServiceRegistry.get(ref, { pluginId })`.
|
|
55
|
+
- `@checkstack/automation-backend`: wire the dispatch `getService` to `env.getService` (was a throwing stub). This also activates run-wide provider-credential masking, because resolving the connection store / secret resolver now flows through the run's masking interceptor.
|
|
56
|
+
|
|
57
|
+
Also fixes a test-only seam where the `core/backend` test preload registered a no-op `registerRouter`, silently disabling oRPC router registration across the suite.
|
|
58
|
+
|
|
59
|
+
### Patch Changes
|
|
60
|
+
|
|
61
|
+
- Updated dependencies [270ef29]
|
|
62
|
+
- Updated dependencies [270ef29]
|
|
63
|
+
- Updated dependencies [270ef29]
|
|
64
|
+
- Updated dependencies [b995afb]
|
|
65
|
+
- Updated dependencies [270ef29]
|
|
66
|
+
- Updated dependencies [270ef29]
|
|
67
|
+
- Updated dependencies [270ef29]
|
|
68
|
+
- Updated dependencies [270ef29]
|
|
69
|
+
- Updated dependencies [270ef29]
|
|
70
|
+
- @checkstack/backend-api@0.19.0
|
|
71
|
+
- @checkstack/cache-api@0.3.7
|
|
72
|
+
- @checkstack/queue-api@0.3.7
|
|
73
|
+
- @checkstack/signal-backend@0.2.11
|
|
74
|
+
|
|
3
75
|
## 0.11.0
|
|
4
76
|
|
|
5
77
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "backend"
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/api-docs-common": "0.1.
|
|
18
|
-
"@checkstack/auth-common": "0.7.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/common": "0.
|
|
17
|
+
"@checkstack/api-docs-common": "0.1.15",
|
|
18
|
+
"@checkstack/auth-common": "0.7.2",
|
|
19
|
+
"@checkstack/backend-api": "0.18.0",
|
|
20
|
+
"@checkstack/common": "0.12.0",
|
|
21
21
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
22
|
-
"@checkstack/cache-api": "0.3.
|
|
23
|
-
"@checkstack/queue-api": "0.3.
|
|
24
|
-
"@checkstack/signal-backend": "0.2.
|
|
25
|
-
"@checkstack/signal-common": "0.2.
|
|
26
|
-
"@checkstack/pluginmanager-common": "0.2.
|
|
22
|
+
"@checkstack/cache-api": "0.3.6",
|
|
23
|
+
"@checkstack/queue-api": "0.3.6",
|
|
24
|
+
"@checkstack/signal-backend": "0.2.10",
|
|
25
|
+
"@checkstack/signal-common": "0.2.5",
|
|
26
|
+
"@checkstack/pluginmanager-common": "0.2.4",
|
|
27
27
|
"@hono/zod-validator": "^0.7.6",
|
|
28
28
|
"@orpc/client": "^1.13.14",
|
|
29
29
|
"@orpc/contract": "^1.13.14",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@types/bun": "latest",
|
|
46
46
|
"@types/semver": "^7.5.0",
|
|
47
47
|
"@checkstack/tsconfig": "0.0.7",
|
|
48
|
-
"@checkstack/scripts": "0.3.
|
|
49
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
48
|
+
"@checkstack/scripts": "0.3.4",
|
|
49
|
+
"@checkstack/test-utils-backend": "0.1.31",
|
|
50
50
|
"drizzle-kit": "^0.31.10"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
RpcClient,
|
|
10
10
|
EventBus as IEventBus,
|
|
11
11
|
AuthenticationStrategy,
|
|
12
|
+
createAdvisoryLockService,
|
|
12
13
|
} from "@checkstack/backend-api";
|
|
13
14
|
import { AuthApi } from "@checkstack/auth-common";
|
|
14
15
|
import type { ServiceRegistry } from "../services/service-registry";
|
|
@@ -97,6 +98,16 @@ export function registerCoreServices({
|
|
|
97
98
|
return createScopedDb(db, assignedSchema);
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
// 1b. Advisory Lock Factory (server-global, backed by the shared admin
|
|
102
|
+
// pool). Session locks need connection affinity, so the service checks
|
|
103
|
+
// out a dedicated client per acquired lock and releases on the SAME
|
|
104
|
+
// client — the scoped per-query DB proxy can't provide that.
|
|
105
|
+
const advisoryLockService = createAdvisoryLockService(adminPool);
|
|
106
|
+
registry.registerFactory(
|
|
107
|
+
coreServices.advisoryLock,
|
|
108
|
+
() => advisoryLockService,
|
|
109
|
+
);
|
|
110
|
+
|
|
100
111
|
// 2. Logger Factory
|
|
101
112
|
registry.registerFactory(coreServices.logger, (metadata) => {
|
|
102
113
|
return rootLogger.child({ plugin: metadata.pluginId });
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createServiceRef,
|
|
4
|
+
createBackendPlugin,
|
|
5
|
+
type BackendPluginRegistry,
|
|
6
|
+
type ServiceRef,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import type { AccessRule, PluginMetadata } from "@checkstack/common";
|
|
9
|
+
import { ServiceRegistry } from "../services/service-registry";
|
|
10
|
+
import { createExtensionPointManager } from "./extension-points";
|
|
11
|
+
import { registerPlugin, type PluginLoaderDeps } from "./plugin-loader";
|
|
12
|
+
import type { PendingInit } from "./types";
|
|
13
|
+
import type { AnyContractRouter } from "@orpc/contract";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Framework-level coverage for the plugin `env.getService`, the registry-backed
|
|
17
|
+
* resolver added so the automation dispatch path can resolve arbitrary
|
|
18
|
+
* cross-plugin refs (connection store, secret resolver) at execute time.
|
|
19
|
+
*
|
|
20
|
+
* Prior to this, `env` exposed `registerService` but NO resolver, so the
|
|
21
|
+
* dispatch engine stubbed `getService` with a throwing placeholder and every
|
|
22
|
+
* provider action threw in production. These tests assert `env.getService`
|
|
23
|
+
* resolves through the REAL `ServiceRegistry` and fails loudly on a missing
|
|
24
|
+
* ref (never silently `undefined`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function makeDeps(registry: ServiceRegistry): PluginLoaderDeps {
|
|
28
|
+
return {
|
|
29
|
+
registry,
|
|
30
|
+
pluginRpcRouters: new Map<string, unknown>(),
|
|
31
|
+
pluginHttpHandlers: new Map<string, (req: Request) => Promise<Response>>(),
|
|
32
|
+
extensionPointManager: createExtensionPointManager(),
|
|
33
|
+
registeredAccessRules: [] as (AccessRule & { pluginId: string })[],
|
|
34
|
+
getAllAccessRules: () => [],
|
|
35
|
+
db: {} as PluginLoaderDeps["db"],
|
|
36
|
+
pluginMetadataRegistry: new Map<string, PluginMetadata>(),
|
|
37
|
+
cleanupHandlers: new Map<string, Array<() => Promise<void>>>(),
|
|
38
|
+
pluginContractRegistry: new Map<string, AnyContractRouter>(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register a no-op plugin whose `register` callback captures the `env` so the
|
|
44
|
+
* test can call `env.getService` the same way a plugin would at init time.
|
|
45
|
+
*/
|
|
46
|
+
function captureEnv(deps: PluginLoaderDeps): BackendPluginRegistry {
|
|
47
|
+
let captured: BackendPluginRegistry | undefined;
|
|
48
|
+
const plugin = createBackendPlugin({
|
|
49
|
+
metadata: { pluginId: "consumer-plugin" },
|
|
50
|
+
register: (env) => {
|
|
51
|
+
captured = env;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const pendingInits: PendingInit[] = [];
|
|
55
|
+
registerPlugin({
|
|
56
|
+
backendPlugin: plugin,
|
|
57
|
+
pluginPath: "/virtual/consumer-plugin",
|
|
58
|
+
pendingInits,
|
|
59
|
+
providedBy: new Map<string, string>(),
|
|
60
|
+
deps,
|
|
61
|
+
});
|
|
62
|
+
if (!captured) throw new Error("env was not captured");
|
|
63
|
+
return captured;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("plugin env.getService", () => {
|
|
67
|
+
it("resolves a service registered by another plugin through the real ServiceRegistry", async () => {
|
|
68
|
+
const registry = new ServiceRegistry();
|
|
69
|
+
const ref = createServiceRef<{ ping: () => string }>("other.service");
|
|
70
|
+
const impl = { ping: () => "pong" };
|
|
71
|
+
registry.register(ref, impl);
|
|
72
|
+
|
|
73
|
+
const env = captureEnv(makeDeps(registry));
|
|
74
|
+
const resolved = await env.getService(ref);
|
|
75
|
+
|
|
76
|
+
expect(resolved).toBe(impl);
|
|
77
|
+
expect(resolved.ping()).toBe("pong");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("resolves a service the SAME plugin registered via env.registerService", async () => {
|
|
81
|
+
const registry = new ServiceRegistry();
|
|
82
|
+
const env = captureEnv(makeDeps(registry));
|
|
83
|
+
const ref = createServiceRef<{ n: number }>("self.service");
|
|
84
|
+
|
|
85
|
+
env.registerService(ref, { n: 42 });
|
|
86
|
+
const resolved = await env.getService(ref);
|
|
87
|
+
|
|
88
|
+
expect(resolved.n).toBe(42);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("throws a clear error (never undefined) when the ref is not registered", async () => {
|
|
92
|
+
const registry = new ServiceRegistry();
|
|
93
|
+
const env = captureEnv(makeDeps(registry));
|
|
94
|
+
const missing: ServiceRef<{ x: number }> =
|
|
95
|
+
createServiceRef<{ x: number }>("missing.service");
|
|
96
|
+
|
|
97
|
+
let error: unknown;
|
|
98
|
+
try {
|
|
99
|
+
await env.getService(missing);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
error = e;
|
|
102
|
+
}
|
|
103
|
+
expect(error).toBeInstanceOf(Error);
|
|
104
|
+
expect((error as Error).message).toContain("missing.service");
|
|
105
|
+
// Consumer identity is THIS plugin (audit / scoped factories).
|
|
106
|
+
expect((error as Error).message).toContain("consumer-plugin");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -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,
|
|
@@ -149,6 +149,14 @@ export function registerPlugin({
|
|
|
149
149
|
providedBy.set(ref.id, pluginId);
|
|
150
150
|
rootLogger.debug(` -> Registered service '${ref.id}'`);
|
|
151
151
|
},
|
|
152
|
+
// Registry-backed resolver for arbitrary cross-plugin refs. The
|
|
153
|
+
// consumer identity is THIS plugin (`pluginId`), which is the correct
|
|
154
|
+
// audit identity for scoped factories. `registry.get` throws a clear
|
|
155
|
+
// error when the ref is unregistered, so a missing service (e.g. a
|
|
156
|
+
// connection store the automation dispatch path needs) fails loudly
|
|
157
|
+
// rather than silently resolving `undefined`.
|
|
158
|
+
getService: <T>(ref: ServiceRef<T>): Promise<T> =>
|
|
159
|
+
deps.registry.get(ref, { pluginId }),
|
|
152
160
|
registerExtensionPoint: (ref, impl) => {
|
|
153
161
|
deps.extensionPointManager.registerExtensionPoint(ref, impl);
|
|
154
162
|
},
|
|
@@ -301,6 +309,17 @@ export async function loadPlugins({
|
|
|
301
309
|
}
|
|
302
310
|
|
|
303
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.
|
|
304
323
|
const logger = await deps.registry.get(coreServices.logger, {
|
|
305
324
|
pluginId: "core",
|
|
306
325
|
});
|
|
@@ -339,9 +358,9 @@ export async function loadPlugins({
|
|
|
339
358
|
// pre-existing behavior and is preferable to a multi-second hang.
|
|
340
359
|
deps.onApiRouteRegistered?.();
|
|
341
360
|
|
|
361
|
+
// Phase 2, pass 1: run every plugin's migrations BEFORE any plugin init.
|
|
342
362
|
for (const id of sortedIds) {
|
|
343
363
|
const p = pendingInits.find((x) => x.metadata.pluginId === id)!;
|
|
344
|
-
rootLogger.info(`🚀 Initializing ${p.metadata.pluginId}...`);
|
|
345
364
|
|
|
346
365
|
try {
|
|
347
366
|
/**
|
|
@@ -364,34 +383,32 @@ export async function loadPlugins({
|
|
|
364
383
|
* causing "relation does not exist" errors since the tables are actually in
|
|
365
384
|
* the plugin's schema (e.g., `plugin_maintenance.maintenances`).
|
|
366
385
|
*
|
|
367
|
-
* ##
|
|
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`.
|
|
368
394
|
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
* -
|
|
372
|
-
*
|
|
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`.
|
|
373
403
|
*
|
|
374
404
|
* ## Why This Doesn't Affect Runtime Queries
|
|
375
405
|
*
|
|
376
406
|
* After migrations complete, plugins receive their database via
|
|
377
407
|
* `createScopedDb()` which wraps every query in a transaction with
|
|
378
408
|
* `SET LOCAL search_path`. This ensures runtime queries always use the
|
|
379
|
-
* correct schema
|
|
380
|
-
*
|
|
381
|
-
* ## Potential Hazards
|
|
382
|
-
*
|
|
383
|
-
* 1. **Error During Migration**: If a migration fails, the search_path may
|
|
384
|
-
* remain set to that plugin's schema. The next plugin's migration would
|
|
385
|
-
* fail visibly (wrong schema), which is better than silent data corruption.
|
|
386
|
-
*
|
|
387
|
-
* 2. **Parallel Migration Execution**: This code assumes sequential plugin
|
|
388
|
-
* initialization (which is enforced by the topologically-sorted loop).
|
|
389
|
-
* If migrations ever run in parallel, search_path conflicts would occur.
|
|
390
|
-
*
|
|
391
|
-
* 3. **Connection Pool Pollution**: `SET` without `LOCAL` affects the entire
|
|
392
|
-
* session. However, we reset to `public` after each plugin's migrations,
|
|
393
|
-
* and runtime queries use `SET LOCAL` anyway, so this is safe.
|
|
409
|
+
* correct schema.
|
|
394
410
|
*
|
|
411
|
+
* @see runPluginMigrations in ../utils/run-plugin-migrations.ts
|
|
395
412
|
* @see createScopedDb in ../utils/scoped-db.ts for runtime query isolation
|
|
396
413
|
* @see getPluginSchemaName in @checkstack/drizzle-helper for schema naming
|
|
397
414
|
* =======================================================================
|
|
@@ -411,29 +428,13 @@ export async function loadPlugins({
|
|
|
411
428
|
` -> Running migrations for ${p.metadata.pluginId} from ${migrationsFolder}`,
|
|
412
429
|
);
|
|
413
430
|
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
sql.raw(`CREATE SCHEMA IF NOT EXISTS "${migrationsSchema}"`),
|
|
419
|
-
);
|
|
420
|
-
|
|
421
|
-
// Set search_path to plugin schema before running migrations.
|
|
422
|
-
// Uses session-level SET (not SET LOCAL) because migrate() may run
|
|
423
|
-
// multiple statements across transaction boundaries.
|
|
424
|
-
// No 'public' fallback: schema is guaranteed to exist from CREATE above.
|
|
425
|
-
await deps.db.execute(
|
|
426
|
-
sql.raw(`SET search_path = "${migrationsSchema}"`),
|
|
427
|
-
);
|
|
428
|
-
// Drizzle migrate() requires NodePgDatabase, cast from SafeDatabase
|
|
429
|
-
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,
|
|
430
435
|
migrationsFolder,
|
|
431
436
|
migrationsSchema,
|
|
432
437
|
});
|
|
433
|
-
|
|
434
|
-
// Reset search_path to public after migrations complete.
|
|
435
|
-
// This prevents search_path leaking into subsequent plugin migrations.
|
|
436
|
-
await deps.db.execute(sql.raw(`SET search_path = public`));
|
|
437
438
|
} catch (error) {
|
|
438
439
|
rootLogger.error(
|
|
439
440
|
`❌ Failed migration of plugin ${p.metadata.pluginId}:`,
|
|
@@ -448,7 +449,25 @@ export async function loadPlugins({
|
|
|
448
449
|
` -> No migrations found for ${p.metadata.pluginId} (skipping)`,
|
|
449
450
|
);
|
|
450
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}...`);
|
|
451
469
|
|
|
470
|
+
try {
|
|
452
471
|
// Resolve Dependencies
|
|
453
472
|
const resolvedDeps: Record<string, unknown> = {};
|
|
454
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
|
});
|
package/src/plugin-manager.ts
CHANGED
|
@@ -634,6 +634,12 @@ export class PluginManager {
|
|
|
634
634
|
registerService: (ref, impl) => {
|
|
635
635
|
this.registry.register(ref, impl);
|
|
636
636
|
},
|
|
637
|
+
// Registry-backed resolver for arbitrary cross-plugin refs, using
|
|
638
|
+
// this plugin's identity as the consumer. Mirrors the plugin-loader
|
|
639
|
+
// `env.getService`; resolves through the real ServiceRegistry and
|
|
640
|
+
// throws clearly on a missing ref (never silently undefined).
|
|
641
|
+
getService: <T>(ref: ServiceRef<T>) =>
|
|
642
|
+
this.registry.get(ref, backendPlugin.metadata),
|
|
637
643
|
registerExtensionPoint: (ref, impl) => {
|
|
638
644
|
this.extensionPointManager.registerExtensionPoint(ref, impl);
|
|
639
645
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
2
|
import { PluginManager } from "./plugin-manager";
|
|
3
|
-
import { coreServices, os,
|
|
3
|
+
import { coreServices, os, createBackendPlugin } from "@checkstack/backend-api";
|
|
4
4
|
import { Hono } from "hono";
|
|
5
5
|
import { createMockDbModule } from "@checkstack/test-utils-backend";
|
|
6
6
|
import { createMockLoggerModule } from "@checkstack/test-utils-backend";
|
|
@@ -10,7 +10,27 @@ mock.module("./db", () => createMockDbModule());
|
|
|
10
10
|
|
|
11
11
|
mock.module("./logger", () => createMockLoggerModule());
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Guards the oRPC router REGISTRATION + MOUNTING wiring contract.
|
|
15
|
+
*
|
|
16
|
+
* Background (silent-failure seam): the core/backend test preload used to
|
|
17
|
+
* register a mock `rpc` service whose `registerRouter` was a no-op, so across
|
|
18
|
+
* the ENTIRE suite a plugin's router never landed in the shared maps the
|
|
19
|
+
* `/api/:pluginId/*` dispatcher reads. The one "reachability" test that
|
|
20
|
+
* existed only asserted on a 200 inside an `if`, so it passed forever on a
|
|
21
|
+
* 404. These tests assert the concrete wiring that was silently broken:
|
|
22
|
+
*
|
|
23
|
+
* 1. Resolving `coreServices.rpc` for a plugin and calling `registerRouter`
|
|
24
|
+
* lands the router AND its contract in the shared maps, keyed by the
|
|
25
|
+
* resolving plugin id (consumed by `createApiRouteHandler`).
|
|
26
|
+
* 2. `loadPlugins` mounts the `/api/:pluginId/*` route on the Hono app, so
|
|
27
|
+
* requests to a registered plugin reach the handler (no Hono-level 404).
|
|
28
|
+
*
|
|
29
|
+
* Note: exercising oRPC's RPC wire protocol end-to-end requires the plugin's
|
|
30
|
+
* real contract + protocol encoding; that is out of scope here. These guards
|
|
31
|
+
* cover the registration/mounting seam — the exact thing the no-op mock hid.
|
|
32
|
+
*/
|
|
33
|
+
describe("oRPC router registration + mounting wiring", () => {
|
|
14
34
|
let pluginManager: PluginManager;
|
|
15
35
|
let app: Hono;
|
|
16
36
|
|
|
@@ -19,69 +39,58 @@ describe("RPC REST Compatibility", () => {
|
|
|
19
39
|
app = new Hono();
|
|
20
40
|
});
|
|
21
41
|
|
|
22
|
-
it("
|
|
23
|
-
// 1. Setup a mock auth router
|
|
42
|
+
it("registers a plugin router + contract into the shared maps the API handler reads", async () => {
|
|
24
43
|
const authRouter = os.router({
|
|
25
|
-
accessRules: os.handler(async () => {
|
|
26
|
-
return { accessRules: ["test-perm"] };
|
|
27
|
-
}),
|
|
44
|
+
accessRules: os.handler(async () => ({ accessRules: ["test-perm"] })),
|
|
28
45
|
});
|
|
46
|
+
const contract = { accessRules: {} };
|
|
29
47
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const rpcService = await pluginManager
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
rpcService?.registerRouter(authRouter, { accessRules: {} });
|
|
48
|
+
// Resolve the rpc service scoped to the `auth` plugin and register the
|
|
49
|
+
// router — exactly how a plugin does it during init().
|
|
50
|
+
const rpcService = await pluginManager
|
|
51
|
+
.getRegistry()
|
|
52
|
+
.get(coreServices.rpc, { pluginId: "auth" });
|
|
53
|
+
rpcService.registerRouter(authRouter, contract);
|
|
37
54
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
pluginManager.
|
|
43
|
-
|
|
44
|
-
// Register other dummy services needed for context
|
|
45
|
-
pluginManager.registerService(coreServices.logger, {
|
|
46
|
-
info: mock(),
|
|
47
|
-
debug: mock(),
|
|
48
|
-
error: mock(),
|
|
49
|
-
warn: mock(),
|
|
50
|
-
} as any);
|
|
51
|
-
pluginManager.registerService(coreServices.database, {} as any);
|
|
52
|
-
pluginManager.registerService(coreServices.fetch, {} as any);
|
|
53
|
-
pluginManager.registerService(coreServices.healthCheckRegistry, {} as any);
|
|
54
|
-
pluginManager.registerService(coreServices.queuePluginRegistry, {
|
|
55
|
-
getPlugins: () => [],
|
|
56
|
-
} as any);
|
|
57
|
-
pluginManager.registerService(coreServices.queueManager, {
|
|
58
|
-
getActivePlugin: () => "none",
|
|
59
|
-
getQueue: () => ({}),
|
|
60
|
-
} as any);
|
|
61
|
-
pluginManager.registerService(coreServices.cachePluginRegistry, {
|
|
62
|
-
getPlugins: () => [],
|
|
63
|
-
} as any);
|
|
64
|
-
pluginManager.registerService(coreServices.cacheManager, {
|
|
65
|
-
getActivePlugin: () => "memory",
|
|
66
|
-
getProvider: () => ({}),
|
|
67
|
-
} as any);
|
|
55
|
+
// The contract MUST land in the shared registry the API route handler
|
|
56
|
+
// (and OpenAPI generation) consumes, keyed by the plugin id —
|
|
57
|
+
// `registerRouter` sets the router map and the contract map together. A
|
|
58
|
+
// no-op `registerRouter` (the prior regression) would leave this empty.
|
|
59
|
+
expect(pluginManager.getAllContracts().get("auth")).toBe(contract);
|
|
60
|
+
});
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
it("mounts the /api/:pluginId/* route so a registered plugin reaches the handler (not a Hono 404)", async () => {
|
|
63
|
+
const authRouter = os.router({
|
|
64
|
+
accessRules: os.handler(async () => ({ accessRules: ["test-perm"] })),
|
|
65
|
+
});
|
|
66
|
+
const rpcService = await pluginManager
|
|
67
|
+
.getRegistry()
|
|
68
|
+
.get(coreServices.rpc, { pluginId: "auth" });
|
|
69
|
+
rpcService.registerRouter(authRouter, { accessRules: {} });
|
|
70
|
+
pluginManager.registerCorePluginMetadata({ pluginId: "auth" });
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// At least one (manual) plugin is required for loadPlugins to proceed
|
|
73
|
+
// past its "no plugins" early-return and actually mount the
|
|
74
|
+
// `/api/:pluginId/*` route.
|
|
75
|
+
const noopPlugin = createBackendPlugin({
|
|
76
|
+
metadata: { pluginId: "noop" },
|
|
77
|
+
register: () => {},
|
|
75
78
|
});
|
|
79
|
+
await pluginManager.loadPlugins(app, [noopPlugin], { skipDiscovery: true });
|
|
76
80
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
// A request to `/api/:pluginId/*` must REACH the assembled API handler
|
|
82
|
+
// (the dispatcher), not fall through to a Hono 404. The dispatcher
|
|
83
|
+
// resolves the RpcContext first and, in this minimal harness, returns a
|
|
84
|
+
// handler-level 500 ("Core services not initialized") — which is exactly
|
|
85
|
+
// the proof we want: the route is mounted and the request reached the
|
|
86
|
+
// handler. (A regression that fails to mount the route would surface as
|
|
87
|
+
// a Hono 404 here instead.)
|
|
88
|
+
const reached = await app.request("/api/auth/accessRules", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "content-type": "application/json" },
|
|
91
|
+
body: "{}",
|
|
92
|
+
});
|
|
93
|
+
expect(reached.status).not.toBe(404);
|
|
94
|
+
expect(reached.status).toBe(500);
|
|
86
95
|
});
|
|
87
96
|
});
|
package/src/test-preload.ts
CHANGED
|
@@ -43,11 +43,18 @@ mock.module(loggerPath, () => createMockLoggerModule());
|
|
|
43
43
|
mock.module(coreServicesPath, () => ({
|
|
44
44
|
registerCoreServices: ({
|
|
45
45
|
registry,
|
|
46
|
+
pluginRpcRouters,
|
|
47
|
+
pluginContractRegistry,
|
|
46
48
|
}: {
|
|
47
49
|
registry: {
|
|
48
|
-
registerFactory: (
|
|
50
|
+
registerFactory: (
|
|
51
|
+
ref: { id: string },
|
|
52
|
+
factory: (metadata: { pluginId: string }) => unknown,
|
|
53
|
+
) => void;
|
|
49
54
|
register: (ref: { id: string }, impl: unknown) => void;
|
|
50
55
|
};
|
|
56
|
+
pluginRpcRouters?: Map<string, unknown>;
|
|
57
|
+
pluginContractRegistry?: Map<string, unknown>;
|
|
51
58
|
}) => {
|
|
52
59
|
// Register mock database factory - includes dialect.migrate for migration tests
|
|
53
60
|
registry.registerFactory(coreServices.database, () => ({
|
|
@@ -101,9 +108,20 @@ mock.module(coreServicesPath, () => ({
|
|
|
101
108
|
getAllStrategies: () => [...strategies.values()],
|
|
102
109
|
}));
|
|
103
110
|
|
|
104
|
-
// Register mock RPC service factory
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
// Register mock RPC service factory.
|
|
112
|
+
//
|
|
113
|
+
// This MUST mirror the production factory's router wiring: it keys the
|
|
114
|
+
// router + contract by the resolving plugin's id into the SAME shared
|
|
115
|
+
// maps the API route handler reads. A previous version stubbed
|
|
116
|
+
// `registerRouter` as a no-op, which silently disabled router
|
|
117
|
+
// registration across the entire core/backend test suite — making oRPC
|
|
118
|
+
// router reachability impossible to verify (a 404 looked identical to a
|
|
119
|
+
// wired route to any test that didn't assert hard).
|
|
120
|
+
registry.registerFactory(coreServices.rpc, (metadata) => ({
|
|
121
|
+
registerRouter: (router: unknown, contract: unknown): void => {
|
|
122
|
+
pluginRpcRouters?.set(metadata.pluginId, router);
|
|
123
|
+
pluginContractRegistry?.set(metadata.pluginId, contract);
|
|
124
|
+
},
|
|
107
125
|
registerHttpHandler: () => {},
|
|
108
126
|
}));
|
|
109
127
|
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
describe("runPluginMigrations", () => {
|
|
32
|
+
it("runs the migrator on a single pinned connection with search_path set first", async () => {
|
|
33
|
+
const { client, queries, isReleased } = makeFakeClient();
|
|
34
|
+
|
|
35
|
+
let connectCount = 0;
|
|
36
|
+
const pool = {
|
|
37
|
+
connect: async () => {
|
|
38
|
+
connectCount++;
|
|
39
|
+
return client;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const fakeDb = { __fake: true } as unknown as MigrationDb;
|
|
44
|
+
let dbPassedToMigrate: unknown;
|
|
45
|
+
let clientPassedToFactory: PoolClient | undefined;
|
|
46
|
+
let queriesBeforeMigrate: string[] = [];
|
|
47
|
+
|
|
48
|
+
await runPluginMigrations({
|
|
49
|
+
pool,
|
|
50
|
+
migrationsFolder: "/plugins/healthcheck/drizzle",
|
|
51
|
+
migrationsSchema: "plugin_healthcheck",
|
|
52
|
+
createMigrationDb: (c) => {
|
|
53
|
+
clientPassedToFactory = c;
|
|
54
|
+
return fakeDb;
|
|
55
|
+
},
|
|
56
|
+
migrate: async (db, config) => {
|
|
57
|
+
dbPassedToMigrate = db;
|
|
58
|
+
queriesBeforeMigrate = [...queries];
|
|
59
|
+
expect(config.migrationsFolder).toBe("/plugins/healthcheck/drizzle");
|
|
60
|
+
expect(config.migrationsSchema).toBe("plugin_healthcheck");
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Exactly ONE connection is checked out: the SET and the migration must
|
|
65
|
+
// share a physical connection, which was the whole bug.
|
|
66
|
+
expect(connectCount).toBe(1);
|
|
67
|
+
|
|
68
|
+
// The migrator runs against a Drizzle instance bound to that same pinned
|
|
69
|
+
// client.
|
|
70
|
+
expect(clientPassedToFactory).toBe(client);
|
|
71
|
+
expect(dbPassedToMigrate).toBe(fakeDb);
|
|
72
|
+
|
|
73
|
+
// search_path is pointed at the plugin schema (after creating it) BEFORE
|
|
74
|
+
// the migrator runs.
|
|
75
|
+
expect(queriesBeforeMigrate).toEqual([
|
|
76
|
+
'CREATE SCHEMA IF NOT EXISTS "plugin_healthcheck"',
|
|
77
|
+
'SET search_path = "plugin_healthcheck"',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// Afterwards the connection is reset and returned to the pool.
|
|
81
|
+
expect(queries.at(-1)).toBe("SET search_path = public");
|
|
82
|
+
expect(isReleased()).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("resets search_path and releases the connection even when the migrator throws", async () => {
|
|
86
|
+
const { client, queries, isReleased } = makeFakeClient();
|
|
87
|
+
const pool = { connect: async () => client };
|
|
88
|
+
const boom = new Error("migration failed");
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
runPluginMigrations({
|
|
92
|
+
pool,
|
|
93
|
+
migrationsFolder: "/x",
|
|
94
|
+
migrationsSchema: "plugin_x",
|
|
95
|
+
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
96
|
+
migrate: async () => {
|
|
97
|
+
throw boom;
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
).rejects.toThrow("migration failed");
|
|
101
|
+
|
|
102
|
+
expect(queries.at(-1)).toBe("SET search_path = public");
|
|
103
|
+
expect(isReleased()).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("never touches anything but connect() on the pool (no session SET on the shared pool)", async () => {
|
|
107
|
+
const { client } = makeFakeClient();
|
|
108
|
+
const pool = { connect: async () => client };
|
|
109
|
+
|
|
110
|
+
await runPluginMigrations({
|
|
111
|
+
pool,
|
|
112
|
+
migrationsFolder: "/x",
|
|
113
|
+
migrationsSchema: "plugin_x",
|
|
114
|
+
createMigrationDb: () => ({}) as unknown as MigrationDb,
|
|
115
|
+
migrate: async () => {},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// The pool surface the helper depends on is exactly `connect`; everything
|
|
119
|
+
// else (CREATE SCHEMA, SET search_path, the migration itself) happens on
|
|
120
|
+
// the checked-out client. If this contract ever widens, the regression
|
|
121
|
+
// that motivated the pinned connection could creep back in.
|
|
122
|
+
expect(Object.keys(pool)).toEqual(["connect"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
|
|
5
|
+
type MigrationDb = NodePgDatabase<Record<string, unknown>>;
|
|
6
|
+
|
|
7
|
+
export interface RunPluginMigrationsArgs {
|
|
8
|
+
/** Shared admin pool; the helper checks out ONE dedicated client from it. */
|
|
9
|
+
pool: Pick<Pool, "connect">;
|
|
10
|
+
/** Absolute path to the plugin's Drizzle migrations folder. */
|
|
11
|
+
migrationsFolder: string;
|
|
12
|
+
/**
|
|
13
|
+
* Postgres schema the plugin's objects live in (e.g. `plugin_healthcheck`).
|
|
14
|
+
* Also used by Drizzle for the per-plugin `__drizzle_migrations` table.
|
|
15
|
+
*/
|
|
16
|
+
migrationsSchema: string;
|
|
17
|
+
/**
|
|
18
|
+
* Builds the Drizzle instance the migrator runs against. Defaults to one
|
|
19
|
+
* bound to the pinned `client`. Injectable so tests can run without a real
|
|
20
|
+
* connection.
|
|
21
|
+
*/
|
|
22
|
+
createMigrationDb?: (client: PoolClient) => MigrationDb;
|
|
23
|
+
/** Drizzle's migrator. Injectable for tests. */
|
|
24
|
+
migrate?: (
|
|
25
|
+
db: MigrationDb,
|
|
26
|
+
config: { migrationsFolder: string; migrationsSchema: string },
|
|
27
|
+
) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run a plugin's Drizzle migrations on a SINGLE pinned pool connection.
|
|
32
|
+
*
|
|
33
|
+
* ## Why a pinned connection is required
|
|
34
|
+
*
|
|
35
|
+
* 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
|
+
* into the plugin's schema (e.g. `plugin_healthcheck`). So `search_path` must
|
|
38
|
+
* be set before the migration SQL runs.
|
|
39
|
+
*
|
|
40
|
+
* Setting it at the *session* level on the shared pool does NOT work, for the
|
|
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.
|
|
46
|
+
*
|
|
47
|
+
* This stays invisible on a fresh database - every object (including each
|
|
48
|
+
* enum) is created within that one transaction, so unqualified references
|
|
49
|
+
* still resolve against whatever schema that connection happens to use. But on
|
|
50
|
+
* an UPGRADE, where earlier migrations already created an enum in the plugin
|
|
51
|
+
* schema and only newer migrations run, a new migration that references the
|
|
52
|
+
* pre-existing enum fails with `type "..." does not exist`.
|
|
53
|
+
*
|
|
54
|
+
* Binding the migrator to ONE pinned client, on which we set `search_path`
|
|
55
|
+
* first, guarantees every migration statement runs under the intended schema.
|
|
56
|
+
*/
|
|
57
|
+
export async function runPluginMigrations({
|
|
58
|
+
pool,
|
|
59
|
+
migrationsFolder,
|
|
60
|
+
migrationsSchema,
|
|
61
|
+
createMigrationDb = (client) => drizzle(client),
|
|
62
|
+
migrate = defaultMigrate,
|
|
63
|
+
}: RunPluginMigrationsArgs): Promise<void> {
|
|
64
|
+
const client = await pool.connect();
|
|
65
|
+
try {
|
|
66
|
+
// Ensure the schema exists before pointing search_path at it. SET to a
|
|
67
|
+
// missing schema silently falls back to `public` at resolution time, which
|
|
68
|
+
// would recreate the very bug this helper exists to prevent.
|
|
69
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS "${migrationsSchema}"`);
|
|
70
|
+
await client.query(`SET search_path = "${migrationsSchema}"`);
|
|
71
|
+
|
|
72
|
+
await migrate(createMigrationDb(client), {
|
|
73
|
+
migrationsFolder,
|
|
74
|
+
migrationsSchema,
|
|
75
|
+
});
|
|
76
|
+
} finally {
|
|
77
|
+
// Reset before the client returns to the pool so the setting never leaks
|
|
78
|
+
// onto an unrelated query that later reuses this physical connection.
|
|
79
|
+
try {
|
|
80
|
+
await client.query("SET search_path = public");
|
|
81
|
+
} catch (resetError) {
|
|
82
|
+
// Best-effort: the release below still returns the connection. Reference
|
|
83
|
+
// the binding so lint doesn't flag an empty catch.
|
|
84
|
+
void resetError;
|
|
85
|
+
}
|
|
86
|
+
client.release();
|
|
87
|
+
}
|
|
88
|
+
}
|