orez-sync-cf-host 0.4.52

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/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export { createSyncDurableObject, createSyncWorker } from './host.js'
2
+ export { MutationApplicationError, registerMutators } from './types.js'
3
+ export type {
4
+ DeferredEffect,
5
+ JsonPrimitive,
6
+ JsonValue,
7
+ MutatorContext,
8
+ MutatorRegistry,
9
+ MutatorSql,
10
+ NormalizedClaims,
11
+ PullCaps,
12
+ QueryResolver,
13
+ RegisteredMutator,
14
+ SyncHostConfig,
15
+ SyncHostEnv,
16
+ ServiceBinding,
17
+ SyncSql,
18
+ UpstreamConfig,
19
+ VisibilityConfig,
20
+ VisibilityFilter,
21
+ ZeroSchemaConfig,
22
+ } from './types.js'
@@ -0,0 +1,119 @@
1
+ import { WorkerEntrypoint } from 'cloudflare:workers'
2
+
3
+ import { ZeroDO } from '../../../src/cf-do/worker.js'
4
+ import { createSyncDurableObject, createSyncWorker } from './index.js'
5
+
6
+ import type { SyncHostConfig, SyncHostEnv, ZeroSchemaConfig } from './index.js'
7
+
8
+ const schema = {
9
+ tables: {
10
+ item: {
11
+ columns: {
12
+ id: { type: 'string' },
13
+ label: { type: 'string' },
14
+ rank: { type: 'number' },
15
+ done: { type: 'boolean' },
16
+ meta: { type: 'json' },
17
+ },
18
+ primaryKey: ['id'],
19
+ },
20
+ },
21
+ } as const satisfies ZeroSchemaConfig
22
+
23
+ type Fetcher = { fetch(input: string | Request, init?: RequestInit): Promise<Response> }
24
+ interface Env extends SyncHostEnv {
25
+ DATA: Fetcher
26
+ APP: Fetcher
27
+ UPSTREAM_DO: DurableObjectNamespace
28
+ }
29
+
30
+ const config: SyncHostConfig<Env> = {
31
+ hostVersion: 'upstream-ingest-harness',
32
+ schema,
33
+ mutateUrl: '/api/zero/push',
34
+ mutateBinding: 'APP',
35
+ upstream: {
36
+ binding: 'DATA',
37
+ namespacePath: (namespace) => `/${namespace}`,
38
+ changeLimit: 2,
39
+ intervalMs: 1_000,
40
+ },
41
+ initialize(sql) {
42
+ sql.exec(
43
+ 'CREATE TABLE IF NOT EXISTS item (id TEXT PRIMARY KEY, label TEXT NOT NULL, rank REAL NOT NULL, done INTEGER NOT NULL, meta TEXT)'
44
+ )
45
+ },
46
+ authenticate(request) {
47
+ const userID = request.headers.get('authorization')?.match(/^Bearer token-(.+)$/)?.[1]
48
+ return userID ? { userID } : null
49
+ },
50
+ namespace(request) {
51
+ return new URL(request.url).pathname.split('/')[1] || null
52
+ },
53
+ }
54
+
55
+ export const SyncDurableObject = createSyncDurableObject(config)
56
+ export { ZeroDO }
57
+
58
+ async function upstreamFetch(request: Request, env: Env): Promise<Response> {
59
+ const url = new URL(request.url)
60
+ const [, namespace, ...rest] = url.pathname.split('/')
61
+ if (!namespace) return new Response('namespace required', { status: 400 })
62
+ const stub = env.UPSTREAM_DO.get(env.UPSTREAM_DO.idFromName(namespace))
63
+ const exec = async (sql: string, params: unknown[] = []) => {
64
+ const response = await stub.fetch('https://upstream.invalid/exec', {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json' },
67
+ body: JSON.stringify({ sql, params }),
68
+ })
69
+ if (!response.ok) throw new Error(`upstream init failed: ${await response.text()}`)
70
+ }
71
+ await exec(
72
+ 'CREATE TABLE IF NOT EXISTS item (id TEXT PRIMARY KEY, label TEXT NOT NULL, rank REAL NOT NULL, done INTEGER NOT NULL, meta TEXT)'
73
+ )
74
+ await exec(
75
+ 'CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)'
76
+ )
77
+ await exec(
78
+ 'INSERT OR IGNORE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)',
79
+ ['item', JSON.stringify(schema.tables.item)]
80
+ )
81
+ url.pathname = `/${rest.join('/')}`
82
+ return stub.fetch(new Request(url, request))
83
+ }
84
+
85
+ /** Self service-binding target backed by the real ZeroSqlDO. */
86
+ export class DataService extends WorkerEntrypoint<Env> {
87
+ fetch(request: Request): Promise<Response> {
88
+ const pathname = new URL(request.url).pathname
89
+ if (!pathname.endsWith('/changes') && !pathname.endsWith('/snapshot')) {
90
+ return Promise.resolve(
91
+ new Response('DATA route rejected non-feed request', { status: 418 })
92
+ )
93
+ }
94
+ return upstreamFetch(request, this.env)
95
+ }
96
+ }
97
+
98
+ export class AppService extends WorkerEntrypoint<Env> {
99
+ fetch(request: Request): Promise<Response> {
100
+ if (!new URL(request.url).pathname.endsWith('/api/zero/push')) {
101
+ return Promise.resolve(
102
+ new Response('APP route rejected non-push request', { status: 418 })
103
+ )
104
+ }
105
+ return upstreamFetch(request, this.env)
106
+ }
107
+ }
108
+
109
+ const syncWorker = createSyncWorker(config)
110
+ export default {
111
+ fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
112
+ const url = new URL(request.url)
113
+ if (url.pathname.startsWith('/upstream/')) {
114
+ url.pathname = url.pathname.slice('/upstream'.length)
115
+ return upstreamFetch(new Request(url, request), env)
116
+ }
117
+ return syncWorker.fetch!(request as never, env, ctx) as Promise<Response>
118
+ },
119
+ }
@@ -0,0 +1,304 @@
1
+ import { DurableObject } from 'cloudflare:workers'
2
+
3
+ import {
4
+ initSync,
5
+ init_probe_schema,
6
+ pull_snapshot,
7
+ push_finalize,
8
+ push_preflight,
9
+ rust_panic_after_writes,
10
+ value_round_trip,
11
+ } from './generated/sync_wasm.js'
12
+ import wasmModule from './generated/sync_wasm_bg.wasm'
13
+ import { SqlStorageSyncDb } from './sql-storage-adapter.js'
14
+
15
+ initSync({ module: wasmModule })
16
+
17
+ interface Env {
18
+ PROBE_DO: DurableObjectNamespace<ProbeDurableObject>
19
+ }
20
+
21
+ type DeferredEffect = { mutationID: string; kind: string }
22
+ type MutatorName = 'read-then-write' | 'multi-table' | 'application-error'
23
+
24
+ // Deterministic local analogue of normal DO idle eviction, matching the
25
+ // harness/cf probe: discard all in-memory state after an idle gap while SQL
26
+ // storage remains intact. This keeps the test sub-second instead of waiting
27
+ // for workerd/platform eviction heuristics.
28
+ const IDLE_TEARDOWN_MS = 250
29
+
30
+ const json = (value: unknown, status = 200) =>
31
+ Response.json(value, {
32
+ status,
33
+ headers: { 'cache-control': 'no-store' },
34
+ })
35
+
36
+ export class ProbeDurableObject extends DurableObject<Env> {
37
+ readonly #db: SqlStorageSyncDb
38
+ #bootID = crypto.randomUUID()
39
+ #lastRequestAt = 0
40
+ #reinstantiations = 0
41
+ #effects: Array<DeferredEffect & { observedCommitted: boolean }> = []
42
+
43
+ constructor(ctx: DurableObjectState, env: Env) {
44
+ super(ctx, env)
45
+ this.#db = new SqlStorageSyncDb(ctx.storage.sql)
46
+ ctx.storage.transactionSync(() => init_probe_schema(this.#db))
47
+ }
48
+
49
+ #maybeReinstantiate(now: number): void {
50
+ if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= IDLE_TEARDOWN_MS) {
51
+ this.#bootID = crypto.randomUUID()
52
+ this.#effects = []
53
+ this.#reinstantiations++
54
+ }
55
+ this.#lastRequestAt = now
56
+ }
57
+
58
+ #state(): Record<string, unknown> {
59
+ const row = this.ctx.storage.sql
60
+ .exec(
61
+ `SELECT
62
+ CAST((SELECT lmid FROM probe_state WHERE singleton = 1) AS TEXT) AS lmid,
63
+ (SELECT balance FROM accounts WHERE id = 'primary') AS balance,
64
+ (SELECT COUNT(*) FROM ledger) AS ledgerCount,
65
+ (SELECT COUNT(*) FROM outbox) AS outboxCount,
66
+ (SELECT COUNT(*) FROM mutation_log) AS mutationCount,
67
+ (SELECT COUNT(*) FROM value_probe) AS valueCount`
68
+ )
69
+ .one()
70
+ return {
71
+ ...row,
72
+ sideEffectCount: this.#effects.length,
73
+ sideEffects: this.#effects,
74
+ }
75
+ }
76
+
77
+ async #mutate(name: MutatorName, deferred: DeferredEffect[]): Promise<void> {
78
+ if (name === 'read-then-write') {
79
+ const { balance } = this.ctx.storage.sql
80
+ .exec("SELECT balance FROM accounts WHERE id = 'primary'")
81
+ .one() as { balance: number }
82
+ await scheduler.wait(1)
83
+ this.ctx.storage.sql.exec(
84
+ "UPDATE accounts SET balance = ? WHERE id = 'primary'",
85
+ balance + 10
86
+ )
87
+ this.ctx.storage.sql.exec(
88
+ 'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
89
+ 'primary',
90
+ 10,
91
+ 'read-then-write'
92
+ )
93
+ deferred.push({ mutationID: '', kind: 'balance-notification' })
94
+ return
95
+ }
96
+
97
+ if (name === 'multi-table') {
98
+ this.ctx.storage.sql.exec(
99
+ "UPDATE accounts SET balance = balance - 5 WHERE id = 'primary'"
100
+ )
101
+ await scheduler.wait(1)
102
+ this.ctx.storage.sql.exec(
103
+ 'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
104
+ 'primary',
105
+ -5,
106
+ 'multi-table'
107
+ )
108
+ this.ctx.storage.sql.exec(
109
+ 'INSERT INTO outbox (topic, payload) VALUES (?, ?)',
110
+ 'account.changed',
111
+ JSON.stringify({ id: 'primary', delta: -5 })
112
+ )
113
+ deferred.push({ mutationID: '', kind: 'outbox-notification' })
114
+ return
115
+ }
116
+
117
+ const { balance } = this.ctx.storage.sql
118
+ .exec("SELECT balance FROM accounts WHERE id = 'primary'")
119
+ .one() as { balance: number }
120
+ this.ctx.storage.sql.exec(
121
+ "UPDATE accounts SET balance = ? WHERE id = 'primary'",
122
+ balance + 777
123
+ )
124
+ await scheduler.wait(1)
125
+ this.ctx.storage.sql.exec(
126
+ 'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
127
+ 'primary',
128
+ 777,
129
+ 'application-error'
130
+ )
131
+ deferred.push({ mutationID: '', kind: 'must-not-run' })
132
+ throw new Error('intentional application mutator error')
133
+ }
134
+
135
+ async #push(name: MutatorName, mutationID: string): Promise<Response> {
136
+ this.#db.resetStats()
137
+ const deferred: DeferredEffect[] = []
138
+ const started = performance.now()
139
+ let lmid: string | undefined
140
+ let wasmMs = 0
141
+ try {
142
+ await this.ctx.storage.transaction(async () => {
143
+ let wasmStarted = performance.now()
144
+ const expected = push_preflight(this.#db, mutationID)
145
+ wasmMs += performance.now() - wasmStarted
146
+
147
+ // This await is deliberately inside the storage transaction and before
148
+ // the remaining SQL operations. M0 exists to prove this contract.
149
+ await scheduler.wait(1)
150
+ await this.#mutate(name, deferred)
151
+
152
+ wasmStarted = performance.now()
153
+ lmid = push_finalize(this.#db, mutationID, expected)
154
+ wasmMs += performance.now() - wasmStarted
155
+ })
156
+ } catch (error) {
157
+ return json(
158
+ {
159
+ ok: false,
160
+ error: String(error),
161
+ awaitedInsideTransaction: true,
162
+ state: this.#state(),
163
+ effectsDeferredButNotRun: deferred.length,
164
+ },
165
+ 409
166
+ )
167
+ }
168
+
169
+ // External effects are not even inspected until the transaction promise
170
+ // resolves. Each effect independently verifies its mutation is durable.
171
+ for (const effect of deferred) {
172
+ effect.mutationID = mutationID
173
+ const committed =
174
+ this.ctx.storage.sql
175
+ .exec(
176
+ 'SELECT 1 AS committed FROM mutation_log WHERE mutation_id = ?',
177
+ mutationID
178
+ )
179
+ .toArray().length === 1
180
+ this.#effects.push({ ...effect, observedCommitted: committed })
181
+ }
182
+
183
+ return json({
184
+ ok: true,
185
+ lmid,
186
+ awaitedInsideTransaction: true,
187
+ state: this.#state(),
188
+ timing: {
189
+ elapsedMs: performance.now() - started,
190
+ wasmMs,
191
+ ...this.#db.stats,
192
+ },
193
+ })
194
+ }
195
+
196
+ async fetch(request: Request): Promise<Response> {
197
+ this.#maybeReinstantiate(Date.now())
198
+ const url = new URL(request.url)
199
+ const [, , ...routeParts] = url.pathname.split('/')
200
+ const route = `/${routeParts.join('/')}`
201
+
202
+ if (route === '/status') {
203
+ return json({
204
+ bootID: this.#bootID,
205
+ idleTeardownMs: IDLE_TEARDOWN_MS,
206
+ reinstantiations: this.#reinstantiations,
207
+ state: this.#state(),
208
+ })
209
+ }
210
+
211
+ if (route === '/pull') {
212
+ this.#db.resetStats()
213
+ const started = performance.now()
214
+ const snapshot = this.ctx.storage.transactionSync(() => pull_snapshot(this.#db))
215
+ return json({
216
+ snapshot,
217
+ transaction: 'transactionSync',
218
+ timing: { elapsedMs: performance.now() - started, ...this.#db.stats },
219
+ })
220
+ }
221
+
222
+ if (route.startsWith('/push/')) {
223
+ const name = route.slice('/push/'.length) as MutatorName
224
+ if (!['read-then-write', 'multi-table', 'application-error'].includes(name)) {
225
+ return json({ error: 'unknown mutator' }, 404)
226
+ }
227
+ const body = (await request.json()) as { mutationID: string }
228
+ return this.#push(name, body.mutationID)
229
+ }
230
+
231
+ if (route === '/js-exception') {
232
+ const before = this.#state()
233
+ try {
234
+ await this.ctx.storage.transaction(async () => {
235
+ const expected = push_preflight(this.#db, 'js-exception')
236
+ this.ctx.storage.sql.exec(
237
+ "UPDATE accounts SET balance = balance + 123 WHERE id = 'primary'"
238
+ )
239
+ await scheduler.wait(1)
240
+ this.ctx.storage.sql.exec(
241
+ 'INSERT INTO outbox (topic, payload) VALUES (?, ?)',
242
+ 'must.rollback',
243
+ '{}'
244
+ )
245
+ push_finalize(this.#db, 'js-exception', expected)
246
+ throw new Error('intentional JS exception after finalization')
247
+ })
248
+ } catch (error) {
249
+ return json(
250
+ { ok: false, error: String(error), before, after: this.#state() },
251
+ 409
252
+ )
253
+ }
254
+ return json({ error: 'JS exception did not escape transaction' }, 500)
255
+ }
256
+
257
+ if (route === '/rust-panic') {
258
+ const before = this.#state()
259
+ try {
260
+ this.ctx.storage.transactionSync(() => rust_panic_after_writes(this.#db))
261
+ } catch (error) {
262
+ return json(
263
+ { ok: false, error: String(error), before, after: this.#state() },
264
+ 409
265
+ )
266
+ }
267
+ return json({ error: 'Rust panic did not escape transaction' }, 500)
268
+ }
269
+
270
+ if (route === '/values') {
271
+ const input = await request.json()
272
+ const output = this.ctx.storage.transactionSync(() =>
273
+ value_round_trip(this.#db, input)
274
+ )
275
+ return json(output)
276
+ }
277
+
278
+ if (route === '/adapter-guard') {
279
+ const errors: string[] = []
280
+ for (const [sql, params] of [
281
+ ['BEGIN TRANSACTION', []],
282
+ ['SELECT ?1', [{ kind: 'integer', value: '1' }]],
283
+ ] as const) {
284
+ try {
285
+ this.#db.exec(sql, [...params] as never)
286
+ } catch (error) {
287
+ errors.push(String(error))
288
+ }
289
+ }
290
+ return json({ errors })
291
+ }
292
+
293
+ return json({ error: 'not found', route }, 404)
294
+ }
295
+ }
296
+
297
+ export default {
298
+ fetch(request: Request, env: Env): Promise<Response> {
299
+ const url = new URL(request.url)
300
+ const [, namespace] = url.pathname.split('/')
301
+ if (!namespace) return Promise.resolve(new Response('orez rust sync M0 probe'))
302
+ return env.PROBE_DO.get(env.PROBE_DO.idFromName(namespace)).fetch(request)
303
+ },
304
+ } satisfies ExportedHandler<Env>
@@ -0,0 +1,168 @@
1
+ import type { MutatorSql, SyncSql } from './types.js'
2
+
3
+ type WireValue =
4
+ | { kind: 'null' }
5
+ | { kind: 'integer'; value: string }
6
+ | { kind: 'real'; value: number }
7
+ | { kind: 'text'; value: string }
8
+ | { kind: 'blob'; value: number[] }
9
+
10
+ type WireRow = { columns: string[]; values: WireValue[] }
11
+
12
+ interface JsSyncDb {
13
+ exec(sql: string, params: WireValue[]): void
14
+ query(sql: string, params: WireValue[]): WireRow[]
15
+ }
16
+
17
+ export type CompiledQuery = { sql: string; params: WireValue[] }
18
+
19
+ export type AdapterStats = {
20
+ execCalls: number
21
+ queryCalls: number
22
+ sqlMs: number
23
+ }
24
+
25
+ // Match transaction control only as the statement itself. CREATE TRIGGER uses
26
+ // `... BEGIN ... END` for its body, which is valid DO SQL and not a transaction.
27
+ const TX_SQL = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)(?=\s|;|$)/i
28
+ const NUMBERED_PARAMETER = /\?[0-9]+/
29
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER)
30
+ const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER)
31
+
32
+ export function assertHostSql(sql: string): void {
33
+ if (TX_SQL.test(sql)) {
34
+ throw new TypeError('transaction SQL is host-owned and forbidden')
35
+ }
36
+ if (NUMBERED_PARAMETER.test(sql)) {
37
+ throw new TypeError('numbered parameters are forbidden; use positional ? bindings')
38
+ }
39
+ }
40
+
41
+ function decodeBinding(value: WireValue): unknown {
42
+ switch (value.kind) {
43
+ case 'null':
44
+ return null
45
+ case 'integer': {
46
+ const exact = BigInt(value.value)
47
+ // SqlStorage does not accept bigint. Safe values can use Number; unsafe
48
+ // values use decimal text and rely on the destination's INTEGER affinity.
49
+ return exact >= MIN_SAFE && exact <= MAX_SAFE ? Number(exact) : value.value
50
+ }
51
+ case 'real':
52
+ case 'text':
53
+ return value.value
54
+ case 'blob':
55
+ return Uint8Array.from(value.value).buffer
56
+ }
57
+ }
58
+
59
+ function encodeResult(value: unknown): WireValue {
60
+ if (value === null) return { kind: 'null' }
61
+ if (typeof value === 'number') {
62
+ return Number.isInteger(value)
63
+ ? { kind: 'integer', value: String(value) }
64
+ : { kind: 'real', value }
65
+ }
66
+ if (typeof value === 'string') return { kind: 'text', value }
67
+ if (value instanceof ArrayBuffer) {
68
+ return { kind: 'blob', value: Array.from(new Uint8Array(value)) }
69
+ }
70
+ if (ArrayBuffer.isView(value)) {
71
+ return {
72
+ kind: 'blob',
73
+ value: Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)),
74
+ }
75
+ }
76
+ throw new TypeError(
77
+ `unsupported SqlStorage result: ${Object.prototype.toString.call(value)}`
78
+ )
79
+ }
80
+
81
+ /**
82
+ * The sole JS SQL adapter used by wasm. It is synchronous, consumes every
83
+ * cursor before returning, accepts positional `?` only, and never starts or
84
+ * finishes a transaction.
85
+ */
86
+ export class SqlStorageSyncDb implements JsSyncDb {
87
+ readonly stats: AdapterStats = { execCalls: 0, queryCalls: 0, sqlMs: 0 }
88
+
89
+ constructor(private readonly sql: SqlStorage) {}
90
+
91
+ resetStats(): void {
92
+ this.stats.execCalls = 0
93
+ this.stats.queryCalls = 0
94
+ this.stats.sqlMs = 0
95
+ }
96
+
97
+ exec(sql: string, params: WireValue[]): void {
98
+ assertHostSql(sql)
99
+ const start = performance.now()
100
+ this.sql.exec(sql, ...params.map(decodeBinding))
101
+ this.stats.execCalls++
102
+ this.stats.sqlMs += performance.now() - start
103
+ }
104
+
105
+ query(sql: string, params: WireValue[]): WireRow[] {
106
+ assertHostSql(sql)
107
+ const start = performance.now()
108
+ const cursor = this.sql.exec(sql, ...params.map(decodeBinding))
109
+ const columns = [...cursor.columnNames]
110
+ // A SqlStorage cursor must be fully consumed before any await. Returning a
111
+ // materialized array here makes that property structural.
112
+ const rows = Array.from(cursor.raw()).map((values) => ({
113
+ columns,
114
+ values: values.map(encodeResult),
115
+ }))
116
+ this.stats.queryCalls++
117
+ this.stats.sqlMs += performance.now() - start
118
+ return rows
119
+ }
120
+ }
121
+
122
+ /** Direct application SQL surface used for initialization and admin reads. */
123
+ export class SqlStorageDirect implements SyncSql {
124
+ constructor(private readonly sql: SqlStorage) {}
125
+
126
+ exec(sql: string, params: readonly unknown[] = []): void {
127
+ assertHostSql(sql)
128
+ this.sql.exec(sql, ...params)
129
+ }
130
+
131
+ query<Row extends Record<string, unknown> = Record<string, unknown>>(
132
+ sql: string,
133
+ params: readonly unknown[] = []
134
+ ): Row[] {
135
+ assertHostSql(sql)
136
+ return this.sql.exec(sql, ...params).toArray() as Row[]
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Async-compatible consumer mutator surface. Each method fully completes and
142
+ * materializes its SqlStorage operation before its promise resolves, so an
143
+ * existing `await tx.query(...)` cannot carry a cursor across an await.
144
+ */
145
+ export class SqlStorageMutatorTransaction implements MutatorSql {
146
+ constructor(
147
+ private readonly direct: SqlStorageDirect,
148
+ private readonly compileQuery: (ast: unknown) => CompiledQuery
149
+ ) {}
150
+
151
+ async exec(sql: string, params: readonly unknown[] = []): Promise<void> {
152
+ this.direct.exec(sql, params)
153
+ }
154
+
155
+ async query<Row extends Record<string, unknown> = Record<string, unknown>>(
156
+ sql: string,
157
+ params: readonly unknown[] = []
158
+ ): Promise<Row[]> {
159
+ return this.direct.query<Row>(sql, params)
160
+ }
161
+
162
+ async queryAst<Row extends Record<string, unknown> = Record<string, unknown>>(
163
+ ast: unknown
164
+ ): Promise<Row[]> {
165
+ const compiled = this.compileQuery(ast)
166
+ return this.direct.query<Row>(compiled.sql, compiled.params.map(decodeBinding))
167
+ }
168
+ }