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.
Files changed (130) hide show
  1. package/dist/cli-entry.js +0 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +14 -11
  10. package/dist/index.js.map +1 -1
  11. package/dist/pg-proxy.d.ts.map +1 -1
  12. package/dist/pg-proxy.js +8 -4
  13. package/dist/pg-proxy.js.map +1 -1
  14. package/dist/pglite-manager.d.ts +12 -0
  15. package/dist/pglite-manager.d.ts.map +1 -1
  16. package/dist/pglite-manager.js +81 -0
  17. package/dist/pglite-manager.js.map +1 -1
  18. package/dist/recovery.js +2 -2
  19. package/dist/recovery.js.map +1 -1
  20. package/dist/replication/change-tracker.js +9 -9
  21. package/dist/replication/change-tracker.js.map +1 -1
  22. package/dist/replication/handler.d.ts +12 -0
  23. package/dist/replication/handler.d.ts.map +1 -1
  24. package/dist/replication/handler.js +34 -6
  25. package/dist/replication/handler.js.map +1 -1
  26. package/dist/worker/browser-build-config.d.ts +59 -0
  27. package/dist/worker/browser-build-config.d.ts.map +1 -0
  28. package/dist/worker/browser-build-config.js +101 -0
  29. package/dist/worker/browser-build-config.js.map +1 -0
  30. package/dist/worker/browser-embed.d.ts +58 -0
  31. package/dist/worker/browser-embed.d.ts.map +1 -0
  32. package/dist/worker/browser-embed.js +195 -0
  33. package/dist/worker/browser-embed.js.map +1 -0
  34. package/dist/worker/cf-patches.d.ts +20 -0
  35. package/dist/worker/cf-patches.d.ts.map +1 -0
  36. package/dist/worker/cf-patches.js +94 -0
  37. package/dist/worker/cf-patches.js.map +1 -0
  38. package/dist/worker/index.d.ts +12 -0
  39. package/dist/worker/index.d.ts.map +1 -0
  40. package/dist/worker/index.js +105 -0
  41. package/dist/worker/index.js.map +1 -0
  42. package/dist/worker/shims/fastify.d.ts +80 -0
  43. package/dist/worker/shims/fastify.d.ts.map +1 -0
  44. package/dist/worker/shims/fastify.js +223 -0
  45. package/dist/worker/shims/fastify.js.map +1 -0
  46. package/dist/worker/shims/http-service.d.ts +104 -0
  47. package/dist/worker/shims/http-service.d.ts.map +1 -0
  48. package/dist/worker/shims/http-service.js +198 -0
  49. package/dist/worker/shims/http-service.js.map +1 -0
  50. package/dist/worker/shims/node-stub.d.ts +147 -0
  51. package/dist/worker/shims/node-stub.d.ts.map +1 -0
  52. package/dist/worker/shims/node-stub.js +204 -0
  53. package/dist/worker/shims/node-stub.js.map +1 -0
  54. package/dist/worker/shims/postgres.d.ts +115 -0
  55. package/dist/worker/shims/postgres.d.ts.map +1 -0
  56. package/dist/worker/shims/postgres.js +1181 -0
  57. package/dist/worker/shims/postgres.js.map +1 -0
  58. package/dist/worker/shims/sqlite-browser.d.ts +54 -0
  59. package/dist/worker/shims/sqlite-browser.d.ts.map +1 -0
  60. package/dist/worker/shims/sqlite-browser.js +144 -0
  61. package/dist/worker/shims/sqlite-browser.js.map +1 -0
  62. package/dist/worker/shims/sqlite.d.ts +126 -0
  63. package/dist/worker/shims/sqlite.d.ts.map +1 -0
  64. package/dist/worker/shims/sqlite.js +599 -0
  65. package/dist/worker/shims/sqlite.js.map +1 -0
  66. package/dist/worker/shims/stream-browser.d.ts +9 -0
  67. package/dist/worker/shims/stream-browser.d.ts.map +1 -0
  68. package/dist/worker/shims/stream-browser.js +13 -0
  69. package/dist/worker/shims/stream-browser.js.map +1 -0
  70. package/dist/worker/shims/ws-browser.d.ts +50 -0
  71. package/dist/worker/shims/ws-browser.d.ts.map +1 -0
  72. package/dist/worker/shims/ws-browser.js +105 -0
  73. package/dist/worker/shims/ws-browser.js.map +1 -0
  74. package/dist/worker/shims/ws.d.ts +62 -0
  75. package/dist/worker/shims/ws.d.ts.map +1 -0
  76. package/dist/worker/shims/ws.js +310 -0
  77. package/dist/worker/shims/ws.js.map +1 -0
  78. package/dist/worker/types.d.ts +57 -0
  79. package/dist/worker/types.d.ts.map +1 -0
  80. package/dist/worker/types.js +9 -0
  81. package/dist/worker/types.js.map +1 -0
  82. package/dist/worker/zero-cache-embed-cf.d.ts +63 -0
  83. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -0
  84. package/dist/worker/zero-cache-embed-cf.js +268 -0
  85. package/dist/worker/zero-cache-embed-cf.js.map +1 -0
  86. package/dist/worker/zero-cache-embed.d.ts +66 -0
  87. package/dist/worker/zero-cache-embed.d.ts.map +1 -0
  88. package/dist/worker/zero-cache-embed.js +200 -0
  89. package/dist/worker/zero-cache-embed.js.map +1 -0
  90. package/package.json +62 -3
  91. package/src/cli-entry.ts +0 -0
  92. package/src/cli.ts +8 -1
  93. package/src/config.ts +2 -0
  94. package/src/index.ts +15 -10
  95. package/src/integration/integration.test.ts +1 -1
  96. package/src/integration/restore-live-stress.test.ts +2 -2
  97. package/src/pg-proxy.ts +9 -4
  98. package/src/pglite-manager.ts +111 -0
  99. package/src/recovery.ts +2 -2
  100. package/src/replication/change-tracker.test.ts +1 -1
  101. package/src/replication/change-tracker.ts +9 -9
  102. package/src/replication/handler.test.ts +37 -0
  103. package/src/replication/handler.ts +46 -6
  104. package/src/wasm-sqlite.test.ts +2 -1
  105. package/src/worker/browser-build-config.test.ts +59 -0
  106. package/src/worker/browser-build-config.ts +105 -0
  107. package/src/worker/browser-embed.ts +306 -0
  108. package/src/worker/cf-patches.ts +114 -0
  109. package/src/worker/embed-integration.test.ts +321 -0
  110. package/src/worker/index.ts +138 -0
  111. package/src/worker/shims/fastify.test.ts +255 -0
  112. package/src/worker/shims/fastify.ts +292 -0
  113. package/src/worker/shims/http-service.test.ts +355 -0
  114. package/src/worker/shims/http-service.ts +293 -0
  115. package/src/worker/shims/node-stub.ts +223 -0
  116. package/src/worker/shims/postgres.test.ts +364 -0
  117. package/src/worker/shims/postgres.ts +1434 -0
  118. package/src/worker/shims/sqlite-browser.test.ts +233 -0
  119. package/src/worker/shims/sqlite-browser.ts +178 -0
  120. package/src/worker/shims/sqlite.test.ts +641 -0
  121. package/src/worker/shims/sqlite.ts +731 -0
  122. package/src/worker/shims/ws-browser.test.ts +184 -0
  123. package/src/worker/shims/ws-browser.ts +125 -0
  124. package/src/worker/shims/ws.test.ts +288 -0
  125. package/src/worker/shims/ws.ts +367 -0
  126. package/src/worker/types.ts +75 -0
  127. package/src/worker/worker-integration.test.ts +223 -0
  128. package/src/worker/worker.test.ts +136 -0
  129. package/src/worker/zero-cache-embed-cf.ts +367 -0
  130. 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
+ })