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.
- package/README.md +140 -10
- package/package.json +15 -4
- package/src/bun-wasm-loader.ts +16 -0
- package/src/config.ts +32 -0
- package/src/generated/sync_wasm.d.ts +37 -2
- package/src/generated/sync_wasm.js +100 -2
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/harness-config.ts +105 -1
- package/src/harness-worker.ts +22 -2
- package/src/host.ts +498 -179
- package/src/index.ts +26 -1
- package/src/ingest-harness-worker.ts +172 -6
- package/src/mutation-error.ts +28 -0
- package/src/platform-probe-worker.ts +122 -9
- package/src/post-commit.ts +40 -0
- package/src/query-compiler.ts +16 -0
- package/src/sql-storage-adapter.ts +88 -19
- package/src/transaction-query.ts +494 -0
- package/src/types.ts +39 -17
- package/src/wasm-platform.ts +10 -0
- package/src/wasm.ts +34 -0
- package/src/write-safeguards.ts +14 -15
|
@@ -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:
|
|
44
|
-
|
|
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
|
-
|
|
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) :
|
|
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 '
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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: (
|
|
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<
|
|
180
|
-
ast: unknown
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
}
|