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.
- package/README.md +116 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +195 -0
- package/dist/index.js.map +1 -0
- package/dist/pg-proxy.d.ts +14 -0
- package/dist/pg-proxy.d.ts.map +1 -0
- package/dist/pg-proxy.js +385 -0
- package/dist/pg-proxy.js.map +1 -0
- package/dist/pglite-manager.d.ts +5 -0
- package/dist/pglite-manager.d.ts.map +1 -0
- package/dist/pglite-manager.js +71 -0
- package/dist/pglite-manager.js.map +1 -0
- package/dist/replication/change-tracker.d.ts +14 -0
- package/dist/replication/change-tracker.d.ts.map +1 -0
- package/dist/replication/change-tracker.js +86 -0
- package/dist/replication/change-tracker.js.map +1 -0
- package/dist/replication/handler.d.ts +24 -0
- package/dist/replication/handler.d.ts.map +1 -0
- package/dist/replication/handler.js +300 -0
- package/dist/replication/handler.js.map +1 -0
- package/dist/replication/pgoutput-encoder.d.ts +26 -0
- package/dist/replication/pgoutput-encoder.d.ts.map +1 -0
- package/dist/replication/pgoutput-encoder.js +204 -0
- package/dist/replication/pgoutput-encoder.js.map +1 -0
- package/dist/s3-local.d.ts +8 -0
- package/dist/s3-local.d.ts.map +1 -0
- package/dist/s3-local.js +131 -0
- package/dist/s3-local.js.map +1 -0
- package/package.json +56 -0
- package/src/config.ts +40 -0
- package/src/index.ts +255 -0
- package/src/pg-proxy.ts +474 -0
- package/src/pglite-manager.ts +105 -0
- package/src/replication/change-tracker.test.ts +179 -0
- package/src/replication/change-tracker.ts +115 -0
- package/src/replication/handler.test.ts +331 -0
- package/src/replication/handler.ts +378 -0
- package/src/replication/pgoutput-encoder.test.ts +381 -0
- package/src/replication/pgoutput-encoder.ts +252 -0
- package/src/replication/tcp-replication.test.ts +824 -0
- package/src/replication/zero-compat.test.ts +882 -0
- 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 }
|