orez 0.2.25 → 0.2.26

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 (117) 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 +642 -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 +96 -17
  21. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  22. package/dist/pg-proxy-do-backend.js +6033 -454
  23. package/dist/pg-proxy-do-backend.js.map +1 -1
  24. package/dist/replication/change-tracker.d.ts.map +1 -1
  25. package/dist/replication/change-tracker.js +18 -1
  26. package/dist/replication/change-tracker.js.map +1 -1
  27. package/dist/replication/handler.d.ts.map +1 -1
  28. package/dist/replication/handler.js +7 -2
  29. package/dist/replication/handler.js.map +1 -1
  30. package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
  31. package/dist/replication/pgoutput-encoder.js +72 -30
  32. package/dist/replication/pgoutput-encoder.js.map +1 -1
  33. package/dist/worker/browser-build-config.d.ts.map +1 -1
  34. package/dist/worker/browser-build-config.js +2 -1
  35. package/dist/worker/browser-build-config.js.map +1 -1
  36. package/dist/worker/cf-patches.d.ts +5 -2
  37. package/dist/worker/cf-patches.d.ts.map +1 -1
  38. package/dist/worker/cf-patches.js +238 -4
  39. package/dist/worker/cf-patches.js.map +1 -1
  40. package/dist/worker/shims/node-stub.d.ts +35 -0
  41. package/dist/worker/shims/node-stub.d.ts.map +1 -1
  42. package/dist/worker/shims/node-stub.js +53 -1
  43. package/dist/worker/shims/node-stub.js.map +1 -1
  44. package/dist/worker/shims/oxfmt.d.ts +4 -0
  45. package/dist/worker/shims/oxfmt.d.ts.map +1 -0
  46. package/dist/worker/shims/oxfmt.js +4 -0
  47. package/dist/worker/shims/oxfmt.js.map +1 -0
  48. package/dist/worker/shims/postgres-socket.js +1 -1
  49. package/dist/worker/shims/postgres-socket.js.map +1 -1
  50. package/dist/worker/shims/sqlite.d.ts +1 -0
  51. package/dist/worker/shims/sqlite.d.ts.map +1 -1
  52. package/dist/worker/shims/sqlite.js +229 -9
  53. package/dist/worker/shims/sqlite.js.map +1 -1
  54. package/dist/worker/shims/ws.d.ts.map +1 -1
  55. package/dist/worker/shims/ws.js +45 -0
  56. package/dist/worker/shims/ws.js.map +1 -1
  57. package/dist/worker/shims/zero-process-env.d.ts +2 -0
  58. package/dist/worker/shims/zero-process-env.d.ts.map +1 -0
  59. package/dist/worker/shims/zero-process-env.js +9 -0
  60. package/dist/worker/shims/zero-process-env.js.map +1 -0
  61. package/dist/worker/zero-cache-embed-cf.d.ts +29 -12
  62. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
  63. package/dist/worker/zero-cache-embed-cf.js +83 -14
  64. package/dist/worker/zero-cache-embed-cf.js.map +1 -1
  65. package/package.json +6 -2
  66. package/src/cf-do/.wrangler/cache/cf.json +1 -0
  67. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  68. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  69. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  70. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
  71. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
  72. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite +0 -0
  73. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
  74. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
  75. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
  76. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
  77. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
  78. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
  79. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
  80. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
  81. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
  82. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
  83. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
  84. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
  85. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  86. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  87. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  88. package/src/cf-do/ARCHITECTURE.md +83 -0
  89. package/src/cf-do/watermark.test.ts +103 -0
  90. package/src/cf-do/watermark.ts +118 -0
  91. package/src/cf-do/worker.ts +1033 -0
  92. package/src/cf-do/wrangler.toml +11 -0
  93. package/src/config.ts +1 -1
  94. package/src/do-sql-tracking.test.ts +19 -0
  95. package/src/do-sql-tracking.ts +19 -0
  96. package/src/index.ts +29 -14
  97. package/src/pg-proxy-browser.ts +6 -6
  98. package/src/pg-proxy-do-backend.test.ts +3890 -0
  99. package/src/pg-proxy-do-backend.ts +6799 -482
  100. package/src/replication/change-tracker.ts +16 -1
  101. package/src/replication/handler.test.ts +35 -0
  102. package/src/replication/handler.ts +7 -2
  103. package/src/replication/pgoutput-encoder.test.ts +71 -2
  104. package/src/replication/pgoutput-encoder.ts +65 -30
  105. package/src/worker/browser-build-config.test.ts +12 -0
  106. package/src/worker/browser-build-config.ts +2 -1
  107. package/src/worker/cf-patches.ts +274 -4
  108. package/src/worker/shims/node-stub.ts +53 -1
  109. package/src/worker/shims/oxfmt.ts +3 -0
  110. package/src/worker/shims/postgres-socket.ts +1 -1
  111. package/src/worker/shims/sqlite.test.ts +145 -0
  112. package/src/worker/shims/sqlite.ts +256 -9
  113. package/src/worker/shims/ws.ts +45 -0
  114. package/src/worker/shims/zero-process-env.ts +11 -0
  115. package/src/worker/zero-cache-embed-cf.ts +114 -18
  116. package/src/query-rewrites.test.ts +0 -30
  117. package/src/query-rewrites.ts +0 -152
@@ -4,28 +4,29 @@
4
4
  * runs zero-cache in-process with SINGLE_PROCESS=1, using bundler aliases
5
5
  * to swap Node.js dependencies for CF-compatible shims:
6
6
  *
7
- * postgres → orez/worker/shims/postgres (PGlite-backed)
7
+ * postgres → orez/worker/shims/postgres-browser
8
+ * (real postgres package over MessagePort)
8
9
  * @rocicorp/zero-sqlite3 → orez/worker/shims/sqlite (DO SQLite)
9
10
  * fastify → orez/worker/shims/fastify (route capture)
10
11
  * ws → orez/worker/shims/ws (CF WebSocket)
11
12
  *
12
- * the consumer's wrangler.toml must configure these aliases and enable
13
- * nodejs_compat for the remaining Node.js APIs (events, stream, etc.).
13
+ * the postgres MessagePort proxy is backed by DoBackend, so zero-cache still
14
+ * uses its real PG wire protocol, but storage is Cloudflare DO SQLite instead
15
+ * of PGlite.
14
16
  *
15
17
  * usage in a Durable Object:
16
18
  *
17
19
  * import { startZeroCacheEmbedCF } from 'orez/worker'
18
20
  *
19
21
  * // in ensureInitialized():
20
- * globalThis.__orez_pglite = pglite // for postgres shim
21
- * globalThis.__orez_do_sqlite = ctx.storage.sql // for sqlite shim
22
- *
23
22
  * const zc = await startZeroCacheEmbedCF({ ... })
24
23
  *
25
24
  * // in DO fetch():
26
25
  * return zc.handleRequest(request)
27
26
  */
28
27
 
28
+ import './shims/zero-process-env.js'
29
+
29
30
  import EventEmitter from 'node:events'
30
31
 
31
32
  // static import so wrangler can follow the dependency tree and bundle
@@ -33,7 +34,8 @@ import EventEmitter from 'node:events'
33
34
  // @ts-expect-error — internal zero-cache module, no type declarations
34
35
  import { runWorker as _runWorker } from '@rocicorp/zero/out/zero-cache/src/server/runner/run-worker.js'
35
36
 
36
- import type { PGlite } from '@electric-sql/pglite'
37
+ import { createBrowserProxy, type BrowserProxy } from '../pg-proxy-browser.js'
38
+ import { DoBackend } from '../pg-proxy-do-backend.js'
37
39
 
38
40
  const runWorkerFn = _runWorker as (
39
41
  parent: unknown,
@@ -41,12 +43,32 @@ const runWorkerFn = _runWorker as (
41
43
  ) => Promise<void>
42
44
 
43
45
  export interface ZeroCacheEmbedCFOptions {
44
- /** PGlite instance (also registered on globalThis.__orez_pglite) */
45
- pglite: PGlite
46
-
47
46
  /** DO SQLite storage (also registered on globalThis.__orez_do_sqlite) */
48
47
  doSqlite: unknown
49
48
 
49
+ /**
50
+ * base URL for the DO SQL execution endpoints (`/exec`, `/batch`).
51
+ * ignored when `backends` is supplied.
52
+ */
53
+ backendUrl?: string
54
+
55
+ /** custom fetch used by DoBackend; lets a DO route directly to another DO stub. */
56
+ backendFetch?: typeof fetch
57
+
58
+ /** namespace sent to the DO SQL endpoints. */
59
+ backendNamespace?: string
60
+
61
+ /** pre-created DoBackend instances. mainly useful for tests. */
62
+ backends?: {
63
+ postgres: DoBackend
64
+ cvr: DoBackend
65
+ cdb: DoBackend
66
+ }
67
+
68
+ /** postgres username/password expected by the in-process proxy. */
69
+ pgUser?: string
70
+ pgPassword?: string
71
+
50
72
  /** zero app ID (default: 'zero') */
51
73
  appId?: string
52
74
 
@@ -56,6 +78,9 @@ export interface ZeroCacheEmbedCFOptions {
56
78
  /** additional env vars passed to zero-cache */
57
79
  env?: Record<string, string>
58
80
 
81
+ /** fetch implementation for Worker-local mutate/query API URLs. */
82
+ apiFetch?: typeof fetch
83
+
59
84
  /** timeout in ms waiting for zero-cache ready (default: 30000) */
60
85
  readyTimeout?: number
61
86
  }
@@ -78,9 +103,8 @@ export interface ZeroCacheEmbedCF {
78
103
  /**
79
104
  * start zero-cache in embedded CF Workers mode.
80
105
  *
81
- * must be called AFTER setting up globalThis:
82
- * globalThis.__orez_pglite = pgliteInstance
83
- * globalThis.__orez_do_sqlite = ctx.storage.sql
106
+ * must be called with a DO SQLite handle for zero-cache's replica storage and
107
+ * a DoBackend target for upstream/CVR/change Postgres connections.
84
108
  */
85
109
  export async function startZeroCacheEmbedCF(
86
110
  opts: ZeroCacheEmbedCFOptions
@@ -88,10 +112,53 @@ export async function startZeroCacheEmbedCF(
88
112
  const appId = opts.appId || 'zero'
89
113
  const publications = opts.publications?.join(',') || `orez_${appId}_public`
90
114
  const readyTimeout = opts.readyTimeout ?? 30000
115
+ const pgUser = opts.pgUser || 'user'
116
+ const pgPassword = opts.pgPassword || ''
117
+ const backendUrl = opts.backendUrl || 'https://orez-do-backend.local'
118
+ const backendNamespace = opts.backendNamespace || appId
119
+
120
+ const backends =
121
+ opts.backends ??
122
+ ({
123
+ postgres: new DoBackend(backendUrl, 'postgres', backendNamespace, {
124
+ fetch: opts.backendFetch,
125
+ }),
126
+ cvr: new DoBackend(backendUrl, 'zero_cvr', backendNamespace, {
127
+ fetch: opts.backendFetch,
128
+ }),
129
+ cdb: new DoBackend(backendUrl, 'zero_cdb', backendNamespace, {
130
+ fetch: opts.backendFetch,
131
+ }),
132
+ } satisfies NonNullable<ZeroCacheEmbedCFOptions['backends']>)
133
+
134
+ await Promise.all([
135
+ backends.postgres.waitReady,
136
+ backends.cvr.waitReady,
137
+ backends.cdb.waitReady,
138
+ ])
139
+
140
+ const proxy: BrowserProxy = await createBrowserProxy(
141
+ {
142
+ postgres: backends.postgres as any,
143
+ cvr: backends.cvr as any,
144
+ cdb: backends.cdb as any,
145
+ postgresReplicas: [],
146
+ } as any,
147
+ {
148
+ pgUser,
149
+ pgPassword,
150
+ singleDb: false,
151
+ logLevel: opts.env?.ZERO_LOG_LEVEL || 'info',
152
+ }
153
+ )
91
154
 
92
155
  // ensure globals are set for shims
93
- ;(globalThis as any).__orez_pglite = opts.pglite
94
156
  ;(globalThis as any).__orez_do_sqlite = opts.doSqlite
157
+ ;(globalThis as any).__orez_proxy_connect = (port: MessagePort) => {
158
+ proxy.handleConnection(port)
159
+ }
160
+ ;(globalThis as any).__orez_proxy_user = pgUser
161
+ ;(globalThis as any).__orez_proxy_password = pgPassword
95
162
 
96
163
  // ensure process.env exists (CF Workers doesn't have it natively)
97
164
  ;(globalThis as any).process ??= {}
@@ -130,30 +197,45 @@ export async function startZeroCacheEmbedCF(
130
197
  const origExit = (globalThis as any).process.exit
131
198
  const origNodeEnv = (globalThis as any).process.env.NODE_ENV
132
199
  const origKill = (globalThis as any).process.kill
200
+ const origFetch = (globalThis as any).fetch
133
201
  ;(globalThis as any).process.exit = (code?: number) => {
134
202
  parent.emit('exit', code ?? 0)
135
203
  }
204
+ if (opts.apiFetch) {
205
+ ;(globalThis as any).fetch = (input: RequestInfo | URL, init?: RequestInit) => {
206
+ const request = new Request(input, init)
207
+ const url = new URL(request.url)
208
+ if (url.hostname === 'orez-zero-api.local') return opts.apiFetch!(request)
209
+ return origFetch(input as any, init as any)
210
+ }
211
+ }
136
212
 
137
213
  // build env for zero-cache
138
214
  const env: Record<string, string> = {
139
215
  ...((globalThis as any).process.env as Record<string, string>),
140
216
  SINGLE_PROCESS: '1',
141
217
  NODE_ENV: 'development',
142
- // these connection strings are intercepted by the postgres shim
143
- ZERO_UPSTREAM_DB: 'pglite://in-process',
144
- ZERO_CVR_DB: 'pglite://in-process',
145
- ZERO_CHANGE_DB: 'pglite://in-process',
218
+ // postgres-browser intercepts these URLs and routes PG wire over
219
+ // MessagePort to the DoBackend-backed proxy above.
220
+ ZERO_UPSTREAM_DB: `postgres://${pgUser}:ignored@127.0.0.1/postgres`,
221
+ ZERO_CVR_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cvr`,
222
+ ZERO_CHANGE_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cdb`,
146
223
  // this path is intercepted by the sqlite shim
147
224
  ZERO_REPLICA_FILE: ':do-sqlite:',
148
225
  // don't bind a port — we route via inject/handoff
149
226
  ZERO_PORT: '0',
150
227
  ZERO_APP_ID: appId,
151
228
  ZERO_APP_PUBLICATIONS: publications,
229
+ ZERO_ADMIN_PASSWORD: opts.env?.ZERO_ADMIN_PASSWORD || crypto.randomUUID(),
152
230
  ZERO_LOG_LEVEL: opts.env?.ZERO_LOG_LEVEL || 'info',
153
231
  ZERO_NUM_SYNC_WORKERS: opts.env?.ZERO_NUM_SYNC_WORKERS || '1',
154
232
  ZERO_ENABLE_QUERY_PLANNER: 'false',
155
233
  ...opts.env,
156
234
  }
235
+ Object.assign((globalThis as any).process.env, env)
236
+
237
+ const debugEmbed =
238
+ env.OREZ_DEBUG_EMBED === '1' || (globalThis as any).__OREZ_DEBUG_EMBED__ === true
157
239
 
158
240
  // wrap parent with onMessageType/onceMessageType helpers
159
241
  // must forward sendHandle (second arg) for WebSocket handoff
@@ -204,6 +286,7 @@ export async function startZeroCacheEmbedCF(
204
286
  }, readyTimeout)
205
287
 
206
288
  parentEmitter.on('message', (msg: unknown) => {
289
+ if (debugEmbed) console.debug('[orez-zero-cache-cf] parent message', msg)
207
290
  if (Array.isArray(msg) && msg[0] === 'ready') {
208
291
  clearTimeout(timeout)
209
292
  isReady = true
@@ -214,6 +297,7 @@ export async function startZeroCacheEmbedCF(
214
297
 
215
298
  // start zero-cache
216
299
  runWorkerPromise = runWorkerFn(wrappedParent, env).catch((err) => {
300
+ if (debugEmbed) console.error('[orez-zero-cache-cf] runWorker error', err)
217
301
  if (!isReady) {
218
302
  throw err
219
303
  }
@@ -255,6 +339,12 @@ export async function startZeroCacheEmbedCF(
255
339
  await Promise.race([runWorkerPromise, new Promise((r) => setTimeout(r, 5000))])
256
340
  }
257
341
  await new Promise((r) => setTimeout(r, 200))
342
+ proxy.close()
343
+ await Promise.all([
344
+ backends.postgres.close(),
345
+ backends.cvr.close(),
346
+ backends.cdb.close(),
347
+ ])
258
348
  // restore all modified globals
259
349
  if (origExit) {
260
350
  ;(globalThis as any).process.exit = origExit
@@ -265,7 +355,13 @@ export async function startZeroCacheEmbedCF(
265
355
  if (origKill) {
266
356
  ;(globalThis as any).process.kill = origKill
267
357
  }
358
+ if (opts.apiFetch) {
359
+ ;(globalThis as any).fetch = origFetch
360
+ }
268
361
  delete (globalThis as any).process.env.SINGLE_PROCESS
362
+ delete (globalThis as any).__orez_proxy_connect
363
+ delete (globalThis as any).__orez_proxy_user
364
+ delete (globalThis as any).__orez_proxy_password
269
365
  },
270
366
  }
271
367
  }
@@ -1,30 +0,0 @@
1
- import { describe, expect, test } from 'vitest'
2
-
3
- import { rewritePgColumnSizeTotalBytesQuery } from './query-rewrites.js'
4
-
5
- describe('rewritePgColumnSizeTotalBytesQuery', () => {
6
- test('rewrites zero totalBytes pg_column_size sums into scalar subselects', () => {
7
- const query =
8
- 'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + SUM(COALESCE(pg_column_size("parts"), 0)) + SUM(COALESCE(pg_column_size("threadId"), 0))) AS "totalBytes" FROM "public"."message" '
9
-
10
- expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(
11
- 'SELECT (SELECT SUM(COALESCE(pg_column_size("id"), 0)) FROM "public"."message") + (SELECT SUM(COALESCE(pg_column_size("parts"), 0)) FROM "public"."message") + (SELECT SUM(COALESCE(pg_column_size("threadId"), 0)) FROM "public"."message") AS "totalBytes"'
12
- )
13
- })
14
-
15
- test('preserves row filters on every scalar subselect', () => {
16
- const query =
17
- 'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + SUM(COALESCE(pg_column_size("parts"), 0))) AS "totalBytes" FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\';'
18
-
19
- expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(
20
- 'SELECT (SELECT SUM(COALESCE(pg_column_size("id"), 0)) FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\') + (SELECT SUM(COALESCE(pg_column_size("parts"), 0)) FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\') AS "totalBytes"'
21
- )
22
- })
23
-
24
- test('leaves non-matching SQL unchanged', () => {
25
- const query =
26
- 'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + count(*)) AS "totalBytes" FROM "public"."message"'
27
-
28
- expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(query)
29
- })
30
- })
@@ -1,152 +0,0 @@
1
- const TOTAL_BYTES_ALIAS_RE = /^AS\s+"totalBytes"\s+/i
2
- const TOTAL_BYTES_TERM_RE =
3
- /^SUM\s*\(\s*COALESCE\s*\(\s*pg_column_size\s*\(\s*((?:"(?:[^"]|"")+")|(?:[a-z_][a-z0-9_$]*))\s*\)\s*,\s*0\s*\)\s*\)$/i
4
-
5
- function findMatchingParen(sql: string, openIndex: number): number {
6
- let depth = 0
7
- let inSingleQuote = false
8
- let inDoubleQuote = false
9
-
10
- for (let i = openIndex; i < sql.length; i++) {
11
- const ch = sql[i]
12
- const next = sql[i + 1]
13
-
14
- if (inSingleQuote) {
15
- if (ch === "'" && next === "'") {
16
- i++
17
- } else if (ch === "'") {
18
- inSingleQuote = false
19
- }
20
- continue
21
- }
22
-
23
- if (inDoubleQuote) {
24
- if (ch === '"' && next === '"') {
25
- i++
26
- } else if (ch === '"') {
27
- inDoubleQuote = false
28
- }
29
- continue
30
- }
31
-
32
- if (ch === "'") {
33
- inSingleQuote = true
34
- continue
35
- }
36
- if (ch === '"') {
37
- inDoubleQuote = true
38
- continue
39
- }
40
- if (ch === '(') {
41
- depth++
42
- continue
43
- }
44
- if (ch === ')') {
45
- depth--
46
- if (depth === 0) return i
47
- }
48
- }
49
-
50
- return -1
51
- }
52
-
53
- function splitTopLevelAddends(expr: string): string[] | null {
54
- const terms: string[] = []
55
- let depth = 0
56
- let start = 0
57
- let inSingleQuote = false
58
- let inDoubleQuote = false
59
-
60
- for (let i = 0; i < expr.length; i++) {
61
- const ch = expr[i]
62
- const next = expr[i + 1]
63
-
64
- if (inSingleQuote) {
65
- if (ch === "'" && next === "'") {
66
- i++
67
- } else if (ch === "'") {
68
- inSingleQuote = false
69
- }
70
- continue
71
- }
72
-
73
- if (inDoubleQuote) {
74
- if (ch === '"' && next === '"') {
75
- i++
76
- } else if (ch === '"') {
77
- inDoubleQuote = false
78
- }
79
- continue
80
- }
81
-
82
- if (ch === "'") {
83
- inSingleQuote = true
84
- continue
85
- }
86
- if (ch === '"') {
87
- inDoubleQuote = true
88
- continue
89
- }
90
- if (ch === '(') {
91
- depth++
92
- continue
93
- }
94
- if (ch === ')') {
95
- depth--
96
- if (depth < 0) return null
97
- continue
98
- }
99
- if (ch === '+' && depth === 0) {
100
- terms.push(expr.slice(start, i).trim())
101
- start = i + 1
102
- }
103
- }
104
-
105
- if (depth !== 0 || inSingleQuote || inDoubleQuote) return null
106
-
107
- terms.push(expr.slice(start).trim())
108
- return terms
109
- }
110
-
111
- function stripTrailingSemicolon(sql: string): string {
112
- const trimmedEnd = sql.trimEnd()
113
- return trimmedEnd.endsWith(';') ? trimmedEnd.slice(0, -1) : sql
114
- }
115
-
116
- export function rewritePgColumnSizeTotalBytesQuery(query: string): string {
117
- const leadingWhitespace = query.match(/^\s*/)?.[0] ?? ''
118
- const trimmedStart = query.slice(leadingWhitespace.length)
119
- if (!/^SELECT\b/i.test(trimmedStart)) return query
120
-
121
- const afterSelect = trimmedStart.slice('SELECT'.length).trimStart()
122
- if (!afterSelect.startsWith('(')) return query
123
-
124
- const openIndex = trimmedStart.indexOf(afterSelect)
125
- const closeIndex = findMatchingParen(trimmedStart, openIndex)
126
- if (closeIndex < 0) return query
127
-
128
- const expression = trimmedStart.slice(openIndex + 1, closeIndex)
129
- const afterExpression = trimmedStart.slice(closeIndex + 1).trimStart()
130
- const aliasMatch = afterExpression.match(TOTAL_BYTES_ALIAS_RE)
131
- if (!aliasMatch) return query
132
-
133
- const fromClause = stripTrailingSemicolon(
134
- afterExpression.slice(aliasMatch[0].length).trim()
135
- )
136
- if (!/^FROM\b/i.test(fromClause)) return query
137
-
138
- const terms = splitTopLevelAddends(expression)
139
- if (!terms || terms.length === 0) return query
140
-
141
- const columns: string[] = []
142
- for (const term of terms) {
143
- const match = term.match(TOTAL_BYTES_TERM_RE)
144
- if (!match) return query
145
- columns.push(match[1])
146
- }
147
-
148
- const rewrittenTerms = columns.map(
149
- (column) => `(SELECT SUM(COALESCE(pg_column_size(${column}), 0)) ${fromClause})`
150
- )
151
- return `${leadingWhitespace}SELECT ${rewrittenTerms.join(' + ')} AS "totalBytes"`
152
- }