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.
@@ -1,18 +1,21 @@
1
- import { DurableObject } from 'cloudflare:workers'
2
-
1
+ import { ZeroDO } from '../../../src/cf-do/worker.js'
2
+ import { createQueryCompiler } from './query-compiler.js'
3
+ import {
4
+ SqlStorageDirect,
5
+ SqlStorageMutatorTransaction,
6
+ SqlStorageSyncDb,
7
+ } from './sql-storage-adapter.js'
3
8
  import {
4
- initSync,
5
9
  init_probe_schema,
6
10
  pull_snapshot,
7
11
  push_finalize,
8
12
  push_preflight,
9
13
  rust_panic_after_writes,
10
14
  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'
15
+ } from './wasm-platform.js'
14
16
 
15
- initSync({ module: wasmModule })
17
+ import type { TransactionQueryFormat } from './transaction-query.js'
18
+ import type { ZeroSchemaConfig } from './types.js'
16
19
 
17
20
  interface Env {
18
21
  PROBE_DO: DurableObjectNamespace<ProbeDurableObject>
@@ -21,6 +24,54 @@ interface Env {
21
24
  type DeferredEffect = { mutationID: string; kind: string }
22
25
  type MutatorName = 'read-then-write' | 'multi-table' | 'application-error'
23
26
 
27
+ const transactionQuerySchema = {
28
+ tables: {
29
+ account: {
30
+ name: 'account',
31
+ serverName: 'accounts',
32
+ columns: {
33
+ id: { type: 'string' },
34
+ balance: { type: 'number' },
35
+ },
36
+ primaryKey: ['id'],
37
+ },
38
+ entry: {
39
+ name: 'entry',
40
+ serverName: 'ledger',
41
+ columns: {
42
+ id: { type: 'number' },
43
+ accountId: { type: 'string', serverName: 'account_id' },
44
+ amount: { type: 'number' },
45
+ note: { type: 'string' },
46
+ },
47
+ primaryKey: ['id'],
48
+ },
49
+ },
50
+ } as const satisfies ZeroSchemaConfig
51
+
52
+ const transactionQueryAst = {
53
+ table: 'account',
54
+ where: {
55
+ type: 'simple',
56
+ op: '=',
57
+ left: { type: 'column', name: 'id' },
58
+ right: { type: 'literal', value: 'primary' },
59
+ },
60
+ related: [
61
+ {
62
+ correlation: { parentField: ['id'], childField: ['accountId'] },
63
+ subquery: { table: 'entry', alias: 'entries', orderBy: [['id', 'asc']] },
64
+ },
65
+ ],
66
+ }
67
+
68
+ const transactionQueryFormat = {
69
+ singular: true,
70
+ relationships: { entries: { singular: false, relationships: {} } },
71
+ } as const satisfies TransactionQueryFormat
72
+
73
+ const compileTransactionQuery = createQueryCompiler(transactionQuerySchema)
74
+
24
75
  // Deterministic local analogue of normal DO idle eviction, matching the
25
76
  // harness/cf probe: discard all in-memory state after an idle gap while SQL
26
77
  // storage remains intact. This keeps the test sub-second instead of waiting
@@ -33,7 +84,7 @@ const json = (value: unknown, status = 200) =>
33
84
  headers: { 'cache-control': 'no-store' },
34
85
  })
35
86
 
36
- export class ProbeDurableObject extends DurableObject<Env> {
87
+ export class ProbeDurableObject extends ZeroDO {
37
88
  readonly #db: SqlStorageSyncDb
38
89
  #bootID = crypto.randomUUID()
39
90
  #lastRequestAt = 0
@@ -41,7 +92,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
41
92
  #effects: Array<DeferredEffect & { observedCommitted: boolean }> = []
42
93
 
43
94
  constructor(ctx: DurableObjectState, env: Env) {
44
- super(ctx, env)
95
+ super(ctx, env as never)
45
96
  this.#db = new SqlStorageSyncDb(ctx.storage.sql)
46
97
  ctx.storage.transactionSync(() => init_probe_schema(this.#db))
47
98
  }
@@ -291,6 +342,68 @@ export class ProbeDurableObject extends DurableObject<Env> {
291
342
  return json({ errors })
292
343
  }
293
344
 
345
+ if (route === '/transaction-query') {
346
+ let plan: ReturnType<typeof compileTransactionQuery> | undefined
347
+ const tx = new SqlStorageMutatorTransaction(
348
+ new SqlStorageDirect(this.ctx.storage.sql),
349
+ (queryAst, queryFormat) => {
350
+ plan = compileTransactionQuery(queryAst, queryFormat)
351
+ return plan
352
+ }
353
+ )
354
+ const result = await tx.queryAst(
355
+ transactionQueryAst,
356
+ transactionQueryFormat,
357
+ 'platformTransactionQuery'
358
+ )
359
+ let malformedFormatStatus: unknown
360
+ try {
361
+ await tx.queryAst(transactionQueryAst, undefined as never)
362
+ } catch (error) {
363
+ malformedFormatStatus = (error as { status?: unknown }).status
364
+ }
365
+ return json({ result, malformedFormatStatus, plan })
366
+ }
367
+
368
+ if (route === '/application-transaction-query') {
369
+ const result = await this.runApplicationTransaction(
370
+ compileTransactionQuery,
371
+ (tx) =>
372
+ tx.queryAst(
373
+ transactionQueryAst,
374
+ transactionQueryFormat,
375
+ 'applicationTransactionQuery'
376
+ ),
377
+ { maxSelects: 8, maxRows: 20 }
378
+ )
379
+ return json({ result })
380
+ }
381
+
382
+ if (route === '/application-transaction-query-budget') {
383
+ try {
384
+ await this.runApplicationTransaction(
385
+ compileTransactionQuery,
386
+ (tx) =>
387
+ tx.queryAst(
388
+ transactionQueryAst,
389
+ transactionQueryFormat,
390
+ 'budgetedApplicationTransactionQuery'
391
+ ),
392
+ { maxSelects: 1 }
393
+ )
394
+ } catch (error) {
395
+ return json(
396
+ {
397
+ code: Reflect.get(error as object, 'code'),
398
+ query: Reflect.get(error as object, 'query'),
399
+ selects: Reflect.get(error as object, 'selects'),
400
+ },
401
+ 409
402
+ )
403
+ }
404
+ return json({ error: 'query budget did not abort the transaction' }, 500)
405
+ }
406
+
294
407
  return json({ error: 'not found', route }, 404)
295
408
  }
296
409
  }
@@ -0,0 +1,40 @@
1
+ export type DeferredEffect = () => void | Promise<void>
2
+
3
+ export type PostCommitEffects = {
4
+ /** discard effects from a storage-transaction attempt before starting another */
5
+ beginAttempt(): void
6
+ defer(effect: DeferredEffect): void
7
+ /** run the committed attempt's effects in order without rejecting */
8
+ runAfterCommit(onError: (error: unknown) => void): Promise<void>
9
+ }
10
+
11
+ /**
12
+ * collect external work during a retryable transaction and run only the effects
13
+ * belonging to the attempt that commits.
14
+ */
15
+ export function createPostCommitEffects(): PostCommitEffects {
16
+ const effects: DeferredEffect[] = []
17
+
18
+ return {
19
+ beginAttempt() {
20
+ effects.length = 0
21
+ },
22
+ defer(effect) {
23
+ effects.push(effect)
24
+ },
25
+ async runAfterCommit(onError) {
26
+ const committed = effects.splice(0)
27
+ for (const effect of committed) {
28
+ try {
29
+ await effect()
30
+ } catch (error) {
31
+ try {
32
+ onError(error)
33
+ } catch {
34
+ // reporting must not turn a committed effect failure into a rejection
35
+ }
36
+ }
37
+ }
38
+ },
39
+ }
40
+ }
@@ -0,0 +1,16 @@
1
+ import { engine_compile_query } from './wasm.js'
2
+
3
+ import type {
4
+ CompiledTransactionQueryPlan,
5
+ TransactionQueryFormat,
6
+ } from './transaction-query.js'
7
+ import type { ZeroSchemaConfig } from './types.js'
8
+
9
+ export type TransactionQueryCompiler = (
10
+ ast: unknown,
11
+ format: TransactionQueryFormat
12
+ ) => CompiledTransactionQueryPlan
13
+
14
+ export function createQueryCompiler(schema: ZeroSchemaConfig): TransactionQueryCompiler {
15
+ return (ast, format) => engine_compile_query(schema, ast, format)
16
+ }
@@ -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
  }