@checkstack/backend 0.4.7 → 0.4.8

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,15 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.4.8
4
+
5
+ ### Patch Changes
6
+
7
+ - dd16be7: Fix plugin schema isolation: create schema before migrations run
8
+
9
+ Previously, schemas were only created when `coreServices.database` was resolved (after migrations), causing tables to be created in the `public` schema instead of plugin-specific schemas. Now schemas are created immediately before migrations run.
10
+
11
+ Also removed the `public` fallback from migration search_path to make errors more visible if schema creation fails.
12
+
3
13
  ## 0.4.7
4
14
 
5
15
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun --env-file=../../.env --watch src/index.ts",
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  createMockQueueManager,
10
10
  createMockLogger,
11
+ createMockDb,
11
12
  } from "@checkstack/test-utils-backend";
12
13
  import { z } from "zod";
13
14
 
@@ -75,10 +76,13 @@ describe("HealthCheck Plugin Integration", () => {
75
76
  // Register mock services since core-services is mocked as no-op
76
77
  pluginManager.registerService(
77
78
  coreServices.queueManager,
78
- createMockQueueManager()
79
+ createMockQueueManager(),
79
80
  );
80
81
  pluginManager.registerService(coreServices.logger, createMockLogger());
81
- pluginManager.registerService(coreServices.database, {} as never); // Mock database
82
+ pluginManager.registerService(
83
+ coreServices.database,
84
+ createMockDb() as never,
85
+ );
82
86
 
83
87
  // 4. Load plugins using the PluginManager with manual injection
84
88
  await pluginManager.loadPlugins(mockRouter, [testPlugin], {
@@ -87,7 +91,7 @@ describe("HealthCheck Plugin Integration", () => {
87
91
 
88
92
  // 5. Verify the strategy is registered in the registry managed by PluginManager
89
93
  const registry = await pluginManager.getService(
90
- coreServices.healthCheckRegistry
94
+ coreServices.healthCheckRegistry,
91
95
  );
92
96
  expect(registry).toBeDefined();
93
97
 
@@ -33,7 +33,7 @@ import { createScopedDb } from "../utils/scoped-db.js";
33
33
  async function schemaExists(pool: Pool, schemaName: string): Promise<boolean> {
34
34
  const result = await pool.query(
35
35
  "SELECT 1 FROM information_schema.schemata WHERE schema_name = $1",
36
- [schemaName]
36
+ [schemaName],
37
37
  );
38
38
  return result.rows.length > 0;
39
39
  }
@@ -70,19 +70,17 @@ export function registerCoreServices({
70
70
 
71
71
  if (oldExists && !newExists) {
72
72
  rootLogger.info(
73
- `🔄 Renaming schema ${oldSchema} → ${assignedSchema} for plugin ${pluginId}`
73
+ `🔄 Renaming schema ${oldSchema} → ${assignedSchema} for plugin ${pluginId}`,
74
74
  );
75
75
  await adminPool.query(
76
- `ALTER SCHEMA "${oldSchema}" RENAME TO "${assignedSchema}"`
76
+ `ALTER SCHEMA "${oldSchema}" RENAME TO "${assignedSchema}"`,
77
77
  );
78
78
  break; // Only one rename needed
79
79
  }
80
80
  }
81
81
  }
82
82
 
83
- // Ensure Schema Exists (creates if not already renamed/created)
84
- await adminPool.query(`CREATE SCHEMA IF NOT EXISTS "${assignedSchema}"`);
85
-
83
+ // Schema is created in plugin-loader.ts before migrations run.
86
84
  // Create scoped proxy on shared pool (no new connections)
87
85
  return createScopedDb(db, assignedSchema);
88
86
  });
@@ -121,12 +119,12 @@ export function registerCoreServices({
121
119
  try {
122
120
  const authStrategy = await registry.get(
123
121
  authenticationStrategyServiceRef,
124
- metadata
122
+ metadata,
125
123
  );
126
124
  if (authStrategy) {
127
125
  // AuthenticationStrategy.validate() returns RealUser | undefined
128
126
  return await (authStrategy as AuthenticationStrategy).validate(
129
- request
127
+ request,
130
128
  );
131
129
  }
132
130
  } catch {
@@ -165,7 +163,7 @@ export function registerCoreServices({
165
163
  } catch (error) {
166
164
  // RPC client not available yet (during startup), return empty
167
165
  rootLogger.warn(
168
- `[auth] getAnonymousAccessRules: RPC failed, returning empty array. Error: ${error}`
166
+ `[auth] getAnonymousAccessRules: RPC failed, returning empty array. Error: ${error}`,
169
167
  );
170
168
  return [];
171
169
  }
@@ -207,7 +205,7 @@ export function registerCoreServices({
207
205
 
208
206
  const fetchWithAuth = async (
209
207
  input: RequestInfo | URL,
210
- init?: RequestInit
208
+ init?: RequestInit,
211
209
  ) => {
212
210
  const { headers: authHeaders } = await auth.getCredentials();
213
211
  const mergedHeaders = new Headers(init?.headers);
@@ -288,13 +286,13 @@ export function registerCoreServices({
288
286
  // 6. Health Check Registry (Scoped Factory - auto-prefixes strategy IDs with pluginId)
289
287
  const globalHealthCheckRegistry = new CoreHealthCheckRegistry();
290
288
  registry.registerFactory(coreServices.healthCheckRegistry, (metadata) =>
291
- createScopedHealthCheckRegistry(globalHealthCheckRegistry, metadata)
289
+ createScopedHealthCheckRegistry(globalHealthCheckRegistry, metadata),
292
290
  );
293
291
 
294
292
  // 6b. Collector Registry (Scoped Factory - injects ownerPlugin automatically)
295
293
  const globalCollectorRegistry = new CoreCollectorRegistry();
296
294
  registry.registerFactory(coreServices.collectorRegistry, (metadata) =>
297
- createScopedCollectorRegistry(globalCollectorRegistry, metadata)
295
+ createScopedCollectorRegistry(globalCollectorRegistry, metadata),
298
296
  );
299
297
 
300
298
  // 7. RPC Service (Scoped Factory - uses pluginId for path derivation)
@@ -305,17 +303,17 @@ export function registerCoreServices({
305
303
  pluginRpcRouters.set(pluginId, router);
306
304
  pluginContractRegistry.set(pluginId, contract);
307
305
  rootLogger.debug(
308
- ` -> Registered oRPC router and contract for '${pluginId}' at '/api/${pluginId}'`
306
+ ` -> Registered oRPC router and contract for '${pluginId}' at '/api/${pluginId}'`,
309
307
  );
310
308
  },
311
309
  registerHttpHandler: (
312
310
  handler: (req: Request) => Promise<Response>,
313
- path = "/"
311
+ path = "/",
314
312
  ): void => {
315
313
  const fullPath = `/api/${pluginId}${path === "/" ? "" : path}`;
316
314
  pluginHttpHandlers.set(fullPath, handler);
317
315
  rootLogger.debug(
318
- ` -> Registered HTTP handler for '${pluginId}' at '${fullPath}'`
316
+ ` -> Registered HTTP handler for '${pluginId}' at '${fullPath}'`,
319
317
  );
320
318
  },
321
319
  } satisfies RpcService;
@@ -333,9 +333,12 @@ export async function loadPlugins({
333
333
  */
334
334
 
335
335
  // Run Migrations
336
- const migrationsFolder = path.join(p.pluginPath, "drizzle");
336
+ // Skip migrations for manual test plugins (no plugin path) - see comment above
337
+ const migrationsFolder = p.pluginPath
338
+ ? path.join(p.pluginPath, "drizzle")
339
+ : undefined;
337
340
  const migrationsSchema = getPluginSchemaName(p.metadata.pluginId);
338
- if (fs.existsSync(migrationsFolder)) {
341
+ if (migrationsFolder && fs.existsSync(migrationsFolder)) {
339
342
  try {
340
343
  // Strip "public". schema references from migration SQL at runtime
341
344
  stripPublicSchemaFromMigrations(migrationsFolder);
@@ -343,11 +346,19 @@ export async function loadPlugins({
343
346
  ` -> Running migrations for ${p.metadata.pluginId} from ${migrationsFolder}`,
344
347
  );
345
348
 
349
+ // Create schema if it doesn't exist BEFORE running migrations.
350
+ // Without this, SET search_path to a non-existent schema causes
351
+ // PostgreSQL to fall back to 'public', creating tables in the wrong schema.
352
+ await deps.db.execute(
353
+ sql.raw(`CREATE SCHEMA IF NOT EXISTS "${migrationsSchema}"`),
354
+ );
355
+
346
356
  // Set search_path to plugin schema before running migrations.
347
357
  // Uses session-level SET (not SET LOCAL) because migrate() may run
348
358
  // multiple statements across transaction boundaries.
359
+ // No 'public' fallback: schema is guaranteed to exist from CREATE above.
349
360
  await deps.db.execute(
350
- sql.raw(`SET search_path = "${migrationsSchema}", public`),
361
+ sql.raw(`SET search_path = "${migrationsSchema}"`),
351
362
  );
352
363
  // Drizzle migrate() requires NodePgDatabase, cast from SafeDatabase
353
364
  await migrate(deps.db as NodePgDatabase<Record<string, unknown>>, {
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  createMockLogger,
11
11
  createMockQueueManager,
12
+ createMockDb,
12
13
  } from "@checkstack/test-utils-backend";
13
14
  import { sortPlugins } from "./plugin-manager/dependency-sorter";
14
15
 
@@ -120,13 +121,13 @@ describe("PluginManager", () => {
120
121
  // provider-1 must come before consumer and provider-2
121
122
  // provider-2 must come before consumer
122
123
  expect(sorted.indexOf("provider-1")).toBeLessThan(
123
- sorted.indexOf("consumer")
124
+ sorted.indexOf("consumer"),
124
125
  );
125
126
  expect(sorted.indexOf("provider-1")).toBeLessThan(
126
- sorted.indexOf("provider-2")
127
+ sorted.indexOf("provider-2"),
127
128
  );
128
129
  expect(sorted.indexOf("provider-2")).toBeLessThan(
129
- sorted.indexOf("consumer")
130
+ sorted.indexOf("consumer"),
130
131
  );
131
132
  });
132
133
 
@@ -145,17 +146,17 @@ describe("PluginManager", () => {
145
146
  ]);
146
147
 
147
148
  expect(() =>
148
- sortPlugins({ pendingInits, providedBy, logger: createMockLogger() })
149
+ sortPlugins({ pendingInits, providedBy, logger: createMockLogger() }),
149
150
  ).toThrow("Circular dependency detected");
150
151
  });
151
152
 
152
153
  describe("Queue Plugin Ordering", () => {
153
154
  it("should initialize queue plugin providers before queue consumers", () => {
154
155
  const queueManagerRef = createServiceRef<unknown>(
155
- coreServices.queueManager.id
156
+ coreServices.queueManager.id,
156
157
  );
157
158
  const queueRegistryRef = createServiceRef<unknown>(
158
- coreServices.queuePluginRegistry.id
159
+ coreServices.queuePluginRegistry.id,
159
160
  );
160
161
 
161
162
  const pendingInits = [
@@ -184,16 +185,16 @@ describe("PluginManager", () => {
184
185
 
185
186
  // Queue provider should come before queue consumer
186
187
  expect(sorted.indexOf("queue-provider")).toBeLessThan(
187
- sorted.indexOf("queue-consumer")
188
+ sorted.indexOf("queue-consumer"),
188
189
  );
189
190
  });
190
191
 
191
192
  it("should handle multiple queue providers and consumers", () => {
192
193
  const queueManagerRef = createServiceRef<unknown>(
193
- coreServices.queueManager.id
194
+ coreServices.queueManager.id,
194
195
  );
195
196
  const queueRegistryRef = createServiceRef<unknown>(
196
- coreServices.queuePluginRegistry.id
197
+ coreServices.queuePluginRegistry.id,
197
198
  );
198
199
  const loggerRef = createServiceRef<unknown>("core.logger");
199
200
 
@@ -253,10 +254,10 @@ describe("PluginManager", () => {
253
254
 
254
255
  it("should respect existing service dependencies while prioritizing queue plugins", () => {
255
256
  const queueManagerRef = createServiceRef<unknown>(
256
- coreServices.queueManager.id
257
+ coreServices.queueManager.id,
257
258
  );
258
259
  const queueRegistryRef = createServiceRef<unknown>(
259
- coreServices.queuePluginRegistry.id
260
+ coreServices.queuePluginRegistry.id,
260
261
  );
261
262
  const customServiceRef = createServiceRef<unknown>("custom.service");
262
263
  const loggerRef = createServiceRef<unknown>("core.logger");
@@ -304,10 +305,10 @@ describe("PluginManager", () => {
304
305
 
305
306
  it("should handle plugins that both provide and consume queues", () => {
306
307
  const queueManagerRef = createServiceRef<unknown>(
307
- coreServices.queueManager.id
308
+ coreServices.queueManager.id,
308
309
  );
309
310
  const queueRegistryRef = createServiceRef<unknown>(
310
- coreServices.queuePluginRegistry.id
311
+ coreServices.queuePluginRegistry.id,
311
312
  );
312
313
 
313
314
  const pendingInits = [
@@ -342,10 +343,10 @@ describe("PluginManager", () => {
342
343
 
343
344
  it("should not create circular dependencies with queue ordering", () => {
344
345
  const queueManagerRef = createServiceRef<unknown>(
345
- coreServices.queueManager.id
346
+ coreServices.queueManager.id,
346
347
  );
347
348
  const queueRegistryRef = createServiceRef<unknown>(
348
- coreServices.queuePluginRegistry.id
349
+ coreServices.queuePluginRegistry.id,
349
350
  );
350
351
 
351
352
  const pendingInits = [
@@ -441,7 +442,7 @@ describe("PluginManager", () => {
441
442
  resource: "perm",
442
443
  level: "read",
443
444
  description: "Access Rule 3",
444
- }
445
+ },
445
446
  );
446
447
 
447
448
  const all = pluginManager.getAllAccessRules();
@@ -470,10 +471,13 @@ describe("PluginManager", () => {
470
471
  // Register mock services since core-services is mocked as no-op
471
472
  pluginManager.registerService(
472
473
  coreServices.queueManager,
473
- createMockQueueManager()
474
+ createMockQueueManager(),
474
475
  );
475
476
  pluginManager.registerService(coreServices.logger, createMockLogger());
476
- pluginManager.registerService(coreServices.database, {} as never); // Mock database
477
+ pluginManager.registerService(
478
+ coreServices.database,
479
+ createMockDb() as never,
480
+ );
477
481
 
478
482
  // Use manual plugin injection with skipDiscovery to avoid loading real plugins
479
483
  await pluginManager.loadPlugins(mockRouter, [testPlugin], {