@checkstack/backend 0.3.0 → 0.4.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,64 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 180be38: # Queue Lag Warning
|
|
8
|
+
|
|
9
|
+
Added a queue lag warning system that displays alerts when pending jobs exceed configurable thresholds.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Backend Stats API**: New `getStats`, `getLagStatus`, and `updateLagThresholds` RPC endpoints
|
|
14
|
+
- **Signal-based Updates**: `QUEUE_LAG_CHANGED` signal for real-time frontend updates
|
|
15
|
+
- **Aggregated Stats**: `QueueManager.getAggregatedStats()` sums stats across all queues
|
|
16
|
+
- **Configurable Thresholds**: Warning (default 100) and Critical (default 500) thresholds stored in config
|
|
17
|
+
- **Dashboard Integration**: Queue lag alert displayed on main Dashboard (access-gated)
|
|
18
|
+
- **Queue Settings Page**: Lag alert and Performance Tuning guidance card with concurrency tips
|
|
19
|
+
|
|
20
|
+
## UI Changes
|
|
21
|
+
|
|
22
|
+
- Queue lag alert banner appears on Dashboard and Queue Settings when pending jobs exceed thresholds
|
|
23
|
+
- New "Performance Tuning" card with concurrency settings guidance and bottleneck indicators
|
|
24
|
+
|
|
25
|
+
- 747206a: ### Schema-Scoped Database: Improved Builder Detection and Security
|
|
26
|
+
|
|
27
|
+
**Features:**
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
- Added `ScopedDatabase<TSchema>` type that excludes the relational query API (`db.query.*`) at compile-time, providing better developer experience for plugin authors.
|
|
31
|
+
|
|
32
|
+
**Security:**
|
|
33
|
+
|
|
34
|
+
- 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.
|
|
35
|
+
- Runtime error with helpful message is thrown if `db.query` is accessed, guiding developers to the correct API.
|
|
36
|
+
|
|
37
|
+
**Documentation:**
|
|
38
|
+
|
|
39
|
+
- Added comprehensive internal documentation explaining the chain-recording approach, why transactions are required for `SET LOCAL`, and how the proxy works.
|
|
40
|
+
|
|
41
|
+
### Patch Changes
|
|
42
|
+
|
|
43
|
+
- Updated dependencies [180be38]
|
|
44
|
+
- Updated dependencies [7a23261]
|
|
45
|
+
- @checkstack/queue-api@0.1.0
|
|
46
|
+
- @checkstack/common@0.3.0
|
|
47
|
+
- @checkstack/backend-api@0.3.2
|
|
48
|
+
- @checkstack/auth-common@0.3.0
|
|
49
|
+
- @checkstack/api-docs-common@0.1.1
|
|
50
|
+
- @checkstack/signal-backend@0.1.2
|
|
51
|
+
- @checkstack/signal-common@0.1.1
|
|
52
|
+
|
|
53
|
+
## 0.3.1
|
|
54
|
+
|
|
55
|
+
### Patch Changes
|
|
56
|
+
|
|
57
|
+
- Updated dependencies [9a27800]
|
|
58
|
+
- @checkstack/queue-api@0.0.6
|
|
59
|
+
- @checkstack/backend-api@0.3.1
|
|
60
|
+
- @checkstack/signal-backend@0.1.1
|
|
61
|
+
|
|
3
62
|
## 0.3.0
|
|
4
63
|
|
|
5
64
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
|
@@ -109,7 +110,7 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
109
110
|
return;
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
const queue = plugin.createQueue<T>(name, this.activeConfig);
|
|
113
|
+
const queue = plugin.createQueue<T>(name, this.activeConfig, this.logger);
|
|
113
114
|
proxy.switchDelegate(queue).catch((error) => {
|
|
114
115
|
this.logger.error(`Failed to initialize queue '${name}'`, error);
|
|
115
116
|
});
|
|
@@ -141,7 +142,11 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
141
142
|
// 3. Test connection
|
|
142
143
|
this.logger.info("🔍 Testing queue connection...");
|
|
143
144
|
try {
|
|
144
|
-
const testQueue = newPlugin.createQueue(
|
|
145
|
+
const testQueue = newPlugin.createQueue(
|
|
146
|
+
"__connection_test__",
|
|
147
|
+
config,
|
|
148
|
+
this.logger
|
|
149
|
+
);
|
|
145
150
|
await testQueue.testConnection();
|
|
146
151
|
await testQueue.stop();
|
|
147
152
|
this.logger.info("✅ Connection test successful");
|
|
@@ -185,7 +190,7 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
185
190
|
// 8. Create new queues and switch delegates
|
|
186
191
|
this.logger.info("🔄 Switching to new backend...");
|
|
187
192
|
for (const [name, proxy] of this.queueProxies.entries()) {
|
|
188
|
-
const newQueue = newPlugin.createQueue(name, config);
|
|
193
|
+
const newQueue = newPlugin.createQueue(name, config, this.logger);
|
|
189
194
|
await proxy.switchDelegate(newQueue);
|
|
190
195
|
}
|
|
191
196
|
|
|
@@ -259,6 +264,34 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
259
264
|
return total;
|
|
260
265
|
}
|
|
261
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
|
+
|
|
262
295
|
async listAllRecurringJobs(): Promise<RecurringJobInfo[]> {
|
|
263
296
|
const jobs: RecurringJobInfo[] = [];
|
|
264
297
|
|
|
@@ -342,7 +375,7 @@ export class QueueManagerImpl implements QueueManager {
|
|
|
342
375
|
// Stop and switch all queues
|
|
343
376
|
for (const [name, proxy] of this.queueProxies.entries()) {
|
|
344
377
|
try {
|
|
345
|
-
const newQueue = plugin.createQueue(name, config);
|
|
378
|
+
const newQueue = plugin.createQueue(name, config, this.logger);
|
|
346
379
|
await proxy.switchDelegate(newQueue);
|
|
347
380
|
} catch (error) {
|
|
348
381
|
this.logger.error(`Failed to switch queue '${name}'`, error);
|
|
@@ -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
|
+
}
|