orez 0.2.20 → 0.2.25
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/dist/browser.d.ts +5 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +1 -0
- package/dist/browser.js.map +1 -1
- package/dist/cf-do/test-protocol.d.ts +11 -0
- package/dist/cf-do/test-protocol.d.ts.map +1 -0
- package/dist/cf-do/test-protocol.js +137 -0
- package/dist/cf-do/test-protocol.js.map +1 -0
- package/dist/cf-do/worker.d.ts +65 -0
- package/dist/cf-do/worker.d.ts.map +1 -0
- package/dist/cf-do/worker.js +440 -0
- package/dist/cf-do/worker.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -28
- package/dist/index.js.map +1 -1
- package/dist/pg-proxy-do-backend.d.ts +49 -0
- package/dist/pg-proxy-do-backend.d.ts.map +1 -0
- package/dist/pg-proxy-do-backend.js +713 -0
- package/dist/pg-proxy-do-backend.js.map +1 -0
- package/dist/pglite-ipc.d.ts +3 -0
- package/dist/pglite-ipc.d.ts.map +1 -1
- package/dist/pglite-ipc.js +34 -12
- package/dist/pglite-ipc.js.map +1 -1
- package/dist/pglite-web-proxy.d.ts +3 -0
- package/dist/pglite-web-proxy.d.ts.map +1 -1
- package/dist/pglite-web-proxy.js +50 -7
- package/dist/pglite-web-proxy.js.map +1 -1
- package/dist/query-rewrites.d.ts +2 -0
- package/dist/query-rewrites.d.ts.map +1 -0
- package/dist/query-rewrites.js +140 -0
- package/dist/query-rewrites.js.map +1 -0
- package/dist/worker/browser-admin.d.ts +13 -0
- package/dist/worker/browser-admin.d.ts.map +1 -0
- package/dist/worker/browser-admin.js +33 -0
- package/dist/worker/browser-admin.js.map +1 -0
- package/dist/worker/browser-embed.d.ts +12 -12
- package/dist/worker/browser-embed.d.ts.map +1 -1
- package/dist/worker/browser-embed.js +7 -0
- package/dist/worker/browser-embed.js.map +1 -1
- package/package.json +2 -2
- package/src/browser.ts +7 -0
- package/src/config.ts +5 -0
- package/src/index.ts +66 -33
- package/src/pg-proxy-do-backend.ts +840 -0
- package/src/pglite-ipc.test.ts +17 -0
- package/src/pglite-ipc.ts +31 -12
- package/src/pglite-web-proxy.test.ts +57 -0
- package/src/pglite-web-proxy.ts +48 -7
- package/src/query-rewrites.test.ts +30 -0
- package/src/query-rewrites.ts +152 -0
- package/src/worker/browser-admin.ts +52 -0
- package/src/worker/browser-embed-admin.test.ts +75 -0
- package/src/worker/browser-embed.ts +21 -12
package/src/pglite-ipc.test.ts
CHANGED
|
@@ -96,4 +96,21 @@ describe('PGliteWorkerProxy', () => {
|
|
|
96
96
|
test('error propagation with SQL code', async () => {
|
|
97
97
|
await expect(proxy.exec('SELECT * FROM nonexistent_table')).rejects.toThrow()
|
|
98
98
|
})
|
|
99
|
+
|
|
100
|
+
test('rejects requests after worker exits', async () => {
|
|
101
|
+
const local = new PGliteWorkerProxy({
|
|
102
|
+
dataDir: 'memory://',
|
|
103
|
+
name: 'exit-test',
|
|
104
|
+
withExtensions: false,
|
|
105
|
+
debug: 0,
|
|
106
|
+
pgliteOptions: {},
|
|
107
|
+
})
|
|
108
|
+
await local.waitReady
|
|
109
|
+
|
|
110
|
+
await (local as any).worker.terminate()
|
|
111
|
+
|
|
112
|
+
await expect(local.query('SELECT 1')).rejects.toThrow(
|
|
113
|
+
/worker exited|worker is closed/
|
|
114
|
+
)
|
|
115
|
+
})
|
|
99
116
|
})
|
package/src/pglite-ipc.ts
CHANGED
|
@@ -51,6 +51,8 @@ export class PGliteWorkerProxy {
|
|
|
51
51
|
private pending = new Map<number, PendingRequest>()
|
|
52
52
|
private nextId = 1
|
|
53
53
|
private notificationCallbacks = new Map<string, Set<(payload: string) => void>>()
|
|
54
|
+
private closed = false
|
|
55
|
+
private failure: Error | null = null
|
|
54
56
|
readonly name: string
|
|
55
57
|
|
|
56
58
|
/** resolves when the worker's PGlite instance is ready */
|
|
@@ -89,24 +91,31 @@ export class PGliteWorkerProxy {
|
|
|
89
91
|
|
|
90
92
|
// handle unexpected worker crashes
|
|
91
93
|
this.worker.on('error', (err) => {
|
|
94
|
+
const failure = new Error(`worker crashed: ${err.message}`)
|
|
92
95
|
log.pglite(`worker ${config.name} error: ${err.message}`)
|
|
93
|
-
|
|
94
|
-
req.reject(new Error(`worker crashed: ${err.message}`))
|
|
95
|
-
}
|
|
96
|
-
this.pending.clear()
|
|
96
|
+
this.failPending(failure)
|
|
97
97
|
})
|
|
98
98
|
|
|
99
99
|
this.worker.on('exit', (code) => {
|
|
100
|
+
if (this.closed) return
|
|
100
101
|
if (code !== 0) {
|
|
102
|
+
const failure = new Error(`worker exited with code ${code}`)
|
|
101
103
|
log.pglite(`worker ${config.name} exited with code ${code}`)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
this.pending.clear()
|
|
104
|
+
this.failPending(failure)
|
|
105
|
+
return
|
|
106
106
|
}
|
|
107
|
+
this.failPending(new Error('worker exited unexpectedly'))
|
|
107
108
|
})
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
private failPending(error: Error) {
|
|
112
|
+
if (!this.failure) this.failure = error
|
|
113
|
+
for (const [, req] of this.pending) {
|
|
114
|
+
req.reject(error)
|
|
115
|
+
}
|
|
116
|
+
this.pending.clear()
|
|
117
|
+
}
|
|
118
|
+
|
|
110
119
|
private installMessageHandler() {
|
|
111
120
|
this.worker.on(
|
|
112
121
|
'message',
|
|
@@ -139,14 +148,22 @@ export class PGliteWorkerProxy {
|
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
private send(msg: Record<string, unknown>, transfer?: ArrayBuffer[]): Promise<any> {
|
|
151
|
+
if (this.failure) return Promise.reject(this.failure)
|
|
152
|
+
if (this.closed) return Promise.reject(new Error('worker is closed'))
|
|
153
|
+
|
|
142
154
|
const id = this.nextId++
|
|
143
155
|
msg.id = id
|
|
144
156
|
return new Promise((resolve, reject) => {
|
|
145
157
|
this.pending.set(id, { resolve, reject })
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
try {
|
|
159
|
+
if (transfer?.length) {
|
|
160
|
+
this.worker.postMessage(msg, transfer)
|
|
161
|
+
} else {
|
|
162
|
+
this.worker.postMessage(msg)
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
this.pending.delete(id)
|
|
166
|
+
reject(err instanceof Error ? err : new Error(String(err)))
|
|
150
167
|
}
|
|
151
168
|
})
|
|
152
169
|
}
|
|
@@ -242,6 +259,8 @@ export class PGliteWorkerProxy {
|
|
|
242
259
|
} catch {
|
|
243
260
|
// worker may already be gone
|
|
244
261
|
}
|
|
262
|
+
this.closed = true
|
|
263
|
+
this.failPending(new Error('worker is closed'))
|
|
245
264
|
await this.worker.terminate()
|
|
246
265
|
}
|
|
247
266
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { PGliteWebProxy } from './pglite-web-proxy.js'
|
|
4
|
+
|
|
5
|
+
class FakeWorker {
|
|
6
|
+
messages: unknown[] = []
|
|
7
|
+
terminated = false
|
|
8
|
+
private listeners = new Map<string, Set<(event: any) => void>>()
|
|
9
|
+
|
|
10
|
+
addEventListener(type: string, handler: (event: any) => void) {
|
|
11
|
+
let handlers = this.listeners.get(type)
|
|
12
|
+
if (!handlers) {
|
|
13
|
+
handlers = new Set()
|
|
14
|
+
this.listeners.set(type, handlers)
|
|
15
|
+
}
|
|
16
|
+
handlers.add(handler)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
removeEventListener(type: string, handler: (event: any) => void) {
|
|
20
|
+
this.listeners.get(type)?.delete(handler)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
postMessage(message: unknown) {
|
|
24
|
+
this.messages.push(message)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
terminate() {
|
|
28
|
+
this.terminated = true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
dispatch(type: string, event: any) {
|
|
32
|
+
for (const handler of this.listeners.get(type) ?? []) {
|
|
33
|
+
handler(event)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('PGliteWebProxy', () => {
|
|
39
|
+
test('rejects pending and future requests when the worker errors', async () => {
|
|
40
|
+
const worker = new FakeWorker()
|
|
41
|
+
const proxy = new PGliteWebProxy(worker as unknown as Worker, 'postgres')
|
|
42
|
+
|
|
43
|
+
worker.dispatch('message', { data: { type: 'ready' } })
|
|
44
|
+
await proxy.waitReady
|
|
45
|
+
|
|
46
|
+
const pending = proxy.query('SELECT 1')
|
|
47
|
+
expect(worker.messages).toHaveLength(1)
|
|
48
|
+
|
|
49
|
+
worker.dispatch('error', {
|
|
50
|
+
error: new Error('pglite worker crashed'),
|
|
51
|
+
message: 'pglite worker crashed',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await expect(pending).rejects.toThrow('pglite worker crashed')
|
|
55
|
+
await expect(proxy.query('SELECT 2')).rejects.toThrow('pglite worker crashed')
|
|
56
|
+
})
|
|
57
|
+
})
|
package/src/pglite-web-proxy.ts
CHANGED
|
@@ -34,6 +34,7 @@ export class PGliteWebProxy {
|
|
|
34
34
|
private pending = new Map<number, PendingRequest>()
|
|
35
35
|
private nextId = 1
|
|
36
36
|
private notificationCallbacks = new Map<string, Set<(payload: string) => void>>()
|
|
37
|
+
private failure: Error | null = null
|
|
37
38
|
readonly name: string
|
|
38
39
|
|
|
39
40
|
readonly waitReady: Promise<void>
|
|
@@ -66,12 +67,26 @@ export class PGliteWebProxy {
|
|
|
66
67
|
|
|
67
68
|
this.worker.addEventListener('message', onMessage)
|
|
68
69
|
this.worker.addEventListener('error', (ev) => {
|
|
69
|
-
|
|
70
|
+
const error = this.errorFromEvent(ev, 'worker failed during startup')
|
|
71
|
+
this.failPending(error)
|
|
72
|
+
rejectReady(error)
|
|
73
|
+
})
|
|
74
|
+
this.worker.addEventListener('messageerror', (ev) => {
|
|
75
|
+
const error = this.errorFromEvent(ev, 'worker message error during startup')
|
|
76
|
+
this.failPending(error)
|
|
77
|
+
rejectReady(error)
|
|
70
78
|
})
|
|
71
79
|
})
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
private installMessageHandler() {
|
|
83
|
+
this.worker.addEventListener('error', (ev) => {
|
|
84
|
+
this.failPending(this.errorFromEvent(ev, 'worker failed'))
|
|
85
|
+
})
|
|
86
|
+
this.worker.addEventListener('messageerror', (ev) => {
|
|
87
|
+
this.failPending(this.errorFromEvent(ev, 'worker message error'))
|
|
88
|
+
})
|
|
89
|
+
|
|
75
90
|
this.worker.addEventListener('message', (ev: MessageEvent) => {
|
|
76
91
|
const msg = ev.data
|
|
77
92
|
if (!msg || typeof msg !== 'object') return
|
|
@@ -102,15 +117,40 @@ export class PGliteWebProxy {
|
|
|
102
117
|
})
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
private errorFromEvent(ev: Event, fallback: string): Error {
|
|
121
|
+
const maybeError = ev as ErrorEvent
|
|
122
|
+
if (maybeError.error instanceof Error) return maybeError.error
|
|
123
|
+
if (maybeError.message) return new Error(maybeError.message)
|
|
124
|
+
return new Error(fallback)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private failPending(error: Error) {
|
|
128
|
+
if (!this.failure) this.failure = error
|
|
129
|
+
this.closed = true
|
|
130
|
+
this.ready = false
|
|
131
|
+
for (const [, req] of this.pending) {
|
|
132
|
+
req.reject(error)
|
|
133
|
+
}
|
|
134
|
+
this.pending.clear()
|
|
135
|
+
}
|
|
136
|
+
|
|
105
137
|
private send(msg: Record<string, unknown>, transfer?: Transferable[]): Promise<any> {
|
|
138
|
+
if (this.failure) return Promise.reject(this.failure)
|
|
139
|
+
if (this.closed) return Promise.reject(new Error('worker is closed'))
|
|
140
|
+
|
|
106
141
|
const id = this.nextId++
|
|
107
142
|
msg.id = id
|
|
108
143
|
return new Promise((resolve, reject) => {
|
|
109
144
|
this.pending.set(id, { resolve, reject })
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
145
|
+
try {
|
|
146
|
+
if (transfer?.length) {
|
|
147
|
+
this.worker.postMessage(msg, transfer)
|
|
148
|
+
} else {
|
|
149
|
+
this.worker.postMessage(msg)
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
this.pending.delete(id)
|
|
153
|
+
reject(err instanceof Error ? err : new Error(String(err)))
|
|
114
154
|
}
|
|
115
155
|
})
|
|
116
156
|
}
|
|
@@ -170,11 +210,12 @@ export class PGliteWebProxy {
|
|
|
170
210
|
}
|
|
171
211
|
|
|
172
212
|
async close(): Promise<void> {
|
|
173
|
-
this.closed = true
|
|
174
|
-
this.ready = false
|
|
175
213
|
try {
|
|
176
214
|
await this.send({ type: 'close' })
|
|
177
215
|
} catch {}
|
|
216
|
+
this.closed = true
|
|
217
|
+
this.ready = false
|
|
218
|
+
this.failPending(new Error('worker is closed'))
|
|
178
219
|
this.worker.terminate()
|
|
179
220
|
}
|
|
180
221
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { rewritePgColumnSizeTotalBytesQuery } from './query-rewrites.js'
|
|
4
|
+
|
|
5
|
+
describe('rewritePgColumnSizeTotalBytesQuery', () => {
|
|
6
|
+
test('rewrites zero totalBytes pg_column_size sums into scalar subselects', () => {
|
|
7
|
+
const query =
|
|
8
|
+
'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + SUM(COALESCE(pg_column_size("parts"), 0)) + SUM(COALESCE(pg_column_size("threadId"), 0))) AS "totalBytes" FROM "public"."message" '
|
|
9
|
+
|
|
10
|
+
expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(
|
|
11
|
+
'SELECT (SELECT SUM(COALESCE(pg_column_size("id"), 0)) FROM "public"."message") + (SELECT SUM(COALESCE(pg_column_size("parts"), 0)) FROM "public"."message") + (SELECT SUM(COALESCE(pg_column_size("threadId"), 0)) FROM "public"."message") AS "totalBytes"'
|
|
12
|
+
)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('preserves row filters on every scalar subselect', () => {
|
|
16
|
+
const query =
|
|
17
|
+
'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + SUM(COALESCE(pg_column_size("parts"), 0))) AS "totalBytes" FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\';'
|
|
18
|
+
|
|
19
|
+
expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(
|
|
20
|
+
'SELECT (SELECT SUM(COALESCE(pg_column_size("id"), 0)) FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\') + (SELECT SUM(COALESCE(pg_column_size("parts"), 0)) FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\') AS "totalBytes"'
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('leaves non-matching SQL unchanged', () => {
|
|
25
|
+
const query =
|
|
26
|
+
'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + count(*)) AS "totalBytes" FROM "public"."message"'
|
|
27
|
+
|
|
28
|
+
expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(query)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const TOTAL_BYTES_ALIAS_RE = /^AS\s+"totalBytes"\s+/i
|
|
2
|
+
const TOTAL_BYTES_TERM_RE =
|
|
3
|
+
/^SUM\s*\(\s*COALESCE\s*\(\s*pg_column_size\s*\(\s*((?:"(?:[^"]|"")+")|(?:[a-z_][a-z0-9_$]*))\s*\)\s*,\s*0\s*\)\s*\)$/i
|
|
4
|
+
|
|
5
|
+
function findMatchingParen(sql: string, openIndex: number): number {
|
|
6
|
+
let depth = 0
|
|
7
|
+
let inSingleQuote = false
|
|
8
|
+
let inDoubleQuote = false
|
|
9
|
+
|
|
10
|
+
for (let i = openIndex; i < sql.length; i++) {
|
|
11
|
+
const ch = sql[i]
|
|
12
|
+
const next = sql[i + 1]
|
|
13
|
+
|
|
14
|
+
if (inSingleQuote) {
|
|
15
|
+
if (ch === "'" && next === "'") {
|
|
16
|
+
i++
|
|
17
|
+
} else if (ch === "'") {
|
|
18
|
+
inSingleQuote = false
|
|
19
|
+
}
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (inDoubleQuote) {
|
|
24
|
+
if (ch === '"' && next === '"') {
|
|
25
|
+
i++
|
|
26
|
+
} else if (ch === '"') {
|
|
27
|
+
inDoubleQuote = false
|
|
28
|
+
}
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (ch === "'") {
|
|
33
|
+
inSingleQuote = true
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (ch === '"') {
|
|
37
|
+
inDoubleQuote = true
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
if (ch === '(') {
|
|
41
|
+
depth++
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (ch === ')') {
|
|
45
|
+
depth--
|
|
46
|
+
if (depth === 0) return i
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return -1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function splitTopLevelAddends(expr: string): string[] | null {
|
|
54
|
+
const terms: string[] = []
|
|
55
|
+
let depth = 0
|
|
56
|
+
let start = 0
|
|
57
|
+
let inSingleQuote = false
|
|
58
|
+
let inDoubleQuote = false
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < expr.length; i++) {
|
|
61
|
+
const ch = expr[i]
|
|
62
|
+
const next = expr[i + 1]
|
|
63
|
+
|
|
64
|
+
if (inSingleQuote) {
|
|
65
|
+
if (ch === "'" && next === "'") {
|
|
66
|
+
i++
|
|
67
|
+
} else if (ch === "'") {
|
|
68
|
+
inSingleQuote = false
|
|
69
|
+
}
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (inDoubleQuote) {
|
|
74
|
+
if (ch === '"' && next === '"') {
|
|
75
|
+
i++
|
|
76
|
+
} else if (ch === '"') {
|
|
77
|
+
inDoubleQuote = false
|
|
78
|
+
}
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (ch === "'") {
|
|
83
|
+
inSingleQuote = true
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (ch === '"') {
|
|
87
|
+
inDoubleQuote = true
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (ch === '(') {
|
|
91
|
+
depth++
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (ch === ')') {
|
|
95
|
+
depth--
|
|
96
|
+
if (depth < 0) return null
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (ch === '+' && depth === 0) {
|
|
100
|
+
terms.push(expr.slice(start, i).trim())
|
|
101
|
+
start = i + 1
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (depth !== 0 || inSingleQuote || inDoubleQuote) return null
|
|
106
|
+
|
|
107
|
+
terms.push(expr.slice(start).trim())
|
|
108
|
+
return terms
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stripTrailingSemicolon(sql: string): string {
|
|
112
|
+
const trimmedEnd = sql.trimEnd()
|
|
113
|
+
return trimmedEnd.endsWith(';') ? trimmedEnd.slice(0, -1) : sql
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function rewritePgColumnSizeTotalBytesQuery(query: string): string {
|
|
117
|
+
const leadingWhitespace = query.match(/^\s*/)?.[0] ?? ''
|
|
118
|
+
const trimmedStart = query.slice(leadingWhitespace.length)
|
|
119
|
+
if (!/^SELECT\b/i.test(trimmedStart)) return query
|
|
120
|
+
|
|
121
|
+
const afterSelect = trimmedStart.slice('SELECT'.length).trimStart()
|
|
122
|
+
if (!afterSelect.startsWith('(')) return query
|
|
123
|
+
|
|
124
|
+
const openIndex = trimmedStart.indexOf(afterSelect)
|
|
125
|
+
const closeIndex = findMatchingParen(trimmedStart, openIndex)
|
|
126
|
+
if (closeIndex < 0) return query
|
|
127
|
+
|
|
128
|
+
const expression = trimmedStart.slice(openIndex + 1, closeIndex)
|
|
129
|
+
const afterExpression = trimmedStart.slice(closeIndex + 1).trimStart()
|
|
130
|
+
const aliasMatch = afterExpression.match(TOTAL_BYTES_ALIAS_RE)
|
|
131
|
+
if (!aliasMatch) return query
|
|
132
|
+
|
|
133
|
+
const fromClause = stripTrailingSemicolon(
|
|
134
|
+
afterExpression.slice(aliasMatch[0].length).trim()
|
|
135
|
+
)
|
|
136
|
+
if (!/^FROM\b/i.test(fromClause)) return query
|
|
137
|
+
|
|
138
|
+
const terms = splitTopLevelAddends(expression)
|
|
139
|
+
if (!terms || terms.length === 0) return query
|
|
140
|
+
|
|
141
|
+
const columns: string[] = []
|
|
142
|
+
for (const term of terms) {
|
|
143
|
+
const match = term.match(TOTAL_BYTES_TERM_RE)
|
|
144
|
+
if (!match) return query
|
|
145
|
+
columns.push(match[1])
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rewrittenTerms = columns.map(
|
|
149
|
+
(column) => `(SELECT SUM(COALESCE(pg_column_size(${column}), 0)) ${fromClause})`
|
|
150
|
+
)
|
|
151
|
+
return `${leadingWhitespace}SELECT ${rewrittenTerms.join(' + ')} AS "totalBytes"`
|
|
152
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface HttpRequest {
|
|
2
|
+
method: string
|
|
3
|
+
url: string
|
|
4
|
+
headers?: Record<string, string>
|
|
5
|
+
body?: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HttpResponse {
|
|
9
|
+
status: number
|
|
10
|
+
headers: Record<string, string>
|
|
11
|
+
body: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ADMIN_HEADERS = {
|
|
15
|
+
'access-control-allow-origin': '*',
|
|
16
|
+
'access-control-allow-methods': 'GET, OPTIONS',
|
|
17
|
+
'access-control-allow-headers': '*',
|
|
18
|
+
'content-type': 'application/json',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function jsonResponse(status: number, body: unknown): HttpResponse {
|
|
22
|
+
return {
|
|
23
|
+
status,
|
|
24
|
+
headers: ADMIN_HEADERS,
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function handleDisabledBrowserAdminRequest(
|
|
30
|
+
request: HttpRequest
|
|
31
|
+
): HttpResponse | null {
|
|
32
|
+
const url = new URL(request.url, 'http://localhost')
|
|
33
|
+
if (!url.pathname.startsWith('/__orez/')) return null
|
|
34
|
+
|
|
35
|
+
const method = request.method.toUpperCase()
|
|
36
|
+
if (method === 'OPTIONS') {
|
|
37
|
+
return { status: 200, headers: ADMIN_HEADERS, body: '' }
|
|
38
|
+
}
|
|
39
|
+
if (method !== 'GET') {
|
|
40
|
+
return jsonResponse(405, { error: 'method not allowed', admin: 'disabled' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (url.pathname === '/__orez/api/logs') {
|
|
44
|
+
return jsonResponse(200, { entries: [], cursor: 0, admin: 'disabled' })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (url.pathname === '/__orez/api/status') {
|
|
48
|
+
return jsonResponse(200, { ready: true, admin: 'disabled' })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return jsonResponse(404, { error: 'not found', admin: 'disabled' })
|
|
52
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { handleDisabledBrowserAdminRequest } from './browser-admin.js'
|
|
4
|
+
|
|
5
|
+
describe('disabled browser admin api', () => {
|
|
6
|
+
it('ignores non-admin routes', () => {
|
|
7
|
+
expect(
|
|
8
|
+
handleDisabledBrowserAdminRequest({
|
|
9
|
+
method: 'GET',
|
|
10
|
+
url: '/sync/v1/connect',
|
|
11
|
+
})
|
|
12
|
+
).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('serves empty logs for disabled browser admin', () => {
|
|
16
|
+
const response = handleDisabledBrowserAdminRequest({
|
|
17
|
+
method: 'GET',
|
|
18
|
+
url: '/__orez/api/logs?limit=100',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(response?.status).toBe(200)
|
|
22
|
+
expect(response?.headers['content-type']).toBe('application/json')
|
|
23
|
+
expect(JSON.parse(response?.body ?? '')).toEqual({
|
|
24
|
+
entries: [],
|
|
25
|
+
cursor: 0,
|
|
26
|
+
admin: 'disabled',
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('serves status for disabled browser admin', () => {
|
|
31
|
+
const response = handleDisabledBrowserAdminRequest({
|
|
32
|
+
method: 'GET',
|
|
33
|
+
url: 'http://localhost:7849/__orez/api/status',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(response?.status).toBe(200)
|
|
37
|
+
expect(JSON.parse(response?.body ?? '')).toEqual({
|
|
38
|
+
ready: true,
|
|
39
|
+
admin: 'disabled',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('handles preflight and disallows writes', () => {
|
|
44
|
+
expect(
|
|
45
|
+
handleDisabledBrowserAdminRequest({
|
|
46
|
+
method: 'OPTIONS',
|
|
47
|
+
url: '/__orez/api/logs',
|
|
48
|
+
})?.status
|
|
49
|
+
).toBe(200)
|
|
50
|
+
|
|
51
|
+
const response = handleDisabledBrowserAdminRequest({
|
|
52
|
+
method: 'POST',
|
|
53
|
+
url: '/__orez/api/actions/restart-zero',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(response?.status).toBe(405)
|
|
57
|
+
expect(JSON.parse(response?.body ?? '')).toEqual({
|
|
58
|
+
error: 'method not allowed',
|
|
59
|
+
admin: 'disabled',
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps unknown admin routes explicit', () => {
|
|
64
|
+
const response = handleDisabledBrowserAdminRequest({
|
|
65
|
+
method: 'GET',
|
|
66
|
+
url: '/__orez/api/actions/restart-zero',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(response?.status).toBe(404)
|
|
70
|
+
expect(JSON.parse(response?.body ?? '')).toEqual({
|
|
71
|
+
error: 'not found',
|
|
72
|
+
admin: 'disabled',
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -36,8 +36,13 @@ import EventEmitter from 'node:events'
|
|
|
36
36
|
// @ts-expect-error — internal zero-cache module, no type declarations
|
|
37
37
|
import { runWorker as _runWorker } from '@rocicorp/zero/out/zero-cache/src/server/runner/run-worker.js'
|
|
38
38
|
|
|
39
|
+
import { handleDisabledBrowserAdminRequest } from './browser-admin.js'
|
|
40
|
+
|
|
41
|
+
import type { HttpRequest, HttpResponse } from './browser-admin.js'
|
|
39
42
|
import type { PGlite } from '@electric-sql/pglite'
|
|
40
43
|
|
|
44
|
+
export type { HttpRequest, HttpResponse } from './browser-admin.js'
|
|
45
|
+
|
|
41
46
|
const runWorkerFn = _runWorker as (
|
|
42
47
|
parent: unknown,
|
|
43
48
|
env: Record<string, string>
|
|
@@ -65,19 +70,17 @@ export interface ZeroCacheEmbedBrowserOptions {
|
|
|
65
70
|
|
|
66
71
|
/** timeout in ms waiting for zero-cache ready (default: 30000) */
|
|
67
72
|
readyTimeout?: number
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface HttpRequest {
|
|
71
|
-
method: string
|
|
72
|
-
url: string
|
|
73
|
-
headers?: Record<string, string>
|
|
74
|
-
body?: string | null
|
|
75
|
-
}
|
|
76
73
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
/**
|
|
75
|
+
* intercept browser-mode orez admin routes before they reach zero-cache.
|
|
76
|
+
*
|
|
77
|
+
* browser embeds do not run the node admin dashboard or log store. leaving
|
|
78
|
+
* `/__orez/*` to fall through to zero-cache makes disabled admin look like
|
|
79
|
+
* an app/zero route miss. keep the contract explicit by returning empty
|
|
80
|
+
* admin responses for the small read-only surface and 404 for the rest.
|
|
81
|
+
* default: true.
|
|
82
|
+
*/
|
|
83
|
+
disableAdminApi?: boolean
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
/** WebSocket-like object — matches CF WebSocket, browser WebSocket, or MessagePort adapter */
|
|
@@ -116,6 +119,7 @@ export async function startZeroCacheEmbedBrowser(
|
|
|
116
119
|
const appId = opts.appId || 'zero'
|
|
117
120
|
const publications = opts.publications?.join(',') || `orez_${appId}_public`
|
|
118
121
|
const readyTimeout = opts.readyTimeout ?? 30000
|
|
122
|
+
const disableAdminApi = opts.disableAdminApi ?? true
|
|
119
123
|
|
|
120
124
|
// set up sqlite storage from sql.js or in-memory
|
|
121
125
|
if (opts.sqlite) {
|
|
@@ -303,6 +307,11 @@ export async function startZeroCacheEmbedBrowser(
|
|
|
303
307
|
},
|
|
304
308
|
|
|
305
309
|
async handleHttp(request: HttpRequest): Promise<HttpResponse> {
|
|
310
|
+
if (disableAdminApi) {
|
|
311
|
+
const adminResponse = handleDisabledBrowserAdminRequest(request)
|
|
312
|
+
if (adminResponse) return adminResponse
|
|
313
|
+
}
|
|
314
|
+
|
|
306
315
|
if (!isReady || !fastifyInstance?.inject) {
|
|
307
316
|
return { status: 503, headers: {}, body: 'not ready' }
|
|
308
317
|
}
|