orez-sync-cf-host 0.5.18 → 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 +74 -7
- package/package.json +15 -4
- package/src/bun-wasm-loader.ts +16 -0
- package/src/config.ts +9 -0
- package/src/generated/sync_wasm.d.ts +2 -2
- package/src/generated/sync_wasm.js +3 -2
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/host.ts +53 -43
- package/src/index.ts +26 -1
- package/src/ingest-harness-worker.ts +97 -2
- 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 +31 -17
- package/src/wasm-platform.ts +10 -0
- package/src/wasm.ts +34 -0
|
@@ -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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import type { DeferredEffect } from './post-commit.js'
|
|
2
|
+
import type {
|
|
3
|
+
TransactionQueryBudget,
|
|
4
|
+
TransactionQueryFormat,
|
|
5
|
+
} from './transaction-query.js'
|
|
6
|
+
|
|
7
|
+
export type { DeferredEffect } from './post-commit.js'
|
|
8
|
+
|
|
1
9
|
export type JsonPrimitive = string | number | boolean | null
|
|
2
10
|
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
|
|
3
11
|
|
|
@@ -12,7 +20,11 @@ export type ZeroSchemaConfig = {
|
|
|
12
20
|
Record<
|
|
13
21
|
string,
|
|
14
22
|
{
|
|
15
|
-
readonly
|
|
23
|
+
readonly name?: string
|
|
24
|
+
readonly serverName?: string
|
|
25
|
+
readonly columns: Readonly<
|
|
26
|
+
Record<string, { readonly type: string; readonly serverName?: string }>
|
|
27
|
+
>
|
|
16
28
|
readonly primaryKey: readonly string[]
|
|
17
29
|
}
|
|
18
30
|
>
|
|
@@ -34,13 +46,13 @@ export interface MutatorSql {
|
|
|
34
46
|
params?: readonly unknown[]
|
|
35
47
|
): Promise<Row[]>
|
|
36
48
|
/** Execute a validated Zero AST inside the current application transaction. */
|
|
37
|
-
queryAst<
|
|
38
|
-
ast: JsonValue
|
|
39
|
-
|
|
49
|
+
queryAst<Result = unknown>(
|
|
50
|
+
ast: JsonValue,
|
|
51
|
+
format: TransactionQueryFormat,
|
|
52
|
+
queryName?: string
|
|
53
|
+
): Promise<Result>
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
export type DeferredEffect = () => void | Promise<void>
|
|
43
|
-
|
|
44
56
|
export type MutatorContext = {
|
|
45
57
|
claims: NormalizedClaims
|
|
46
58
|
/** Current Zero mutation identity, when invoked by the production host. */
|
|
@@ -64,15 +76,7 @@ export function registerMutators<
|
|
|
64
76
|
return Object.freeze({ ...registry })
|
|
65
77
|
}
|
|
66
78
|
|
|
67
|
-
export
|
|
68
|
-
constructor(
|
|
69
|
-
readonly details: string,
|
|
70
|
-
message = details
|
|
71
|
-
) {
|
|
72
|
-
super(message)
|
|
73
|
-
this.name = 'MutationApplicationError'
|
|
74
|
-
}
|
|
75
|
-
}
|
|
79
|
+
export { MutationApplicationError, isMutationApplicationError } from './mutation-error.js'
|
|
76
80
|
|
|
77
81
|
export type VisibilityFilter = {
|
|
78
82
|
/** SQL WHERE fragment only, without the WHERE keyword. */
|
|
@@ -141,11 +145,19 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv> = {
|
|
|
141
145
|
hostVersion: string
|
|
142
146
|
schema: ZeroSchemaConfig
|
|
143
147
|
mutators?: MutatorRegistry
|
|
144
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* absolute app push path on the delegated mutation service. a successful
|
|
150
|
+
* response must be causally visible through the configured upstream data
|
|
151
|
+
* feed before the app returns, because the host ingests effects before it
|
|
152
|
+
* records the mutation's lmid.
|
|
153
|
+
*/
|
|
145
154
|
mutateUrl?: string
|
|
146
155
|
/** Absolute origin used for delegated push requests through the service binding. */
|
|
147
156
|
mutateOrigin?: string
|
|
148
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* env binding for delegated pushes; defaults to upstream.binding. the bound
|
|
159
|
+
* service and upstream feed must satisfy the mutateUrl causality contract.
|
|
160
|
+
*/
|
|
149
161
|
mutateBinding?: string
|
|
150
162
|
delegatedPushRetry?: DelegatedPushRetryConfig
|
|
151
163
|
/** Required for delegated push; forbidden with local mutators (no dual apply). */
|
|
@@ -177,5 +189,7 @@ export type SyncHostConfig<Env extends SyncHostEnv = SyncHostEnv> = {
|
|
|
177
189
|
caps?: Partial<PullCaps>
|
|
178
190
|
idleTeardownMs?: number
|
|
179
191
|
wakeCoalesceMs?: number
|
|
192
|
+
/** per-query guard for recursive transaction query materialization. */
|
|
193
|
+
transactionQueryBudget?: Partial<TransactionQueryBudget>
|
|
180
194
|
authorizeAdmin?: (request: Request, env: Env) => boolean | Promise<boolean>
|
|
181
195
|
}
|
package/src/wasm.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { initSync } from './generated/sync_wasm.js'
|
|
2
|
+
import wasmModule from './generated/sync_wasm_bg.wasm'
|
|
3
|
+
|
|
4
|
+
if (!(wasmModule instanceof WebAssembly.Module)) {
|
|
5
|
+
throw new TypeError(
|
|
6
|
+
'.wasm import did not resolve to WebAssembly.Module; Bun consumers must register orez-sync-cf-host/bun-wasm-loader via bunfig preload'
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
initSync({ module: wasmModule })
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
engine_apply_snapshot_changes,
|
|
14
|
+
engine_apply_snapshot_page,
|
|
15
|
+
engine_apply_upstream,
|
|
16
|
+
engine_assemble_push_response,
|
|
17
|
+
engine_begin_snapshot_generation,
|
|
18
|
+
engine_compile_query,
|
|
19
|
+
engine_finalize,
|
|
20
|
+
engine_finalize_snapshot_generation,
|
|
21
|
+
engine_handle_pull,
|
|
22
|
+
engine_handle_query_pull,
|
|
23
|
+
engine_init_query_schema,
|
|
24
|
+
engine_init_schema,
|
|
25
|
+
engine_invalidate,
|
|
26
|
+
engine_memory_bytes,
|
|
27
|
+
engine_preflight,
|
|
28
|
+
engine_prune,
|
|
29
|
+
engine_push_validate,
|
|
30
|
+
engine_read_snapshot_progress,
|
|
31
|
+
engine_record_app_error,
|
|
32
|
+
engine_state,
|
|
33
|
+
engine_version,
|
|
34
|
+
} from './generated/sync_wasm.js'
|