orez 0.1.36 → 0.1.38
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/cli-entry.js +0 -0
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -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.map +1 -1
- package/dist/index.js +14 -11
- package/dist/index.js.map +1 -1
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +8 -4
- package/dist/pg-proxy.js.map +1 -1
- package/dist/pglite-manager.d.ts +12 -0
- package/dist/pglite-manager.d.ts.map +1 -1
- package/dist/pglite-manager.js +81 -0
- package/dist/pglite-manager.js.map +1 -1
- package/dist/recovery.js +2 -2
- package/dist/recovery.js.map +1 -1
- package/dist/replication/change-tracker.js +9 -9
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts +12 -0
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +34 -6
- package/dist/replication/handler.js.map +1 -1
- package/dist/worker/browser-build-config.d.ts +59 -0
- package/dist/worker/browser-build-config.d.ts.map +1 -0
- package/dist/worker/browser-build-config.js +101 -0
- package/dist/worker/browser-build-config.js.map +1 -0
- package/dist/worker/browser-embed.d.ts +58 -0
- package/dist/worker/browser-embed.d.ts.map +1 -0
- package/dist/worker/browser-embed.js +195 -0
- package/dist/worker/browser-embed.js.map +1 -0
- package/dist/worker/cf-patches.d.ts +20 -0
- package/dist/worker/cf-patches.d.ts.map +1 -0
- package/dist/worker/cf-patches.js +94 -0
- package/dist/worker/cf-patches.js.map +1 -0
- package/dist/worker/index.d.ts +12 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +105 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/shims/fastify.d.ts +80 -0
- package/dist/worker/shims/fastify.d.ts.map +1 -0
- package/dist/worker/shims/fastify.js +223 -0
- package/dist/worker/shims/fastify.js.map +1 -0
- package/dist/worker/shims/http-service.d.ts +104 -0
- package/dist/worker/shims/http-service.d.ts.map +1 -0
- package/dist/worker/shims/http-service.js +198 -0
- package/dist/worker/shims/http-service.js.map +1 -0
- package/dist/worker/shims/node-stub.d.ts +147 -0
- package/dist/worker/shims/node-stub.d.ts.map +1 -0
- package/dist/worker/shims/node-stub.js +204 -0
- package/dist/worker/shims/node-stub.js.map +1 -0
- package/dist/worker/shims/postgres.d.ts +115 -0
- package/dist/worker/shims/postgres.d.ts.map +1 -0
- package/dist/worker/shims/postgres.js +1181 -0
- package/dist/worker/shims/postgres.js.map +1 -0
- package/dist/worker/shims/sqlite-browser.d.ts +54 -0
- package/dist/worker/shims/sqlite-browser.d.ts.map +1 -0
- package/dist/worker/shims/sqlite-browser.js +144 -0
- package/dist/worker/shims/sqlite-browser.js.map +1 -0
- package/dist/worker/shims/sqlite.d.ts +126 -0
- package/dist/worker/shims/sqlite.d.ts.map +1 -0
- package/dist/worker/shims/sqlite.js +599 -0
- package/dist/worker/shims/sqlite.js.map +1 -0
- package/dist/worker/shims/stream-browser.d.ts +9 -0
- package/dist/worker/shims/stream-browser.d.ts.map +1 -0
- package/dist/worker/shims/stream-browser.js +13 -0
- package/dist/worker/shims/stream-browser.js.map +1 -0
- package/dist/worker/shims/ws-browser.d.ts +50 -0
- package/dist/worker/shims/ws-browser.d.ts.map +1 -0
- package/dist/worker/shims/ws-browser.js +105 -0
- package/dist/worker/shims/ws-browser.js.map +1 -0
- package/dist/worker/shims/ws.d.ts +62 -0
- package/dist/worker/shims/ws.d.ts.map +1 -0
- package/dist/worker/shims/ws.js +310 -0
- package/dist/worker/shims/ws.js.map +1 -0
- package/dist/worker/types.d.ts +57 -0
- package/dist/worker/types.d.ts.map +1 -0
- package/dist/worker/types.js +9 -0
- package/dist/worker/types.js.map +1 -0
- package/dist/worker/zero-cache-embed-cf.d.ts +63 -0
- package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -0
- package/dist/worker/zero-cache-embed-cf.js +268 -0
- package/dist/worker/zero-cache-embed-cf.js.map +1 -0
- package/dist/worker/zero-cache-embed.d.ts +66 -0
- package/dist/worker/zero-cache-embed.d.ts.map +1 -0
- package/dist/worker/zero-cache-embed.js +200 -0
- package/dist/worker/zero-cache-embed.js.map +1 -0
- package/package.json +62 -3
- package/src/cli-entry.ts +0 -0
- package/src/cli.ts +8 -1
- package/src/config.ts +2 -0
- package/src/index.ts +15 -10
- package/src/integration/integration.test.ts +1 -1
- package/src/integration/restore-live-stress.test.ts +2 -2
- package/src/pg-proxy.ts +9 -4
- package/src/pglite-manager.ts +111 -0
- package/src/recovery.ts +2 -2
- package/src/replication/change-tracker.test.ts +1 -1
- package/src/replication/change-tracker.ts +9 -9
- package/src/replication/handler.test.ts +37 -0
- package/src/replication/handler.ts +46 -6
- package/src/wasm-sqlite.test.ts +2 -1
- package/src/worker/browser-build-config.test.ts +59 -0
- package/src/worker/browser-build-config.ts +105 -0
- package/src/worker/browser-embed.ts +306 -0
- package/src/worker/cf-patches.ts +114 -0
- package/src/worker/embed-integration.test.ts +321 -0
- package/src/worker/index.ts +138 -0
- package/src/worker/shims/fastify.test.ts +255 -0
- package/src/worker/shims/fastify.ts +292 -0
- package/src/worker/shims/http-service.test.ts +355 -0
- package/src/worker/shims/http-service.ts +293 -0
- package/src/worker/shims/node-stub.ts +223 -0
- package/src/worker/shims/postgres.test.ts +364 -0
- package/src/worker/shims/postgres.ts +1434 -0
- package/src/worker/shims/sqlite-browser.test.ts +233 -0
- package/src/worker/shims/sqlite-browser.ts +178 -0
- package/src/worker/shims/sqlite.test.ts +641 -0
- package/src/worker/shims/sqlite.ts +731 -0
- package/src/worker/shims/ws-browser.test.ts +184 -0
- package/src/worker/shims/ws-browser.ts +125 -0
- package/src/worker/shims/ws.test.ts +288 -0
- package/src/worker/shims/ws.ts +367 -0
- package/src/worker/types.ts +75 -0
- package/src/worker/worker-integration.test.ts +223 -0
- package/src/worker/worker.test.ts +136 -0
- package/src/worker/zero-cache-embed-cf.ts +367 -0
- package/src/worker/zero-cache-embed.ts +277 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* integration test for zero-cache embedded mode.
|
|
3
|
+
*
|
|
4
|
+
* validates that zero-cache can run in-process with SINGLE_PROCESS=1,
|
|
5
|
+
* connected to PGlite via the TCP proxy. this is the same pipeline as
|
|
6
|
+
* the full integration test but without child_process.fork().
|
|
7
|
+
*
|
|
8
|
+
* test flow:
|
|
9
|
+
* 1. create PGlite instances (postgres, cvr, cdb)
|
|
10
|
+
* 2. start TCP proxy
|
|
11
|
+
* 3. start zero-cache in-process via startZeroCacheEmbed()
|
|
12
|
+
* 4. connect WebSocket client and verify sync
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdirSync } from 'node:fs'
|
|
16
|
+
import { resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
19
|
+
import WebSocket from 'ws'
|
|
20
|
+
|
|
21
|
+
import { getConfig, getConnectionString } from '../config.js'
|
|
22
|
+
import {
|
|
23
|
+
ensureTablesInPublications,
|
|
24
|
+
installAllowAllPermissions,
|
|
25
|
+
} from '../integration/test-permissions.js'
|
|
26
|
+
import { startPgProxy } from '../pg-proxy.js'
|
|
27
|
+
import { createPGliteInstances, type PGliteInstances } from '../pglite-manager.js'
|
|
28
|
+
import { installChangeTracking } from '../replication/change-tracker.js'
|
|
29
|
+
import { startZeroCacheEmbed, type ZeroCacheEmbed } from './zero-cache-embed.js'
|
|
30
|
+
|
|
31
|
+
import type { PGlite } from '@electric-sql/pglite'
|
|
32
|
+
|
|
33
|
+
const SYNC_PROTOCOL_VERSION = 49
|
|
34
|
+
|
|
35
|
+
function encodeSecProtocols(
|
|
36
|
+
initConnectionMessage: unknown,
|
|
37
|
+
authToken: string | undefined
|
|
38
|
+
): string {
|
|
39
|
+
const payload = JSON.stringify({ initConnectionMessage, authToken })
|
|
40
|
+
return encodeURIComponent(Buffer.from(payload, 'utf-8').toString('base64'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('zero-cache embed integration', { timeout: 120000 }, () => {
|
|
44
|
+
let db: PGlite
|
|
45
|
+
let instances: PGliteInstances
|
|
46
|
+
let pgServer: ReturnType<Awaited<ReturnType<typeof startPgProxy>>>
|
|
47
|
+
let embed: ZeroCacheEmbed
|
|
48
|
+
let zeroPort: number
|
|
49
|
+
let pgPort: number
|
|
50
|
+
let dataDir: string
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
// use random ports to avoid conflicts with other tests
|
|
54
|
+
pgPort = 24000 + Math.floor(Math.random() * 1000)
|
|
55
|
+
zeroPort = pgPort + 100
|
|
56
|
+
|
|
57
|
+
dataDir = `.orez-embed-test-${Date.now()}`
|
|
58
|
+
|
|
59
|
+
const config = getConfig({
|
|
60
|
+
pgPort,
|
|
61
|
+
zeroPort,
|
|
62
|
+
dataDir,
|
|
63
|
+
logLevel: 'info',
|
|
64
|
+
useWorkerThreads: false,
|
|
65
|
+
singleDb: false,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
mkdirSync(dataDir, { recursive: true })
|
|
69
|
+
|
|
70
|
+
// create PGlite instances
|
|
71
|
+
instances = await createPGliteInstances(config)
|
|
72
|
+
db = instances.postgres
|
|
73
|
+
|
|
74
|
+
// create test table
|
|
75
|
+
await db.exec(`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS foo (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
value TEXT,
|
|
79
|
+
num INTEGER
|
|
80
|
+
)
|
|
81
|
+
`)
|
|
82
|
+
|
|
83
|
+
// set up publications
|
|
84
|
+
const pubName = `orez_zero_public`
|
|
85
|
+
process.env.ZERO_APP_PUBLICATIONS = pubName
|
|
86
|
+
await db.exec(`CREATE PUBLICATION "${pubName}"`).catch(() => {})
|
|
87
|
+
await db
|
|
88
|
+
.exec(`ALTER PUBLICATION "${pubName}" ADD TABLE "public"."foo"`)
|
|
89
|
+
.catch(() => {})
|
|
90
|
+
|
|
91
|
+
// install change tracking
|
|
92
|
+
await installChangeTracking(db)
|
|
93
|
+
|
|
94
|
+
// install allow-all permissions for test
|
|
95
|
+
await installAllowAllPermissions(db, ['foo'])
|
|
96
|
+
await ensureTablesInPublications(db, ['foo'])
|
|
97
|
+
|
|
98
|
+
// start TCP proxy
|
|
99
|
+
pgServer = await startPgProxy(instances, config)
|
|
100
|
+
|
|
101
|
+
// start zero-cache in-process
|
|
102
|
+
const upstreamDb = getConnectionString(config, 'postgres')
|
|
103
|
+
const cvrDb = getConnectionString(config, 'zero_cvr')
|
|
104
|
+
const changeDb = getConnectionString(config, 'zero_cdb')
|
|
105
|
+
const replicaFile = resolve(dataDir, 'zero-replica.db')
|
|
106
|
+
|
|
107
|
+
console.log(`[embed-test] starting in-process zero-cache on port ${zeroPort}`)
|
|
108
|
+
console.log(`[embed-test] upstream: ${upstreamDb}`)
|
|
109
|
+
|
|
110
|
+
embed = await startZeroCacheEmbed({
|
|
111
|
+
pglite: db,
|
|
112
|
+
upstreamDb,
|
|
113
|
+
cvrDb,
|
|
114
|
+
changeDb,
|
|
115
|
+
replicaFile,
|
|
116
|
+
port: zeroPort,
|
|
117
|
+
publications: [pubName],
|
|
118
|
+
env: {
|
|
119
|
+
ZERO_LOG_LEVEL: 'info',
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
console.log(`[embed-test] zero-cache ready on port ${embed.port}`)
|
|
124
|
+
|
|
125
|
+
// wait for HTTP health check
|
|
126
|
+
await waitForZero(zeroPort, 30000)
|
|
127
|
+
console.log(`[embed-test] health check passed`)
|
|
128
|
+
}, 120000)
|
|
129
|
+
|
|
130
|
+
afterAll(async () => {
|
|
131
|
+
if (embed) await embed.stop()
|
|
132
|
+
if (pgServer) pgServer.close()
|
|
133
|
+
if (instances) {
|
|
134
|
+
await instances.postgres.close().catch(() => {})
|
|
135
|
+
await instances.cvr.close().catch(() => {})
|
|
136
|
+
await instances.cdb.close().catch(() => {})
|
|
137
|
+
}
|
|
138
|
+
if (dataDir) {
|
|
139
|
+
const { rmSync } = await import('node:fs')
|
|
140
|
+
try {
|
|
141
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('zero-cache is ready', () => {
|
|
147
|
+
expect(embed.ready).toBe(true)
|
|
148
|
+
expect(embed.port).toBe(zeroPort)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('accepts WebSocket connections', async () => {
|
|
152
|
+
const cg = `test-cg-${Date.now()}`
|
|
153
|
+
const cid = `test-client-${Date.now()}`
|
|
154
|
+
const secProtocol = encodeSecProtocols(
|
|
155
|
+
[
|
|
156
|
+
'initConnection',
|
|
157
|
+
{
|
|
158
|
+
desiredQueriesPatch: [],
|
|
159
|
+
clientSchema: {
|
|
160
|
+
tables: {
|
|
161
|
+
foo: {
|
|
162
|
+
columns: {
|
|
163
|
+
id: { type: 'string' },
|
|
164
|
+
value: { type: 'string' },
|
|
165
|
+
num: { type: 'number' },
|
|
166
|
+
},
|
|
167
|
+
primaryKey: ['id'],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
undefined
|
|
174
|
+
)
|
|
175
|
+
const ws = new WebSocket(
|
|
176
|
+
`ws://localhost:${zeroPort}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
|
|
177
|
+
`?clientGroupID=${cg}&clientID=${cid}&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
|
|
178
|
+
secProtocol
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// collect messages — attach listener before open to catch everything
|
|
182
|
+
const messages: unknown[] = []
|
|
183
|
+
ws.on('message', (data) => {
|
|
184
|
+
messages.push(JSON.parse(data.toString()))
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const connected = new Promise<void>((resolve, reject) => {
|
|
188
|
+
ws.on('open', resolve)
|
|
189
|
+
ws.on('error', reject)
|
|
190
|
+
setTimeout(() => reject(new Error('ws connect timeout')), 10000)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await connected
|
|
194
|
+
|
|
195
|
+
// wait for messages to arrive
|
|
196
|
+
const deadline = Date.now() + 10000
|
|
197
|
+
while (
|
|
198
|
+
Date.now() < deadline &&
|
|
199
|
+
!messages.some((m) => Array.isArray(m) && m[0] === 'connected')
|
|
200
|
+
) {
|
|
201
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const connectedMsg = messages.find((m) => Array.isArray(m) && m[0] === 'connected')
|
|
205
|
+
expect(connectedMsg).toMatchObject(['connected', { wsid: 'ws1' }])
|
|
206
|
+
ws.close()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('live replication: insert triggers poke', async () => {
|
|
210
|
+
// insert data
|
|
211
|
+
await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
|
|
212
|
+
'embed-row',
|
|
213
|
+
'hello-embed',
|
|
214
|
+
42,
|
|
215
|
+
])
|
|
216
|
+
|
|
217
|
+
// wait for replication to deliver
|
|
218
|
+
await waitForReplicationCatchup(db)
|
|
219
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
220
|
+
|
|
221
|
+
// connect and subscribe
|
|
222
|
+
const downstream: unknown[] = []
|
|
223
|
+
const ws = connectAndSubscribe(zeroPort, downstream)
|
|
224
|
+
|
|
225
|
+
// wait for poke with our data
|
|
226
|
+
const deadline = Date.now() + 30000
|
|
227
|
+
let found = false
|
|
228
|
+
while (Date.now() < deadline && !found) {
|
|
229
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
230
|
+
for (const msg of downstream) {
|
|
231
|
+
if (Array.isArray(msg) && msg[0] === 'pokePart' && msg[1]?.rowsPatch) {
|
|
232
|
+
for (const patch of msg[1].rowsPatch) {
|
|
233
|
+
if (
|
|
234
|
+
patch.op === 'put' &&
|
|
235
|
+
patch.tableName === 'foo' &&
|
|
236
|
+
patch.value?.id === 'embed-row'
|
|
237
|
+
) {
|
|
238
|
+
found = true
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
ws.close()
|
|
247
|
+
expect(found).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
function connectAndSubscribe(port: number, downstream: unknown[]): WebSocket {
|
|
252
|
+
const cg = `test-cg-${Date.now()}`
|
|
253
|
+
const cid = `test-client-${Date.now()}`
|
|
254
|
+
const secProtocol = encodeSecProtocols(
|
|
255
|
+
[
|
|
256
|
+
'initConnection',
|
|
257
|
+
{
|
|
258
|
+
desiredQueriesPatch: [
|
|
259
|
+
{
|
|
260
|
+
op: 'put',
|
|
261
|
+
hash: 'q1',
|
|
262
|
+
ast: {
|
|
263
|
+
table: 'foo',
|
|
264
|
+
orderBy: [['id', 'asc']],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
clientSchema: {
|
|
269
|
+
tables: {
|
|
270
|
+
foo: {
|
|
271
|
+
columns: {
|
|
272
|
+
id: { type: 'string' },
|
|
273
|
+
value: { type: 'string' },
|
|
274
|
+
num: { type: 'number' },
|
|
275
|
+
},
|
|
276
|
+
primaryKey: ['id'],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
undefined
|
|
283
|
+
)
|
|
284
|
+
const ws = new WebSocket(
|
|
285
|
+
`ws://localhost:${port}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
|
|
286
|
+
`?clientGroupID=${cg}&clientID=${cid}&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
|
|
287
|
+
secProtocol
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
ws.on('message', (data) => {
|
|
291
|
+
downstream.push(JSON.parse(data.toString()))
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
return ws
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function waitForReplicationCatchup(
|
|
298
|
+
pglite: PGlite,
|
|
299
|
+
timeoutMs = 15000
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const deadline = Date.now() + timeoutMs
|
|
302
|
+
while (Date.now() < deadline) {
|
|
303
|
+
const result = await pglite.query<{ count: string }>(
|
|
304
|
+
`SELECT count(*)::text as count FROM _orez.changes`
|
|
305
|
+
)
|
|
306
|
+
if (Number(result.rows[0]?.count) === 0) return
|
|
307
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function waitForZero(port: number, timeoutMs = 30000) {
|
|
312
|
+
const deadline = Date.now() + timeoutMs
|
|
313
|
+
while (Date.now() < deadline) {
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(`http://localhost:${port}/`)
|
|
316
|
+
if (res.ok || res.status === 404) return
|
|
317
|
+
} catch {}
|
|
318
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
319
|
+
}
|
|
320
|
+
throw new Error(`zero-cache not ready on port ${port} after ${timeoutMs}ms`)
|
|
321
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// NOTE THIS IS NOT OREZ NODE THIS IS NOT A GOOD REFERENCE BECAUSE ITS OUR EARLY GUESS AT WHAT COULD WORK
|
|
2
|
+
// DO NOT STUDY THIS, THE OTHER STUFF IN SRC IS WHERE YOU EANT TO LOOK
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* orez/worker: embeddable PGlite + change tracking.
|
|
6
|
+
*
|
|
7
|
+
* runs without Node.js dependencies — works in CF Workers, browsers,
|
|
8
|
+
* vitest, bun, deno. provides the PGlite database layer with change
|
|
9
|
+
* tracking and replication encoding that zero-cache needs.
|
|
10
|
+
*
|
|
11
|
+
* usage:
|
|
12
|
+
* import { createOrezWorker } from 'orez/worker'
|
|
13
|
+
*
|
|
14
|
+
* const orez = await createOrezWorker({
|
|
15
|
+
* pgliteOptions: { dataDir: 'memory://' },
|
|
16
|
+
* })
|
|
17
|
+
* await orez.exec('CREATE TABLE foo (id TEXT PRIMARY KEY, name TEXT)')
|
|
18
|
+
* await orez.installChangeTracking()
|
|
19
|
+
* await orez.query('INSERT INTO foo VALUES ($1, $2)', ['1', 'bar'])
|
|
20
|
+
* const changes = await orez.getChangesSince(0)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Mutex } from '../mutex.js'
|
|
24
|
+
import {
|
|
25
|
+
installChangeTracking,
|
|
26
|
+
getChangesSince,
|
|
27
|
+
getCurrentWatermark,
|
|
28
|
+
purgeConsumedChanges,
|
|
29
|
+
} from '../replication/change-tracker.js'
|
|
30
|
+
import { handleStartReplication } from '../replication/handler.js'
|
|
31
|
+
|
|
32
|
+
import type { ChangeRecord } from '../replication/change-tracker.js'
|
|
33
|
+
import type { ReplicationWriter } from '../replication/handler.js'
|
|
34
|
+
import type { OrezWorkerOptions, OrezWorker } from './types.js'
|
|
35
|
+
import type { PGlite, Results } from '@electric-sql/pglite'
|
|
36
|
+
|
|
37
|
+
export type { OrezWorkerOptions, OrezWorker } from './types.js'
|
|
38
|
+
export type { ChangeRecord } from '../replication/change-tracker.js'
|
|
39
|
+
export type { ReplicationWriter } from '../replication/handler.js'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* create an orez worker instance.
|
|
43
|
+
*
|
|
44
|
+
* accepts either a pre-created PGlite instance or PGliteOptions to
|
|
45
|
+
* create one. installs the _orez schema and change tracking infrastructure.
|
|
46
|
+
*/
|
|
47
|
+
export async function createOrezWorker(opts: OrezWorkerOptions): Promise<OrezWorker> {
|
|
48
|
+
let db: PGlite
|
|
49
|
+
let ownsInstance: boolean
|
|
50
|
+
|
|
51
|
+
if (opts.pglite) {
|
|
52
|
+
db = opts.pglite
|
|
53
|
+
ownsInstance = false
|
|
54
|
+
} else if (opts.pgliteOptions) {
|
|
55
|
+
// dynamic import so PGlite isn't required at module load time.
|
|
56
|
+
// this lets the worker module be imported in environments where
|
|
57
|
+
// PGlite is provided externally (CF Workers with custom WASM).
|
|
58
|
+
const { PGlite: PGliteCtor } = await import('@electric-sql/pglite')
|
|
59
|
+
db = new PGliteCtor(opts.pgliteOptions)
|
|
60
|
+
await db.waitReady
|
|
61
|
+
ownsInstance = true
|
|
62
|
+
} else {
|
|
63
|
+
throw new Error('orez/worker: provide either pglite or pgliteOptions')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const mutex = new Mutex()
|
|
67
|
+
|
|
68
|
+
// set up publication env if provided (change-tracker reads this)
|
|
69
|
+
if (opts.publications?.length) {
|
|
70
|
+
// change-tracker reads ZERO_APP_PUBLICATIONS to decide which tables to track.
|
|
71
|
+
// in non-Node environments globalThis may not have process.env, so we
|
|
72
|
+
// set it defensively.
|
|
73
|
+
if (typeof globalThis !== 'undefined') {
|
|
74
|
+
;(globalThis as any).process ??= {}
|
|
75
|
+
;(globalThis as any).process.env ??= {}
|
|
76
|
+
;(globalThis as any).process.env.ZERO_APP_PUBLICATIONS = opts.publications.join(',')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// install core schema (plpgsql, _orez schema)
|
|
81
|
+
await db.exec('CREATE EXTENSION IF NOT EXISTS plpgsql')
|
|
82
|
+
|
|
83
|
+
// install change tracking (creates _orez schema, tables, trigger function)
|
|
84
|
+
await installChangeTracking(db)
|
|
85
|
+
|
|
86
|
+
const worker: OrezWorker = {
|
|
87
|
+
get db() {
|
|
88
|
+
return db
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
get mutex() {
|
|
92
|
+
return mutex
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
get ownsInstance() {
|
|
96
|
+
return ownsInstance
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async query<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
100
|
+
sql: string,
|
|
101
|
+
params?: unknown[]
|
|
102
|
+
): Promise<Results<T>> {
|
|
103
|
+
return db.query<T>(sql, params)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async exec(sql: string): Promise<void> {
|
|
107
|
+
await db.exec(sql)
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async installChangeTracking(): Promise<void> {
|
|
111
|
+
await installChangeTracking(db)
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async getChangesSince(watermark: number, limit?: number): Promise<ChangeRecord[]> {
|
|
115
|
+
return getChangesSince(db, watermark, limit)
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async getCurrentWatermark(): Promise<number> {
|
|
119
|
+
return getCurrentWatermark(db)
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async purgeChanges(watermark: number): Promise<number> {
|
|
123
|
+
return purgeConsumedChanges(db, watermark)
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async startReplication(writer: ReplicationWriter): Promise<void> {
|
|
127
|
+
await handleStartReplication('START_REPLICATION', writer, db, mutex)
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async close(): Promise<void> {
|
|
131
|
+
if (ownsInstance && !db.closed) {
|
|
132
|
+
await db.close()
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return worker
|
|
138
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import Fastify, { type FastifyShim } from './fastify.js'
|
|
4
|
+
|
|
5
|
+
describe('Fastify shim', () => {
|
|
6
|
+
let app: FastifyShim
|
|
7
|
+
let origGlobal: unknown
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
origGlobal = (globalThis as any).__orez_fastify_instance
|
|
11
|
+
app = Fastify()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (origGlobal !== undefined) {
|
|
16
|
+
;(globalThis as any).__orez_fastify_instance = origGlobal
|
|
17
|
+
} else {
|
|
18
|
+
delete (globalThis as any).__orez_fastify_instance
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('constructor', () => {
|
|
23
|
+
it('creates an instance', () => {
|
|
24
|
+
expect(app).toBeDefined()
|
|
25
|
+
expect(app.server).toBeDefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('registers itself on globalThis', () => {
|
|
29
|
+
expect((globalThis as any).__orez_fastify_instance).toBe(app)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('route registration', () => {
|
|
34
|
+
it('registers GET routes', async () => {
|
|
35
|
+
app.get('/', (_req, reply) => reply.send('ok'))
|
|
36
|
+
const result = await app.inject({ method: 'GET', url: '/' })
|
|
37
|
+
expect(result.statusCode).toBe(200)
|
|
38
|
+
expect(result.body).toBe('ok')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('registers POST routes', async () => {
|
|
42
|
+
app.post('/data', (_req, reply) => reply.send('created'))
|
|
43
|
+
const result = await app.inject({ method: 'POST', url: '/data' })
|
|
44
|
+
expect(result.statusCode).toBe(200)
|
|
45
|
+
expect(result.body).toBe('created')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('registers PUT routes', async () => {
|
|
49
|
+
app.put('/item', (_req, reply) => {
|
|
50
|
+
reply.code(200).send('updated')
|
|
51
|
+
})
|
|
52
|
+
const result = await app.inject({ method: 'PUT', url: '/item' })
|
|
53
|
+
expect(result.statusCode).toBe(200)
|
|
54
|
+
expect(result.body).toBe('updated')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('registers DELETE routes', async () => {
|
|
58
|
+
app.delete('/item', (_req, reply) => {
|
|
59
|
+
reply.code(204).send('')
|
|
60
|
+
})
|
|
61
|
+
const result = await app.inject({ method: 'DELETE', url: '/item' })
|
|
62
|
+
expect(result.statusCode).toBe(204)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('inject()', () => {
|
|
67
|
+
it('returns 404 for unregistered routes', async () => {
|
|
68
|
+
const result = await app.inject({ method: 'GET', url: '/nope' })
|
|
69
|
+
expect(result.statusCode).toBe(404)
|
|
70
|
+
expect(result.body).toBe('Not Found')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('passes request headers to handler', async () => {
|
|
74
|
+
let capturedHeaders: Record<string, string | undefined> = {}
|
|
75
|
+
app.get('/headers', (req, reply) => {
|
|
76
|
+
capturedHeaders = req.headers
|
|
77
|
+
reply.send('ok')
|
|
78
|
+
})
|
|
79
|
+
await app.inject({
|
|
80
|
+
method: 'GET',
|
|
81
|
+
url: '/headers',
|
|
82
|
+
headers: { 'x-custom': 'test-value' },
|
|
83
|
+
})
|
|
84
|
+
expect(capturedHeaders['x-custom']).toBe('test-value')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('passes query parameters', async () => {
|
|
88
|
+
let capturedQuery: Record<string, string> = {}
|
|
89
|
+
app.get('/search', (req, reply) => {
|
|
90
|
+
capturedQuery = req.query || {}
|
|
91
|
+
reply.send('ok')
|
|
92
|
+
})
|
|
93
|
+
await app.inject({ method: 'GET', url: '/search?q=hello&page=2' })
|
|
94
|
+
expect(capturedQuery.q).toBe('hello')
|
|
95
|
+
expect(capturedQuery.page).toBe('2')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('passes parsed JSON body', async () => {
|
|
99
|
+
let capturedBody: unknown
|
|
100
|
+
app.post('/json', (req, reply) => {
|
|
101
|
+
capturedBody = req.body
|
|
102
|
+
reply.send('ok')
|
|
103
|
+
})
|
|
104
|
+
await app.inject({
|
|
105
|
+
method: 'POST',
|
|
106
|
+
url: '/json',
|
|
107
|
+
payload: '{"name":"test"}',
|
|
108
|
+
})
|
|
109
|
+
expect(capturedBody).toEqual({ name: 'test' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('passes raw string body if not JSON', async () => {
|
|
113
|
+
let capturedBody: unknown
|
|
114
|
+
app.post('/raw', (req, reply) => {
|
|
115
|
+
capturedBody = req.body
|
|
116
|
+
reply.send('ok')
|
|
117
|
+
})
|
|
118
|
+
await app.inject({
|
|
119
|
+
method: 'POST',
|
|
120
|
+
url: '/raw',
|
|
121
|
+
payload: 'not json',
|
|
122
|
+
})
|
|
123
|
+
expect(capturedBody).toBe('not json')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('reply.code() sets status code', async () => {
|
|
127
|
+
app.get('/created', (_req, reply) => {
|
|
128
|
+
reply.code(201).send('done')
|
|
129
|
+
})
|
|
130
|
+
const result = await app.inject({ method: 'GET', url: '/created' })
|
|
131
|
+
expect(result.statusCode).toBe(201)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('reply.header() sets response headers', async () => {
|
|
135
|
+
app.get('/custom', (_req, reply) => {
|
|
136
|
+
reply.header('X-Custom', 'value').send('ok')
|
|
137
|
+
})
|
|
138
|
+
const result = await app.inject({ method: 'GET', url: '/custom' })
|
|
139
|
+
expect(result.headers['x-custom']).toBe('value')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('reply.type() sets content-type', async () => {
|
|
143
|
+
app.get('/typed', (_req, reply) => {
|
|
144
|
+
reply.type('text/html').send('<h1>hi</h1>')
|
|
145
|
+
})
|
|
146
|
+
const result = await app.inject({ method: 'GET', url: '/typed' })
|
|
147
|
+
expect(result.headers['content-type']).toBe('text/html')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('auto-serializes object responses as JSON', async () => {
|
|
151
|
+
app.get('/obj', (_req, reply) => {
|
|
152
|
+
reply.send({ foo: 'bar' })
|
|
153
|
+
})
|
|
154
|
+
const result = await app.inject({ method: 'GET', url: '/obj' })
|
|
155
|
+
expect(result.headers['content-type']).toBe('application/json')
|
|
156
|
+
expect(JSON.parse(result.body)).toEqual({ foo: 'bar' })
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('uses handler return value if reply.send() not called', async () => {
|
|
160
|
+
app.get('/return', () => 'returned')
|
|
161
|
+
const result = await app.inject({ method: 'GET', url: '/return' })
|
|
162
|
+
expect(result.body).toBe('returned')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('returns 500 on handler error', async () => {
|
|
166
|
+
app.get('/boom', () => {
|
|
167
|
+
throw new Error('handler error')
|
|
168
|
+
})
|
|
169
|
+
const result = await app.inject({ method: 'GET', url: '/boom' })
|
|
170
|
+
expect(result.statusCode).toBe(500)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('handles async handlers', async () => {
|
|
174
|
+
app.get('/async', async (_req, reply) => {
|
|
175
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
176
|
+
reply.send('async ok')
|
|
177
|
+
})
|
|
178
|
+
const result = await app.inject({ method: 'GET', url: '/async' })
|
|
179
|
+
expect(result.statusCode).toBe(200)
|
|
180
|
+
expect(result.body).toBe('async ok')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('is case-insensitive on method matching', async () => {
|
|
184
|
+
app.get('/test', (_req, reply) => reply.send('ok'))
|
|
185
|
+
const result = await app.inject({ method: 'get', url: '/test' })
|
|
186
|
+
expect(result.statusCode).toBe(200)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('lifecycle', () => {
|
|
191
|
+
it('listen() resolves to an address string', async () => {
|
|
192
|
+
const addr = await app.listen({ host: '::', port: 4848 })
|
|
193
|
+
expect(typeof addr).toBe('string')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('ready() resolves', async () => {
|
|
197
|
+
await expect(app.ready()).resolves.toBeUndefined()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('close() resolves', async () => {
|
|
201
|
+
await expect(app.close()).resolves.toBeUndefined()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('register() returns this for chaining', () => {
|
|
205
|
+
const result = app.register(() => {})
|
|
206
|
+
expect(result).toBe(app)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('FakeHttpServer', () => {
|
|
211
|
+
it('has address() method', () => {
|
|
212
|
+
const addr = app.server.address()
|
|
213
|
+
expect(addr).toHaveProperty('address')
|
|
214
|
+
expect(addr).toHaveProperty('port')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('supports onMessageType for EventEmitter IPC', () => {
|
|
218
|
+
let received: unknown = null
|
|
219
|
+
let receivedHandle: unknown = null
|
|
220
|
+
|
|
221
|
+
app.server.onMessageType('handoff', (msg: unknown, handle?: unknown) => {
|
|
222
|
+
received = msg
|
|
223
|
+
receivedHandle = handle
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const payload = { message: { url: '/test' }, head: new Uint8Array(0) }
|
|
227
|
+
const fakeSocket = { accept: () => {} }
|
|
228
|
+
|
|
229
|
+
app.server.emit('message', ['handoff', payload], fakeSocket)
|
|
230
|
+
|
|
231
|
+
expect(received).toEqual(payload)
|
|
232
|
+
expect(receivedHandle).toBe(fakeSocket)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('onMessageType ignores non-matching types', () => {
|
|
236
|
+
let called = false
|
|
237
|
+
app.server.onMessageType('handoff', () => {
|
|
238
|
+
called = true
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
app.server.emit('message', ['ready', { ready: true }])
|
|
242
|
+
expect(called).toBe(false)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('onMessageType ignores non-array messages', () => {
|
|
246
|
+
let called = false
|
|
247
|
+
app.server.onMessageType('handoff', () => {
|
|
248
|
+
called = true
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
app.server.emit('message', 'not an array')
|
|
252
|
+
expect(called).toBe(false)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|