orez-sync-cf-host 0.5.17 → 0.5.19

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.
@@ -1,5 +1,11 @@
1
+ import { executeTransactionQueryPlan } from './transaction-query.js'
1
2
  import { trackBillableCursorRows } from './write-safeguards.js'
2
3
 
4
+ import type {
5
+ CompiledTransactionQueryPlan,
6
+ TransactionQueryBudget,
7
+ TransactionQueryFormat,
8
+ } from './transaction-query.js'
3
9
  import type { MutatorSql, SyncSql } from './types.js'
4
10
 
5
11
  type WireValue =
@@ -16,8 +22,6 @@ interface JsSyncDb {
16
22
  query(sql: string, params: WireValue[]): WireRow[]
17
23
  }
18
24
 
19
- export type CompiledQuery = { sql: string; params: WireValue[] }
20
-
21
25
  export type AdapterStats = {
22
26
  execCalls: number
23
27
  queryCalls: number
@@ -40,24 +44,79 @@ export function assertHostSql(sql: string): void {
40
44
  }
41
45
  }
42
46
 
43
- function decodeBinding(value: WireValue): unknown {
44
- switch (value.kind) {
47
+ function decodeBinding(value: unknown, path = 'binding'): unknown {
48
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
49
+ throw new TypeError(`${path} must be a typed SQL value`)
50
+ }
51
+ const binding = value as { kind?: unknown; value?: unknown }
52
+ const keys = Object.keys(binding)
53
+ const assertValueShape = () => {
54
+ if (keys.length !== 2 || !keys.includes('kind') || !keys.includes('value')) {
55
+ throw new TypeError(`${path} must contain only kind and value`)
56
+ }
57
+ }
58
+ switch (binding.kind) {
45
59
  case 'null':
60
+ if (keys.length !== 1 || keys[0] !== 'kind') {
61
+ throw new TypeError(`${path} null binding must contain only kind`)
62
+ }
46
63
  return null
47
64
  case 'integer': {
48
- const exact = BigInt(value.value)
65
+ assertValueShape()
66
+ if (typeof binding.value !== 'string') {
67
+ throw new TypeError(`${path}.value must be an i64 decimal string`)
68
+ }
69
+ if (!/^-?\d+$/.test(binding.value)) {
70
+ throw new TypeError(`${path}.value must be an i64 decimal string`)
71
+ }
72
+ let exact: bigint
73
+ try {
74
+ exact = BigInt(binding.value)
75
+ } catch {
76
+ throw new TypeError(`${path}.value must be an i64 decimal string`)
77
+ }
78
+ if (exact < -(1n << 63n) || exact > (1n << 63n) - 1n) {
79
+ throw new TypeError(`${path}.value is outside the i64 range`)
80
+ }
49
81
  // SqlStorage does not accept bigint. Safe values can use Number; unsafe
50
82
  // values use decimal text and rely on the destination's INTEGER affinity.
51
- return exact >= MIN_SAFE && exact <= MAX_SAFE ? Number(exact) : value.value
83
+ return exact >= MIN_SAFE && exact <= MAX_SAFE ? Number(exact) : binding.value
84
+ }
85
+ case 'real': {
86
+ assertValueShape()
87
+ if (typeof binding.value !== 'number' || !Number.isFinite(binding.value)) {
88
+ throw new TypeError(`${path}.value must be a finite number`)
89
+ }
90
+ return binding.value
91
+ }
92
+ case 'text': {
93
+ assertValueShape()
94
+ if (typeof binding.value !== 'string') {
95
+ throw new TypeError(`${path}.value must be a string`)
96
+ }
97
+ return binding.value
52
98
  }
53
- case 'real':
54
- case 'text':
55
- return value.value
56
- case 'blob':
57
- return Uint8Array.from(value.value).buffer
99
+ case 'blob': {
100
+ assertValueShape()
101
+ if (
102
+ !Array.isArray(binding.value) ||
103
+ binding.value.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255)
104
+ ) {
105
+ throw new TypeError(`${path}.value must be an array of bytes`)
106
+ }
107
+ return Uint8Array.from(binding.value).buffer
108
+ }
109
+ default:
110
+ throw new TypeError(`${path}.kind must be null, integer, real, text, or blob`)
58
111
  }
59
112
  }
60
113
 
114
+ export function decodeSqlParams(value: unknown): unknown[] {
115
+ if (value === undefined) return []
116
+ if (!Array.isArray(value)) throw new TypeError('params must be an array')
117
+ return value.map((binding, index) => decodeBinding(binding, `params[${index}]`))
118
+ }
119
+
61
120
  function encodeResult(value: unknown): WireValue {
62
121
  if (value === null) return { kind: 'null' }
63
122
  if (typeof value === 'number') {
@@ -103,7 +162,7 @@ export class SqlStorageSyncDb implements JsSyncDb {
103
162
  assertHostSql(sql)
104
163
  const start = performance.now()
105
164
  trackBillableCursorRows(
106
- this.sql.exec(sql, ...params.map(decodeBinding)),
165
+ this.sql.exec(sql, ...params.map((value) => decodeBinding(value))),
107
166
  this.recordRowsWritten
108
167
  )
109
168
  this.stats.execCalls++
@@ -114,7 +173,7 @@ export class SqlStorageSyncDb implements JsSyncDb {
114
173
  assertHostSql(sql)
115
174
  const start = performance.now()
116
175
  const cursor = trackBillableCursorRows(
117
- this.sql.exec(sql, ...params.map(decodeBinding)),
176
+ this.sql.exec(sql, ...params.map((value) => decodeBinding(value))),
118
177
  this.recordRowsWritten
119
178
  )
120
179
  const columns = [...cursor.columnNames]
@@ -162,7 +221,11 @@ export class SqlStorageDirect implements SyncSql {
162
221
  export class SqlStorageMutatorTransaction implements MutatorSql {
163
222
  constructor(
164
223
  private readonly direct: SqlStorageDirect,
165
- private readonly compileQuery: (ast: unknown) => CompiledQuery
224
+ private readonly compileQuery: (
225
+ ast: unknown,
226
+ format: TransactionQueryFormat
227
+ ) => CompiledTransactionQueryPlan,
228
+ private readonly queryBudget?: Partial<TransactionQueryBudget>
166
229
  ) {}
167
230
 
168
231
  async exec(sql: string, params: readonly unknown[] = []): Promise<void> {
@@ -176,10 +239,16 @@ export class SqlStorageMutatorTransaction implements MutatorSql {
176
239
  return this.direct.query<Row>(sql, params)
177
240
  }
178
241
 
179
- async queryAst<Row extends Record<string, unknown> = Record<string, unknown>>(
180
- ast: unknown
181
- ): Promise<Row[]> {
182
- const compiled = this.compileQuery(ast)
183
- return this.direct.query<Row>(compiled.sql, compiled.params.map(decodeBinding))
242
+ async queryAst<Result = unknown>(
243
+ ast: unknown,
244
+ format: TransactionQueryFormat,
245
+ queryName?: string
246
+ ): Promise<Result> {
247
+ const compiled = this.compileQuery(ast, format)
248
+ return executeTransactionQueryPlan<Result>(
249
+ compiled,
250
+ (sql, params) => this.direct.query(sql, params),
251
+ { queryName, budget: this.queryBudget }
252
+ )
184
253
  }
185
254
  }
@@ -0,0 +1,494 @@
1
+ export type TransactionQueryWireValue =
2
+ | { kind: 'null' }
3
+ | { kind: 'integer'; value: string }
4
+ | { kind: 'real'; value: number }
5
+ | { kind: 'text'; value: string }
6
+ | { kind: 'blob'; value: number[] }
7
+
8
+ export type TransactionQueryBinding =
9
+ | { kind: 'literal'; value: TransactionQueryWireValue }
10
+ | { kind: 'parent_field'; field: string }
11
+
12
+ export type TransactionQueryColumnType = 'string' | 'number' | 'boolean' | 'json' | 'null'
13
+
14
+ export type TransactionQueryColumn = {
15
+ name: string
16
+ columnType: TransactionQueryColumnType
17
+ }
18
+
19
+ export type CompiledTransactionQueryRelationship = {
20
+ name: string
21
+ node: CompiledTransactionQueryNode
22
+ }
23
+
24
+ export type CompiledTransactionQueryNode = {
25
+ table: string
26
+ singular: boolean
27
+ sql: string
28
+ bindings: TransactionQueryBinding[]
29
+ columns: TransactionQueryColumn[]
30
+ relationships: CompiledTransactionQueryRelationship[]
31
+ }
32
+
33
+ export type CompiledTransactionQueryPlan = {
34
+ rootTable: string
35
+ planHash: string
36
+ root: CompiledTransactionQueryNode
37
+ }
38
+
39
+ export type TransactionQueryBudget = {
40
+ maxSelects: number
41
+ maxRows: number
42
+ }
43
+
44
+ export type TransactionQueryExecutionOptions = {
45
+ queryName?: string
46
+ budget?: Partial<TransactionQueryBudget>
47
+ }
48
+
49
+ export type TransactionQueryFormat = {
50
+ singular: boolean
51
+ relationships: Readonly<Record<string, TransactionQueryFormat>>
52
+ }
53
+
54
+ export const DEFAULT_TRANSACTION_QUERY_BUDGET: TransactionQueryBudget = {
55
+ maxSelects: 256,
56
+ maxRows: 10_000,
57
+ }
58
+
59
+ export class TransactionQueryBudgetError extends Error {
60
+ readonly code = 'transaction_query_budget_exceeded'
61
+
62
+ constructor(
63
+ readonly query: string,
64
+ readonly selects: number,
65
+ readonly rows: number,
66
+ readonly maxSelects: number,
67
+ readonly maxRows: number
68
+ ) {
69
+ super(
70
+ `transaction_query_budget_exceeded: query=${query} selects=${selects} rows=${rows} maxSelects=${maxSelects} maxRows=${maxRows}`
71
+ )
72
+ this.name = 'TransactionQueryBudgetError'
73
+ }
74
+ }
75
+
76
+ type MaterializedRow = Record<string, unknown>
77
+ type ExecuteSelect = (
78
+ sql: string,
79
+ params: readonly unknown[]
80
+ ) => readonly MaterializedRow[]
81
+ type ExecuteSelectAsync = (
82
+ sql: string,
83
+ params: readonly unknown[]
84
+ ) => readonly MaterializedRow[] | Promise<readonly MaterializedRow[]>
85
+
86
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER)
87
+ const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER)
88
+ const PLAN_HASH = /^[0-9a-f]{16}$/
89
+ const COLUMN_TYPES = new Set<TransactionQueryColumnType>([
90
+ 'string',
91
+ 'number',
92
+ 'boolean',
93
+ 'json',
94
+ 'null',
95
+ ])
96
+
97
+ function isObject(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
99
+ }
100
+
101
+ function malformed(detail: string): TypeError {
102
+ return new TypeError(`compiled transaction query plan ${detail}`)
103
+ }
104
+
105
+ function assertWireValue(value: unknown, path: string): TransactionQueryWireValue {
106
+ if (!isObject(value) || typeof value.kind !== 'string') {
107
+ throw malformed(`${path} has an invalid literal`)
108
+ }
109
+ switch (value.kind) {
110
+ case 'null':
111
+ return value as TransactionQueryWireValue
112
+ case 'integer':
113
+ if (typeof value.value !== 'string' || !/^-?[0-9]+$/.test(value.value)) {
114
+ throw malformed(`${path} has an invalid integer literal`)
115
+ }
116
+ return value as TransactionQueryWireValue
117
+ case 'real':
118
+ if (typeof value.value !== 'number' || !Number.isFinite(value.value)) {
119
+ throw malformed(`${path} has an invalid real literal`)
120
+ }
121
+ return value as TransactionQueryWireValue
122
+ case 'text':
123
+ if (typeof value.value !== 'string') {
124
+ throw malformed(`${path} has an invalid text literal`)
125
+ }
126
+ return value as TransactionQueryWireValue
127
+ case 'blob':
128
+ if (
129
+ !Array.isArray(value.value) ||
130
+ value.value.some(
131
+ (byte) => !Number.isInteger(byte) || Number(byte) < 0 || Number(byte) > 255
132
+ )
133
+ ) {
134
+ throw malformed(`${path} has an invalid blob literal`)
135
+ }
136
+ return value as TransactionQueryWireValue
137
+ default:
138
+ throw malformed(`${path} has an unknown literal kind`)
139
+ }
140
+ }
141
+
142
+ function assertNode(
143
+ value: unknown,
144
+ path: string,
145
+ depth: number
146
+ ): asserts value is CompiledTransactionQueryNode {
147
+ if (depth > 64) throw malformed(`${path} exceeds the maximum relationship depth`)
148
+ if (!isObject(value)) throw malformed(`${path} must be an object`)
149
+ if (typeof value.table !== 'string' || value.table.length === 0) {
150
+ throw malformed(`${path}.table must be a non-empty string`)
151
+ }
152
+ if (typeof value.singular !== 'boolean') {
153
+ throw malformed(`${path}.singular must be a boolean`)
154
+ }
155
+ if (typeof value.sql !== 'string' || value.sql.length === 0) {
156
+ throw malformed(`${path}.sql must be a non-empty string`)
157
+ }
158
+ if (!Array.isArray(value.bindings)) {
159
+ throw malformed(`${path}.bindings must be an array`)
160
+ }
161
+ for (const [index, binding] of value.bindings.entries()) {
162
+ if (!isObject(binding)) throw malformed(`${path}.bindings[${index}] is invalid`)
163
+ if (binding.kind === 'literal') {
164
+ assertWireValue(binding.value, `${path}.bindings[${index}]`)
165
+ } else if (
166
+ binding.kind !== 'parent_field' ||
167
+ typeof binding.field !== 'string' ||
168
+ binding.field.length === 0
169
+ ) {
170
+ throw malformed(`${path}.bindings[${index}] is invalid`)
171
+ }
172
+ }
173
+ if (!Array.isArray(value.columns) || value.columns.length === 0) {
174
+ throw malformed(`${path}.columns must be a non-empty array`)
175
+ }
176
+ const names = new Set<string>()
177
+ for (const [index, column] of value.columns.entries()) {
178
+ if (
179
+ !isObject(column) ||
180
+ typeof column.name !== 'string' ||
181
+ column.name.length === 0 ||
182
+ typeof column.columnType !== 'string' ||
183
+ !COLUMN_TYPES.has(column.columnType as TransactionQueryColumnType)
184
+ ) {
185
+ throw malformed(`${path}.columns[${index}] is invalid`)
186
+ }
187
+ if (names.has(column.name)) {
188
+ throw malformed(`${path}.columns contains duplicate '${column.name}'`)
189
+ }
190
+ names.add(column.name)
191
+ }
192
+ if (!Array.isArray(value.relationships)) {
193
+ throw malformed(`${path}.relationships must be an array`)
194
+ }
195
+ const relationships = new Set<string>()
196
+ for (const [index, relationship] of value.relationships.entries()) {
197
+ if (
198
+ !isObject(relationship) ||
199
+ typeof relationship.name !== 'string' ||
200
+ relationship.name.length === 0
201
+ ) {
202
+ throw malformed(`${path}.relationships[${index}] is invalid`)
203
+ }
204
+ if (relationships.has(relationship.name) || names.has(relationship.name)) {
205
+ throw malformed(`${path}.relationships contains conflicting '${relationship.name}'`)
206
+ }
207
+ relationships.add(relationship.name)
208
+ assertNode(relationship.node, `${path}.relationships[${index}].node`, depth + 1)
209
+ }
210
+ }
211
+
212
+ function assertPlan(value: unknown): asserts value is CompiledTransactionQueryPlan {
213
+ if (!isObject(value)) throw malformed('must be an object')
214
+ if (typeof value.rootTable !== 'string' || value.rootTable.length === 0) {
215
+ throw malformed('rootTable must be a non-empty string')
216
+ }
217
+ if (typeof value.planHash !== 'string' || !PLAN_HASH.test(value.planHash)) {
218
+ throw malformed('planHash must be 16 lowercase hexadecimal characters')
219
+ }
220
+ assertNode(value.root, 'root', 0)
221
+ if (value.root.table !== value.rootTable) {
222
+ throw malformed('root.table must match rootTable')
223
+ }
224
+ }
225
+
226
+ function resolveBudget(
227
+ budget: Partial<TransactionQueryBudget> | undefined
228
+ ): TransactionQueryBudget {
229
+ const resolved = { ...DEFAULT_TRANSACTION_QUERY_BUDGET, ...budget }
230
+ for (const [name, value] of Object.entries(resolved)) {
231
+ if (!Number.isSafeInteger(value) || value < 1) {
232
+ throw new TypeError(
233
+ `transaction query budget ${name} must be a positive safe integer`
234
+ )
235
+ }
236
+ }
237
+ return resolved
238
+ }
239
+
240
+ function decodeWireValue(value: TransactionQueryWireValue): unknown {
241
+ switch (value.kind) {
242
+ case 'null':
243
+ return null
244
+ case 'integer': {
245
+ const exact = BigInt(value.value)
246
+ return exact >= MIN_SAFE && exact <= MAX_SAFE ? Number(exact) : value.value
247
+ }
248
+ case 'real':
249
+ case 'text':
250
+ return value.value
251
+ case 'blob':
252
+ return Uint8Array.from(value.value)
253
+ }
254
+ }
255
+
256
+ function bytes(value: unknown): Uint8Array | undefined {
257
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
258
+ if (ArrayBuffer.isView(value)) {
259
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
260
+ }
261
+ return undefined
262
+ }
263
+
264
+ function decodeColumn(
265
+ value: unknown,
266
+ columnType: TransactionQueryColumnType,
267
+ path: string
268
+ ): unknown {
269
+ if (value === null) return null
270
+ switch (columnType) {
271
+ case 'string':
272
+ if (typeof value === 'string') return value
273
+ break
274
+ case 'number':
275
+ if (typeof value === 'number' && Number.isFinite(value)) return value
276
+ if (typeof value === 'bigint' && value >= MIN_SAFE && value <= MAX_SAFE) {
277
+ return Number(value)
278
+ }
279
+ break
280
+ case 'boolean':
281
+ if (typeof value === 'boolean') return value
282
+ if (value === 0 || value === 0n || value === '0') return false
283
+ if (value === 1 || value === 1n || value === '1') return true
284
+ break
285
+ case 'json': {
286
+ if (typeof value === 'string') {
287
+ try {
288
+ return JSON.parse(value)
289
+ } catch {
290
+ throw new TypeError(`transaction query row ${path} contains invalid JSON`)
291
+ }
292
+ }
293
+ const encoded = bytes(value)
294
+ if (encoded) {
295
+ try {
296
+ return JSON.parse(new TextDecoder().decode(encoded))
297
+ } catch {
298
+ throw new TypeError(`transaction query row ${path} contains invalid JSON`)
299
+ }
300
+ }
301
+ if (typeof value === 'object') return value
302
+ break
303
+ }
304
+ case 'null':
305
+ break
306
+ }
307
+ throw new TypeError(
308
+ `transaction query row ${path} does not match schema type ${columnType}`
309
+ )
310
+ }
311
+
312
+ function decodeRow(
313
+ raw: MaterializedRow,
314
+ node: CompiledTransactionQueryNode
315
+ ): MaterializedRow {
316
+ const decoded: MaterializedRow = {}
317
+ for (const column of node.columns) {
318
+ if (!Object.hasOwn(raw, column.name)) {
319
+ throw new TypeError(
320
+ `transaction query row for ${node.table} is missing column ${column.name}`
321
+ )
322
+ }
323
+ decoded[column.name] = decodeColumn(
324
+ raw[column.name],
325
+ column.columnType,
326
+ `${node.table}.${column.name}`
327
+ )
328
+ }
329
+ return decoded
330
+ }
331
+
332
+ type TransactionQueryExecutionState = {
333
+ budget: TransactionQueryBudget
334
+ query: string
335
+ rows: number
336
+ selects: number
337
+ }
338
+
339
+ type PreparedSelect = {
340
+ params: unknown[]
341
+ state: TransactionQueryExecutionState
342
+ }
343
+
344
+ type DecodedSelect = {
345
+ rows: Array<{ decoded: MaterializedRow; raw: MaterializedRow }>
346
+ state: TransactionQueryExecutionState
347
+ }
348
+
349
+ type NodeExecution = {
350
+ state: TransactionQueryExecutionState
351
+ value: MaterializedRow[] | MaterializedRow | null | undefined
352
+ }
353
+
354
+ function createExecutionState(
355
+ plan: CompiledTransactionQueryPlan,
356
+ options: TransactionQueryExecutionOptions
357
+ ): TransactionQueryExecutionState {
358
+ assertPlan(plan)
359
+ return {
360
+ budget: resolveBudget(options.budget),
361
+ query: options.queryName?.trim() || `${plan.rootTable}:${plan.planHash}`,
362
+ rows: 0,
363
+ selects: 0,
364
+ }
365
+ }
366
+
367
+ function assertWithinBudget(state: TransactionQueryExecutionState): void {
368
+ if (state.selects <= state.budget.maxSelects && state.rows <= state.budget.maxRows) {
369
+ return
370
+ }
371
+ throw new TransactionQueryBudgetError(
372
+ state.query,
373
+ state.selects,
374
+ state.rows,
375
+ state.budget.maxSelects,
376
+ state.budget.maxRows
377
+ )
378
+ }
379
+
380
+ function prepareSelect(
381
+ state: TransactionQueryExecutionState,
382
+ node: CompiledTransactionQueryNode,
383
+ parent?: MaterializedRow
384
+ ): PreparedSelect {
385
+ const params = node.bindings.map((binding) => {
386
+ if (binding.kind === 'literal') return decodeWireValue(binding.value)
387
+ if (!parent || !Object.hasOwn(parent, binding.field)) {
388
+ throw new TypeError(
389
+ `transaction query parent row for ${node.table} is missing field ${binding.field}`
390
+ )
391
+ }
392
+ return parent[binding.field]
393
+ })
394
+ const next = { ...state, selects: state.selects + 1 }
395
+ assertWithinBudget(next)
396
+ return { params, state: next }
397
+ }
398
+
399
+ function decodeSelect(
400
+ state: TransactionQueryExecutionState,
401
+ node: CompiledTransactionQueryNode,
402
+ materialized: readonly MaterializedRow[]
403
+ ): DecodedSelect {
404
+ if (!Array.isArray(materialized)) {
405
+ throw new TypeError('transaction query select must return a materialized row array')
406
+ }
407
+ const next = { ...state, rows: state.rows + materialized.length }
408
+ assertWithinBudget(next)
409
+ const rows = materialized.map((raw) => {
410
+ if (!isObject(raw)) {
411
+ throw new TypeError(
412
+ `transaction query select for ${node.table} returned a non-object row`
413
+ )
414
+ }
415
+ return { decoded: decodeRow(raw, node), raw }
416
+ })
417
+ return { rows, state: next }
418
+ }
419
+
420
+ function shapeRows(
421
+ node: CompiledTransactionQueryNode,
422
+ rows: MaterializedRow[]
423
+ ): NodeExecution['value'] {
424
+ return node.singular ? rows[0] : rows
425
+ }
426
+
427
+ export function executeTransactionQueryPlan<Result = unknown>(
428
+ plan: CompiledTransactionQueryPlan,
429
+ execute: ExecuteSelect,
430
+ options: TransactionQueryExecutionOptions = {}
431
+ ): Result {
432
+ const run = (
433
+ node: CompiledTransactionQueryNode,
434
+ state: TransactionQueryExecutionState,
435
+ parent?: MaterializedRow
436
+ ): NodeExecution => {
437
+ const prepared = prepareSelect(state, node, parent)
438
+ const selected = decodeSelect(
439
+ prepared.state,
440
+ node,
441
+ execute(node.sql, prepared.params)
442
+ )
443
+ const hydrated: MaterializedRow[] = []
444
+ let current = selected.state
445
+ for (const { decoded, raw } of selected.rows) {
446
+ for (const relationship of node.relationships) {
447
+ const related = run(relationship.node, current, raw)
448
+ current = related.state
449
+ decoded[relationship.name] =
450
+ relationship.node.singular && related.value === undefined ? null : related.value
451
+ }
452
+ hydrated.push(decoded)
453
+ }
454
+ return { state: current, value: shapeRows(node, hydrated) }
455
+ }
456
+
457
+ const state = createExecutionState(plan, options)
458
+ return run(plan.root, state).value as Result
459
+ }
460
+
461
+ export async function executeTransactionQueryPlanAsync<Result = unknown>(
462
+ plan: CompiledTransactionQueryPlan,
463
+ execute: ExecuteSelectAsync,
464
+ options: TransactionQueryExecutionOptions = {}
465
+ ): Promise<Result> {
466
+ const run = async (
467
+ node: CompiledTransactionQueryNode,
468
+ state: TransactionQueryExecutionState,
469
+ parent?: MaterializedRow
470
+ ): Promise<NodeExecution> => {
471
+ const prepared = prepareSelect(state, node, parent)
472
+ const selected = decodeSelect(
473
+ prepared.state,
474
+ node,
475
+ await execute(node.sql, prepared.params)
476
+ )
477
+ const hydrated: MaterializedRow[] = []
478
+ let current = selected.state
479
+ for (const { decoded, raw } of selected.rows) {
480
+ for (const relationship of node.relationships) {
481
+ const related = await run(relationship.node, current, raw)
482
+ current = related.state
483
+ decoded[relationship.name] =
484
+ relationship.node.singular && related.value === undefined ? null : related.value
485
+ }
486
+ hydrated.push(decoded)
487
+ }
488
+ return { state: current, value: shapeRows(node, hydrated) }
489
+ }
490
+
491
+ const state = createExecutionState(plan, options)
492
+ const { value } = await run(plan.root, state)
493
+ return value as Result
494
+ }