orez 0.2.25 → 0.2.27

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 (175) hide show
  1. package/dist/cf-do/watermark.d.ts +21 -0
  2. package/dist/cf-do/watermark.d.ts.map +1 -0
  3. package/dist/cf-do/watermark.js +93 -0
  4. package/dist/cf-do/watermark.js.map +1 -0
  5. package/dist/cf-do/worker.d.ts +48 -22
  6. package/dist/cf-do/worker.d.ts.map +1 -1
  7. package/dist/cf-do/worker.js +650 -269
  8. package/dist/cf-do/worker.js.map +1 -1
  9. package/dist/config.js +1 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/do-sql-tracking.d.ts +6 -0
  12. package/dist/do-sql-tracking.d.ts.map +1 -0
  13. package/dist/do-sql-tracking.js +14 -0
  14. package/dist/do-sql-tracking.js.map +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +28 -14
  17. package/dist/index.js.map +1 -1
  18. package/dist/pg-proxy-browser.js +6 -6
  19. package/dist/pg-proxy-browser.js.map +1 -1
  20. package/dist/pg-proxy-do-backend.d.ts +98 -17
  21. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  22. package/dist/pg-proxy-do-backend.js +6075 -454
  23. package/dist/pg-proxy-do-backend.js.map +1 -1
  24. package/dist/pg-sqlite-compiler/catalog/seed.d.ts +67 -0
  25. package/dist/pg-sqlite-compiler/catalog/seed.d.ts.map +1 -0
  26. package/dist/pg-sqlite-compiler/catalog/seed.js +436 -0
  27. package/dist/pg-sqlite-compiler/catalog/seed.js.map +1 -0
  28. package/dist/pg-sqlite-compiler/index.d.ts +12 -0
  29. package/dist/pg-sqlite-compiler/index.d.ts.map +1 -0
  30. package/dist/pg-sqlite-compiler/index.js +59 -0
  31. package/dist/pg-sqlite-compiler/index.js.map +1 -0
  32. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts +48 -0
  33. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts.map +1 -0
  34. package/dist/pg-sqlite-compiler/passes/ast-utils.js +93 -0
  35. package/dist/pg-sqlite-compiler/passes/ast-utils.js.map +1 -0
  36. package/dist/pg-sqlite-compiler/passes/catalog.d.ts +34 -0
  37. package/dist/pg-sqlite-compiler/passes/catalog.d.ts.map +1 -0
  38. package/dist/pg-sqlite-compiler/passes/catalog.js +30 -0
  39. package/dist/pg-sqlite-compiler/passes/catalog.js.map +1 -0
  40. package/dist/pg-sqlite-compiler/passes/datetime.d.ts +21 -0
  41. package/dist/pg-sqlite-compiler/passes/datetime.d.ts.map +1 -0
  42. package/dist/pg-sqlite-compiler/passes/datetime.js +53 -0
  43. package/dist/pg-sqlite-compiler/passes/datetime.js.map +1 -0
  44. package/dist/pg-sqlite-compiler/passes/index.d.ts +21 -0
  45. package/dist/pg-sqlite-compiler/passes/index.d.ts.map +1 -0
  46. package/dist/pg-sqlite-compiler/passes/index.js +39 -0
  47. package/dist/pg-sqlite-compiler/passes/index.js.map +1 -0
  48. package/dist/pg-sqlite-compiler/passes/types.d.ts +41 -0
  49. package/dist/pg-sqlite-compiler/passes/types.d.ts.map +1 -0
  50. package/dist/pg-sqlite-compiler/passes/types.js +103 -0
  51. package/dist/pg-sqlite-compiler/passes/types.js.map +1 -0
  52. package/dist/pg-sqlite-compiler/test/oracle.d.ts +34 -0
  53. package/dist/pg-sqlite-compiler/test/oracle.d.ts.map +1 -0
  54. package/dist/pg-sqlite-compiler/test/oracle.js +204 -0
  55. package/dist/pg-sqlite-compiler/test/oracle.js.map +1 -0
  56. package/dist/pg-sqlite-compiler/types.d.ts +55 -0
  57. package/dist/pg-sqlite-compiler/types.d.ts.map +1 -0
  58. package/dist/pg-sqlite-compiler/types.js +2 -0
  59. package/dist/pg-sqlite-compiler/types.js.map +1 -0
  60. package/dist/replication/change-tracker.d.ts.map +1 -1
  61. package/dist/replication/change-tracker.js +18 -1
  62. package/dist/replication/change-tracker.js.map +1 -1
  63. package/dist/replication/handler.d.ts.map +1 -1
  64. package/dist/replication/handler.js +7 -2
  65. package/dist/replication/handler.js.map +1 -1
  66. package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
  67. package/dist/replication/pgoutput-encoder.js +72 -30
  68. package/dist/replication/pgoutput-encoder.js.map +1 -1
  69. package/dist/worker/browser-build-config.d.ts.map +1 -1
  70. package/dist/worker/browser-build-config.js +2 -1
  71. package/dist/worker/browser-build-config.js.map +1 -1
  72. package/dist/worker/cf-patches.d.ts +5 -2
  73. package/dist/worker/cf-patches.d.ts.map +1 -1
  74. package/dist/worker/cf-patches.js +238 -4
  75. package/dist/worker/cf-patches.js.map +1 -1
  76. package/dist/worker/shims/node-stub.d.ts +35 -0
  77. package/dist/worker/shims/node-stub.d.ts.map +1 -1
  78. package/dist/worker/shims/node-stub.js +53 -1
  79. package/dist/worker/shims/node-stub.js.map +1 -1
  80. package/dist/worker/shims/oxfmt.d.ts +4 -0
  81. package/dist/worker/shims/oxfmt.d.ts.map +1 -0
  82. package/dist/worker/shims/oxfmt.js +4 -0
  83. package/dist/worker/shims/oxfmt.js.map +1 -0
  84. package/dist/worker/shims/postgres-socket.js +1 -1
  85. package/dist/worker/shims/postgres-socket.js.map +1 -1
  86. package/dist/worker/shims/sqlite.d.ts +1 -0
  87. package/dist/worker/shims/sqlite.d.ts.map +1 -1
  88. package/dist/worker/shims/sqlite.js +229 -9
  89. package/dist/worker/shims/sqlite.js.map +1 -1
  90. package/dist/worker/shims/ws.d.ts.map +1 -1
  91. package/dist/worker/shims/ws.js +45 -0
  92. package/dist/worker/shims/ws.js.map +1 -1
  93. package/dist/worker/shims/zero-process-env.d.ts +2 -0
  94. package/dist/worker/shims/zero-process-env.d.ts.map +1 -0
  95. package/dist/worker/shims/zero-process-env.js +9 -0
  96. package/dist/worker/shims/zero-process-env.js.map +1 -0
  97. package/dist/worker/zero-cache-embed-cf.d.ts +29 -12
  98. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
  99. package/dist/worker/zero-cache-embed-cf.js +83 -14
  100. package/dist/worker/zero-cache-embed-cf.js.map +1 -1
  101. package/package.json +11 -2
  102. package/src/cf-do/.wrangler/cache/cf.json +1 -0
  103. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  104. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  105. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  106. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
  107. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  108. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  109. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  110. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +11 -0
  111. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +134 -0
  112. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +11 -0
  113. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +134 -0
  114. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +1059 -0
  115. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +8 -0
  116. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +1059 -0
  117. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +8 -0
  118. package/src/cf-do/ARCHITECTURE.md +93 -0
  119. package/src/cf-do/CHAT_E2E.md +213 -0
  120. package/src/cf-do/watermark.test.ts +103 -0
  121. package/src/cf-do/watermark.ts +118 -0
  122. package/src/cf-do/worker.ts +1041 -0
  123. package/src/cf-do/wrangler.toml +11 -0
  124. package/src/cli.test.ts +3 -1
  125. package/src/config.ts +1 -1
  126. package/src/do-sql-tracking.test.ts +19 -0
  127. package/src/do-sql-tracking.ts +19 -0
  128. package/src/index.ts +29 -14
  129. package/src/pg-proxy-browser.ts +6 -6
  130. package/src/pg-proxy-do-backend.test.ts +3890 -0
  131. package/src/pg-proxy-do-backend.ts +6833 -482
  132. package/src/pg-sqlite-compiler/README.md +53 -0
  133. package/src/pg-sqlite-compiler/catalog/seed.ts +524 -0
  134. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +307 -0
  135. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +377 -0
  136. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +12 -0
  137. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +447 -0
  138. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +32 -0
  139. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +397 -0
  140. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +337 -0
  141. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +337 -0
  142. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +537 -0
  143. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +1837 -0
  144. package/src/pg-sqlite-compiler/index.ts +73 -0
  145. package/src/pg-sqlite-compiler/integration.test.ts +136 -0
  146. package/src/pg-sqlite-compiler/passes/ast-utils.ts +113 -0
  147. package/src/pg-sqlite-compiler/passes/catalog.ts +65 -0
  148. package/src/pg-sqlite-compiler/passes/datetime.ts +74 -0
  149. package/src/pg-sqlite-compiler/passes/index.ts +49 -0
  150. package/src/pg-sqlite-compiler/passes/types.ts +156 -0
  151. package/src/pg-sqlite-compiler/smoke.test.ts +69 -0
  152. package/src/pg-sqlite-compiler/test/catalog.test.ts +171 -0
  153. package/src/pg-sqlite-compiler/test/corpus.test.ts +161 -0
  154. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +102 -0
  155. package/src/pg-sqlite-compiler/test/oracle.ts +237 -0
  156. package/src/pg-sqlite-compiler/test/types.test.ts +109 -0
  157. package/src/pg-sqlite-compiler/types.ts +63 -0
  158. package/src/replication/change-tracker.ts +16 -1
  159. package/src/replication/handler.test.ts +35 -0
  160. package/src/replication/handler.ts +7 -2
  161. package/src/replication/pgoutput-encoder.test.ts +71 -2
  162. package/src/replication/pgoutput-encoder.ts +65 -30
  163. package/src/worker/browser-build-config.test.ts +12 -0
  164. package/src/worker/browser-build-config.ts +2 -1
  165. package/src/worker/cf-patches.ts +274 -4
  166. package/src/worker/shims/node-stub.ts +53 -1
  167. package/src/worker/shims/oxfmt.ts +3 -0
  168. package/src/worker/shims/postgres-socket.ts +1 -1
  169. package/src/worker/shims/sqlite.test.ts +145 -0
  170. package/src/worker/shims/sqlite.ts +256 -9
  171. package/src/worker/shims/ws.ts +45 -0
  172. package/src/worker/shims/zero-process-env.ts +11 -0
  173. package/src/worker/zero-cache-embed-cf.ts +114 -18
  174. package/src/query-rewrites.test.ts +0 -30
  175. package/src/query-rewrites.ts +0 -152
@@ -0,0 +1,237 @@
1
+ /**
2
+ * pgsqlite-backed oracle for compiler quality.
3
+ *
4
+ * Spawns a real pgsqlite server, sends PG SQL via the PG wire protocol,
5
+ * captures the result set. Compares against running the same query through
6
+ * our compiler + bun:sqlite. Equivalence at the result-set level is what we
7
+ * actually care about — pgsqlite is the oracle, not the spec.
8
+ *
9
+ * The pgsqlite binary path comes from `vendor/pgsqlite/.resolved-path`,
10
+ * populated by `scripts/pgsqlite/ensure.ts`. If empty, oracle tests should
11
+ * be marked `it.skip` so the suite still runs without pgsqlite installed.
12
+ */
13
+ import { spawn, type ChildProcess } from 'node:child_process'
14
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
15
+ import { createConnection, createServer } from 'node:net'
16
+ import { tmpdir } from 'node:os'
17
+ import { resolve } from 'node:path'
18
+
19
+ import Database from '@rocicorp/zero-sqlite3'
20
+
21
+ const VENDOR_PATH_FILE = resolve(
22
+ import.meta.dirname,
23
+ '..',
24
+ '..',
25
+ '..',
26
+ 'vendor',
27
+ 'pgsqlite',
28
+ '.resolved-path'
29
+ )
30
+
31
+ export function pgsqliteBinPath(): string | null {
32
+ if (!existsSync(VENDOR_PATH_FILE)) return null
33
+ const path = readFileSync(VENDOR_PATH_FILE, 'utf-8').trim()
34
+ return path && existsSync(path) ? path : null
35
+ }
36
+
37
+ export const ORACLE_AVAILABLE = pgsqliteBinPath() !== null
38
+
39
+ export interface OracleServer {
40
+ port: number
41
+ dbPath: string
42
+ stop(): Promise<void>
43
+ }
44
+
45
+ /**
46
+ * Pick a free TCP port by binding ephemeral and reading what we got.
47
+ * Then close immediately and hand the port to pgsqlite. There's a tiny TOCTOU
48
+ * window where another process could grab it before pgsqlite binds, but
49
+ * vitest's parallel test files all go through this helper, so the only races
50
+ * are against unrelated processes on the host — vanishingly unlikely in CI.
51
+ */
52
+ async function getFreePort(): Promise<number> {
53
+ return new Promise((resolveFn, reject) => {
54
+ const server = createServer()
55
+ server.unref()
56
+ server.on('error', reject)
57
+ server.listen(0, '127.0.0.1', () => {
58
+ const addr = server.address()
59
+ if (!addr || typeof addr === 'string') {
60
+ server.close()
61
+ reject(new Error('failed to get free port'))
62
+ return
63
+ }
64
+ const port = addr.port
65
+ server.close(() => resolveFn(port))
66
+ })
67
+ })
68
+ }
69
+
70
+ async function probePort(port: number, timeoutMs: number): Promise<boolean> {
71
+ return new Promise((resolveFn) => {
72
+ const sock = createConnection({ host: '127.0.0.1', port })
73
+ const cleanup = (val: boolean) => {
74
+ try {
75
+ sock.destroy()
76
+ } catch {}
77
+ resolveFn(val)
78
+ }
79
+ const timer = setTimeout(() => cleanup(false), timeoutMs)
80
+ sock.once('connect', () => {
81
+ clearTimeout(timer)
82
+ cleanup(true)
83
+ })
84
+ sock.once('error', () => {
85
+ clearTimeout(timer)
86
+ cleanup(false)
87
+ })
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Start a pgsqlite server on an OS-assigned ephemeral port with an ephemeral
93
+ * database. Throws on probe timeout. Caller is responsible for calling
94
+ * `stop()` in a `finally` / `afterAll` hook — `stop()` awaits child exit
95
+ * and cleans up the tempdir.
96
+ */
97
+ export async function startPgsqliteServer(): Promise<OracleServer> {
98
+ const bin = pgsqliteBinPath()
99
+ if (!bin) throw new Error('pgsqlite binary not available')
100
+
101
+ const port = await getFreePort()
102
+ const tmpDir = mkdtempSync(resolve(tmpdir(), 'orez-oracle-'))
103
+ const dbPath = resolve(tmpDir, 'pg.db')
104
+
105
+ const proc: ChildProcess = spawn(
106
+ bin,
107
+ ['--port', String(port), '--database', dbPath, '--log-level', 'error'],
108
+ { stdio: ['ignore', 'pipe', 'pipe'] }
109
+ )
110
+
111
+ const exited: Promise<void> = new Promise((res) => proc.once('exit', () => res()))
112
+
113
+ // wait for "ready" — poll until the server accepts a connection or we time out
114
+ let ready = false
115
+ const start = Date.now()
116
+ while (Date.now() - start < 10_000) {
117
+ if (await probePort(port, 250)) {
118
+ ready = true
119
+ break
120
+ }
121
+ if (proc.exitCode !== null) {
122
+ throw new Error(`pgsqlite exited before becoming ready (code=${proc.exitCode})`)
123
+ }
124
+ await new Promise((r) => setTimeout(r, 50))
125
+ }
126
+ if (!ready) {
127
+ try {
128
+ proc.kill('SIGKILL')
129
+ } catch {}
130
+ try {
131
+ rmSync(tmpDir, { recursive: true, force: true })
132
+ } catch {}
133
+ throw new Error(`pgsqlite did not become ready on port ${port} within 10s`)
134
+ }
135
+
136
+ return {
137
+ port,
138
+ dbPath,
139
+ async stop() {
140
+ try {
141
+ proc.kill('SIGTERM')
142
+ } catch {}
143
+ // wait up to 2s for graceful exit, then SIGKILL
144
+ const killTimer = setTimeout(() => {
145
+ try {
146
+ proc.kill('SIGKILL')
147
+ } catch {}
148
+ }, 2_000)
149
+ try {
150
+ await exited
151
+ } finally {
152
+ clearTimeout(killTimer)
153
+ }
154
+ try {
155
+ rmSync(tmpDir, { recursive: true, force: true })
156
+ } catch {}
157
+ },
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Helper: open a postgres.js client to a running pgsqlite server.
163
+ * Requires `postgres` to be available (we already ship it via @rocicorp/zero).
164
+ */
165
+ export async function connectOracle(server: OracleServer): Promise<{
166
+ exec: (sql: string, params?: any[]) => Promise<any[]>
167
+ end: () => Promise<void>
168
+ }> {
169
+ const { default: postgres } = await import('postgres')
170
+ const sql = postgres({
171
+ host: '127.0.0.1',
172
+ port: server.port,
173
+ user: 'oracle',
174
+ password: '',
175
+ database: 'main',
176
+ ssl: false,
177
+ max: 1,
178
+ idle_timeout: 5,
179
+ connect_timeout: 5,
180
+ fetch_types: false,
181
+ prepare: false,
182
+ })
183
+ return {
184
+ exec: async (s: string, params: any[] = []) => {
185
+ const rows = await sql.unsafe(s, params as any[])
186
+ return rows as any[]
187
+ },
188
+ end: () => sql.end({ timeout: 2 }).then(() => undefined),
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Run the same query against pgsqlite (oracle) and our compiler+SQLite (under
194
+ * test). Return both result sets so the test can diff them however it wants.
195
+ *
196
+ * Uses freshly-spawned servers/dbs for each call — slow but isolated. For
197
+ * batches use the lower-level helpers above.
198
+ */
199
+ export async function runOracleAndCompiler(
200
+ setupSql: string[],
201
+ pgSql: string,
202
+ params: any[] = []
203
+ ): Promise<{
204
+ oracle: any[]
205
+ ours: any[]
206
+ }> {
207
+ const server = await startPgsqliteServer()
208
+ try {
209
+ const conn = await connectOracle(server)
210
+ try {
211
+ for (const s of setupSql) await conn.exec(s)
212
+ const oracle = await conn.exec(pgSql, params)
213
+
214
+ // ours: setup + query against fresh in-memory sqlite (after compile())
215
+ const { compile } = await import('../index.js')
216
+ const db = new Database(':memory:')
217
+ for (const s of setupSql) {
218
+ const { sql: translated } = compile(s)
219
+ db.exec(translated)
220
+ }
221
+ const { sql: translatedQuery } = compile(pgSql)
222
+ // postgres.js uses $1 params; sqlite uses ?
223
+ const sqliteSql = translatedQuery.replace(/\$(\d+)/g, '?')
224
+ const stmt = db.prepare(sqliteSql)
225
+ const ours = (
226
+ params.length > 0 ? stmt.all(...(params as any[])) : stmt.all()
227
+ ) as any[]
228
+ db.close()
229
+
230
+ return { oracle, ours }
231
+ } finally {
232
+ await conn.end()
233
+ }
234
+ } finally {
235
+ await server.stop()
236
+ }
237
+ }
@@ -0,0 +1,109 @@
1
+ import Database from '@rocicorp/zero-sqlite3'
2
+ /**
3
+ * Snapshot tests for the types pass. Each case asserts that the emitted
4
+ * SQLite SQL has the expected normalized type name AND that the result
5
+ * actually executes against a fresh @rocicorp/zero-sqlite3 in-memory db.
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+
9
+ import { compile } from '../index.js'
10
+
11
+ function compilesAndRuns(pgSql: string): { sql: string } {
12
+ const { sql, warnings } = compile(pgSql)
13
+ expect(warnings).toEqual([])
14
+ const db = new Database(':memory:')
15
+ db.exec(sql)
16
+ db.close()
17
+ return { sql }
18
+ }
19
+
20
+ describe('types pass', () => {
21
+ it('BIGSERIAL → INTEGER (rowid alias on PRIMARY KEY)', () => {
22
+ const { sql } = compilesAndRuns('CREATE TABLE t (id BIGSERIAL PRIMARY KEY)')
23
+ expect(sql).toMatch(/INTEGER PRIMARY KEY/i)
24
+ expect(sql).not.toMatch(/bigserial/i)
25
+ })
26
+
27
+ it('jsonb → TEXT', () => {
28
+ const { sql } = compilesAndRuns(
29
+ 'CREATE TABLE t (id text PRIMARY KEY, p jsonb NOT NULL)'
30
+ )
31
+ expect(sql).toMatch(/p TEXT NOT NULL/i)
32
+ expect(sql).not.toMatch(/jsonb/i)
33
+ })
34
+
35
+ it('text[] → TEXT (arrays as JSON text)', () => {
36
+ const { sql } = compilesAndRuns(
37
+ 'CREATE TABLE t (id text PRIMARY KEY, tags text[] NOT NULL)'
38
+ )
39
+ expect(sql).toMatch(/tags TEXT NOT NULL/i)
40
+ expect(sql).not.toMatch(/\[/)
41
+ })
42
+
43
+ it('timestamp with time zone → TEXT', () => {
44
+ const { sql } = compilesAndRuns(
45
+ 'CREATE TABLE t (id text PRIMARY KEY, ts timestamp with time zone NOT NULL)'
46
+ )
47
+ expect(sql).toMatch(/ts TEXT NOT NULL/i)
48
+ expect(sql).not.toMatch(/timestamp/i)
49
+ expect(sql).not.toMatch(/with time zone/i)
50
+ })
51
+
52
+ it('varchar(N) → TEXT (drop length typmod)', () => {
53
+ const { sql } = compilesAndRuns(
54
+ 'CREATE TABLE t (id text PRIMARY KEY, val varchar(64))'
55
+ )
56
+ expect(sql).toMatch(/val TEXT/i)
57
+ expect(sql).not.toMatch(/varchar/i)
58
+ expect(sql).not.toMatch(/\(64\)/)
59
+ })
60
+
61
+ it('boolean → INTEGER', () => {
62
+ const { sql } = compilesAndRuns(
63
+ 'CREATE TABLE t (id text PRIMARY KEY, enabled boolean NOT NULL DEFAULT false)'
64
+ )
65
+ expect(sql).toMatch(/enabled INTEGER NOT NULL/i)
66
+ expect(sql).not.toMatch(/boolean/i)
67
+ })
68
+
69
+ it('uuid → TEXT', () => {
70
+ const { sql } = compilesAndRuns('CREATE TABLE t (id uuid PRIMARY KEY)')
71
+ expect(sql).toMatch(/id TEXT PRIMARY KEY/i)
72
+ expect(sql).not.toMatch(/uuid/i)
73
+ })
74
+
75
+ it('bytea → BLOB', () => {
76
+ const { sql } = compilesAndRuns('CREATE TABLE t (id text PRIMARY KEY, body bytea)')
77
+ expect(sql).toMatch(/body BLOB/i)
78
+ expect(sql).not.toMatch(/bytea/i)
79
+ })
80
+
81
+ it('numeric(10,2) → NUMERIC (drops precision)', () => {
82
+ const { sql } = compilesAndRuns(
83
+ 'CREATE TABLE t (id text PRIMARY KEY, amount numeric(10,2))'
84
+ )
85
+ expect(sql).toMatch(/amount NUMERIC/i)
86
+ expect(sql).not.toMatch(/\(10/)
87
+ })
88
+
89
+ it('composite: all common chat-app types in one table', () => {
90
+ const { sql } = compilesAndRuns(
91
+ 'CREATE TABLE event (' +
92
+ 'id BIGSERIAL PRIMARY KEY, ' +
93
+ 'user_id uuid NOT NULL, ' +
94
+ '"createdAt" timestamp with time zone DEFAULT NOW() NOT NULL, ' +
95
+ "payload jsonb NOT NULL DEFAULT '{}', " +
96
+ "tags text[] NOT NULL DEFAULT '{}', " +
97
+ 'amount numeric(10,2), ' +
98
+ 'enabled boolean NOT NULL DEFAULT true' +
99
+ ')'
100
+ )
101
+ expect(sql).toMatch(/INTEGER PRIMARY KEY/i)
102
+ expect(sql).toMatch(/user_id TEXT NOT NULL/i)
103
+ expect(sql).toMatch(/"createdAt" TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL/i)
104
+ expect(sql).toMatch(/payload TEXT NOT NULL/i)
105
+ expect(sql).toMatch(/tags TEXT NOT NULL/i)
106
+ expect(sql).toMatch(/amount NUMERIC/i)
107
+ expect(sql).toMatch(/enabled INTEGER NOT NULL/i)
108
+ })
109
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Schema metadata available to passes for type-aware translation.
3
+ *
4
+ * Implementations are caller-provided. The default NOOP_SCHEMA returns nothing
5
+ * and passes degrade gracefully (regex/AST-shape fallbacks).
6
+ *
7
+ * For orez, the eventual implementation will read from `ctx.storage.sql`
8
+ * PRAGMA + the `_orez_pg_metadata` catalog table.
9
+ */
10
+ export interface SchemaInfo {
11
+ /** PG type name (e.g. "jsonb", "text[]") for a column, or undefined. */
12
+ getColumnType(schema: string, table: string, column: string): string | undefined
13
+
14
+ /** ENUM definition lookup by PG type name. */
15
+ getEnum(typeName: string): EnumInfo | undefined
16
+
17
+ /** Validate an ENUM literal value. */
18
+ isEnumValue(typeOid: number, label: string): boolean
19
+
20
+ /** Column list for a table (for SELECT * expansion, RETURNING * etc.). */
21
+ getTableColumns(schema: string, table: string): readonly string[] | undefined
22
+ }
23
+
24
+ export interface EnumInfo {
25
+ typeOid: number
26
+ values: readonly string[]
27
+ }
28
+
29
+ export interface CompileWarning {
30
+ kind: string
31
+ message: string
32
+ /** PG AST node tag where the warning was raised. */
33
+ near?: string
34
+ }
35
+
36
+ export interface CompileResult {
37
+ sql: string
38
+ warnings: CompileWarning[]
39
+ }
40
+
41
+ export interface CompileOptions {
42
+ schema?: SchemaInfo
43
+ pgVersion?: number
44
+ /** Override pass list (mainly for testing individual passes). */
45
+ passes?: Pass[]
46
+ }
47
+
48
+ /** Context passed to every pass. */
49
+ export interface PassContext {
50
+ schema: SchemaInfo
51
+ warnings: CompileWarning[]
52
+ /**
53
+ * Optional pass list — if set, runPasses uses these instead of the default
54
+ * pipeline. Otherwise the full default pipeline runs.
55
+ */
56
+ passes?: Pass[]
57
+ }
58
+
59
+ /** A pass is a function that mutates a RawStmt in place. */
60
+ export interface Pass {
61
+ name: string
62
+ run(rawStmt: any, ctx: PassContext): void
63
+ }
@@ -20,6 +20,16 @@ export interface ChangeTrackingDb {
20
20
  query<T>(sql: string, params?: unknown[]): Promise<{ rows: T[] }>
21
21
  }
22
22
 
23
+ // PGlite returns JSONB columns as parsed objects; the DO backend returns them
24
+ // as JSON strings (it stores `row_data TEXT`). normalize once at the consumer
25
+ // boundary so callers always get an object.
26
+ function jsonRecord(value: unknown): Record<string, unknown> | null {
27
+ if (value === null || value === undefined) return null
28
+ if (typeof value === 'object') return value as Record<string, unknown>
29
+ if (typeof value !== 'string' || value === '') return null
30
+ return JSON.parse(value) as Record<string, unknown>
31
+ }
32
+
23
33
  export async function installChangeTracking(db: ChangeTrackingDb): Promise<void> {
24
34
  // use _orez schema for internal tables - survives pg_restore of public schema
25
35
  await db.exec(`CREATE SCHEMA IF NOT EXISTS _orez`)
@@ -241,7 +251,12 @@ export async function getChangesSince(
241
251
  'SELECT watermark, table_name, op, row_data, old_data FROM _orez._zero_changes WHERE watermark > $1 ORDER BY watermark LIMIT $2',
242
252
  [watermark, limit]
243
253
  )
244
- return result.rows
254
+ return result.rows.map((row) => ({
255
+ ...row,
256
+ watermark: Number(row.watermark),
257
+ row_data: jsonRecord(row.row_data),
258
+ old_data: jsonRecord(row.old_data),
259
+ }))
245
260
  }
246
261
 
247
262
  export async function purgeConsumedChanges(
@@ -174,6 +174,19 @@ describe('handleStartReplication', () => {
174
174
  return types
175
175
  }
176
176
 
177
+ function countCopyDataFrames(buf: Uint8Array): number {
178
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
179
+ let pos = 0
180
+ let count = 0
181
+ while (pos < buf.length) {
182
+ if (buf[pos] !== 0x64) return count
183
+ const len = dv.getInt32(pos + 1)
184
+ pos += 1 + len
185
+ count++
186
+ }
187
+ return count
188
+ }
189
+
177
190
  it('sends CopyBothResponse first', async () => {
178
191
  const { written, writer } = createWriter()
179
192
 
@@ -238,6 +251,28 @@ describe('handleStartReplication', () => {
238
251
  expect(insIdx).toBeLessThan(comIdx)
239
252
  })
240
253
 
254
+ it('writes one CopyData frame per socket chunk', async () => {
255
+ const { written, writer } = createWriter()
256
+
257
+ replicationPromise = handleStartReplication(
258
+ 'START_REPLICATION SLOT "s" LOGICAL 0/0',
259
+ writer,
260
+ db,
261
+ testMutex
262
+ )
263
+
264
+ await new Promise((r) => setTimeout(r, 100))
265
+ await db.exec(`INSERT INTO public.items (name, value) VALUES ('chunked', 123)`)
266
+ signalReplicationChange()
267
+ await new Promise((r) => setTimeout(r, 700))
268
+
269
+ const copyDataWrites = written.filter((msg) => msg[0] === 0x64)
270
+ expect(copyDataWrites.length).toBeGreaterThanOrEqual(4)
271
+ for (const msg of copyDataWrites) {
272
+ expect(countCopyDataFrames(msg)).toBe(1)
273
+ }
274
+ })
275
+
241
276
  it('streams UPDATE and DELETE operations', async () => {
242
277
  const { written, writer } = createWriter()
243
278
 
@@ -117,6 +117,10 @@ let _replicationWakeup: (() => void) | null = null
117
117
  * called by the proxy after executing writes on the postgres instance. */
118
118
  export function signalReplicationChange() {
119
119
  _replicationWakeup?.()
120
+ const globalWakeup = (globalThis as any).__orez_signal_replication
121
+ if (typeof globalWakeup === 'function' && globalWakeup !== _replicationWakeup) {
122
+ globalWakeup()
123
+ }
120
124
  }
121
125
 
122
126
  // cached setup results so reconnects skip the expensive mutex-holding setup phase.
@@ -1146,8 +1150,9 @@ async function streamChanges(
1146
1150
  const endLsn = nextLsn()
1147
1151
  messages.push(encodeWrappedChange(endLsn, endLsn, ts, encodeCommit(0, lsn, endLsn, ts)))
1148
1152
 
1149
- // write messages individually works for both TCP sockets and in-process
1150
- // pipes (browser pipe handler parses one message per write() call)
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.
1151
1156
  let totalSize = 0
1152
1157
  for (const msg of messages) totalSize += msg.length
1153
1158
  log.debug.repl(
@@ -40,6 +40,24 @@ function rText(buf: Uint8Array, off: number): [string, number] {
40
40
  const str = new TextDecoder().decode(buf.subarray(off + 4, off + 4 + len))
41
41
  return [str, off + 4 + len]
42
42
  }
43
+ function rTupleTextValues(buf: Uint8Array, off: number): [Array<string | null>, number] {
44
+ const values: Array<string | null> = []
45
+ const count = r16(buf, off)
46
+ let pos = off + 2
47
+ for (let i = 0; i < count; i++) {
48
+ const kind = buf[pos++]
49
+ if (kind === 0x6e) {
50
+ values.push(null)
51
+ continue
52
+ }
53
+ expect(kind).toBe(0x74)
54
+ const len = r32(buf, pos)
55
+ pos += 4
56
+ values.push(new TextDecoder().decode(buf.subarray(pos, pos + len)))
57
+ pos += len
58
+ }
59
+ return [values, pos]
60
+ }
43
61
 
44
62
  describe('pgoutput-encoder', () => {
45
63
  describe('encodeBegin', () => {
@@ -276,6 +294,26 @@ describe('pgoutput-encoder', () => {
276
294
  }
277
295
  expect(buf[pos]).toBe(0x4e) // 'N' new tuple marker
278
296
  })
297
+
298
+ it('encodes sqlite integer booleans as postgres bool text', () => {
299
+ const boolCols: ColumnInfo[] = [
300
+ { name: 'id', typeOid: 25, typeMod: -1, isKey: true },
301
+ { name: 'completed', typeOid: 16, typeMod: -1 },
302
+ ]
303
+ const buf = encodeUpdate(
304
+ 16384,
305
+ { id: '1', completed: 1 },
306
+ { id: '1', completed: 0 },
307
+ boolCols
308
+ )
309
+
310
+ expect(buf[5]).toBe(0x4f) // 'O' old tuple
311
+ const [oldValues, afterOld] = rTupleTextValues(buf, 6)
312
+ expect(oldValues).toEqual(['1', 'f'])
313
+ expect(buf[afterOld]).toBe(0x4e) // 'N' new tuple
314
+ const [newValues] = rTupleTextValues(buf, afterOld + 1)
315
+ expect(newValues).toEqual(['1', 't'])
316
+ })
279
317
  })
280
318
 
281
319
  describe('encodeDelete', () => {
@@ -364,6 +402,12 @@ describe('pgoutput-encoder', () => {
364
402
  const b = getTableOid('oid_test_y')
365
403
  expect(a).not.toBe(b)
366
404
  })
405
+
406
+ it('matches DoBackend catalog oids for flattened schema tables', () => {
407
+ expect(getTableOid('public.todo')).toBe(4392680)
408
+ expect(getTableOid('todo_0.clients')).toBe(9663976)
409
+ expect(getTableOid('todo_0.mutations')).toBe(8519194)
410
+ })
367
411
  })
368
412
 
369
413
  // roundtrip tests: encode with orez → parse with zero-cache's parser
@@ -384,8 +428,16 @@ describe('pgoutput-encoder', () => {
384
428
  PgoutputParser = (await import(parserPath)).PgoutputParser
385
429
  })
386
430
 
387
- function makeParser() {
388
- return new PgoutputParser(typeParsers)
431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ function makeParser(parserOverrides: any = typeParsers) {
433
+ return new PgoutputParser(parserOverrides)
434
+ }
435
+
436
+ function makeBoolAwareParser() {
437
+ return makeParser({
438
+ getTypeParser: (oid: number) =>
439
+ oid === 16 ? (value: string) => value === 't' : String,
440
+ })
389
441
  }
390
442
 
391
443
  it('BEGIN roundtrip', () => {
@@ -490,6 +542,23 @@ describe('pgoutput-encoder', () => {
490
542
  expect(parsed.key).toBeNull()
491
543
  })
492
544
 
545
+ it('UPDATE roundtrip decodes sqlite boolean integers through zero-cache parser', () => {
546
+ const oid = getTableOid('rt.bool_update')
547
+ const cols: ColumnInfo[] = [
548
+ { name: 'id', typeOid: 25, typeMod: -1, isKey: true },
549
+ { name: 'completed', typeOid: 16, typeMod: -1 },
550
+ ]
551
+ const parser = makeBoolAwareParser()
552
+ parser.parse(encodeRelation(oid, 'public', 'bool_update', 0x64, cols))
553
+
554
+ const parsed = parser.parse(
555
+ encodeUpdate(oid, { id: '1', completed: 1 }, { id: '1', completed: 0 }, cols)
556
+ )
557
+ expect(parsed.tag).toBe('update')
558
+ expect(parsed.old.completed).toBe(false)
559
+ expect(parsed.new.completed).toBe(true)
560
+ })
561
+
493
562
  it('DELETE roundtrip', () => {
494
563
  const oid = getTableOid('rt.del_test')
495
564
  const cols: ColumnInfo[] = [