@checkstack/backend 0.13.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 CHANGED
@@ -1,5 +1,35 @@
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
+
3
33
  ## 0.13.0
4
34
 
5
35
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "Elastic-2.0",
5
5
  "checkstack": {
6
6
  "type": "backend"
@@ -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
- // search_path is pointed at the plugin schema (after creating it) BEFORE
74
- // the migrator runs.
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) happens on
120
- // the checked-out client. If this contract ever widens, the regression
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
- * ## Why a pinned connection is required
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
- * into the plugin's schema (e.g. `plugin_healthcheck`). So `search_path` must
38
- * be set before the migration SQL runs.
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
- * 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.
73
+ * ## Relocating legacy `public` objects first
46
74
  *
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`.
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
- * Binding the migrator to ONE pinned client, on which we set `search_path`
55
- * first, guarantees every migration statement runs under the intended schema.
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
- // 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.
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), {