orez 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +116 -0
  2. package/dist/config.d.ts +15 -0
  3. package/dist/config.d.ts.map +1 -0
  4. package/dist/config.js +20 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/index.d.ts +15 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +195 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/pg-proxy.d.ts +14 -0
  11. package/dist/pg-proxy.d.ts.map +1 -0
  12. package/dist/pg-proxy.js +385 -0
  13. package/dist/pg-proxy.js.map +1 -0
  14. package/dist/pglite-manager.d.ts +5 -0
  15. package/dist/pglite-manager.d.ts.map +1 -0
  16. package/dist/pglite-manager.js +71 -0
  17. package/dist/pglite-manager.js.map +1 -0
  18. package/dist/replication/change-tracker.d.ts +14 -0
  19. package/dist/replication/change-tracker.d.ts.map +1 -0
  20. package/dist/replication/change-tracker.js +86 -0
  21. package/dist/replication/change-tracker.js.map +1 -0
  22. package/dist/replication/handler.d.ts +24 -0
  23. package/dist/replication/handler.d.ts.map +1 -0
  24. package/dist/replication/handler.js +300 -0
  25. package/dist/replication/handler.js.map +1 -0
  26. package/dist/replication/pgoutput-encoder.d.ts +26 -0
  27. package/dist/replication/pgoutput-encoder.d.ts.map +1 -0
  28. package/dist/replication/pgoutput-encoder.js +204 -0
  29. package/dist/replication/pgoutput-encoder.js.map +1 -0
  30. package/dist/s3-local.d.ts +8 -0
  31. package/dist/s3-local.d.ts.map +1 -0
  32. package/dist/s3-local.js +131 -0
  33. package/dist/s3-local.js.map +1 -0
  34. package/package.json +56 -0
  35. package/src/config.ts +40 -0
  36. package/src/index.ts +255 -0
  37. package/src/pg-proxy.ts +474 -0
  38. package/src/pglite-manager.ts +105 -0
  39. package/src/replication/change-tracker.test.ts +179 -0
  40. package/src/replication/change-tracker.ts +115 -0
  41. package/src/replication/handler.test.ts +331 -0
  42. package/src/replication/handler.ts +378 -0
  43. package/src/replication/pgoutput-encoder.test.ts +381 -0
  44. package/src/replication/pgoutput-encoder.ts +252 -0
  45. package/src/replication/tcp-replication.test.ts +824 -0
  46. package/src/replication/zero-compat.test.ts +882 -0
  47. package/src/s3-local.ts +179 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * replication protocol handler.
3
+ *
4
+ * intercepts replication-mode queries (IDENTIFY_SYSTEM, CREATE_REPLICATION_SLOT,
5
+ * START_REPLICATION) and returns fake responses that make zero-cache believe
6
+ * it's talking to a real postgres with logical replication.
7
+ */
8
+
9
+ import type { PGlite } from '@electric-sql/pglite'
10
+ import {
11
+ encodeBegin,
12
+ encodeCommit,
13
+ encodeRelation,
14
+ encodeInsert,
15
+ encodeUpdate,
16
+ encodeDelete,
17
+ encodeKeepalive,
18
+ wrapXLogData,
19
+ wrapCopyData,
20
+ getTableOid,
21
+ inferColumns,
22
+ type ColumnInfo,
23
+ } from './pgoutput-encoder'
24
+ import { getChangesSince, getCurrentWatermark, type ChangeRecord } from './change-tracker'
25
+
26
+ export interface ReplicationWriter {
27
+ write(data: Uint8Array): void
28
+ }
29
+
30
+ // current lsn counter
31
+ let currentLsn = 0x1000000n
32
+ function nextLsn(): bigint {
33
+ currentLsn += 0x100n
34
+ return currentLsn
35
+ }
36
+
37
+ function lsnToString(lsn: bigint): string {
38
+ const high = Number(lsn >> 32n)
39
+ const low = Number(lsn & 0xffffffffn)
40
+ return `${high.toString(16).toUpperCase()}/${low.toString(16).toUpperCase()}`
41
+ }
42
+
43
+ function nowMicros(): bigint {
44
+ return BigInt(Date.now()) * 1000n
45
+ }
46
+
47
+ // build a wire protocol row description + data row response
48
+ function buildSimpleResponse(columns: string[], values: string[]): Uint8Array {
49
+ const parts: Uint8Array[] = []
50
+ const encoder = new TextEncoder()
51
+
52
+ // RowDescription (0x54)
53
+ let rdSize = 6 // int32 len + int16 numFields
54
+ const colBytes: Uint8Array[] = []
55
+ for (const col of columns) {
56
+ const b = encoder.encode(col)
57
+ colBytes.push(b)
58
+ rdSize += b.length + 1 + 4 + 2 + 4 + 2 + 4 + 2 // name+null + tableOid + colAttr + typeOid + typeLen + typeMod + formatCode
59
+ }
60
+ const rd = new Uint8Array(1 + rdSize)
61
+ const rdv = new DataView(rd.buffer)
62
+ rd[0] = 0x54
63
+ rdv.setInt32(1, rdSize)
64
+ rdv.setInt16(5, columns.length)
65
+ let pos = 7
66
+ for (let i = 0; i < columns.length; i++) {
67
+ rd.set(colBytes[i], pos)
68
+ pos += colBytes[i].length
69
+ rd[pos++] = 0
70
+ rdv.setInt32(pos, 0) // tableOid
71
+ pos += 4
72
+ rdv.setInt16(pos, 0) // colAttr
73
+ pos += 2
74
+ rdv.setInt32(pos, 25) // typeOid (text)
75
+ pos += 4
76
+ rdv.setInt16(pos, -1) // typeLen
77
+ pos += 2
78
+ rdv.setInt32(pos, -1) // typeMod
79
+ pos += 4
80
+ rdv.setInt16(pos, 0) // formatCode (text)
81
+ pos += 2
82
+ }
83
+ parts.push(rd)
84
+
85
+ // DataRow (0x44)
86
+ let drSize = 6 // int32 len + int16 numCols
87
+ const valBytes: Uint8Array[] = []
88
+ for (const val of values) {
89
+ const b = encoder.encode(val)
90
+ valBytes.push(b)
91
+ drSize += 4 + b.length
92
+ }
93
+ const dr = new Uint8Array(1 + drSize)
94
+ const drv = new DataView(dr.buffer)
95
+ dr[0] = 0x44
96
+ drv.setInt32(1, drSize)
97
+ drv.setInt16(5, values.length)
98
+ pos = 7
99
+ for (const vb of valBytes) {
100
+ drv.setInt32(pos, vb.length)
101
+ pos += 4
102
+ dr.set(vb, pos)
103
+ pos += vb.length
104
+ }
105
+ parts.push(dr)
106
+
107
+ // CommandComplete (0x43)
108
+ const tag = encoder.encode('SELECT 1\0')
109
+ const cc = new Uint8Array(1 + 4 + tag.length)
110
+ cc[0] = 0x43
111
+ new DataView(cc.buffer).setInt32(1, 4 + tag.length)
112
+ cc.set(tag, 5)
113
+ parts.push(cc)
114
+
115
+ // ReadyForQuery (0x5a)
116
+ const rfq = new Uint8Array(6)
117
+ rfq[0] = 0x5a
118
+ new DataView(rfq.buffer).setInt32(1, 5)
119
+ rfq[5] = 0x49 // 'I' idle
120
+ parts.push(rfq)
121
+
122
+ // concatenate
123
+ const totalLen = parts.reduce((sum, p) => sum + p.length, 0)
124
+ const result = new Uint8Array(totalLen)
125
+ let offset = 0
126
+ for (const p of parts) {
127
+ result.set(p, offset)
128
+ offset += p.length
129
+ }
130
+ return result
131
+ }
132
+
133
+ function buildCommandComplete(tag: string): Uint8Array {
134
+ const encoder = new TextEncoder()
135
+ const tagBytes = encoder.encode(tag + '\0')
136
+ const cc = new Uint8Array(1 + 4 + tagBytes.length)
137
+ cc[0] = 0x43
138
+ new DataView(cc.buffer).setInt32(1, 4 + tagBytes.length)
139
+ cc.set(tagBytes, 5)
140
+
141
+ const rfq = new Uint8Array(6)
142
+ rfq[0] = 0x5a
143
+ new DataView(rfq.buffer).setInt32(1, 5)
144
+ rfq[5] = 0x49
145
+
146
+ const result = new Uint8Array(cc.length + rfq.length)
147
+ result.set(cc, 0)
148
+ result.set(rfq, cc.length)
149
+ return result
150
+ }
151
+
152
+ function buildErrorResponse(message: string): Uint8Array {
153
+ const encoder = new TextEncoder()
154
+ const msgBytes = encoder.encode(message)
155
+ // S(severity) + M(message) + null terminator
156
+ const fields = new Uint8Array(2 + 6 + 2 + msgBytes.length + 1 + 1) // S + ERROR\0 + M + msg\0 + terminator
157
+ let pos = 0
158
+ fields[pos++] = 0x53 // 'S'
159
+ const sev = encoder.encode('ERROR\0')
160
+ fields.set(sev, pos)
161
+ pos += sev.length
162
+ fields[pos++] = 0x4d // 'M'
163
+ fields.set(msgBytes, pos)
164
+ pos += msgBytes.length
165
+ fields[pos++] = 0 // null terminate message
166
+ fields[pos++] = 0 // final terminator
167
+
168
+ const buf = new Uint8Array(1 + 4 + pos)
169
+ buf[0] = 0x45 // 'E'
170
+ new DataView(buf.buffer).setInt32(1, 4 + pos)
171
+ buf.set(fields.subarray(0, pos), 5)
172
+ return buf
173
+ }
174
+
175
+ /**
176
+ * handle a replication query. returns response bytes or null if not handled.
177
+ * async because slot operations need to write to pglite.
178
+ */
179
+ export async function handleReplicationQuery(query: string, db: PGlite): Promise<Uint8Array | null> {
180
+ const trimmed = query.trim().replace(/;$/, '').trim()
181
+ const upper = trimmed.toUpperCase()
182
+
183
+ if (upper === 'IDENTIFY_SYSTEM') {
184
+ const lsn = lsnToString(currentLsn)
185
+ return buildSimpleResponse(
186
+ ['systemid', 'timeline', 'xlogpos', 'dbname'],
187
+ ['1234567890', '1', lsn, 'postgres']
188
+ )
189
+ }
190
+
191
+ if (upper.startsWith('CREATE_REPLICATION_SLOT')) {
192
+ const match = trimmed.match(/CREATE_REPLICATION_SLOT\s+"?(\w[^"\s]*)"?\s+/i)
193
+ const slotName = match?.[1] || 'zero_slot'
194
+ const lsn = lsnToString(nextLsn())
195
+ const snapshotName = `00000003-00000001-1`
196
+
197
+ // persist slot so pg_replication_slots queries find it
198
+ await db.query(
199
+ `INSERT INTO public._zero_replication_slots (slot_name, restart_lsn, confirmed_flush_lsn)
200
+ VALUES ($1, $2, $2)
201
+ ON CONFLICT (slot_name) DO UPDATE SET restart_lsn = $2, confirmed_flush_lsn = $2`,
202
+ [slotName, lsn]
203
+ )
204
+
205
+ return buildSimpleResponse(
206
+ ['slot_name', 'consistent_point', 'snapshot_name', 'output_plugin'],
207
+ [slotName, lsn, snapshotName, 'pgoutput']
208
+ )
209
+ }
210
+
211
+ if (upper.startsWith('DROP_REPLICATION_SLOT')) {
212
+ const match = trimmed.match(/DROP_REPLICATION_SLOT\s+"?(\w[^"\s]*)"?/i)
213
+ const slotName = match?.[1]
214
+ if (slotName) {
215
+ await db.query(`DELETE FROM public._zero_replication_slots WHERE slot_name = $1`, [slotName])
216
+ }
217
+ return buildCommandComplete('DROP_REPLICATION_SLOT')
218
+ }
219
+
220
+ // wal_level check via simple query
221
+ if (upper.includes('WAL_LEVEL') && upper.includes('CURRENT_SETTING')) {
222
+ return buildSimpleResponse(['walLevel', 'version'], ['logical', '160004'])
223
+ }
224
+
225
+ // ALTER ROLE for replication permission
226
+ if (upper.startsWith('ALTER ROLE') && upper.includes('REPLICATION')) {
227
+ return buildCommandComplete('ALTER ROLE')
228
+ }
229
+
230
+ return null
231
+ }
232
+
233
+ /**
234
+ * start streaming replication changes to the client.
235
+ * this runs indefinitely until the connection is closed.
236
+ */
237
+ export async function handleStartReplication(
238
+ query: string,
239
+ writer: ReplicationWriter,
240
+ db: PGlite
241
+ ): Promise<void> {
242
+ console.info('[orez] replication: entering streaming mode')
243
+
244
+ // send CopyBothResponse to enter streaming mode
245
+ const copyBoth = new Uint8Array(1 + 4 + 1 + 2)
246
+ copyBoth[0] = 0x57 // 'W' CopyBothResponse
247
+ new DataView(copyBoth.buffer).setInt32(1, 4 + 1 + 2)
248
+ copyBoth[5] = 0 // overall format (0 = text)
249
+ new DataView(copyBoth.buffer).setInt16(6, 0) // 0 columns
250
+ writer.write(copyBoth)
251
+
252
+ let lastWatermark = 0
253
+
254
+ // set up LISTEN for real-time change notifications
255
+ await db.exec(`
256
+ CREATE OR REPLACE FUNCTION public._zero_notify_change() RETURNS TRIGGER AS $$
257
+ BEGIN
258
+ PERFORM pg_notify('_zero_changes', TG_TABLE_NAME);
259
+ RETURN NULL;
260
+ END;
261
+ $$ LANGUAGE plpgsql;
262
+ `)
263
+
264
+ // install notify trigger on all tracked tables
265
+ const tables = await db.query<{ tablename: string }>(
266
+ `SELECT tablename FROM pg_tables
267
+ WHERE schemaname = 'public'
268
+ AND tablename NOT IN ('migrations', '_zero_changes')
269
+ AND tablename NOT LIKE '_zero_%'`
270
+ )
271
+
272
+ for (const { tablename } of tables.rows) {
273
+ const quoted = '"' + tablename.replace(/"/g, '""') + '"'
274
+ await db.exec(`
275
+ DROP TRIGGER IF EXISTS _zero_notify_trigger ON public.${quoted};
276
+ CREATE TRIGGER _zero_notify_trigger
277
+ AFTER INSERT OR UPDATE OR DELETE ON public.${quoted}
278
+ FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();
279
+ `)
280
+ }
281
+
282
+ // track which tables we've sent RELATION messages for
283
+ const sentRelations = new Set<string>()
284
+ let txCounter = 1
285
+
286
+ // polling + notification loop
287
+ const pollInterval = 500
288
+ let running = true
289
+
290
+ const poll = async () => {
291
+ while (running) {
292
+ try {
293
+ const changes = await getChangesSince(db, lastWatermark, 100)
294
+
295
+ if (changes.length > 0) {
296
+ await streamChanges(changes, writer, sentRelations, txCounter++)
297
+ lastWatermark = changes[changes.length - 1].watermark
298
+ }
299
+
300
+ // send keepalive
301
+ const ts = nowMicros()
302
+ writer.write(encodeKeepalive(currentLsn, ts, false))
303
+
304
+ await new Promise((resolve) => setTimeout(resolve, pollInterval))
305
+ } catch (err: unknown) {
306
+ const msg = err instanceof Error ? err.message : String(err)
307
+ console.info(`[orez] replication poll error: ${msg}`)
308
+ if (msg.includes('closed') || msg.includes('destroyed')) {
309
+ running = false
310
+ break
311
+ }
312
+ await new Promise((resolve) => setTimeout(resolve, 1000))
313
+ }
314
+ }
315
+ }
316
+
317
+ // start polling (runs until connection closes)
318
+ console.info('[orez] replication: starting poll loop')
319
+ await poll()
320
+ console.info('[orez] replication: poll loop exited')
321
+ }
322
+
323
+ async function streamChanges(
324
+ changes: ChangeRecord[],
325
+ writer: ReplicationWriter,
326
+ sentRelations: Set<string>,
327
+ txId: number
328
+ ): Promise<void> {
329
+ const ts = nowMicros()
330
+ const lsn = nextLsn()
331
+
332
+ // BEGIN
333
+ const beginMsg = wrapXLogData(lsn, lsn, ts, encodeBegin(lsn, ts, txId))
334
+ writer.write(wrapCopyData(beginMsg))
335
+
336
+ for (const change of changes) {
337
+ const tableOid = getTableOid(change.table_name)
338
+ const row = change.row_data || change.old_data
339
+ if (!row) continue
340
+
341
+ const columns = inferColumns(row)
342
+
343
+ // send RELATION if not yet sent
344
+ if (!sentRelations.has(change.table_name)) {
345
+ const relMsg = encodeRelation(tableOid, 'public', change.table_name, 0x64, columns)
346
+ writer.write(wrapCopyData(wrapXLogData(lsn, lsn, ts, relMsg)))
347
+ sentRelations.add(change.table_name)
348
+ }
349
+
350
+ // send the change
351
+ let changeMsg: Uint8Array | null = null
352
+ switch (change.op) {
353
+ case 'INSERT':
354
+ if (!change.row_data) continue
355
+ changeMsg = encodeInsert(tableOid, change.row_data, columns)
356
+ break
357
+ case 'UPDATE':
358
+ if (!change.row_data) continue
359
+ changeMsg = encodeUpdate(tableOid, change.row_data, change.old_data, columns)
360
+ break
361
+ case 'DELETE':
362
+ if (!change.old_data) continue
363
+ changeMsg = encodeDelete(tableOid, change.old_data, columns)
364
+ break
365
+ default:
366
+ continue
367
+ }
368
+
369
+ writer.write(wrapCopyData(wrapXLogData(lsn, lsn, ts, changeMsg)))
370
+ }
371
+
372
+ // COMMIT
373
+ const endLsn = nextLsn()
374
+ const commitMsg = wrapXLogData(endLsn, endLsn, ts, encodeCommit(0, lsn, endLsn, ts))
375
+ writer.write(wrapCopyData(commitMsg))
376
+ }
377
+
378
+ export { buildErrorResponse }