orez 0.2.27 → 0.2.30
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/cf-do/worker.d.ts +3 -0
- package/dist/cf-do/worker.d.ts.map +1 -1
- package/dist/cf-do/worker.js +37 -15
- package/dist/cf-do/worker.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/admin/admin-data.test.ts +0 -348
- package/src/admin/http-proxy.ts +0 -252
- package/src/admin/log-store.ts +0 -192
- package/src/admin/server.ts +0 -471
- package/src/admin/ui.ts +0 -1322
- package/src/bench/proxy-throughput.bench.ts +0 -343
- package/src/bench/serial-mutations.bench.ts +0 -270
- package/src/browser.ts +0 -203
- package/src/cf-do/.wrangler/cache/cf.json +0 -1
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
- package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
- package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
- package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
- package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
- package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
- package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
- package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
- package/src/cf-do/ARCHITECTURE.md +0 -93
- package/src/cf-do/CHAT_E2E.md +0 -213
- package/src/cf-do/watermark.test.ts +0 -103
- package/src/cf-do/watermark.ts +0 -118
- package/src/cf-do/worker.ts +0 -1041
- package/src/cf-do/wrangler.toml +0 -11
- package/src/cf-pglite/README.md +0 -19
- package/src/change-tracking.ts +0 -25
- package/src/child-process.test.ts +0 -147
- package/src/child-process.ts +0 -90
- package/src/cli-entry.ts +0 -72
- package/src/cli.test.ts +0 -40
- package/src/cli.ts +0 -1214
- package/src/config.ts +0 -150
- package/src/do-sql-tracking.test.ts +0 -19
- package/src/do-sql-tracking.ts +0 -19
- package/src/index.ts +0 -1215
- package/src/integration/integration.test.ts +0 -517
- package/src/integration/native-binary.guard.test.ts +0 -13
- package/src/integration/native-startup.test.ts +0 -44
- package/src/integration/replication-latency.test.ts +0 -428
- package/src/integration/restore-live-stress.test.ts +0 -433
- package/src/integration/restore-reset.test.ts +0 -400
- package/src/integration/restore.test.ts +0 -274
- package/src/integration/test-permissions.ts +0 -147
- package/src/load-config.ts +0 -46
- package/src/log.ts +0 -96
- package/src/mutex.ts +0 -47
- package/src/pg-proxy-browser.singledb.test.ts +0 -233
- package/src/pg-proxy-browser.ts +0 -2022
- package/src/pg-proxy-do-backend.test.ts +0 -3890
- package/src/pg-proxy-do-backend.ts +0 -7191
- package/src/pg-proxy.ts +0 -1087
- package/src/pg-sqlite-compiler/README.md +0 -53
- package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
- package/src/pg-sqlite-compiler/index.ts +0 -73
- package/src/pg-sqlite-compiler/integration.test.ts +0 -136
- package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
- package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
- package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
- package/src/pg-sqlite-compiler/passes/index.ts +0 -49
- package/src/pg-sqlite-compiler/passes/types.ts +0 -156
- package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
- package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
- package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
- package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
- package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
- package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
- package/src/pg-sqlite-compiler/types.ts +0 -63
- package/src/pglite-ipc.test.ts +0 -116
- package/src/pglite-ipc.ts +0 -266
- package/src/pglite-manager.ts +0 -557
- package/src/pglite-web-proxy.test.ts +0 -57
- package/src/pglite-web-proxy.ts +0 -221
- package/src/pglite-web-worker.ts +0 -152
- package/src/pglite-worker-thread.ts +0 -253
- package/src/port.ts +0 -25
- package/src/process-title.ts +0 -9
- package/src/recovery.ts +0 -155
- package/src/replication/change-tracker.test.ts +0 -357
- package/src/replication/change-tracker.ts +0 -279
- package/src/replication/handler.test.ts +0 -511
- package/src/replication/handler.ts +0 -1190
- package/src/replication/pgoutput-encoder.test.ts +0 -697
- package/src/replication/pgoutput-encoder.ts +0 -373
- package/src/replication/tcp-replication.test.ts +0 -876
- package/src/replication/zero-compat.test.ts +0 -1150
- package/src/restore-stress.test.ts +0 -188
- package/src/s3-local.ts +0 -203
- package/src/shim/hooks.mjs +0 -120
- package/src/shim/register.mjs +0 -4
- package/src/sqlite-mode/apply-mode.ts +0 -224
- package/src/sqlite-mode/index.ts +0 -15
- package/src/sqlite-mode/native-binary.ts +0 -89
- package/src/sqlite-mode/package-resolve.ts +0 -17
- package/src/sqlite-mode/resolve-mode.ts +0 -80
- package/src/sqlite-mode/shim-template.ts +0 -159
- package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
- package/src/sqlite-mode/types.ts +0 -30
- package/src/vite-plugin.ts +0 -67
- package/src/wasm-sqlite.test.ts +0 -537
- package/src/worker/browser-admin.ts +0 -52
- package/src/worker/browser-build-config.test.ts +0 -71
- package/src/worker/browser-build-config.ts +0 -109
- package/src/worker/browser-embed-admin.test.ts +0 -75
- package/src/worker/browser-embed.ts +0 -345
- package/src/worker/cf-patches.ts +0 -384
- package/src/worker/embed-integration.test.ts +0 -321
- package/src/worker/index.ts +0 -138
- package/src/worker/shims/fastify.test.ts +0 -255
- package/src/worker/shims/fastify.ts +0 -306
- package/src/worker/shims/http-service.test.ts +0 -355
- package/src/worker/shims/http-service.ts +0 -293
- package/src/worker/shims/node-stub.ts +0 -290
- package/src/worker/shims/oxfmt.ts +0 -3
- package/src/worker/shims/postgres-browser.ts +0 -59
- package/src/worker/shims/postgres-socket.test.ts +0 -576
- package/src/worker/shims/postgres-socket.ts +0 -310
- package/src/worker/shims/postgres.test.ts +0 -364
- package/src/worker/shims/postgres.ts +0 -1454
- package/src/worker/shims/sqlite-browser.test.ts +0 -233
- package/src/worker/shims/sqlite-browser.ts +0 -175
- package/src/worker/shims/sqlite.test.ts +0 -786
- package/src/worker/shims/sqlite.ts +0 -978
- package/src/worker/shims/stream-browser.ts +0 -15
- package/src/worker/shims/ws-browser.test.ts +0 -205
- package/src/worker/shims/ws-browser.ts +0 -248
- package/src/worker/shims/ws.test.ts +0 -288
- package/src/worker/shims/ws.ts +0 -467
- package/src/worker/shims/zero-process-env.ts +0 -11
- package/src/worker/types.ts +0 -75
- package/src/worker/worker-integration.test.ts +0 -223
- package/src/worker/worker.test.ts +0 -136
- package/src/worker/zero-cache-embed-cf.ts +0 -463
- package/src/worker/zero-cache-embed.ts +0 -277
|
@@ -1,1190 +0,0 @@
|
|
|
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 { log } from '../log.js'
|
|
10
|
-
|
|
11
|
-
const textEncoder = new TextEncoder()
|
|
12
|
-
import {
|
|
13
|
-
getChangesSince,
|
|
14
|
-
getCurrentWatermark,
|
|
15
|
-
purgeConsumedChanges,
|
|
16
|
-
installTriggersOnShardTables,
|
|
17
|
-
type ChangeRecord,
|
|
18
|
-
} from './change-tracker.js'
|
|
19
|
-
import {
|
|
20
|
-
encodeBegin,
|
|
21
|
-
encodeCommit,
|
|
22
|
-
encodeRelation,
|
|
23
|
-
encodeInsert,
|
|
24
|
-
encodeUpdate,
|
|
25
|
-
encodeDelete,
|
|
26
|
-
encodeKeepalive,
|
|
27
|
-
encodeWrappedChange,
|
|
28
|
-
getTableOid,
|
|
29
|
-
inferColumns,
|
|
30
|
-
} from './pgoutput-encoder.js'
|
|
31
|
-
|
|
32
|
-
import type { Mutex } from '../mutex.js'
|
|
33
|
-
import type { PGlite } from '@electric-sql/pglite'
|
|
34
|
-
|
|
35
|
-
// types pglite can't replicate — excluded from change tracking columns
|
|
36
|
-
const UNSUPPORTED_TYPES = new Set(['tsvector', 'tsquery', 'USER-DEFINED'])
|
|
37
|
-
|
|
38
|
-
// pg data_type string → wire protocol oid mapping
|
|
39
|
-
const PG_DATA_TYPE_OIDS: Record<string, number> = {
|
|
40
|
-
boolean: 16,
|
|
41
|
-
bytea: 17,
|
|
42
|
-
bigint: 20,
|
|
43
|
-
smallint: 21,
|
|
44
|
-
integer: 23,
|
|
45
|
-
text: 25,
|
|
46
|
-
json: 114,
|
|
47
|
-
real: 700,
|
|
48
|
-
'double precision': 701,
|
|
49
|
-
character: 1042,
|
|
50
|
-
'character varying': 1043,
|
|
51
|
-
date: 1082,
|
|
52
|
-
'time without time zone': 1083,
|
|
53
|
-
'timestamp without time zone': 1114,
|
|
54
|
-
'timestamp with time zone': 1184,
|
|
55
|
-
'time with time zone': 1266,
|
|
56
|
-
numeric: 1700,
|
|
57
|
-
uuid: 2950,
|
|
58
|
-
jsonb: 3802,
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface ReplicationWriter {
|
|
62
|
-
write(data: Uint8Array): void
|
|
63
|
-
readonly closed?: boolean
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* in-process replication writer. routes pgoutput data via callback
|
|
68
|
-
* instead of a TCP socket. used in CF Workers / embedded mode where
|
|
69
|
-
* there's no network between orez and zero-cache.
|
|
70
|
-
*/
|
|
71
|
-
export class InProcessWriter implements ReplicationWriter {
|
|
72
|
-
#onData: (data: Uint8Array) => void
|
|
73
|
-
#closed = false
|
|
74
|
-
|
|
75
|
-
constructor(onData: (data: Uint8Array) => void) {
|
|
76
|
-
this.#onData = onData
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
write(data: Uint8Array): void {
|
|
80
|
-
if (!this.#closed) {
|
|
81
|
-
this.#onData(data)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
get closed(): boolean {
|
|
86
|
-
return this.#closed
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
close(): void {
|
|
90
|
-
this.#closed = true
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const MIN_LSN = 0x1000000n
|
|
95
|
-
const LSN_INCREMENT = 0x1_0000_0000n
|
|
96
|
-
const LSN_TIME_SHIFT = 12n
|
|
97
|
-
|
|
98
|
-
function lsnFloorFromTime(): bigint {
|
|
99
|
-
const timeLsn = BigInt(Date.now()) << LSN_TIME_SHIFT
|
|
100
|
-
return timeLsn > MIN_LSN ? timeLsn : MIN_LSN
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// current lsn counter. seed from wall time so a restarted browser proxy never
|
|
104
|
-
// emits stream lsns behind zero-cache's persisted initial-sync watermark.
|
|
105
|
-
// use a large stride because zero-cache's initial backfill can reserve
|
|
106
|
-
// watermarks after slot creation while app writes are already queued; the next
|
|
107
|
-
// streamed transaction still has to land beyond that warmup range.
|
|
108
|
-
let currentLsn = lsnFloorFromTime()
|
|
109
|
-
// persistent watermark across handler restarts so new handlers
|
|
110
|
-
// don't replay already-streamed changes
|
|
111
|
-
let lastStreamedWatermark = 0
|
|
112
|
-
|
|
113
|
-
// direct wakeup from proxy — bypasses pg_notify for instant replication
|
|
114
|
-
let _replicationWakeup: (() => void) | null = null
|
|
115
|
-
|
|
116
|
-
/** signal the replication handler that changes may be available.
|
|
117
|
-
* called by the proxy after executing writes on the postgres instance. */
|
|
118
|
-
export function signalReplicationChange() {
|
|
119
|
-
_replicationWakeup?.()
|
|
120
|
-
const globalWakeup = (globalThis as any).__orez_signal_replication
|
|
121
|
-
if (typeof globalWakeup === 'function' && globalWakeup !== _replicationWakeup) {
|
|
122
|
-
globalWakeup()
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// cached setup results so reconnects skip the expensive mutex-holding setup phase.
|
|
127
|
-
// zero-cache reconnects the replication stream after initial sync, and if setup
|
|
128
|
-
// takes too long (holding the mutex, blocking proxy queries), zero-cache's
|
|
129
|
-
// queries timeout and it kills the connection.
|
|
130
|
-
let cachedTableKeyColumns: Map<string, Set<string>> | null = null
|
|
131
|
-
let cachedExcludedColumns: Map<string, Set<string>> | null = null
|
|
132
|
-
let cachedColumnTypeOids: Map<string, Map<string, number>> | null = null
|
|
133
|
-
|
|
134
|
-
/** reset module state (for tests) */
|
|
135
|
-
export function resetReplicationState(): void {
|
|
136
|
-
currentLsn = lsnFloorFromTime()
|
|
137
|
-
lastStreamedWatermark = 0
|
|
138
|
-
cachedTableKeyColumns = null
|
|
139
|
-
cachedExcludedColumns = null
|
|
140
|
-
cachedColumnTypeOids = null
|
|
141
|
-
cachedColumns.clear()
|
|
142
|
-
}
|
|
143
|
-
function nextLsn(): bigint {
|
|
144
|
-
const floor = lsnFloorFromTime()
|
|
145
|
-
if (currentLsn < floor) currentLsn = floor
|
|
146
|
-
currentLsn += LSN_INCREMENT
|
|
147
|
-
return currentLsn
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function lsnToString(lsn: bigint): string {
|
|
151
|
-
const high = Number(lsn >> 32n)
|
|
152
|
-
const low = Number(lsn & 0xffffffffn)
|
|
153
|
-
return `${high.toString(16).toUpperCase()}/${low.toString(16).toUpperCase()}`
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* parse an LSN string like "0/01000300" into a bigint.
|
|
158
|
-
* returns null if the string doesn't match (e.g. "0/0" still parses to 0n).
|
|
159
|
-
*/
|
|
160
|
-
function lsnFromString(s: string): bigint | null {
|
|
161
|
-
const m = s.trim().match(/^([0-9a-f]+)\/([0-9a-f]+)$/i)
|
|
162
|
-
if (!m) return null
|
|
163
|
-
return (BigInt('0x' + m[1]) << 32n) | BigInt('0x' + m[2])
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* extract the client-supplied LSN from a START_REPLICATION query.
|
|
168
|
-
* format: START_REPLICATION SLOT name LOGICAL <high>/<low> [proto_version 'N', publication_names 'X']
|
|
169
|
-
* accepts optional surrounding quotes (some clients send `LOGICAL '0/0'`).
|
|
170
|
-
* returns null if no parseable LSN is found.
|
|
171
|
-
*/
|
|
172
|
-
export function extractStartLsn(query: string): bigint | null {
|
|
173
|
-
const m = query.match(/\bLOGICAL\s+'?([0-9a-f]+\/[0-9a-f]+)'?/i)
|
|
174
|
-
if (!m) return null
|
|
175
|
-
return lsnFromString(m[1])
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export { lsnFromString }
|
|
179
|
-
|
|
180
|
-
function nowMicros(): bigint {
|
|
181
|
-
return BigInt(Date.now()) * 1000n
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// build a wire protocol row description + data row response
|
|
185
|
-
function buildSimpleResponse(columns: string[], values: string[]): Uint8Array {
|
|
186
|
-
const parts: Uint8Array[] = []
|
|
187
|
-
const encoder = textEncoder
|
|
188
|
-
|
|
189
|
-
// RowDescription (0x54)
|
|
190
|
-
let rdSize = 6 // int32 len + int16 numFields
|
|
191
|
-
const colBytes: Uint8Array[] = []
|
|
192
|
-
for (const col of columns) {
|
|
193
|
-
const b = encoder.encode(col)
|
|
194
|
-
colBytes.push(b)
|
|
195
|
-
rdSize += b.length + 1 + 4 + 2 + 4 + 2 + 4 + 2 // name+null + tableOid + colAttr + typeOid + typeLen + typeMod + formatCode
|
|
196
|
-
}
|
|
197
|
-
const rd = new Uint8Array(1 + rdSize)
|
|
198
|
-
const rdv = new DataView(rd.buffer)
|
|
199
|
-
rd[0] = 0x54
|
|
200
|
-
rdv.setInt32(1, rdSize)
|
|
201
|
-
rdv.setInt16(5, columns.length)
|
|
202
|
-
let pos = 7
|
|
203
|
-
for (let i = 0; i < columns.length; i++) {
|
|
204
|
-
rd.set(colBytes[i], pos)
|
|
205
|
-
pos += colBytes[i].length
|
|
206
|
-
rd[pos++] = 0
|
|
207
|
-
rdv.setInt32(pos, 0) // tableOid
|
|
208
|
-
pos += 4
|
|
209
|
-
rdv.setInt16(pos, 0) // colAttr
|
|
210
|
-
pos += 2
|
|
211
|
-
rdv.setInt32(pos, 25) // typeOid (text)
|
|
212
|
-
pos += 4
|
|
213
|
-
rdv.setInt16(pos, -1) // typeLen
|
|
214
|
-
pos += 2
|
|
215
|
-
rdv.setInt32(pos, -1) // typeMod
|
|
216
|
-
pos += 4
|
|
217
|
-
rdv.setInt16(pos, 0) // formatCode (text)
|
|
218
|
-
pos += 2
|
|
219
|
-
}
|
|
220
|
-
parts.push(rd)
|
|
221
|
-
|
|
222
|
-
// DataRow (0x44)
|
|
223
|
-
let drSize = 6 // int32 len + int16 numCols
|
|
224
|
-
const valBytes: Uint8Array[] = []
|
|
225
|
-
for (const val of values) {
|
|
226
|
-
const b = encoder.encode(val)
|
|
227
|
-
valBytes.push(b)
|
|
228
|
-
drSize += 4 + b.length
|
|
229
|
-
}
|
|
230
|
-
const dr = new Uint8Array(1 + drSize)
|
|
231
|
-
const drv = new DataView(dr.buffer)
|
|
232
|
-
dr[0] = 0x44
|
|
233
|
-
drv.setInt32(1, drSize)
|
|
234
|
-
drv.setInt16(5, values.length)
|
|
235
|
-
pos = 7
|
|
236
|
-
for (const vb of valBytes) {
|
|
237
|
-
drv.setInt32(pos, vb.length)
|
|
238
|
-
pos += 4
|
|
239
|
-
dr.set(vb, pos)
|
|
240
|
-
pos += vb.length
|
|
241
|
-
}
|
|
242
|
-
parts.push(dr)
|
|
243
|
-
|
|
244
|
-
// CommandComplete (0x43)
|
|
245
|
-
const tag = encoder.encode('SELECT 1\0')
|
|
246
|
-
const cc = new Uint8Array(1 + 4 + tag.length)
|
|
247
|
-
cc[0] = 0x43
|
|
248
|
-
new DataView(cc.buffer).setInt32(1, 4 + tag.length)
|
|
249
|
-
cc.set(tag, 5)
|
|
250
|
-
parts.push(cc)
|
|
251
|
-
|
|
252
|
-
// ReadyForQuery (0x5a)
|
|
253
|
-
const rfq = new Uint8Array(6)
|
|
254
|
-
rfq[0] = 0x5a
|
|
255
|
-
new DataView(rfq.buffer).setInt32(1, 5)
|
|
256
|
-
rfq[5] = 0x49 // 'I' idle
|
|
257
|
-
parts.push(rfq)
|
|
258
|
-
|
|
259
|
-
// concatenate
|
|
260
|
-
const totalLen = parts.reduce((sum, p) => sum + p.length, 0)
|
|
261
|
-
const result = new Uint8Array(totalLen)
|
|
262
|
-
let offset = 0
|
|
263
|
-
for (const p of parts) {
|
|
264
|
-
result.set(p, offset)
|
|
265
|
-
offset += p.length
|
|
266
|
-
}
|
|
267
|
-
return result
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function buildCommandComplete(tag: string): Uint8Array {
|
|
271
|
-
const encoder = textEncoder
|
|
272
|
-
const tagBytes = encoder.encode(tag + '\0')
|
|
273
|
-
const cc = new Uint8Array(1 + 4 + tagBytes.length)
|
|
274
|
-
cc[0] = 0x43
|
|
275
|
-
new DataView(cc.buffer).setInt32(1, 4 + tagBytes.length)
|
|
276
|
-
cc.set(tagBytes, 5)
|
|
277
|
-
|
|
278
|
-
const rfq = new Uint8Array(6)
|
|
279
|
-
rfq[0] = 0x5a
|
|
280
|
-
new DataView(rfq.buffer).setInt32(1, 5)
|
|
281
|
-
rfq[5] = 0x49
|
|
282
|
-
|
|
283
|
-
const result = new Uint8Array(cc.length + rfq.length)
|
|
284
|
-
result.set(cc, 0)
|
|
285
|
-
result.set(rfq, cc.length)
|
|
286
|
-
return result
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function buildErrorResponse(message: string): Uint8Array {
|
|
290
|
-
const encoder = textEncoder
|
|
291
|
-
const msgBytes = encoder.encode(message)
|
|
292
|
-
// S(severity) + M(message) + null terminator
|
|
293
|
-
const fields = new Uint8Array(2 + 6 + 2 + msgBytes.length + 1 + 1) // S + ERROR\0 + M + msg\0 + terminator
|
|
294
|
-
let pos = 0
|
|
295
|
-
fields[pos++] = 0x53 // 'S'
|
|
296
|
-
const sev = encoder.encode('ERROR\0')
|
|
297
|
-
fields.set(sev, pos)
|
|
298
|
-
pos += sev.length
|
|
299
|
-
fields[pos++] = 0x4d // 'M'
|
|
300
|
-
fields.set(msgBytes, pos)
|
|
301
|
-
pos += msgBytes.length
|
|
302
|
-
fields[pos++] = 0 // null terminate message
|
|
303
|
-
fields[pos++] = 0 // final terminator
|
|
304
|
-
|
|
305
|
-
const buf = new Uint8Array(1 + 4 + pos)
|
|
306
|
-
buf[0] = 0x45 // 'E'
|
|
307
|
-
new DataView(buf.buffer).setInt32(1, 4 + pos)
|
|
308
|
-
buf.set(fields.subarray(0, pos), 5)
|
|
309
|
-
return buf
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* handle a replication query. returns response bytes or null if not handled.
|
|
314
|
-
* async because slot operations need to write to pglite.
|
|
315
|
-
*/
|
|
316
|
-
export async function handleReplicationQuery(
|
|
317
|
-
query: string,
|
|
318
|
-
db: PGlite
|
|
319
|
-
): Promise<Uint8Array | null> {
|
|
320
|
-
const trimmed = query.trim().replace(/;$/, '').trim()
|
|
321
|
-
const upper = trimmed.toUpperCase()
|
|
322
|
-
|
|
323
|
-
if (upper === 'IDENTIFY_SYSTEM') {
|
|
324
|
-
const lsn = lsnToString(currentLsn)
|
|
325
|
-
return buildSimpleResponse(
|
|
326
|
-
['systemid', 'timeline', 'xlogpos', 'dbname'],
|
|
327
|
-
['1234567890', '1', lsn, 'postgres']
|
|
328
|
-
)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (upper.startsWith('CREATE_REPLICATION_SLOT')) {
|
|
332
|
-
const match = trimmed.match(
|
|
333
|
-
/CREATE_REPLICATION_SLOT\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i
|
|
334
|
-
)
|
|
335
|
-
const slotName = match?.[1] || match?.[2] || match?.[3] || 'zero_slot'
|
|
336
|
-
const lsn = lsnToString(nextLsn())
|
|
337
|
-
const snapshotName = `00000003-00000001-1`
|
|
338
|
-
|
|
339
|
-
// set watermark to current DB state so replication only delivers changes
|
|
340
|
-
// that happen AFTER this point. this mirrors real postgres behavior where
|
|
341
|
-
// CREATE_REPLICATION_SLOT creates a consistent snapshot — the initial copy
|
|
342
|
-
// captures everything up to this point, and replication picks up from here.
|
|
343
|
-
// on reconnect this is effectively a no-op since the watermark is already
|
|
344
|
-
// at or past the current DB state.
|
|
345
|
-
const currentWm = await getCurrentWatermark(db)
|
|
346
|
-
if (currentWm > lastStreamedWatermark) {
|
|
347
|
-
lastStreamedWatermark = currentWm
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// persist slot so pg_replication_slots queries find it
|
|
351
|
-
await db.query(
|
|
352
|
-
`INSERT INTO _orez._zero_replication_slots (slot_name, restart_lsn, confirmed_flush_lsn)
|
|
353
|
-
VALUES ($1, $2, $2)
|
|
354
|
-
ON CONFLICT (slot_name) DO UPDATE SET restart_lsn = $2, confirmed_flush_lsn = $2`,
|
|
355
|
-
[slotName, lsn]
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
return buildSimpleResponse(
|
|
359
|
-
['slot_name', 'consistent_point', 'snapshot_name', 'output_plugin'],
|
|
360
|
-
[slotName, lsn, snapshotName, 'pgoutput']
|
|
361
|
-
)
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (upper.startsWith('DROP_REPLICATION_SLOT')) {
|
|
365
|
-
const match = trimmed.match(/DROP_REPLICATION_SLOT\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i)
|
|
366
|
-
const slotName = match?.[1] || match?.[2] || match?.[3]
|
|
367
|
-
if (slotName) {
|
|
368
|
-
await db.query(`DELETE FROM _orez._zero_replication_slots WHERE slot_name = $1`, [
|
|
369
|
-
slotName,
|
|
370
|
-
])
|
|
371
|
-
}
|
|
372
|
-
return buildCommandComplete('DROP_REPLICATION_SLOT')
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// wal_level check via simple query
|
|
376
|
-
if (upper.includes('WAL_LEVEL') && upper.includes('CURRENT_SETTING')) {
|
|
377
|
-
return buildSimpleResponse(['walLevel', 'version'], ['logical', '170004'])
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ALTER ROLE for replication permission
|
|
381
|
-
if (upper.startsWith('ALTER ROLE') && upper.includes('REPLICATION')) {
|
|
382
|
-
return buildCommandComplete('ALTER ROLE')
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// SET TRANSACTION - pglite rejects this if any query ran first (e.g. SET search_path).
|
|
386
|
-
// return synthetic response since pglite is single-connection and doesn't need isolation levels.
|
|
387
|
-
if (upper.startsWith('SET TRANSACTION') || upper.startsWith('SET SESSION')) {
|
|
388
|
-
return buildCommandComplete('SET')
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
return null
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* start streaming replication changes to the client.
|
|
396
|
-
* this runs indefinitely until the connection is closed.
|
|
397
|
-
*/
|
|
398
|
-
export async function handleStartReplication(
|
|
399
|
-
query: string,
|
|
400
|
-
writer: ReplicationWriter,
|
|
401
|
-
db: PGlite,
|
|
402
|
-
mutex: Mutex
|
|
403
|
-
): Promise<void> {
|
|
404
|
-
log.debug.repl('entering streaming mode')
|
|
405
|
-
|
|
406
|
-
// honor zero-cache's resume LSN. without this, after a page reload the
|
|
407
|
-
// in-memory currentLsn / lastStreamedWatermark are reset to defaults but
|
|
408
|
-
// changeLog persists with prior LSNs — re-streaming from BIGINT 0 makes
|
|
409
|
-
// the change-streamer try to INSERT (watermark, pos) tuples that already
|
|
410
|
-
// exist, hitting `changeLog_pkey` violations and tearing down the loop.
|
|
411
|
-
// by advancing currentLsn past the client's last-seen LSN we guarantee
|
|
412
|
-
// newly-emitted batches use strictly higher LSNs, and by jumping
|
|
413
|
-
// lastStreamedWatermark to the current sequence value we skip already-
|
|
414
|
-
// streamed _zero_changes rows.
|
|
415
|
-
const clientStartLsn = extractStartLsn(query)
|
|
416
|
-
if (clientStartLsn !== null && clientStartLsn > currentLsn) {
|
|
417
|
-
log.debug.repl(
|
|
418
|
-
`advancing currentLsn ${lsnToString(currentLsn)} → ${lsnToString(clientStartLsn)} from client START_REPLICATION`
|
|
419
|
-
)
|
|
420
|
-
currentLsn = clientStartLsn
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// send CopyBothResponse to enter streaming mode
|
|
424
|
-
const copyBoth = new Uint8Array(1 + 4 + 1 + 2)
|
|
425
|
-
copyBoth[0] = 0x57 // 'W' CopyBothResponse
|
|
426
|
-
new DataView(copyBoth.buffer).setInt32(1, 4 + 1 + 2)
|
|
427
|
-
copyBoth[5] = 0 // overall format (0 = text)
|
|
428
|
-
new DataView(copyBoth.buffer).setInt16(6, 0) // 0 columns
|
|
429
|
-
writer.write(copyBoth)
|
|
430
|
-
|
|
431
|
-
// resume from where the previous handler left off to avoid
|
|
432
|
-
// replaying already-streamed changes after reconnect.
|
|
433
|
-
// when client supplied a NON-ZERO LSN (i.e. this is a reconnect to an
|
|
434
|
-
// existing slot with prior progress), also bump lastStreamedWatermark to
|
|
435
|
-
// the current sequence value — anything before that has already been
|
|
436
|
-
// written to changeLog, so re-streaming would just produce duplicate-key
|
|
437
|
-
// errors. `0/0` indicates "fresh slot" and must NOT trigger this jump,
|
|
438
|
-
// otherwise we'd skip rows that legitimately need to be streamed for the
|
|
439
|
-
// initial sync.
|
|
440
|
-
if (clientStartLsn !== null && clientStartLsn > 0n) {
|
|
441
|
-
try {
|
|
442
|
-
const currentWm = await getCurrentWatermark(db)
|
|
443
|
-
if (currentWm > lastStreamedWatermark) {
|
|
444
|
-
log.debug.repl(
|
|
445
|
-
`advancing lastStreamedWatermark ${lastStreamedWatermark} → ${currentWm} on reconnect`
|
|
446
|
-
)
|
|
447
|
-
lastStreamedWatermark = currentWm
|
|
448
|
-
}
|
|
449
|
-
} catch (err) {
|
|
450
|
-
log.repl(
|
|
451
|
-
`getCurrentWatermark failed on reconnect: ${(err as Error)?.message || err}`
|
|
452
|
-
)
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
let lastWatermark = lastStreamedWatermark
|
|
456
|
-
|
|
457
|
-
// use cached setup results on reconnect to avoid holding the mutex
|
|
458
|
-
// for seconds doing trigger installation + schema queries. zero-cache
|
|
459
|
-
// disconnects if its proxy queries are blocked too long by the mutex.
|
|
460
|
-
let tableKeyColumns: Map<string, Set<string>>
|
|
461
|
-
let excludedColumns: Map<string, Set<string>>
|
|
462
|
-
let columnTypeOids: Map<string, Map<string, number>>
|
|
463
|
-
|
|
464
|
-
if (cachedTableKeyColumns && cachedExcludedColumns && cachedColumnTypeOids) {
|
|
465
|
-
log.debug.repl('reconnect: using cached setup (skipping mutex)')
|
|
466
|
-
tableKeyColumns = cachedTableKeyColumns
|
|
467
|
-
excludedColumns = cachedExcludedColumns
|
|
468
|
-
columnTypeOids = cachedColumnTypeOids
|
|
469
|
-
} else {
|
|
470
|
-
tableKeyColumns = new Map()
|
|
471
|
-
excludedColumns = new Map()
|
|
472
|
-
columnTypeOids = new Map()
|
|
473
|
-
|
|
474
|
-
// acquire mutex for all setup queries to avoid conflicting with proxy connections.
|
|
475
|
-
// the change-streamer's initial copy also queries PGlite via the proxy, and
|
|
476
|
-
// direct db.query()/db.exec() calls here bypass the proxy's mutex, causing
|
|
477
|
-
// "already in transaction" errors when they interleave.
|
|
478
|
-
// phase 1: DDL operations (trigger installation) under mutex
|
|
479
|
-
// split into two phases so proxy queries can run between them
|
|
480
|
-
await mutex.acquire()
|
|
481
|
-
let relevantSchemas: string[]
|
|
482
|
-
try {
|
|
483
|
-
// install change tracking triggers on shard schema tables (e.g. chat_0.clients)
|
|
484
|
-
await installTriggersOnShardTables(db)
|
|
485
|
-
|
|
486
|
-
// set up LISTEN + install notify triggers in one batch
|
|
487
|
-
const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
|
|
488
|
-
let tables: { tablename: string }[]
|
|
489
|
-
if (pubName) {
|
|
490
|
-
const result = await db.query<{ tablename: string }>(
|
|
491
|
-
`SELECT tablename FROM pg_publication_tables
|
|
492
|
-
WHERE pubname = $1 AND schemaname = 'public' AND tablename NOT LIKE '_zero_%'`,
|
|
493
|
-
[pubName]
|
|
494
|
-
)
|
|
495
|
-
tables = result.rows
|
|
496
|
-
if (tables.length === 0) {
|
|
497
|
-
log.proxy(
|
|
498
|
-
`publication "${pubName}" is empty; installing no public notify triggers`
|
|
499
|
-
)
|
|
500
|
-
}
|
|
501
|
-
} else {
|
|
502
|
-
const all = await db.query<{ tablename: string }>(
|
|
503
|
-
`SELECT tablename FROM pg_tables
|
|
504
|
-
WHERE schemaname = 'public'
|
|
505
|
-
AND tablename NOT IN ('migrations', '_zero_changes')
|
|
506
|
-
AND tablename NOT LIKE '_zero_%'`
|
|
507
|
-
)
|
|
508
|
-
tables = all.rows
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// combine notify function creation + trigger installations into single exec
|
|
512
|
-
const ddlParts: string[] = [
|
|
513
|
-
`CREATE OR REPLACE FUNCTION public._zero_notify_change() RETURNS TRIGGER AS $$
|
|
514
|
-
BEGIN
|
|
515
|
-
PERFORM pg_notify('_zero_changes', TG_TABLE_NAME);
|
|
516
|
-
RETURN NULL;
|
|
517
|
-
END;
|
|
518
|
-
$$ LANGUAGE plpgsql;`,
|
|
519
|
-
]
|
|
520
|
-
for (const { tablename } of tables) {
|
|
521
|
-
const quoted = '"' + tablename.replace(/"/g, '""') + '"'
|
|
522
|
-
ddlParts.push(
|
|
523
|
-
`DROP TRIGGER IF EXISTS _zero_notify_trigger ON public.${quoted};
|
|
524
|
-
CREATE TRIGGER _zero_notify_trigger
|
|
525
|
-
AFTER INSERT OR UPDATE OR DELETE ON public.${quoted}
|
|
526
|
-
FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();`
|
|
527
|
-
)
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// discover shard schemas and install their triggers in same batch
|
|
531
|
-
const shardSchemas = await db.query<{ nspname: string }>(
|
|
532
|
-
`SELECT nspname FROM pg_namespace
|
|
533
|
-
WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
|
|
534
|
-
AND nspname NOT LIKE 'pg_%'
|
|
535
|
-
AND nspname NOT LIKE 'zero_%'
|
|
536
|
-
AND nspname NOT LIKE '_zero_%'
|
|
537
|
-
AND nspname NOT LIKE '%/%'`
|
|
538
|
-
)
|
|
539
|
-
relevantSchemas = ['public', ...shardSchemas.rows.map((r) => r.nspname)]
|
|
540
|
-
|
|
541
|
-
const shardClientSchemas = shardSchemas.rows
|
|
542
|
-
.map((r) => r.nspname)
|
|
543
|
-
.filter((s) => s !== 'public')
|
|
544
|
-
if (shardClientSchemas.length > 0) {
|
|
545
|
-
const shardTables = await db.query<{ schemaname: string; tablename: string }>(
|
|
546
|
-
`SELECT schemaname, tablename FROM pg_tables
|
|
547
|
-
WHERE schemaname = ANY($1) AND tablename = ANY($2)`,
|
|
548
|
-
[shardClientSchemas, ['clients', 'mutations']]
|
|
549
|
-
)
|
|
550
|
-
for (const { schemaname, tablename } of shardTables.rows) {
|
|
551
|
-
const qs = '"' + schemaname.replace(/"/g, '""') + '"'
|
|
552
|
-
const qt = '"' + tablename.replace(/"/g, '""') + '"'
|
|
553
|
-
ddlParts.push(
|
|
554
|
-
`DROP TRIGGER IF EXISTS _zero_notify_trigger ON ${qs}.${qt};
|
|
555
|
-
CREATE TRIGGER _zero_notify_trigger
|
|
556
|
-
AFTER INSERT OR UPDATE OR DELETE ON ${qs}.${qt}
|
|
557
|
-
FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();`
|
|
558
|
-
)
|
|
559
|
-
}
|
|
560
|
-
if (shardTables.rows.length > 0) {
|
|
561
|
-
log.debug.proxy(
|
|
562
|
-
`installed notify triggers on ${shardTables.rows.length} shard tables`
|
|
563
|
-
)
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
await db.exec(ddlParts.join('\n'))
|
|
568
|
-
if (tables.length > 0) {
|
|
569
|
-
log.proxy(`installed notify triggers on ${tables.length} public table(s)`)
|
|
570
|
-
}
|
|
571
|
-
} finally {
|
|
572
|
-
mutex.release()
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// phase 2: schema introspection (read-only, separate mutex acquisition)
|
|
576
|
-
// releasing between phases lets proxy queries run during the gap
|
|
577
|
-
await mutex.acquire()
|
|
578
|
-
try {
|
|
579
|
-
// combined PK + column introspection in a single query using UNION ALL
|
|
580
|
-
const schemaResult = await db.query<{
|
|
581
|
-
kind: string
|
|
582
|
-
table_schema: string
|
|
583
|
-
table_name: string
|
|
584
|
-
column_name: string
|
|
585
|
-
data_type: string | null
|
|
586
|
-
ordinal_position: number
|
|
587
|
-
}>(
|
|
588
|
-
`SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type, kcu.ordinal_position
|
|
589
|
-
FROM information_schema.table_constraints tc
|
|
590
|
-
JOIN information_schema.key_column_usage kcu
|
|
591
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
592
|
-
AND tc.table_schema = kcu.table_schema
|
|
593
|
-
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
594
|
-
AND tc.table_schema = ANY($1)
|
|
595
|
-
UNION ALL
|
|
596
|
-
SELECT 'col' AS kind, table_schema, table_name, column_name, data_type, ordinal_position
|
|
597
|
-
FROM information_schema.columns
|
|
598
|
-
WHERE table_schema = ANY($1)
|
|
599
|
-
ORDER BY table_schema, table_name, kind, ordinal_position`,
|
|
600
|
-
[relevantSchemas]
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
for (const row of schemaResult.rows) {
|
|
604
|
-
const key = `${row.table_schema}.${row.table_name}`
|
|
605
|
-
if (row.kind === 'pk') {
|
|
606
|
-
let keys = tableKeyColumns.get(key)
|
|
607
|
-
if (!keys) {
|
|
608
|
-
keys = new Set()
|
|
609
|
-
tableKeyColumns.set(key, keys)
|
|
610
|
-
}
|
|
611
|
-
keys.add(row.column_name)
|
|
612
|
-
} else {
|
|
613
|
-
if (row.data_type && UNSUPPORTED_TYPES.has(row.data_type)) {
|
|
614
|
-
let cols = excludedColumns.get(key)
|
|
615
|
-
if (!cols) {
|
|
616
|
-
cols = new Set()
|
|
617
|
-
excludedColumns.set(key, cols)
|
|
618
|
-
}
|
|
619
|
-
cols.add(row.column_name)
|
|
620
|
-
}
|
|
621
|
-
if (row.data_type) {
|
|
622
|
-
const oid = PG_DATA_TYPE_OIDS[row.data_type]
|
|
623
|
-
if (oid !== undefined) {
|
|
624
|
-
let cols = columnTypeOids.get(key)
|
|
625
|
-
if (!cols) {
|
|
626
|
-
cols = new Map()
|
|
627
|
-
columnTypeOids.set(key, cols)
|
|
628
|
-
}
|
|
629
|
-
cols.set(row.column_name, oid)
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
log.debug.proxy(`loaded primary keys for ${tableKeyColumns.size} tables`)
|
|
635
|
-
if (excludedColumns.size > 0) {
|
|
636
|
-
log.debug.proxy(
|
|
637
|
-
`excluding unsupported columns: ${[...excludedColumns.entries()].map(([t, c]) => `${t}(${[...c].join(',')})`).join(', ')}`
|
|
638
|
-
)
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// cache for subsequent reconnects
|
|
642
|
-
cachedTableKeyColumns = tableKeyColumns
|
|
643
|
-
cachedExcludedColumns = excludedColumns
|
|
644
|
-
cachedColumnTypeOids = columnTypeOids
|
|
645
|
-
} finally {
|
|
646
|
-
mutex.release()
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// track which tables we've sent RELATION messages for
|
|
651
|
-
const sentRelations = new Set<string>()
|
|
652
|
-
let txCounter = 1
|
|
653
|
-
|
|
654
|
-
// event-driven replication: proxy signals changes directly via signalReplicationChange(),
|
|
655
|
-
// pg_notify as secondary signal, polling as final fallback.
|
|
656
|
-
const pollIntervalIdle = 5000
|
|
657
|
-
const batchSize = 50000
|
|
658
|
-
const purgeEveryN = 1
|
|
659
|
-
const shardRescanIntervalMs = 10_000
|
|
660
|
-
let running = true
|
|
661
|
-
let pollsSincePurge = 0
|
|
662
|
-
let tryAcquireFailures = 0
|
|
663
|
-
let lastShardRescan = -shardRescanIntervalMs
|
|
664
|
-
let hasStreamedOnce = false
|
|
665
|
-
|
|
666
|
-
// promise-based wakeup mechanism.
|
|
667
|
-
// signalPending captures signals that arrive while the handler is
|
|
668
|
-
// processing (not in waitForWakeup), preventing signal loss.
|
|
669
|
-
let wakeupResolve: (() => void) | null = null
|
|
670
|
-
let signalPending = false
|
|
671
|
-
let lastWakeupTime = 0
|
|
672
|
-
const wakeup = () => {
|
|
673
|
-
signalPending = true
|
|
674
|
-
if (wakeupResolve) {
|
|
675
|
-
lastWakeupTime = performance.now()
|
|
676
|
-
log.debug.repl('signal received, waking up')
|
|
677
|
-
wakeupResolve()
|
|
678
|
-
wakeupResolve = null
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
const waitForWakeup = (timeoutMs: number): Promise<boolean> => {
|
|
682
|
-
return new Promise((resolve) => {
|
|
683
|
-
const timer = setTimeout(() => {
|
|
684
|
-
wakeupResolve = null
|
|
685
|
-
resolve(false)
|
|
686
|
-
}, timeoutMs)
|
|
687
|
-
wakeupResolve = () => {
|
|
688
|
-
clearTimeout(timer)
|
|
689
|
-
resolve(true)
|
|
690
|
-
}
|
|
691
|
-
})
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// register direct wakeup so the proxy can signal us immediately
|
|
695
|
-
_replicationWakeup = wakeup
|
|
696
|
-
|
|
697
|
-
// expose on globalThis so external code (e.g. pglite-pool) can signal
|
|
698
|
-
// without importing from this module (works across separate bundles)
|
|
699
|
-
;(globalThis as any).__orez_signal_replication = wakeup
|
|
700
|
-
|
|
701
|
-
// also set up LISTEN as secondary signal
|
|
702
|
-
let unsubscribe: (() => Promise<void>) | null = null
|
|
703
|
-
try {
|
|
704
|
-
unsubscribe = await db.listen('_zero_changes', wakeup)
|
|
705
|
-
log.debug.proxy('replication: listening for _zero_changes notifications')
|
|
706
|
-
} catch {
|
|
707
|
-
log.debug.proxy('replication: LISTEN not available')
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
const poll = async () => {
|
|
711
|
-
let queryPending = true // query immediately on first iteration
|
|
712
|
-
let idleTimeoutCount = 0
|
|
713
|
-
|
|
714
|
-
while (running) {
|
|
715
|
-
// check if the connection or database was closed
|
|
716
|
-
if (writer.closed || db.closed) {
|
|
717
|
-
log.debug.proxy('replication: writer/db closed, exiting poll loop')
|
|
718
|
-
running = false
|
|
719
|
-
break
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
try {
|
|
723
|
-
// when no query is pending, wait for a signal or timeout.
|
|
724
|
-
// signals fire instantly when the proxy processes a write,
|
|
725
|
-
// so we only hit the timeout when truly idle.
|
|
726
|
-
if (!queryPending) {
|
|
727
|
-
// check if a signal arrived while we were processing
|
|
728
|
-
if (!signalPending) {
|
|
729
|
-
log.debug.repl(
|
|
730
|
-
`waiting for signal (lastWm=${lastWatermark}, streamed=${hasStreamedOnce})`
|
|
731
|
-
)
|
|
732
|
-
const wasSignaled = await waitForWakeup(pollIntervalIdle)
|
|
733
|
-
if (writer.closed || db.closed) {
|
|
734
|
-
running = false
|
|
735
|
-
break
|
|
736
|
-
}
|
|
737
|
-
if (!wasSignaled) {
|
|
738
|
-
idleTimeoutCount++
|
|
739
|
-
// send keepalive on every timeout
|
|
740
|
-
writer.write(encodeKeepalive(currentLsn, nowMicros(), false))
|
|
741
|
-
log.debug.repl(`idle keepalive (lastWatermark=${lastWatermark})`)
|
|
742
|
-
// re-scan for new shard schemas during idle
|
|
743
|
-
if (performance.now() - lastShardRescan > shardRescanIntervalMs) {
|
|
744
|
-
if (mutex.tryAcquire()) {
|
|
745
|
-
lastShardRescan = performance.now()
|
|
746
|
-
try {
|
|
747
|
-
await installTriggersOnShardTables(db)
|
|
748
|
-
} finally {
|
|
749
|
-
mutex.release()
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
// safety poll every ~30s to catch edge cases (6 * 5000ms)
|
|
754
|
-
if (idleTimeoutCount < 6) continue
|
|
755
|
-
idleTimeoutCount = 0
|
|
756
|
-
log.debug.repl('safety poll')
|
|
757
|
-
// fall through to query
|
|
758
|
-
} else {
|
|
759
|
-
idleTimeoutCount = 0
|
|
760
|
-
}
|
|
761
|
-
} else {
|
|
762
|
-
idleTimeoutCount = 0
|
|
763
|
-
}
|
|
764
|
-
signalPending = false
|
|
765
|
-
}
|
|
766
|
-
queryPending = false
|
|
767
|
-
|
|
768
|
-
// periodically re-scan for new shard schemas (e.g. chat_0 created by zero-cache)
|
|
769
|
-
if (performance.now() - lastShardRescan > shardRescanIntervalMs) {
|
|
770
|
-
if (mutex.tryAcquire()) {
|
|
771
|
-
lastShardRescan = performance.now()
|
|
772
|
-
try {
|
|
773
|
-
await installTriggersOnShardTables(db)
|
|
774
|
-
} finally {
|
|
775
|
-
mutex.release()
|
|
776
|
-
}
|
|
777
|
-
} else {
|
|
778
|
-
log.debug.repl('shard rescan skipped: mutex busy')
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// try to acquire mutex without blocking proxy connections.
|
|
783
|
-
// post-sync: short backoff since writes signal us directly.
|
|
784
|
-
// pre-sync: yield more generously so zero-cache initial copy can finish.
|
|
785
|
-
log.debug.repl(
|
|
786
|
-
`pre-query: tryAcquire mutex (streamed=${hasStreamedOnce}, fails=${tryAcquireFailures})`
|
|
787
|
-
)
|
|
788
|
-
if (!mutex.tryAcquire()) {
|
|
789
|
-
if (hasStreamedOnce) {
|
|
790
|
-
// post-sync: block immediately. change query is fast (~0.5ms),
|
|
791
|
-
// so holding the mutex briefly doesn't starve proxy connections.
|
|
792
|
-
// avoids 25ms+ backoff delays that cause test flakiness.
|
|
793
|
-
await mutex.acquire()
|
|
794
|
-
} else {
|
|
795
|
-
tryAcquireFailures++
|
|
796
|
-
if (tryAcquireFailures < 10) {
|
|
797
|
-
// pre-sync: yield so zero-cache initial copy can finish
|
|
798
|
-
await waitForWakeup(Math.min(10 * tryAcquireFailures, 100))
|
|
799
|
-
queryPending = true
|
|
800
|
-
continue
|
|
801
|
-
}
|
|
802
|
-
await mutex.acquire()
|
|
803
|
-
tryAcquireFailures = 0
|
|
804
|
-
}
|
|
805
|
-
} else {
|
|
806
|
-
tryAcquireFailures = 0
|
|
807
|
-
}
|
|
808
|
-
let changes: Awaited<ReturnType<typeof getChangesSince>>
|
|
809
|
-
const queryStart = performance.now()
|
|
810
|
-
try {
|
|
811
|
-
try {
|
|
812
|
-
changes = await getChangesSince(db, lastWatermark, batchSize)
|
|
813
|
-
} catch (queryErr: unknown) {
|
|
814
|
-
// pglite is single-connection — if we acquire the mutex between
|
|
815
|
-
// extended protocol messages and the previous query left an aborted
|
|
816
|
-
// transaction, we'll get 25P02. rollback and retry once.
|
|
817
|
-
const code =
|
|
818
|
-
queryErr && typeof queryErr === 'object' && 'code' in queryErr
|
|
819
|
-
? (queryErr as { code: string }).code
|
|
820
|
-
: ''
|
|
821
|
-
if (code === '25P02') {
|
|
822
|
-
try {
|
|
823
|
-
await db.exec('ROLLBACK')
|
|
824
|
-
} catch {}
|
|
825
|
-
changes = await getChangesSince(db, lastWatermark, batchSize)
|
|
826
|
-
} else {
|
|
827
|
-
throw queryErr
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
} finally {
|
|
831
|
-
mutex.release()
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
if (changes.length > 0) {
|
|
835
|
-
const queryMs = performance.now() - queryStart
|
|
836
|
-
const signalToQueryMs =
|
|
837
|
-
lastWakeupTime > 0 ? (performance.now() - lastWakeupTime).toFixed(1) : '?'
|
|
838
|
-
// summarize which tables changed
|
|
839
|
-
const tableSummary = [...new Set(changes.map((c) => c.table_name))].join(',')
|
|
840
|
-
log.debug.repl(
|
|
841
|
-
`found ${changes.length} changes [${tableSummary}] (wm ${lastWatermark}→${changes[changes.length - 1].watermark}) query=${queryMs.toFixed(1)}ms signal→query=${signalToQueryMs}ms`
|
|
842
|
-
)
|
|
843
|
-
// filter out shard tables that zero-cache doesn't expect.
|
|
844
|
-
// `clients` advances lmid; `mutations` carries mutation results.
|
|
845
|
-
// other shard tables (e.g. replicas) crash zero-cache with
|
|
846
|
-
// "Unknown table" in the change processor.
|
|
847
|
-
const batchEnd = changes[changes.length - 1].watermark
|
|
848
|
-
const preFilterCount = changes.length
|
|
849
|
-
changes = changes.filter((c) => {
|
|
850
|
-
const dot = c.table_name.indexOf('.')
|
|
851
|
-
if (dot === -1) return true
|
|
852
|
-
const schema = c.table_name.substring(0, dot)
|
|
853
|
-
if (schema === 'public') return true
|
|
854
|
-
const table = c.table_name.substring(dot + 1)
|
|
855
|
-
return table === 'clients' || table === 'mutations'
|
|
856
|
-
})
|
|
857
|
-
log.debug.repl(`filter: ${preFilterCount} → ${changes.length} changes`)
|
|
858
|
-
|
|
859
|
-
if (changes.length === 0) {
|
|
860
|
-
lastWatermark = batchEnd
|
|
861
|
-
lastStreamedWatermark = batchEnd
|
|
862
|
-
// all changes were filtered out (e.g. shard internal tables).
|
|
863
|
-
// brief wait to avoid tight loop, then recheck.
|
|
864
|
-
await waitForWakeup(200)
|
|
865
|
-
queryPending = true
|
|
866
|
-
continue
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
await ensureMetadataForChangedTables(
|
|
870
|
-
db,
|
|
871
|
-
mutex,
|
|
872
|
-
changes,
|
|
873
|
-
tableKeyColumns,
|
|
874
|
-
excludedColumns,
|
|
875
|
-
columnTypeOids
|
|
876
|
-
)
|
|
877
|
-
|
|
878
|
-
log.debug.repl(`streaming ${changes.length} changes to writer`)
|
|
879
|
-
await streamChanges(
|
|
880
|
-
changes,
|
|
881
|
-
writer,
|
|
882
|
-
sentRelations,
|
|
883
|
-
txCounter++,
|
|
884
|
-
tableKeyColumns,
|
|
885
|
-
excludedColumns,
|
|
886
|
-
columnTypeOids
|
|
887
|
-
)
|
|
888
|
-
lastWatermark = batchEnd
|
|
889
|
-
lastStreamedWatermark = batchEnd
|
|
890
|
-
log.debug.repl(`streamed ok, watermark=${batchEnd}`)
|
|
891
|
-
hasStreamedOnce = true
|
|
892
|
-
|
|
893
|
-
// purge consumed changes periodically to free wasm memory
|
|
894
|
-
pollsSincePurge++
|
|
895
|
-
if (pollsSincePurge >= purgeEveryN && mutex.tryAcquire()) {
|
|
896
|
-
pollsSincePurge = 0
|
|
897
|
-
try {
|
|
898
|
-
const purged = await purgeConsumedChanges(db, lastWatermark)
|
|
899
|
-
if (purged > 0) {
|
|
900
|
-
log.debug.proxy(`purged ${purged} consumed changes`)
|
|
901
|
-
}
|
|
902
|
-
} finally {
|
|
903
|
-
mutex.release()
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// got changes - continue immediately to check for more
|
|
908
|
-
queryPending = true
|
|
909
|
-
continue
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
// no changes: send keepalive
|
|
913
|
-
const ts = nowMicros()
|
|
914
|
-
writer.write(encodeKeepalive(currentLsn, ts, false))
|
|
915
|
-
log.debug.repl(`idle (lastWatermark=${lastWatermark})`)
|
|
916
|
-
// next iteration will wait for signal at the top
|
|
917
|
-
} catch (err: unknown) {
|
|
918
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
919
|
-
log.repl(`replication poll error: ${msg}`)
|
|
920
|
-
if (
|
|
921
|
-
msg.includes('closed') ||
|
|
922
|
-
msg.includes('destroyed') ||
|
|
923
|
-
msg.includes('ECONNRESET') ||
|
|
924
|
-
msg.includes('EPIPE')
|
|
925
|
-
) {
|
|
926
|
-
running = false
|
|
927
|
-
break
|
|
928
|
-
}
|
|
929
|
-
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
log.debug.repl(`starting poll (lastWatermark=${lastWatermark})`)
|
|
935
|
-
try {
|
|
936
|
-
await poll()
|
|
937
|
-
} finally {
|
|
938
|
-
// only clear if still pointing to our wakeup (a new handler may have replaced it)
|
|
939
|
-
if (_replicationWakeup === wakeup) {
|
|
940
|
-
_replicationWakeup = null
|
|
941
|
-
}
|
|
942
|
-
if (unsubscribe) {
|
|
943
|
-
await unsubscribe().catch(() => {})
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
log.repl('poll loop exited')
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
async function ensureMetadataForChangedTables(
|
|
950
|
-
db: PGlite,
|
|
951
|
-
mutex: Mutex,
|
|
952
|
-
changes: ChangeRecord[],
|
|
953
|
-
tableKeyColumns: Map<string, Set<string>>,
|
|
954
|
-
excludedColumns: Map<string, Set<string>>,
|
|
955
|
-
columnTypeOids: Map<string, Map<string, number>>
|
|
956
|
-
): Promise<void> {
|
|
957
|
-
const missing = new Map<string, { schema: string; table: string }>()
|
|
958
|
-
|
|
959
|
-
for (const change of changes) {
|
|
960
|
-
if (
|
|
961
|
-
tableKeyColumns.has(change.table_name) ||
|
|
962
|
-
columnTypeOids.has(change.table_name) ||
|
|
963
|
-
excludedColumns.has(change.table_name)
|
|
964
|
-
) {
|
|
965
|
-
continue
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
const dot = change.table_name.indexOf('.')
|
|
969
|
-
const schema = dot === -1 ? 'public' : change.table_name.substring(0, dot)
|
|
970
|
-
const table = dot === -1 ? change.table_name : change.table_name.substring(dot + 1)
|
|
971
|
-
missing.set(`${schema}.${table}`, { schema, table })
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
if (missing.size === 0) return
|
|
975
|
-
|
|
976
|
-
const schemas = [...new Set([...missing.values()].map((entry) => entry.schema))]
|
|
977
|
-
const tables = [...new Set([...missing.values()].map((entry) => entry.table))]
|
|
978
|
-
|
|
979
|
-
await mutex.acquire()
|
|
980
|
-
try {
|
|
981
|
-
const schemaResult = await db.query<{
|
|
982
|
-
kind: string
|
|
983
|
-
table_schema: string
|
|
984
|
-
table_name: string
|
|
985
|
-
column_name: string
|
|
986
|
-
data_type: string | null
|
|
987
|
-
ordinal_position: number
|
|
988
|
-
}>(
|
|
989
|
-
`SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type, kcu.ordinal_position
|
|
990
|
-
FROM information_schema.table_constraints tc
|
|
991
|
-
JOIN information_schema.key_column_usage kcu
|
|
992
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
993
|
-
AND tc.table_schema = kcu.table_schema
|
|
994
|
-
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
995
|
-
AND tc.table_schema = ANY($1)
|
|
996
|
-
AND tc.table_name = ANY($2)
|
|
997
|
-
UNION ALL
|
|
998
|
-
SELECT 'col' AS kind, table_schema, table_name, column_name, data_type, ordinal_position
|
|
999
|
-
FROM information_schema.columns
|
|
1000
|
-
WHERE table_schema = ANY($1)
|
|
1001
|
-
AND table_name = ANY($2)
|
|
1002
|
-
ORDER BY table_schema, table_name, kind, ordinal_position`,
|
|
1003
|
-
[schemas, tables]
|
|
1004
|
-
)
|
|
1005
|
-
|
|
1006
|
-
for (const row of schemaResult.rows) {
|
|
1007
|
-
const key = `${row.table_schema}.${row.table_name}`
|
|
1008
|
-
if (!missing.has(key)) continue
|
|
1009
|
-
|
|
1010
|
-
if (row.kind === 'pk') {
|
|
1011
|
-
let keys = tableKeyColumns.get(key)
|
|
1012
|
-
if (!keys) {
|
|
1013
|
-
keys = new Set()
|
|
1014
|
-
tableKeyColumns.set(key, keys)
|
|
1015
|
-
}
|
|
1016
|
-
keys.add(row.column_name)
|
|
1017
|
-
} else {
|
|
1018
|
-
if (row.data_type && UNSUPPORTED_TYPES.has(row.data_type)) {
|
|
1019
|
-
let cols = excludedColumns.get(key)
|
|
1020
|
-
if (!cols) {
|
|
1021
|
-
cols = new Set()
|
|
1022
|
-
excludedColumns.set(key, cols)
|
|
1023
|
-
}
|
|
1024
|
-
cols.add(row.column_name)
|
|
1025
|
-
}
|
|
1026
|
-
if (row.data_type) {
|
|
1027
|
-
const oid = PG_DATA_TYPE_OIDS[row.data_type]
|
|
1028
|
-
if (oid !== undefined) {
|
|
1029
|
-
let cols = columnTypeOids.get(key)
|
|
1030
|
-
if (!cols) {
|
|
1031
|
-
cols = new Map()
|
|
1032
|
-
columnTypeOids.set(key, cols)
|
|
1033
|
-
}
|
|
1034
|
-
cols.set(row.column_name, oid)
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
log.debug.repl(
|
|
1041
|
-
`refreshed metadata for ${missing.size} late table(s): ${[...missing.keys()].join(',')}`
|
|
1042
|
-
)
|
|
1043
|
-
for (const key of missing.keys()) cachedColumns.delete(key)
|
|
1044
|
-
} finally {
|
|
1045
|
-
mutex.release()
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// cache column info per table to avoid per-change allocation
|
|
1050
|
-
const cachedColumns = new Map<string, ReturnType<typeof inferColumns>>()
|
|
1051
|
-
|
|
1052
|
-
async function streamChanges(
|
|
1053
|
-
changes: ChangeRecord[],
|
|
1054
|
-
writer: ReplicationWriter,
|
|
1055
|
-
sentRelations: Set<string>,
|
|
1056
|
-
txId: number,
|
|
1057
|
-
tableKeyColumns: Map<string, Set<string>>,
|
|
1058
|
-
excludedColumns: Map<string, Set<string>>,
|
|
1059
|
-
columnTypeOids: Map<string, Map<string, number>>
|
|
1060
|
-
): Promise<void> {
|
|
1061
|
-
const ts = nowMicros()
|
|
1062
|
-
const lsn = nextLsn()
|
|
1063
|
-
|
|
1064
|
-
// collect all encoded messages into a list, then batch-write
|
|
1065
|
-
// to minimize syscalls (each writer.write → socket.write is a syscall)
|
|
1066
|
-
const messages: Uint8Array[] = []
|
|
1067
|
-
|
|
1068
|
-
// BEGIN
|
|
1069
|
-
messages.push(encodeWrappedChange(lsn, lsn, ts, encodeBegin(lsn, ts, txId)))
|
|
1070
|
-
|
|
1071
|
-
for (const change of changes) {
|
|
1072
|
-
// parse schema-qualified name (schema.table or bare table)
|
|
1073
|
-
const dot = change.table_name.indexOf('.')
|
|
1074
|
-
const schema = dot !== -1 ? change.table_name.substring(0, dot) : 'public'
|
|
1075
|
-
const tableName =
|
|
1076
|
-
dot !== -1 ? change.table_name.substring(dot + 1) : change.table_name
|
|
1077
|
-
const qualifiedKey = `${schema}.${tableName}`
|
|
1078
|
-
|
|
1079
|
-
const tableOid = getTableOid(qualifiedKey)
|
|
1080
|
-
const excluded = excludedColumns.get(qualifiedKey)
|
|
1081
|
-
|
|
1082
|
-
// filter out unsupported columns from row data
|
|
1083
|
-
let rowData = change.row_data
|
|
1084
|
-
let oldData = change.old_data
|
|
1085
|
-
if (excluded && excluded.size > 0) {
|
|
1086
|
-
if (rowData) {
|
|
1087
|
-
rowData = Object.fromEntries(
|
|
1088
|
-
Object.entries(rowData).filter(([k]) => !excluded.has(k))
|
|
1089
|
-
)
|
|
1090
|
-
}
|
|
1091
|
-
if (oldData) {
|
|
1092
|
-
oldData = Object.fromEntries(
|
|
1093
|
-
Object.entries(oldData).filter(([k]) => !excluded.has(k))
|
|
1094
|
-
)
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
// zero-cache expects specific camel-cased keys in shard clients rows
|
|
1099
|
-
if (schema !== 'public' && tableName === 'clients') {
|
|
1100
|
-
rowData = normalizeShardClientsRow(rowData)
|
|
1101
|
-
oldData = normalizeShardClientsRow(oldData)
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
const row = rowData || oldData
|
|
1105
|
-
if (!row) continue
|
|
1106
|
-
|
|
1107
|
-
// use cached columns or build and cache them
|
|
1108
|
-
let columns = cachedColumns.get(qualifiedKey)
|
|
1109
|
-
if (!columns) {
|
|
1110
|
-
const keySet = tableKeyColumns.get(qualifiedKey)
|
|
1111
|
-
const typeOids = columnTypeOids.get(qualifiedKey)
|
|
1112
|
-
columns = inferColumns(row).map((col) => ({
|
|
1113
|
-
...col,
|
|
1114
|
-
typeOid: typeOids?.get(col.name) ?? col.typeOid,
|
|
1115
|
-
isKey: keySet?.has(col.name) ?? false,
|
|
1116
|
-
}))
|
|
1117
|
-
cachedColumns.set(qualifiedKey, columns)
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// send RELATION if not yet sent
|
|
1121
|
-
if (!sentRelations.has(qualifiedKey)) {
|
|
1122
|
-
const relMsg = encodeRelation(tableOid, schema, tableName, 0x64, columns)
|
|
1123
|
-
messages.push(encodeWrappedChange(lsn, lsn, ts, relMsg))
|
|
1124
|
-
sentRelations.add(qualifiedKey)
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// encode the change
|
|
1128
|
-
let changeMsg: Uint8Array | null = null
|
|
1129
|
-
switch (change.op) {
|
|
1130
|
-
case 'INSERT':
|
|
1131
|
-
if (!rowData) continue
|
|
1132
|
-
changeMsg = encodeInsert(tableOid, rowData, columns)
|
|
1133
|
-
break
|
|
1134
|
-
case 'UPDATE':
|
|
1135
|
-
if (!rowData) continue
|
|
1136
|
-
changeMsg = encodeUpdate(tableOid, rowData, oldData, columns)
|
|
1137
|
-
break
|
|
1138
|
-
case 'DELETE':
|
|
1139
|
-
if (!oldData) continue
|
|
1140
|
-
changeMsg = encodeDelete(tableOid, oldData, columns)
|
|
1141
|
-
break
|
|
1142
|
-
default:
|
|
1143
|
-
continue
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
messages.push(encodeWrappedChange(lsn, lsn, ts, changeMsg))
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// COMMIT
|
|
1150
|
-
const endLsn = nextLsn()
|
|
1151
|
-
messages.push(encodeWrappedChange(endLsn, endLsn, ts, encodeCommit(0, lsn, endLsn, ts)))
|
|
1152
|
-
|
|
1153
|
-
// The MessagePort-backed socket delivers each write as one readable chunk.
|
|
1154
|
-
// zero-cache parses one replication payload per chunk, so each CopyData frame
|
|
1155
|
-
// must be written separately.
|
|
1156
|
-
let totalSize = 0
|
|
1157
|
-
for (const msg of messages) totalSize += msg.length
|
|
1158
|
-
log.debug.repl(
|
|
1159
|
-
`streaming ${messages.length} wal messages (${totalSize} bytes, txId=${txId})`
|
|
1160
|
-
)
|
|
1161
|
-
for (const msg of messages) {
|
|
1162
|
-
writer.write(msg)
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// hook for arch instrumentation (soot-arch sq-write events)
|
|
1166
|
-
const hook = (globalThis as any).__orez_on_repl_commit
|
|
1167
|
-
if (hook) {
|
|
1168
|
-
const tables = new Set(changes.map((c) => c.table_name))
|
|
1169
|
-
hook({ changes: changes.length, tables: [...tables], txId })
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
function normalizeShardClientsRow(
|
|
1174
|
-
row: Record<string, unknown> | null
|
|
1175
|
-
): Record<string, unknown> | null {
|
|
1176
|
-
if (!row) return row
|
|
1177
|
-
const out: Record<string, unknown> = { ...row }
|
|
1178
|
-
if (out.clientGroupID === undefined && out.clientgroupid !== undefined) {
|
|
1179
|
-
out.clientGroupID = out.clientgroupid
|
|
1180
|
-
}
|
|
1181
|
-
if (out.clientID === undefined && out.clientid !== undefined) {
|
|
1182
|
-
out.clientID = out.clientid
|
|
1183
|
-
}
|
|
1184
|
-
if (out.lastMutationID === undefined && out.lastmutationid !== undefined) {
|
|
1185
|
-
out.lastMutationID = out.lastmutationid
|
|
1186
|
-
}
|
|
1187
|
-
return out
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
export { buildErrorResponse }
|