@checkstack/backend 0.3.1 → 0.4.1

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,62 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [df6ac7b]
8
+ - @checkstack/auth-common@0.4.0
9
+
10
+ ## 0.4.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 180be38: # Queue Lag Warning
15
+
16
+ Added a queue lag warning system that displays alerts when pending jobs exceed configurable thresholds.
17
+
18
+ ## Features
19
+
20
+ - **Backend Stats API**: New `getStats`, `getLagStatus`, and `updateLagThresholds` RPC endpoints
21
+ - **Signal-based Updates**: `QUEUE_LAG_CHANGED` signal for real-time frontend updates
22
+ - **Aggregated Stats**: `QueueManager.getAggregatedStats()` sums stats across all queues
23
+ - **Configurable Thresholds**: Warning (default 100) and Critical (default 500) thresholds stored in config
24
+ - **Dashboard Integration**: Queue lag alert displayed on main Dashboard (access-gated)
25
+ - **Queue Settings Page**: Lag alert and Performance Tuning guidance card with concurrency tips
26
+
27
+ ## UI Changes
28
+
29
+ - Queue lag alert banner appears on Dashboard and Queue Settings when pending jobs exceed thresholds
30
+ - New "Performance Tuning" card with concurrency settings guidance and bottleneck indicators
31
+
32
+ - 747206a: ### Schema-Scoped Database: Improved Builder Detection and Security
33
+
34
+ **Features:**
35
+
36
+ - Implemented `entityKind`-based detection of Drizzle query builders, replacing the hardcoded method name list. This automatically handles new Drizzle methods that use existing builder types.
37
+ - Added `ScopedDatabase<TSchema>` type that excludes the relational query API (`db.query.*`) at compile-time, providing better developer experience for plugin authors.
38
+
39
+ **Security:**
40
+
41
+ - Blocked access to `db.query.*` (relational query API) in schema-scoped databases because it bypasses schema isolation. Plugins must use the standard query builder API (`db.select().from(table)`) instead.
42
+ - Runtime error with helpful message is thrown if `db.query` is accessed, guiding developers to the correct API.
43
+
44
+ **Documentation:**
45
+
46
+ - Added comprehensive internal documentation explaining the chain-recording approach, why transactions are required for `SET LOCAL`, and how the proxy works.
47
+
48
+ ### Patch Changes
49
+
50
+ - Updated dependencies [180be38]
51
+ - Updated dependencies [7a23261]
52
+ - @checkstack/queue-api@0.1.0
53
+ - @checkstack/common@0.3.0
54
+ - @checkstack/backend-api@0.3.2
55
+ - @checkstack/auth-common@0.3.0
56
+ - @checkstack/api-docs-common@0.1.1
57
+ - @checkstack/signal-backend@0.1.2
58
+ - @checkstack/signal-common@0.1.1
59
+
3
60
  ## 0.3.1
4
61
 
5
62
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun --env-file=../../.env --watch src/index.ts",
@@ -1,5 +1,4 @@
1
1
  import { Pool } from "pg";
2
- import { drizzle } from "drizzle-orm/node-postgres";
3
2
  import { createORPCClient } from "@orpc/client";
4
3
  import { RPCLink } from "@orpc/client/fetch";
5
4
  import {
@@ -26,6 +25,7 @@ import {
26
25
  } from "../services/collector-registry";
27
26
  import { EventBus } from "../services/event-bus.js";
28
27
  import { getPluginSchemaName } from "@checkstack/drizzle-helper";
28
+ import { createScopedDb } from "../utils/scoped-db.js";
29
29
 
30
30
  /**
31
31
  * Check if a PostgreSQL schema exists.
@@ -83,15 +83,8 @@ export function registerCoreServices({
83
83
  // Ensure Schema Exists (creates if not already renamed/created)
84
84
  await adminPool.query(`CREATE SCHEMA IF NOT EXISTS "${assignedSchema}"`);
85
85
 
86
- // Create Scoped Connection
87
- const baseUrl = process.env.DATABASE_URL;
88
- if (!baseUrl) throw new Error("DATABASE_URL is not defined");
89
-
90
- const connector = baseUrl.includes("?") ? "&" : "?";
91
- const scopedUrl = `${baseUrl}${connector}options=-c%20search_path%3D${assignedSchema}`;
92
-
93
- const pluginPool = new Pool({ connectionString: scopedUrl });
94
- return drizzle(pluginPool);
86
+ // Create scoped proxy on shared pool (no new connections)
87
+ return createScopedDb(db, assignedSchema);
95
88
  });
96
89
 
97
90
  // 2. Logger Factory
@@ -1,6 +1,4 @@
1
- import { drizzle } from "drizzle-orm/node-postgres";
2
1
  import { migrate } from "drizzle-orm/node-postgres/migrator";
3
- import { Pool } from "pg";
4
2
  import path from "node:path";
5
3
  import fs from "node:fs";
6
4
  import type { Hono } from "hono";
@@ -35,6 +33,7 @@ import type { ExtensionPointManager } from "./extension-points";
35
33
  import { Router } from "@orpc/server";
36
34
  import { AnyContractRouter } from "@orpc/contract";
37
35
  import type { PluginMetadata } from "@checkstack/common";
36
+ import { createScopedDb } from "../utils/scoped-db";
38
37
 
39
38
  export interface PluginLoaderDeps {
40
39
  registry: ServiceRegistry;
@@ -321,11 +320,8 @@ export async function loadPlugins({
321
320
 
322
321
  // Inject Schema-aware Database if schema is provided
323
322
  if (p.schema) {
324
- const baseUrl = process.env.DATABASE_URL;
325
323
  const assignedSchema = getPluginSchemaName(p.metadata.pluginId);
326
- const scopedUrl = `${baseUrl}?options=-c%20search_path%3D${assignedSchema}`;
327
- const pluginPool = new Pool({ connectionString: scopedUrl });
328
- resolvedDeps["database"] = drizzle(pluginPool, { schema: p.schema });
324
+ resolvedDeps["database"] = createScopedDb(deps.db, assignedSchema);
329
325
  }
330
326
 
331
327
  try {
@@ -406,11 +402,8 @@ export async function loadPlugins({
406
402
  }
407
403
 
408
404
  if (p.schema) {
409
- const baseUrl = process.env.DATABASE_URL;
410
405
  const assignedSchema = getPluginSchemaName(p.metadata.pluginId);
411
- const scopedUrl = `${baseUrl}?options=-c%20search_path%3D${assignedSchema}`;
412
- const pluginPool = new Pool({ connectionString: scopedUrl });
413
- resolvedDeps["database"] = drizzle(pluginPool, { schema: p.schema });
406
+ resolvedDeps["database"] = createScopedDb(deps.db, assignedSchema);
414
407
  }
415
408
 
416
409
  const eventBus = await deps.registry.get(coreServices.eventBus, {
@@ -3,6 +3,7 @@ import type {
3
3
  QueueManager,
4
4
  SwitchResult,
5
5
  RecurringJobInfo,
6
+ QueueStats,
6
7
  } from "@checkstack/queue-api";
7
8
  import type { QueuePluginRegistryImpl } from "./queue-plugin-registry";
8
9
  import type { Logger, ConfigService } from "@checkstack/backend-api";
@@ -263,6 +264,34 @@ export class QueueManagerImpl implements QueueManager {
263
264
  return total;
264
265
  }
265
266
 
267
+ async getAggregatedStats(): Promise<QueueStats> {
268
+ const aggregated: QueueStats = {
269
+ pending: 0,
270
+ processing: 0,
271
+ completed: 0,
272
+ failed: 0,
273
+ consumerGroups: 0,
274
+ };
275
+
276
+ for (const proxy of this.queueProxies.values()) {
277
+ try {
278
+ const delegate = proxy.getDelegate();
279
+ if (delegate) {
280
+ const stats = await delegate.getStats();
281
+ aggregated.pending += stats.pending;
282
+ aggregated.processing += stats.processing;
283
+ aggregated.completed += stats.completed;
284
+ aggregated.failed += stats.failed;
285
+ aggregated.consumerGroups += stats.consumerGroups;
286
+ }
287
+ } catch {
288
+ // Queue may not be initialized yet
289
+ }
290
+ }
291
+
292
+ return aggregated;
293
+ }
294
+
266
295
  async listAllRecurringJobs(): Promise<RecurringJobInfo[]> {
267
296
  const jobs: RecurringJobInfo[] = [];
268
297
 
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { entityKind } from "drizzle-orm";
3
+ import { createScopedDb } from "./scoped-db";
4
+
5
+ describe("createScopedDb", () => {
6
+ const mockExecute = mock();
7
+ const mockTransaction = mock();
8
+
9
+ // Create a proper thenable mock that behaves like a Drizzle builder
10
+ const createThenableMock = () => {
11
+ const thenMock = mock();
12
+ thenMock.mockImplementation(
13
+ (
14
+ onFulfilled?: (value: unknown[]) => unknown,
15
+ _onRejected?: (reason: unknown) => unknown
16
+ ) => {
17
+ // Simulate async resolution like a real query
18
+ if (onFulfilled) {
19
+ return Promise.resolve(onFulfilled([]));
20
+ }
21
+ return Promise.resolve([]);
22
+ }
23
+ );
24
+ return thenMock;
25
+ };
26
+
27
+ /**
28
+ * Creates a mock builder with Drizzle's entityKind symbol.
29
+ * This is required for the new entityKind-based detection to work.
30
+ */
31
+ const createMockBuilderClass = (kind: string) => {
32
+ class MockBuilder {
33
+ static [entityKind] = kind;
34
+ }
35
+ return MockBuilder;
36
+ };
37
+
38
+ let mockFromThen: ReturnType<typeof mock>;
39
+ let mockFrom: ReturnType<typeof mock>;
40
+ let mockSelectThen: ReturnType<typeof mock>;
41
+ let mockSelect: ReturnType<typeof mock>;
42
+ let mockValuesThen: ReturnType<typeof mock>;
43
+ let mockValues: ReturnType<typeof mock>;
44
+ let mockInsertThen: ReturnType<typeof mock>;
45
+ let mockInsert: ReturnType<typeof mock>;
46
+ let mockDb: {
47
+ execute: ReturnType<typeof mock>;
48
+ select: ReturnType<typeof mock>;
49
+ insert: ReturnType<typeof mock>;
50
+ transaction: ReturnType<typeof mock>;
51
+ };
52
+
53
+ // Helper to extract SQL string from the sql.raw call argument
54
+ const getSqlString = (callArg: unknown): string => {
55
+ const arg = callArg as { queryChunks?: Array<{ value: unknown[] }> };
56
+ if (arg?.queryChunks?.[0]?.value) {
57
+ const value = arg.queryChunks[0].value;
58
+ if (Array.isArray(value)) {
59
+ return value[0] as string;
60
+ }
61
+ return value as string;
62
+ }
63
+ return String(callArg);
64
+ };
65
+
66
+ /**
67
+ * Creates a mock builder instance with the proper entityKind.
68
+ * The instance needs to be an object whose constructor has the entityKind symbol.
69
+ */
70
+ const createMockBuilder = (
71
+ kind: string,
72
+ methods: Record<string, unknown>
73
+ ): object => {
74
+ const BuilderClass = createMockBuilderClass(kind);
75
+ const instance = Object.create(BuilderClass.prototype);
76
+ Object.assign(instance, methods);
77
+ return instance;
78
+ };
79
+
80
+ beforeEach(() => {
81
+ mockExecute.mockClear();
82
+ mockExecute.mockResolvedValue({ rows: [] });
83
+ mockTransaction.mockClear();
84
+
85
+ // Set up fresh thenable mocks for each test
86
+ mockFromThen = createThenableMock();
87
+ // The result of .from() also needs entityKind - it returns a PgSelect
88
+ const fromResult = createMockBuilder("PgSelect", {
89
+ where: mock(),
90
+ then: mockFromThen,
91
+ });
92
+ mockFrom = mock().mockReturnValue(fromResult);
93
+
94
+ mockSelectThen = createThenableMock();
95
+ // select() returns a PgSelectBuilder
96
+ const selectResult = createMockBuilder("PgSelectBuilder", {
97
+ from: mockFrom,
98
+ then: mockSelectThen,
99
+ });
100
+ mockSelect = mock().mockReturnValue(selectResult);
101
+
102
+ mockValuesThen = createThenableMock();
103
+ // .values() returns a PgInsert
104
+ const valuesResult = createMockBuilder("PgInsert", {
105
+ then: mockValuesThen,
106
+ });
107
+ mockValues = mock().mockReturnValue(valuesResult);
108
+
109
+ mockInsertThen = createThenableMock();
110
+ // insert() returns a PgInsertBuilder
111
+ const insertResult = createMockBuilder("PgInsertBuilder", {
112
+ values: mockValues,
113
+ then: mockInsertThen,
114
+ });
115
+ mockInsert = mock().mockReturnValue(insertResult);
116
+
117
+ // Set up transaction mock to pass through to a callback with tx mock
118
+ // The tx mock needs to have the same methods as the main db
119
+ mockTransaction.mockImplementation(
120
+ async (cb: (tx: typeof mockDb) => Promise<unknown>) => {
121
+ // Create a tx mock that has the same structure
122
+ const txMock = {
123
+ execute: mockExecute,
124
+ select: mockSelect,
125
+ insert: mockInsert,
126
+ transaction: mockTransaction,
127
+ };
128
+ return cb(txMock);
129
+ }
130
+ );
131
+
132
+ mockDb = {
133
+ execute: mockExecute,
134
+ select: mockSelect,
135
+ insert: mockInsert,
136
+ transaction: mockTransaction,
137
+ };
138
+ });
139
+
140
+ it("preserves chaining - select().from() works synchronously", () => {
141
+ const scopedDb = createScopedDb(
142
+ mockDb as unknown as Parameters<typeof createScopedDb>[0],
143
+ "plugin_test"
144
+ );
145
+
146
+ // This should NOT throw - chaining must work synchronously
147
+ scopedDb.select().from({} as unknown as Parameters<typeof mockFrom>[0]);
148
+
149
+ expect(mockSelect).toHaveBeenCalledTimes(1);
150
+ expect(mockFrom).toHaveBeenCalledTimes(1);
151
+ // Transaction should NOT be called yet - only when query is awaited
152
+ expect(mockTransaction).toHaveBeenCalledTimes(0);
153
+ });
154
+
155
+ it("sets search_path when select query is awaited (via .then())", async () => {
156
+ const scopedDb = createScopedDb(
157
+ mockDb as unknown as Parameters<typeof createScopedDb>[0],
158
+ "plugin_test"
159
+ );
160
+
161
+ // When we await the query, .then() is called which triggers a transaction
162
+ await scopedDb
163
+ .select()
164
+ .from({} as unknown as Parameters<typeof mockFrom>[0]);
165
+
166
+ // Transaction should have been called to wrap the query
167
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
168
+ // Execute should have been called to set search_path
169
+ expect(mockExecute).toHaveBeenCalled();
170
+ const sqlStr = getSqlString(mockExecute.mock.calls[0][0]);
171
+ expect(sqlStr).toContain("SET LOCAL search_path");
172
+ expect(sqlStr).toContain("plugin_test");
173
+ });
174
+
175
+ it("sets search_path before insert query is awaited", async () => {
176
+ const scopedDb = createScopedDb(
177
+ mockDb as unknown as Parameters<typeof createScopedDb>[0],
178
+ "plugin_healthcheck"
179
+ );
180
+
181
+ await scopedDb
182
+ .insert({} as unknown as Parameters<typeof mockInsert>[0])
183
+ .values({});
184
+
185
+ // Transaction should have been called
186
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
187
+ expect(mockExecute).toHaveBeenCalled();
188
+ const sqlStr = getSqlString(mockExecute.mock.calls[0][0]);
189
+ expect(sqlStr).toContain("plugin_healthcheck");
190
+ });
191
+
192
+ it("sets search_path once at transaction start", async () => {
193
+ const scopedDb = createScopedDb(
194
+ mockDb as unknown as Parameters<typeof createScopedDb>[0],
195
+ "plugin_auth"
196
+ );
197
+
198
+ await scopedDb.transaction(async () => {
199
+ // Transaction body
200
+ });
201
+
202
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
203
+ expect(mockExecute).toHaveBeenCalledTimes(1); // Only once for SET LOCAL
204
+ const sqlStr = getSqlString(mockExecute.mock.calls[0][0]);
205
+ expect(sqlStr).toContain("plugin_auth");
206
+ });
207
+
208
+ it("sets search_path for direct execute() calls", async () => {
209
+ const scopedDb = createScopedDb(
210
+ mockDb as unknown as Parameters<typeof createScopedDb>[0],
211
+ "plugin_direct"
212
+ );
213
+
214
+ await scopedDb.execute({} as unknown as Parameters<typeof mockExecute>[0]);
215
+
216
+ // Transaction should have been used
217
+ expect(mockTransaction).toHaveBeenCalledTimes(1);
218
+ // First execute is SET LOCAL, second is the actual execute
219
+ expect(mockExecute).toHaveBeenCalledTimes(2);
220
+ const sqlStr = getSqlString(mockExecute.mock.calls[0][0]);
221
+ expect(sqlStr).toContain("SET LOCAL search_path");
222
+ expect(sqlStr).toContain("plugin_direct");
223
+ });
224
+
225
+ it("BLOCKS access to db.query (relational query API) to prevent schema bypass", () => {
226
+ // Create a mock for db.query that returns a RelationalQueryBuilder
227
+ const relationalQueryBuilder = createMockBuilder(
228
+ "PgRelationalQueryBuilder",
229
+ {
230
+ findFirst: mock().mockReturnValue(
231
+ createMockBuilder("PgRelationalQuery", {
232
+ then: createThenableMock(),
233
+ })
234
+ ),
235
+ }
236
+ );
237
+ const mockQuery = { users: relationalQueryBuilder };
238
+
239
+ const dbWithQuery = {
240
+ ...mockDb,
241
+ query: mockQuery,
242
+ };
243
+
244
+ const scopedDb = createScopedDb(
245
+ dbWithQuery as unknown as Parameters<typeof createScopedDb>[0],
246
+ "plugin_test"
247
+ );
248
+
249
+ // Accessing db.query should throw an error because it would bypass
250
+ // the schema isolation. Note: We use type assertion here since the type
251
+ // system correctly excludes `query` - we're testing the runtime fallback.
252
+ expect(() => (scopedDb as unknown as { query: unknown }).query).toThrow(
253
+ /relational query API.*is not supported/i
254
+ );
255
+ });
256
+ });
@@ -0,0 +1,489 @@
1
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { sql, entityKind } from "drizzle-orm";
3
+
4
+ /**
5
+ * =============================================================================
6
+ * SCOPED DATABASE PROXY
7
+ * =============================================================================
8
+ *
9
+ * This module provides schema-scoped database isolation for plugins without
10
+ * creating separate connection pools. It solves the "Multi-Pool Exhaustion
11
+ * Hazard" by using a single shared connection pool with per-query schema
12
+ * isolation via PostgreSQL's `SET LOCAL search_path`.
13
+ *
14
+ * ## Background: The Problem
15
+ *
16
+ * In a multi-tenant plugin architecture, each plugin needs database isolation
17
+ * to prevent schema collisions. The naive approach is to create a separate
18
+ * connection pool per plugin, but this leads to connection pool exhaustion
19
+ * when many plugins are loaded.
20
+ *
21
+ * ## Solution: Schema-Scoped Proxy
22
+ *
23
+ * Instead of separate pools, we:
24
+ * 1. Use a SINGLE shared connection pool for all plugins
25
+ * 2. Wrap the database instance in a Proxy
26
+ * 3. Inject `SET LOCAL search_path = "plugin_schema", public` before each query
27
+ * 4. The query then runs with the correct schema context
28
+ *
29
+ * ## Critical Implementation Constraint: Transactions Required
30
+ *
31
+ * `SET LOCAL` only persists within the current transaction. In autocommit mode
32
+ * (the default), each SQL statement is its own transaction:
33
+ *
34
+ * ```
35
+ * SET LOCAL search_path = "my_schema" <-- Transaction 1 (commits immediately)
36
+ * SELECT * FROM users <-- Transaction 2 (search_path is reset!)
37
+ * ```
38
+ *
39
+ * This means SET LOCAL has NO EFFECT on the subsequent query because they're
40
+ * in different transactions.
41
+ *
42
+ * To solve this, we wrap each query execution in an EXPLICIT transaction:
43
+ *
44
+ * ```
45
+ * BEGIN
46
+ * SET LOCAL search_path = "my_schema" <-- Same transaction
47
+ * SELECT * FROM users <-- search_path is now effective!
48
+ * COMMIT
49
+ * ```
50
+ *
51
+ * ## Implementation: Chain Recording & Replay
52
+ *
53
+ * Drizzle's query builders use a synchronous chaining API:
54
+ * `db.select().from(users).where(eq(users.id, 1))`
55
+ *
56
+ * We can't wrap these methods in async functions (it would break chaining).
57
+ * Instead, we:
58
+ *
59
+ * 1. **Record the chain**: Track which methods are called with which arguments
60
+ * 2. **Intercept execution**: When `.then()` or `.execute()` is called
61
+ * 3. **Replay in transaction**: Start a transaction, set search_path, then
62
+ * replay the entire chain on the transaction's database instance
63
+ *
64
+ * This ensures:
65
+ * - Synchronous chaining is preserved (no breaking changes to calling code)
66
+ * - search_path is set in the same transaction as the query
67
+ * - All queries are properly isolated to their plugin's schema
68
+ *
69
+ * ## Flow Example
70
+ *
71
+ * ```typescript
72
+ * // Plugin code (unchanged):
73
+ * const users = await db.select().from(schema.users).where(eq(schema.users.id, 1));
74
+ *
75
+ * // What actually happens:
76
+ * // 1. db.select() -> returns wrapped builder, records: { method: "select", args: [] }
77
+ * // 2. .from(schema.users) -> records: chain = [{ method: "from", args: [users] }]
78
+ * // 3. .where(...) -> records: chain = [..., { method: "where", args: [...] }]
79
+ * // 4. await (calls .then()) -> triggers transaction:
80
+ * // BEGIN
81
+ * // SET LOCAL search_path = "plugin_xyz", public
82
+ * // tx.select().from(users).where(...) -- replayed on tx
83
+ * // COMMIT
84
+ * // 5. Returns query results
85
+ * ```
86
+ *
87
+ * @see /docs/backend/database-architecture.md for more context
88
+ * =============================================================================
89
+ */
90
+
91
+ /**
92
+ * Drizzle entity kinds that identify query builders requiring schema isolation.
93
+ *
94
+ * Instead of maintaining a list of METHOD NAMES (which changes when Drizzle
95
+ * adds new methods), we identify builders by their ENTITY KIND (which changes
96
+ * only when Drizzle adds entirely new builder types - much rarer).
97
+ *
98
+ * Each Drizzle class has a static `entityKind` symbol that identifies its type.
99
+ * We check if returned objects match these known query builder kinds.
100
+ *
101
+ * WHY NOT wrap all thenables?
102
+ * - The relational query API (db.query.*) returns thenables with entityKind
103
+ * "PgRelationalQueryBuilder" which has a different internal structure and
104
+ * would break if wrapped with our chain-replay mechanism.
105
+ * - Other Drizzle utilities may return thenables that aren't query builders.
106
+ *
107
+ * @see https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/entity.ts
108
+ */
109
+ const WRAPPABLE_ENTITY_KINDS = new Set([
110
+ // SELECT builders (from select, selectDistinct, selectDistinctOn)
111
+ "PgSelectBuilder",
112
+ // INSERT builder (from insert)
113
+ "PgInsertBuilder",
114
+ // UPDATE builder (from update)
115
+ "PgUpdateBuilder",
116
+ // DELETE builder (from delete) - note: uses PgDelete, not PgDeleteBuilder
117
+ "PgDelete",
118
+ // Materialized view refresh (from refreshMaterializedView)
119
+ "PgRefreshMaterializedView",
120
+ ]);
121
+
122
+ /**
123
+ * Checks if a value is a Drizzle query builder that should be wrapped.
124
+ *
125
+ * Uses Drizzle's internal entityKind symbol to identify builder types.
126
+ * This is more robust than checking method names because:
127
+ * - New methods using existing builder types automatically work
128
+ * - Typos in method names don't silently break (they fail visibly)
129
+ * - The relational query API is correctly excluded
130
+ */
131
+ function isWrappableBuilder(value: unknown): boolean {
132
+ if (!value || typeof value !== "object") return false;
133
+
134
+ // Get the constructor of the object
135
+ const constructor = (value as object).constructor;
136
+ if (!constructor) return false;
137
+
138
+ // Check if it has an entityKind and if that kind is wrappable
139
+ if (entityKind in constructor) {
140
+ const kind = (constructor as Record<symbol, unknown>)[entityKind] as string;
141
+ return WRAPPABLE_ENTITY_KINDS.has(kind);
142
+ }
143
+
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * A schema-scoped database type that excludes the relational query API.
149
+ *
150
+ * This type explicitly removes `query` from NodePgDatabase so that plugin
151
+ * authors get compile-time errors when trying to use `db.query.*` instead
152
+ * of runtime errors. This provides better DX and catches isolation bypasses
153
+ * at development time.
154
+ *
155
+ * The relational query API is excluded because it uses a different internal
156
+ * execution path that bypasses our schema isolation mechanism.
157
+ */
158
+ export type ScopedDatabase<TSchema extends Record<string, unknown>> = Omit<
159
+ NodePgDatabase<TSchema>,
160
+ "query"
161
+ >;
162
+
163
+ /**
164
+ * Creates a schema-scoped database proxy without creating a new connection pool.
165
+ *
166
+ * The returned database instance can be used exactly like a normal Drizzle
167
+ * database, but all queries will be automatically scoped to the specified
168
+ * PostgreSQL schema.
169
+ *
170
+ * @param baseDb - The shared database instance (must support transactions)
171
+ * @param schemaName - PostgreSQL schema to scope queries to (e.g., "plugin_auth")
172
+ * @returns A proxied database instance that automatically sets search_path
173
+ *
174
+ * @example
175
+ * ```typescript
176
+ * // Create a scoped database for the auth plugin
177
+ * const authDb = createScopedDb(sharedDb, "plugin_auth");
178
+ *
179
+ * // All queries through authDb will target the plugin_auth schema
180
+ * const users = await authDb.select().from(schema.users);
181
+ * // Executes: BEGIN; SET LOCAL search_path = "plugin_auth", public; SELECT * FROM users; COMMIT;
182
+ * ```
183
+ */
184
+ export function createScopedDb<TSchema extends Record<string, unknown>>(
185
+ baseDb: NodePgDatabase<Record<string, unknown>>,
186
+ schemaName: string
187
+ ): ScopedDatabase<TSchema> {
188
+ const wrappedDb = baseDb as NodePgDatabase<TSchema>;
189
+
190
+ /**
191
+ * WeakMap to track query chains for each builder instance.
192
+ *
193
+ * Key: The builder object (e.g., the object returned by db.select())
194
+ * Value: The chain info including:
195
+ * - method: The initial builder method ("select", "insert", etc.)
196
+ * - args: Arguments passed to the initial method
197
+ * - chain: Array of subsequent method calls (from, where, orderBy, etc.)
198
+ *
199
+ * Using WeakMap ensures we don't leak memory - when the builder is GC'd,
200
+ * its chain info is automatically cleaned up.
201
+ */
202
+ const pendingChains = new WeakMap<
203
+ object,
204
+ {
205
+ method: string;
206
+ args: unknown[];
207
+ chain: Array<{ method: string; args: unknown[] }>;
208
+ }
209
+ >();
210
+
211
+ /**
212
+ * Wraps a query builder to track method calls and execute within a transaction.
213
+ *
214
+ * This function creates a Proxy around the builder that:
215
+ * 1. Records each chained method call (from, where, orderBy, limit, etc.)
216
+ * 2. Intercepts terminal methods (.then(), .execute()) to trigger execution
217
+ * 3. When executed, replays the chain inside a transaction with search_path set
218
+ *
219
+ * @param builder - The original Drizzle query builder
220
+ * @param initialMethod - The method that created this builder ("select", etc.)
221
+ * @param initialArgs - Arguments passed to the initial method
222
+ * @param chain - Accumulated chain of method calls so far
223
+ */
224
+ function wrapBuilder<T extends object>(
225
+ builder: T,
226
+ initialMethod: string,
227
+ initialArgs: unknown[],
228
+ chain: Array<{ method: string; args: unknown[] }> = []
229
+ ): T {
230
+ // Store chain info for this builder instance
231
+ pendingChains.set(builder, {
232
+ method: initialMethod,
233
+ args: initialArgs,
234
+ chain,
235
+ });
236
+
237
+ return new Proxy(builder, {
238
+ get(builderTarget, prop, receiver) {
239
+ const value = Reflect.get(builderTarget, prop, receiver);
240
+
241
+ /**
242
+ * Intercept .then() - this is called when the query is awaited.
243
+ *
244
+ * JavaScript's await calls .then() on thenable objects. Drizzle query
245
+ * builders are thenables, so this is where execution happens.
246
+ *
247
+ * When .then() is called, we:
248
+ * 1. Start a transaction on the base database
249
+ * 2. Set the search_path inside the transaction
250
+ * 3. Replay the entire query chain on the transaction's db instance
251
+ * 4. Return the results through the promise chain
252
+ */
253
+ if (prop === "then" && typeof value === "function") {
254
+ return (
255
+ onFulfilled?: (value: unknown) => unknown,
256
+ onRejected?: (reason: unknown) => unknown
257
+ ) => {
258
+ const chainInfo = pendingChains.get(builder);
259
+ if (!chainInfo) {
260
+ // Fallback: no chain info means this wasn't created by our proxy
261
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
262
+ return (value as Function).call(
263
+ builderTarget,
264
+ onFulfilled,
265
+ onRejected
266
+ );
267
+ }
268
+
269
+ // Execute the query inside a transaction with search_path set
270
+ const promise = baseDb.transaction(async (tx) => {
271
+ // Set the schema search_path for this transaction
272
+ // SET LOCAL ensures it only affects this transaction
273
+ await tx.execute(
274
+ sql.raw(`SET LOCAL search_path = "${schemaName}", public`)
275
+ );
276
+
277
+ // Rebuild the query on the transaction connection
278
+ // We call the same method (select/insert/etc.) on tx instead of target
279
+ type TxMethod = (...args: unknown[]) => unknown;
280
+ let txQuery = (
281
+ tx[chainInfo.method as keyof typeof tx] as TxMethod
282
+ )(...chainInfo.args);
283
+
284
+ // Replay all the chained method calls (from, where, orderBy, etc.)
285
+ for (const call of chainInfo.chain) {
286
+ txQuery = (txQuery as Record<string, TxMethod>)[call.method](
287
+ ...call.args
288
+ );
289
+ }
290
+
291
+ // Execute the query and return results
292
+ // Must await because txQuery is a thenable builder
293
+ return await txQuery;
294
+ });
295
+
296
+ // Chain the user's handlers onto our promise
297
+ return promise.then(onFulfilled, onRejected);
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Intercept .execute() - explicit execution method.
303
+ *
304
+ * Some Drizzle operations use .execute() instead of being awaited.
305
+ * We handle this the same way as .then().
306
+ */
307
+ if (prop === "execute" && typeof value === "function") {
308
+ return async (...args: unknown[]) => {
309
+ const chainInfo = pendingChains.get(builder);
310
+ if (!chainInfo) {
311
+ return (value as (...a: unknown[]) => Promise<unknown>).apply(
312
+ builderTarget,
313
+ args
314
+ );
315
+ }
316
+
317
+ return baseDb.transaction(async (tx) => {
318
+ await tx.execute(
319
+ sql.raw(`SET LOCAL search_path = "${schemaName}", public`)
320
+ );
321
+
322
+ type TxMethod = (...args: unknown[]) => unknown;
323
+ let txQuery = (
324
+ tx[chainInfo.method as keyof typeof tx] as TxMethod
325
+ )(...chainInfo.args);
326
+
327
+ for (const call of chainInfo.chain) {
328
+ txQuery = (txQuery as Record<string, TxMethod>)[call.method](
329
+ ...call.args
330
+ );
331
+ }
332
+
333
+ // Call .execute() on the rebuilt query with original args
334
+ return (
335
+ txQuery as { execute: (...a: unknown[]) => Promise<unknown> }
336
+ ).execute(...args);
337
+ });
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Intercept chained methods (from, where, orderBy, limit, etc.)
343
+ *
344
+ * These are NOT terminal - they return new builder objects that
345
+ * continue the chain. We:
346
+ * 1. Call the original method
347
+ * 2. Record the call in our chain
348
+ * 3. Wrap the returned builder with our proxy
349
+ */
350
+ if (typeof value === "function") {
351
+ return (...args: unknown[]) => {
352
+ // Call the original method
353
+ const result = (value as (...a: unknown[]) => unknown).apply(
354
+ builderTarget,
355
+ args
356
+ );
357
+
358
+ // If it returns an object (likely another builder), wrap it
359
+ if (result && typeof result === "object") {
360
+ const chainInfo = pendingChains.get(builder);
361
+ // Extend the chain with this method call
362
+ const newChain = chainInfo
363
+ ? [...chainInfo.chain, { method: String(prop), args }]
364
+ : [{ method: String(prop), args }];
365
+
366
+ return wrapBuilder(
367
+ result as object,
368
+ chainInfo?.method || initialMethod,
369
+ chainInfo?.args || initialArgs,
370
+ newChain
371
+ );
372
+ }
373
+ return result;
374
+ };
375
+ }
376
+
377
+ return value;
378
+ },
379
+ });
380
+ }
381
+
382
+ /**
383
+ * Main proxy that wraps the database instance.
384
+ *
385
+ * This intercepts:
386
+ * - transaction(): Wraps user's transaction callback to set search_path once
387
+ * - execute(): Wraps raw SQL execution in a transaction
388
+ * - Builder methods: Returns wrapped builders that track the chain
389
+ */
390
+ return new Proxy(wrappedDb, {
391
+ get(target, prop, receiver) {
392
+ const value = Reflect.get(target, prop, receiver);
393
+
394
+ /**
395
+ * BLOCK the relational query API (db.query.*).
396
+ *
397
+ * The relational query API uses a different internal execution path
398
+ * that bypasses our chain-replay mechanism. If we allowed it, queries
399
+ * would run WITHOUT the search_path being set, potentially accessing
400
+ * data in other schemas.
401
+ *
402
+ * Plugins MUST use the standard query builder API instead:
403
+ * - db.select().from(table) instead of db.query.table.findMany()
404
+ * - db.select().from(table).limit(1) instead of db.query.table.findFirst()
405
+ */
406
+ if (prop === "query") {
407
+ throw new Error(
408
+ `[Schema Isolation] The relational query API (db.query.*) is not ` +
409
+ `supported in schema-scoped databases because it bypasses schema ` +
410
+ `isolation. Use the standard query builder API instead:\n` +
411
+ ` - db.select().from(table) instead of db.query.table.findMany()\n` +
412
+ ` - db.select().from(table).where(...).limit(1) instead of db.query.table.findFirst()\n` +
413
+ `Current schema: "${schemaName}"`
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Handle explicit transactions.
419
+ *
420
+ * When the user calls db.transaction(), we wrap it to automatically
421
+ * set the search_path at the start of the transaction. This way,
422
+ * all queries within the transaction use the correct schema without
423
+ * needing the chain-replay mechanism.
424
+ */
425
+ if (prop === "transaction") {
426
+ return async <T>(
427
+ callback: (tx: ScopedDatabase<TSchema>) => Promise<T>
428
+ ): Promise<T> => {
429
+ return target.transaction(async (tx) => {
430
+ // Set search_path once at transaction start
431
+ await tx.execute(
432
+ sql.raw(`SET LOCAL search_path = "${schemaName}", public`)
433
+ );
434
+ // User's callback runs with the correct schema
435
+ return callback(tx as ScopedDatabase<TSchema>);
436
+ });
437
+ };
438
+ }
439
+
440
+ /**
441
+ * Handle direct db.execute() calls for raw SQL.
442
+ *
443
+ * Raw SQL also needs the search_path set, so we wrap it in a transaction.
444
+ */
445
+ if (prop === "execute" && typeof value === "function") {
446
+ return async (...args: unknown[]) => {
447
+ return target.transaction(async (tx) => {
448
+ await tx.execute(
449
+ sql.raw(`SET LOCAL search_path = "${schemaName}", public`)
450
+ );
451
+ return (tx.execute as (...a: unknown[]) => Promise<unknown>).apply(
452
+ tx,
453
+ args
454
+ );
455
+ });
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Dynamic detection of query builder methods.
461
+ *
462
+ * Instead of hardcoding method names, we:
463
+ * 1. Call any function property
464
+ * 2. Check if the returned object is a Drizzle query builder (via entityKind)
465
+ * 3. If so, wrap it with our chain-tracking proxy
466
+ *
467
+ * This automatically handles new Drizzle methods that use existing builders.
468
+ */
469
+ if (typeof value === "function" && typeof prop === "string") {
470
+ return (...args: unknown[]) => {
471
+ const result = (value as (...a: unknown[]) => unknown).apply(
472
+ target,
473
+ args
474
+ );
475
+
476
+ // Check if the result is a query builder that needs wrapping
477
+ if (isWrappableBuilder(result)) {
478
+ return wrapBuilder(result as object, prop, args);
479
+ }
480
+
481
+ return result;
482
+ };
483
+ }
484
+
485
+ // Pass through all other properties unchanged
486
+ return value;
487
+ },
488
+ });
489
+ }