orez 0.2.26 → 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 (103) hide show
  1. package/dist/cf-do/worker.d.ts.map +1 -1
  2. package/dist/cf-do/worker.js +9 -1
  3. package/dist/cf-do/worker.js.map +1 -1
  4. package/dist/pg-proxy-do-backend.d.ts +2 -0
  5. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  6. package/dist/pg-proxy-do-backend.js +49 -7
  7. package/dist/pg-proxy-do-backend.js.map +1 -1
  8. package/dist/pg-sqlite-compiler/catalog/seed.d.ts +67 -0
  9. package/dist/pg-sqlite-compiler/catalog/seed.d.ts.map +1 -0
  10. package/dist/pg-sqlite-compiler/catalog/seed.js +436 -0
  11. package/dist/pg-sqlite-compiler/catalog/seed.js.map +1 -0
  12. package/dist/pg-sqlite-compiler/index.d.ts +12 -0
  13. package/dist/pg-sqlite-compiler/index.d.ts.map +1 -0
  14. package/dist/pg-sqlite-compiler/index.js +59 -0
  15. package/dist/pg-sqlite-compiler/index.js.map +1 -0
  16. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts +48 -0
  17. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts.map +1 -0
  18. package/dist/pg-sqlite-compiler/passes/ast-utils.js +93 -0
  19. package/dist/pg-sqlite-compiler/passes/ast-utils.js.map +1 -0
  20. package/dist/pg-sqlite-compiler/passes/catalog.d.ts +34 -0
  21. package/dist/pg-sqlite-compiler/passes/catalog.d.ts.map +1 -0
  22. package/dist/pg-sqlite-compiler/passes/catalog.js +30 -0
  23. package/dist/pg-sqlite-compiler/passes/catalog.js.map +1 -0
  24. package/dist/pg-sqlite-compiler/passes/datetime.d.ts +21 -0
  25. package/dist/pg-sqlite-compiler/passes/datetime.d.ts.map +1 -0
  26. package/dist/pg-sqlite-compiler/passes/datetime.js +53 -0
  27. package/dist/pg-sqlite-compiler/passes/datetime.js.map +1 -0
  28. package/dist/pg-sqlite-compiler/passes/index.d.ts +21 -0
  29. package/dist/pg-sqlite-compiler/passes/index.d.ts.map +1 -0
  30. package/dist/pg-sqlite-compiler/passes/index.js +39 -0
  31. package/dist/pg-sqlite-compiler/passes/index.js.map +1 -0
  32. package/dist/pg-sqlite-compiler/passes/types.d.ts +41 -0
  33. package/dist/pg-sqlite-compiler/passes/types.d.ts.map +1 -0
  34. package/dist/pg-sqlite-compiler/passes/types.js +103 -0
  35. package/dist/pg-sqlite-compiler/passes/types.js.map +1 -0
  36. package/dist/pg-sqlite-compiler/test/oracle.d.ts +34 -0
  37. package/dist/pg-sqlite-compiler/test/oracle.d.ts.map +1 -0
  38. package/dist/pg-sqlite-compiler/test/oracle.js +204 -0
  39. package/dist/pg-sqlite-compiler/test/oracle.js.map +1 -0
  40. package/dist/pg-sqlite-compiler/types.d.ts +55 -0
  41. package/dist/pg-sqlite-compiler/types.d.ts.map +1 -0
  42. package/dist/pg-sqlite-compiler/types.js +2 -0
  43. package/dist/pg-sqlite-compiler/types.js.map +1 -0
  44. package/package.json +7 -2
  45. package/src/cf-do/.wrangler/cache/cf.json +1 -1
  46. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  47. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  48. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  49. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  50. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +11 -0
  51. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +134 -0
  52. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +11 -0
  53. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +134 -0
  54. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +1059 -0
  55. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +8 -0
  56. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +1059 -0
  57. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +8 -0
  58. package/src/cf-do/ARCHITECTURE.md +10 -0
  59. package/src/cf-do/CHAT_E2E.md +213 -0
  60. package/src/cf-do/worker.ts +11 -3
  61. package/src/cli.test.ts +3 -1
  62. package/src/pg-proxy-do-backend.ts +44 -10
  63. package/src/pg-sqlite-compiler/README.md +53 -0
  64. package/src/pg-sqlite-compiler/catalog/seed.ts +524 -0
  65. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +307 -0
  66. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +377 -0
  67. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +12 -0
  68. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +447 -0
  69. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +32 -0
  70. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +397 -0
  71. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +337 -0
  72. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +337 -0
  73. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +537 -0
  74. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +1837 -0
  75. package/src/pg-sqlite-compiler/index.ts +73 -0
  76. package/src/pg-sqlite-compiler/integration.test.ts +136 -0
  77. package/src/pg-sqlite-compiler/passes/ast-utils.ts +113 -0
  78. package/src/pg-sqlite-compiler/passes/catalog.ts +65 -0
  79. package/src/pg-sqlite-compiler/passes/datetime.ts +74 -0
  80. package/src/pg-sqlite-compiler/passes/index.ts +49 -0
  81. package/src/pg-sqlite-compiler/passes/types.ts +156 -0
  82. package/src/pg-sqlite-compiler/smoke.test.ts +69 -0
  83. package/src/pg-sqlite-compiler/test/catalog.test.ts +171 -0
  84. package/src/pg-sqlite-compiler/test/corpus.test.ts +161 -0
  85. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +102 -0
  86. package/src/pg-sqlite-compiler/test/oracle.ts +237 -0
  87. package/src/pg-sqlite-compiler/test/types.test.ts +109 -0
  88. package/src/pg-sqlite-compiler/types.ts +63 -0
  89. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
  90. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
  91. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
  92. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
  93. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
  94. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
  95. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
  96. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
  97. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
  98. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
  99. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
  100. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
  101. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
  102. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
  103. /package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/{204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite → 0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite} +0 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * pg-sqlite-compiler — PostgreSQL SQL → SQLite SQL.
3
+ *
4
+ * Single-pass visitor over the libpg_query AST, emitting via pgsql-deparser.
5
+ *
6
+ * Public API:
7
+ * compile(pgSql, opts?) → { sql, warnings }
8
+ * compileMany(pgSqls, opts?) → results[]
9
+ */
10
+ import { deparseSync, parseSync } from 'pgsql-parser'
11
+
12
+ import { runPasses } from './passes/index.js'
13
+
14
+ import type { CompileOptions, CompileResult, SchemaInfo } from './types.js'
15
+
16
+ const DEFAULT_VERSION = 170004
17
+
18
+ const NOOP_SCHEMA: SchemaInfo = {
19
+ getColumnType: () => undefined,
20
+ getEnum: () => undefined,
21
+ isEnumValue: () => false,
22
+ getTableColumns: () => undefined,
23
+ }
24
+
25
+ function stripTrailingSemicolon(s: string): string {
26
+ let i = s.length
27
+ while (i > 0 && (s[i - 1] === ';' || s[i - 1] === ' ' || s[i - 1] === '\n')) i--
28
+ return s.slice(0, i)
29
+ }
30
+
31
+ /**
32
+ * Compile a single PG SQL statement into a SQLite-compatible statement.
33
+ *
34
+ * The compiler is best-effort: it applies the registered passes and emits via
35
+ * pgsql-deparser. Some PG-isms are not translatable; those produce warnings
36
+ * but the SQL is still emitted (caller can decide to reject or run anyway).
37
+ */
38
+ export function compile(pgSql: string, opts: CompileOptions = {}): CompileResult {
39
+ const schema = opts.schema ?? NOOP_SCHEMA
40
+ const version = opts.pgVersion ?? DEFAULT_VERSION
41
+ const passes = opts.passes
42
+ const warnings: CompileResult['warnings'] = []
43
+
44
+ const trimmed = stripTrailingSemicolon(pgSql.trim())
45
+ if (!trimmed) return { sql: '', warnings }
46
+
47
+ // parseSync returns a ParseResult: { version, stmts: [{ stmt: {...} }, ...] }
48
+ const parsed = parseSync(trimmed) as { version?: number; stmts?: any[] } | any
49
+ const stmts: any[] = Array.isArray(parsed?.stmts) ? parsed.stmts : []
50
+ if (stmts.length === 0) {
51
+ return {
52
+ sql: trimmed,
53
+ warnings: [{ kind: 'parse-empty', message: 'no statements parsed' }],
54
+ }
55
+ }
56
+
57
+ // Run all passes on each top-level RawStmt entry (so passes can walk from root).
58
+ for (let i = 0; i < stmts.length; i++) {
59
+ runPasses(stmts[i], { schema, warnings, passes })
60
+ }
61
+
62
+ const emitted = deparseSync({ version: parsed.version ?? version, stmts } as any)
63
+ return { sql: stripTrailingSemicolon(emitted.trim()), warnings }
64
+ }
65
+
66
+ export function compileMany(
67
+ pgSqls: string[],
68
+ opts: CompileOptions = {}
69
+ ): CompileResult[] {
70
+ return pgSqls.map((s) => compile(s, opts))
71
+ }
72
+
73
+ export type { CompileOptions, CompileResult, SchemaInfo } from './types.js'
@@ -0,0 +1,136 @@
1
+ import Database from '@rocicorp/zero-sqlite3'
2
+ /**
3
+ * Integration test — drives the full pass pipeline against representative
4
+ * chat-app workloads: CREATE TABLE + INSERT + SELECT + catalog probe, all
5
+ * round-tripping through a real @rocicorp/zero-sqlite3 instance.
6
+ *
7
+ * Tests the END-TO-END story: "the compiler turns PG SQL into SQLite SQL
8
+ * that executes correctly against DO storage, including the catalog probes
9
+ * zero-cache makes on startup."
10
+ */
11
+ import { describe, expect, it } from 'vitest'
12
+
13
+ import { buildCatalogTables } from './catalog/seed.js'
14
+ import { compile } from './index.js'
15
+
16
+ function rewriteParams(sql: string): string {
17
+ return sql.replace(/\$\d+/g, '?')
18
+ }
19
+
20
+ describe('full compiler pipeline against chat-app workload', () => {
21
+ it('round-trip: schema → insert → select → catalog probe', () => {
22
+ const db = new Database(':memory:')
23
+
24
+ // 1. CREATE TABLE with PG types — types pass should make these all
25
+ // SQLite-valid and the DDL should execute.
26
+ const ddlStatements = [
27
+ `CREATE TABLE event (
28
+ id BIGSERIAL PRIMARY KEY,
29
+ user_id uuid NOT NULL,
30
+ "createdAt" timestamp with time zone DEFAULT NOW() NOT NULL,
31
+ payload jsonb NOT NULL DEFAULT '{}',
32
+ tags text[] NOT NULL DEFAULT '[]'
33
+ )`,
34
+ `CREATE TABLE message (
35
+ id text PRIMARY KEY,
36
+ content text NOT NULL,
37
+ sent_at timestamp DEFAULT NOW() NOT NULL,
38
+ read boolean NOT NULL DEFAULT false
39
+ )`,
40
+ ]
41
+ for (const s of ddlStatements) {
42
+ const { sql, warnings } = compile(s)
43
+ expect(warnings).toEqual([])
44
+ db.exec(sql)
45
+ }
46
+
47
+ // 2. INSERT + SELECT roundtrip
48
+ const { sql: insertSql } = compile(
49
+ 'INSERT INTO message (id, content) VALUES ($1, $2)'
50
+ )
51
+ db.prepare(rewriteParams(insertSql)).run('m1', 'hello world')
52
+
53
+ const { sql: selectSql } = compile(
54
+ 'SELECT id, content, sent_at FROM message WHERE id = $1'
55
+ )
56
+ const rows = db.prepare(rewriteParams(selectSql)).all('m1') as any[]
57
+ expect(rows).toHaveLength(1)
58
+ expect(rows[0].id).toBe('m1')
59
+ expect(rows[0].content).toBe('hello world')
60
+ expect(String(rows[0].sent_at)).toMatch(/^\d{4}-\d{2}-\d{2}/)
61
+
62
+ // 3. Catalog seed + probe — zero-cache on startup
63
+ buildCatalogTables(db, { publications: ['orez_zero_public'] })
64
+
65
+ const { sql: catalogSql } = compile(
66
+ "SELECT relname FROM pg_catalog.pg_class WHERE relkind = 'r' ORDER BY relname"
67
+ )
68
+ const catalogRows = db.prepare(rewriteParams(catalogSql)).all() as {
69
+ relname: string
70
+ }[]
71
+ const tableNames = catalogRows.map((r) => r.relname)
72
+ expect(tableNames).toContain('event')
73
+ expect(tableNames).toContain('message')
74
+
75
+ // 4. information_schema.columns probe
76
+ const { sql: colSql } = compile(
77
+ 'SELECT column_name, data_type FROM information_schema.columns ' +
78
+ "WHERE table_name = 'message' ORDER BY ordinal_position"
79
+ )
80
+ const cols = db.prepare(rewriteParams(colSql)).all() as {
81
+ column_name: string
82
+ data_type: string
83
+ }[]
84
+ expect(cols.map((c) => c.column_name)).toEqual(['id', 'content', 'sent_at', 'read'])
85
+
86
+ // 5. pg_publication probe
87
+ const { sql: pubSql } = compile(
88
+ "SELECT pubname FROM pg_catalog.pg_publication WHERE pubname = 'orez_zero_public'"
89
+ )
90
+ const pubs = db.prepare(rewriteParams(pubSql)).all() as { pubname: string }[]
91
+ expect(pubs).toHaveLength(1)
92
+ expect(pubs[0].pubname).toBe('orez_zero_public')
93
+
94
+ db.close()
95
+ })
96
+
97
+ it('insert with ON CONFLICT + RETURNING', () => {
98
+ const db = new Database(':memory:')
99
+ const { sql: ddl } = compile(
100
+ 'CREATE TABLE counter (key text PRIMARY KEY, count integer NOT NULL DEFAULT 0)'
101
+ )
102
+ db.exec(ddl)
103
+
104
+ const { sql: insertSql } = compile(
105
+ 'INSERT INTO counter (key, count) VALUES ($1, 1) ' +
106
+ 'ON CONFLICT (key) DO UPDATE SET count = counter.count + 1 ' +
107
+ 'RETURNING key, count'
108
+ )
109
+ const r1 = db.prepare(rewriteParams(insertSql)).all('hits') as any[]
110
+ expect(r1).toEqual([{ key: 'hits', count: 1 }])
111
+ const r2 = db.prepare(rewriteParams(insertSql)).all('hits') as any[]
112
+ expect(r2).toEqual([{ key: 'hits', count: 2 }])
113
+ db.close()
114
+ })
115
+
116
+ it('UPDATE with NOW() in SET', () => {
117
+ const db = new Database(':memory:')
118
+ const { sql: ddl } = compile(
119
+ 'CREATE TABLE event (id text PRIMARY KEY, "updatedAt" timestamp)'
120
+ )
121
+ db.exec(ddl)
122
+ db.prepare('INSERT INTO event (id) VALUES (?)').run('e1')
123
+
124
+ const { sql: updateSql } = compile(
125
+ 'UPDATE event SET "updatedAt" = NOW() WHERE id = $1'
126
+ )
127
+ db.prepare(rewriteParams(updateSql)).run('e1')
128
+
129
+ const row = db
130
+ .prepare('SELECT id, "updatedAt" FROM event WHERE id = ?')
131
+ .get('e1') as any
132
+ expect(row.id).toBe('e1')
133
+ expect(String(row.updatedAt)).toMatch(/^\d{4}-\d{2}-\d{2}/)
134
+ db.close()
135
+ })
136
+ })
@@ -0,0 +1,113 @@
1
+ /**
2
+ * AST traversal helpers tailored to the libpg_query output.
3
+ *
4
+ * The official @pgsql/traverse offers two APIs:
5
+ *
6
+ * walk() — uses a runtime schema; recurses into both tag-wrapped nodes
7
+ * ({SelectStmt:...}) AND unwrapped sub-fields (ColumnDef.typeName
8
+ * is *not* tag-wrapped — it's a plain object). Good for traversal,
9
+ * but mutation requires keyPath bookkeeping.
10
+ *
11
+ * visit() — only recurses into tag-wrapped objects with exactly one key.
12
+ * Misses non-wrapped sub-fields like typeName.
13
+ *
14
+ * For our compiler passes we want: "visit every node of a given tag, AND
15
+ * also visit unwrapped sub-objects that have a known tag-like field set."
16
+ * The simplest reliable approach is a hand-rolled walk: recurse over every
17
+ * object/array, fire callbacks keyed by either (a) the tag wrapper key, or
18
+ * (b) the parent field-name for in-place node types we want to visit
19
+ * (typeName etc.).
20
+ *
21
+ * Each callback gets `(node, parent, key)` so mutation is local: just
22
+ * reassign `parent[key]` (for tag-wrapped) or mutate `node` in place (for
23
+ * unwrapped sub-fields).
24
+ */
25
+
26
+ export type NodeCallback = (node: any, parent: any, key: string | number) => void
27
+
28
+ export interface VisitorMap {
29
+ /**
30
+ * Tag-wrapped nodes: keyed by the wrapper tag name (FuncCall, SelectStmt,
31
+ * SQLValueFunction, etc.). The callback receives the *inner* node data.
32
+ *
33
+ * Also accepts the synthetic tag names for unwrapped sub-fields listed in
34
+ * UNWRAPPED_FIELDS — e.g. `TypeName` fires for the value at any
35
+ * `someParent.typeName` slot.
36
+ */
37
+ [tag: string]: NodeCallback | undefined
38
+ }
39
+
40
+ /**
41
+ * Unwrapped fields: child-key → semantic tag name. When a node has a child
42
+ * field with one of these names whose value is a plain object (no tag wrapper),
43
+ * we fire the corresponding visitor against that value.
44
+ */
45
+ const UNWRAPPED_FIELDS: Record<string, string> = {
46
+ // ColumnDef.typeName, TypeCast.typeName, etc. are TypeName nodes, not wrapped
47
+ typeName: 'TypeName',
48
+ // SelectStmt.fromClause[i] is sometimes a wrapped RangeVar but in some
49
+ // sub-positions (FROM-list with single table) it appears unwrapped.
50
+ relation: 'RangeVar',
51
+ }
52
+
53
+ function fireForChild(
54
+ visitors: VisitorMap,
55
+ childKey: string,
56
+ childValue: any,
57
+ parent: any
58
+ ): void {
59
+ const tag = UNWRAPPED_FIELDS[childKey]
60
+ if (!tag) return
61
+ if (!childValue || typeof childValue !== 'object' || Array.isArray(childValue)) return
62
+ const cb = visitors[tag]
63
+ if (cb) cb(childValue, parent, childKey)
64
+ }
65
+
66
+ /**
67
+ * Walk a node tree firing callbacks. For tag-wrapped nodes ({Tag: data}),
68
+ * the callback fires on `data` (inner) with parent=the wrapper's parent,
69
+ * key=where the wrapper sits. For unwrapped sub-fields (typeName etc.),
70
+ * fires on the object directly with parent=its container, key=field name.
71
+ *
72
+ * Mutation rules:
73
+ * - tag-wrapped: assign `parent[key] = newWrapper` to replace the node
74
+ * - unwrapped: mutate `node` in place (it's already the live object)
75
+ */
76
+ export function walkAst(root: any, visitors: VisitorMap): void {
77
+ function recurse(node: any, parent: any, key: string | number): void {
78
+ if (node == null || typeof node !== 'object') return
79
+
80
+ if (Array.isArray(node)) {
81
+ for (let i = 0; i < node.length; i++) recurse(node[i], node, i)
82
+ return
83
+ }
84
+
85
+ const keys = Object.keys(node)
86
+
87
+ // Tag-wrapper case: {Tag: data}
88
+ if (keys.length === 1 && /^[A-Z]/.test(keys[0])) {
89
+ const tag = keys[0]
90
+ const data = node[tag]
91
+ const cb = visitors[tag]
92
+ if (cb) cb(data, parent, key)
93
+ // recurse into the inner data
94
+ if (data && typeof data === 'object') {
95
+ for (const childKey of Object.keys(data)) {
96
+ // fire unwrapped-field visitors BEFORE recursing into the field
97
+ fireForChild(visitors, childKey, data[childKey], data)
98
+ recurse(data[childKey], data, childKey)
99
+ }
100
+ }
101
+ return
102
+ }
103
+
104
+ // Non-wrapper object case (already an unwrapped sub-tree). Recurse,
105
+ // firing unwrapped-field visitors as we encounter named child fields.
106
+ for (const childKey of keys) {
107
+ fireForChild(visitors, childKey, node[childKey], node)
108
+ recurse(node[childKey], node, childKey)
109
+ }
110
+ }
111
+
112
+ recurse(root, null as any, '' as any)
113
+ }
@@ -0,0 +1,65 @@
1
+ import { walkAst } from './ast-utils.js'
2
+
3
+ /**
4
+ * Catalog pass.
5
+ *
6
+ * Rewrites schema-qualified PG catalog references to the
7
+ * `_orez_catalog__*` namespace (seeded by `catalog/seed.ts` at DO init):
8
+ *
9
+ * pg_catalog.pg_class → _orez_catalog__pg_class
10
+ * information_schema.columns → _orez_catalog__information_schema_columns
11
+ *
12
+ * Why this matters:
13
+ * zero-cache (and most PG client libraries) probes the PG system catalog
14
+ * on startup. They always qualify these references with `pg_catalog.` or
15
+ * `information_schema.` — that's how `search_path` resolution works in PG
16
+ * and how every generated catalog query (libpq, psql, postgres.js) emits
17
+ * them.
18
+ *
19
+ * Why we DON'T rewrite bare `pg_class` (no schema):
20
+ * - it's user-table-namespace ambiguous (an app could legitimately call
21
+ * a table `pg_user`, `pg_views`, etc.)
22
+ * - bare catalog refs only resolve in PG via search_path; clients
23
+ * emitting catalog queries qualify them explicitly. Bare references in
24
+ * real apps are essentially always user tables.
25
+ * - silently hijacking them caused WRITE-path bugs (DML against a user
26
+ * table got routed to a synthetic catalog table) in earlier iterations
27
+ *
28
+ * If a future workload sends bare catalog refs we'll add a per-statement
29
+ * opt-in (e.g. `WHERE rewrite_unqualified_catalog`) rather than a
30
+ * footgun-prone global toggle.
31
+ *
32
+ * Companion module: `catalog/seed.ts` creates the target tables on DO init.
33
+ */
34
+ import type { Pass } from '../types.js'
35
+
36
+ const CATALOG_PREFIX = '_orez_catalog__'
37
+ const FLATTENED_SCHEMAS = new Set(['information_schema'])
38
+
39
+ function rewriteRangeVar(node: any): void {
40
+ // pg_catalog.X — strip schema, prefix relname
41
+ if (node.schemaname === 'pg_catalog') {
42
+ node.relname = `${CATALOG_PREFIX}${node.relname}`
43
+ delete node.schemaname
44
+ return
45
+ }
46
+
47
+ // information_schema.X — flatten to _orez_catalog__information_schema_X
48
+ if (FLATTENED_SCHEMAS.has(node.schemaname)) {
49
+ node.relname = `${CATALOG_PREFIX}${node.schemaname}_${node.relname}`
50
+ delete node.schemaname
51
+ return
52
+ }
53
+ }
54
+
55
+ export const catalogPass: Pass = {
56
+ name: 'catalog',
57
+ run(rawStmt, _ctx) {
58
+ walkAst(rawStmt, {
59
+ RangeVar: (node: any) => {
60
+ if (!node || typeof node !== 'object') return
61
+ rewriteRangeVar(node)
62
+ },
63
+ })
64
+ },
65
+ }
@@ -0,0 +1,74 @@
1
+ import { walkAst } from './ast-utils.js'
2
+
3
+ /**
4
+ * datetime pass.
5
+ *
6
+ * Rewrites PG datetime functions to SQLite-native equivalents:
7
+ * NOW() → CURRENT_TIMESTAMP (function call → keyword)
8
+ * CURRENT_TIMESTAMP() → CURRENT_TIMESTAMP (if it ever parses as a FuncCall)
9
+ * CURRENT_DATE() → CURRENT_DATE
10
+ * CURRENT_TIME() → CURRENT_TIME
11
+ * pg_catalog.now() → CURRENT_TIMESTAMP
12
+ *
13
+ * Why it matters: in SQLite, `DEFAULT NOW()` is rejected because column
14
+ * defaults only accept a small expression grammar. `DEFAULT CURRENT_TIMESTAMP`
15
+ * is accepted everywhere (and is what every "I want NOW() in a default" PG
16
+ * user actually wants).
17
+ *
18
+ * For richer datetime work (EXTRACT, DATE_TRUNC, INTERVAL arithmetic) we'll
19
+ * extend this pass in follow-ups. v1 covers the high-frequency cases.
20
+ */
21
+ import type { Pass } from '../types.js'
22
+
23
+ function lowerFuncName(funcname: any[] | undefined): string | undefined {
24
+ if (!funcname || funcname.length === 0) return undefined
25
+ const last = funcname[funcname.length - 1]
26
+ const str = last?.String?.sval ?? last?.String?.str
27
+ return typeof str === 'string' ? str.toLowerCase() : undefined
28
+ }
29
+
30
+ /**
31
+ * Build a PG `SQLValueFunction` node — this is the canonical AST representation
32
+ * of bareword time keywords like CURRENT_TIMESTAMP / CURRENT_DATE / CURRENT_TIME.
33
+ * The deparser emits these as bareword keywords (no quotes, no parens), and
34
+ * SQLite accepts CURRENT_TIMESTAMP/CURRENT_DATE/CURRENT_TIME as keywords too.
35
+ */
36
+ function svfWrapper(op: string): {
37
+ SQLValueFunction: { op: string; typmod: number; location: number }
38
+ } {
39
+ return {
40
+ SQLValueFunction: { op, typmod: -1, location: 0 },
41
+ }
42
+ }
43
+
44
+ export const datetimePass: Pass = {
45
+ name: 'datetime',
46
+ run(rawStmt, _ctx) {
47
+ walkAst(rawStmt, {
48
+ FuncCall: (node: any, parent: any, key: string | number) => {
49
+ if (parent == null) return // can't replace a root-positioned FuncCall
50
+ const name = lowerFuncName(node.funcname)
51
+ if (!name) return
52
+ const argless = !node.args || (Array.isArray(node.args) && node.args.length === 0)
53
+ if (!argless) return
54
+
55
+ // Map PG datetime function calls → SQL bareword time keywords. The PG
56
+ // AST distinguishes function-form (`now()`) from keyword-form
57
+ // (`CURRENT_TIMESTAMP`) at the parse level; SQLite only accepts the
58
+ // keyword form in DEFAULT clauses and uniformly elsewhere.
59
+ if (name === 'now' || name === 'current_timestamp') {
60
+ parent[key] = svfWrapper('SVFOP_CURRENT_TIMESTAMP')
61
+ return
62
+ }
63
+ if (name === 'current_date') {
64
+ parent[key] = svfWrapper('SVFOP_CURRENT_DATE')
65
+ return
66
+ }
67
+ if (name === 'current_time') {
68
+ parent[key] = svfWrapper('SVFOP_CURRENT_TIME')
69
+ return
70
+ }
71
+ },
72
+ })
73
+ },
74
+ }
@@ -0,0 +1,49 @@
1
+ import { catalogPass } from './catalog.js'
2
+ import { datetimePass } from './datetime.js'
3
+ import { typesPass } from './types.js'
4
+
5
+ /**
6
+ * Pass pipeline.
7
+ *
8
+ * Each pass is a focused visitor over the PG AST that mutates nodes in place
9
+ * to make the tree SQLite-emittable. Order matters: type normalization runs
10
+ * first (so other passes see SQLite-native type names), datetime runs after
11
+ * (function-form → SQLValueFunction), catalog rewrites last (after every
12
+ * other pass has stabilized).
13
+ */
14
+ import type { Pass, PassContext } from '../types.js'
15
+
16
+ export const DEFAULT_PASSES: Pass[] = [
17
+ typesPass,
18
+ datetimePass,
19
+ catalogPass,
20
+ // future:
21
+ // castPass,
22
+ // arrayPass,
23
+ // jsonPass,
24
+ // insertPass,
25
+ ]
26
+
27
+ /**
28
+ * Run all passes on a single top-level RawStmt entry.
29
+ *
30
+ * Input shape: `{ stmt: { TagName: data } }` (a libpg_query RawStmt).
31
+ * Passes use `walkAst()` (in passes/ast-utils.ts) which expects a tag-wrapped
32
+ * node — so we hand them `rawStmt.stmt`, the inner `{ TagName: data }`.
33
+ * Callbacks receive (data, parent, key) and mutate via `parent[key] = ...`.
34
+ */
35
+ export function runPasses(rawStmt: any, ctx: PassContext): void {
36
+ const stmt = rawStmt?.stmt ?? rawStmt
37
+ if (!stmt || typeof stmt !== 'object') return
38
+ const passes = ctx.passes ?? DEFAULT_PASSES
39
+ for (const pass of passes) {
40
+ try {
41
+ pass.run(stmt, ctx)
42
+ } catch (err: any) {
43
+ ctx.warnings.push({
44
+ kind: 'pass-error',
45
+ message: `pass ${pass.name} threw: ${err.message}`,
46
+ })
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,156 @@
1
+ import { walkAst } from './ast-utils.js'
2
+
3
+ /**
4
+ * Types pass.
5
+ *
6
+ * Normalizes PG type names in CREATE TABLE / ALTER TABLE / CAST / column
7
+ * defs to SQLite-compatible equivalents:
8
+ *
9
+ * bigserial / serial → INTEGER (and PRIMARY KEY on INTEGER becomes
10
+ * SQLite's rowid alias, equivalent to
11
+ * AUTOINCREMENT for our purposes)
12
+ * smallserial → INTEGER
13
+ * jsonb / json → TEXT (stored as JSON text; SQLite json
14
+ * functions operate on TEXT directly)
15
+ * uuid → TEXT
16
+ * bytea → BLOB
17
+ * varchar / character varying → TEXT (drop length typmods)
18
+ * char(N) / character(N)→ TEXT (drop length typmods)
19
+ * text → TEXT
20
+ * timestamp / timestamptz / timestamp with time zone → TEXT
21
+ * (stored as ISO; SQLite datetime fns
22
+ * accept ISO text)
23
+ * date / time / timetz → TEXT
24
+ * interval → TEXT
25
+ * numeric / decimal → NUMERIC (SQLite native affinity; precision/
26
+ * scale typmods dropped)
27
+ * real / float4 → REAL
28
+ * double precision / float8 → REAL
29
+ * integer / int / int4 → INTEGER
30
+ * bigint / int8 → INTEGER
31
+ * smallint / int2 → INTEGER
32
+ * boolean → INTEGER (0/1; SQLite has no BOOLEAN type but
33
+ * the BOOLEAN keyword is accepted as
34
+ * affinity)
35
+ *
36
+ * Array types (any T[] with arrayBounds) become TEXT (stored as JSON arrays).
37
+ *
38
+ * pg_catalog.* prefix is stripped (libpg-query adds it canonically; SQLite
39
+ * doesn't know about pg_catalog).
40
+ */
41
+ import type { Pass } from '../types.js'
42
+
43
+ const TYPE_MAP: Record<string, string> = {
44
+ // serial → integer (PRIMARY KEY on INTEGER is rowid alias in SQLite)
45
+ bigserial: 'INTEGER',
46
+ serial: 'INTEGER',
47
+ serial4: 'INTEGER',
48
+ serial8: 'INTEGER',
49
+ smallserial: 'INTEGER',
50
+ serial2: 'INTEGER',
51
+
52
+ // integer
53
+ int: 'INTEGER',
54
+ integer: 'INTEGER',
55
+ int2: 'INTEGER',
56
+ int4: 'INTEGER',
57
+ int8: 'INTEGER',
58
+ bigint: 'INTEGER',
59
+ smallint: 'INTEGER',
60
+
61
+ // floats
62
+ real: 'REAL',
63
+ float4: 'REAL',
64
+ float8: 'REAL',
65
+ 'double precision': 'REAL',
66
+ double: 'REAL',
67
+
68
+ // numerics — keep affinity name; SQLite parses but ignores precision
69
+ numeric: 'NUMERIC',
70
+ decimal: 'NUMERIC',
71
+
72
+ // text-ish
73
+ text: 'TEXT',
74
+ varchar: 'TEXT',
75
+ 'character varying': 'TEXT',
76
+ bpchar: 'TEXT',
77
+ character: 'TEXT',
78
+ char: 'TEXT',
79
+ name: 'TEXT',
80
+ citext: 'TEXT',
81
+
82
+ // json / structured
83
+ json: 'TEXT',
84
+ jsonb: 'TEXT',
85
+ uuid: 'TEXT',
86
+ xml: 'TEXT',
87
+
88
+ // bin
89
+ bytea: 'BLOB',
90
+
91
+ // bools
92
+ bool: 'INTEGER',
93
+ boolean: 'INTEGER',
94
+
95
+ // time
96
+ timestamp: 'TEXT',
97
+ timestamptz: 'TEXT',
98
+ 'timestamp with time zone': 'TEXT',
99
+ 'timestamp without time zone': 'TEXT',
100
+ date: 'TEXT',
101
+ time: 'TEXT',
102
+ timetz: 'TEXT',
103
+ 'time with time zone': 'TEXT',
104
+ 'time without time zone': 'TEXT',
105
+ interval: 'TEXT',
106
+
107
+ // network / ranges → TEXT for storage compatibility
108
+ inet: 'TEXT',
109
+ cidr: 'TEXT',
110
+ macaddr: 'TEXT',
111
+ macaddr8: 'TEXT',
112
+ money: 'NUMERIC',
113
+ }
114
+
115
+ function extractName(names: any[] | undefined): string | undefined {
116
+ if (!Array.isArray(names) || names.length === 0) return undefined
117
+ // strip pg_catalog. prefix if present
118
+ const last = names[names.length - 1]
119
+ const sval = last?.String?.sval ?? last?.String?.str
120
+ return typeof sval === 'string' ? sval.toLowerCase() : undefined
121
+ }
122
+
123
+ function setTypeName(typeName: any, sqliteName: string): void {
124
+ typeName.names = [{ String: { sval: sqliteName } }]
125
+ // drop length/precision typmods
126
+ delete typeName.typmods
127
+ // drop array bounds (SQLite has no array type; we store as JSON text)
128
+ delete typeName.arrayBounds
129
+ }
130
+
131
+ export const typesPass: Pass = {
132
+ name: 'types',
133
+ run(rawStmt, _ctx) {
134
+ walkAst(rawStmt, {
135
+ TypeName: (node: any) => {
136
+ const pgName = extractName(node.names)
137
+ if (!pgName) return
138
+ const hasArrayBounds =
139
+ Array.isArray(node.arrayBounds) && node.arrayBounds.length > 0
140
+
141
+ // Array of anything → TEXT
142
+ if (hasArrayBounds) {
143
+ setTypeName(node, 'TEXT')
144
+ return
145
+ }
146
+
147
+ const sqliteName = TYPE_MAP[pgName]
148
+ if (sqliteName) {
149
+ setTypeName(node, sqliteName)
150
+ }
151
+ // Unknown types we leave alone — let SQLite reject loudly so we know
152
+ // to add a mapping.
153
+ },
154
+ })
155
+ },
156
+ }