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
package/src/index.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
export { createSyncDurableObject, createSyncWorker } from './host.js'
|
|
2
|
-
export {
|
|
2
|
+
export { createQueryCompiler } from './query-compiler.js'
|
|
3
|
+
export {
|
|
4
|
+
MutationApplicationError,
|
|
5
|
+
isMutationApplicationError,
|
|
6
|
+
registerMutators,
|
|
7
|
+
} from './types.js'
|
|
8
|
+
export {
|
|
9
|
+
DEFAULT_TRANSACTION_QUERY_BUDGET,
|
|
10
|
+
TransactionQueryBudgetError,
|
|
11
|
+
executeTransactionQueryPlan,
|
|
12
|
+
executeTransactionQueryPlanAsync,
|
|
13
|
+
} from './transaction-query.js'
|
|
14
|
+
|
|
3
15
|
export type {
|
|
4
16
|
DeferredEffect,
|
|
5
17
|
JsonPrimitive,
|
|
@@ -20,3 +32,16 @@ export type {
|
|
|
20
32
|
VisibilityFilter,
|
|
21
33
|
ZeroSchemaConfig,
|
|
22
34
|
} from './types.js'
|
|
35
|
+
export type {
|
|
36
|
+
CompiledTransactionQueryNode,
|
|
37
|
+
CompiledTransactionQueryPlan,
|
|
38
|
+
CompiledTransactionQueryRelationship,
|
|
39
|
+
TransactionQueryBinding,
|
|
40
|
+
TransactionQueryBudget,
|
|
41
|
+
TransactionQueryColumn,
|
|
42
|
+
TransactionQueryColumnType,
|
|
43
|
+
TransactionQueryExecutionOptions,
|
|
44
|
+
TransactionQueryFormat,
|
|
45
|
+
TransactionQueryWireValue,
|
|
46
|
+
} from './transaction-query.js'
|
|
47
|
+
export type { TransactionQueryCompiler } from './query-compiler.js'
|
|
@@ -24,6 +24,7 @@ type Fetcher = { fetch(input: string | Request, init?: RequestInit): Promise<Res
|
|
|
24
24
|
interface Env extends SyncHostEnv {
|
|
25
25
|
DATA: Fetcher
|
|
26
26
|
APP: Fetcher
|
|
27
|
+
CAPPED_SYNC_DO: DurableObjectNamespace
|
|
27
28
|
UPSTREAM_DO: DurableObjectNamespace
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -32,15 +33,24 @@ const numericTextNamespaces = new Set<string>()
|
|
|
32
33
|
const jsonValueNamespaces = new Set<string>()
|
|
33
34
|
const hydratedNamespaces = new Set<string>()
|
|
34
35
|
const heldSnapshots = new Set<string>()
|
|
36
|
+
const holdSnapshotsAfterCursor = new Set<string>()
|
|
35
37
|
const activeSnapshots = new Set<string>()
|
|
38
|
+
const snapshotLimits = new Map<string, number[]>()
|
|
39
|
+
const heldDelegatedPushes = new Set<string>()
|
|
40
|
+
const activeDelegatedPushes = new Set<string>()
|
|
41
|
+
const completedDelegatedPushes = new Set<string>()
|
|
42
|
+
const heldChangeResponses = new Set<string>()
|
|
43
|
+
const activeHeldChangeResponses = new Set<string>()
|
|
36
44
|
let delegatedFailuresRemaining = 0
|
|
37
45
|
let delegatedAttempts = 0
|
|
38
46
|
let delegatedPushFailedRemaining = 0
|
|
47
|
+
let delegatedUrl = ''
|
|
39
48
|
|
|
40
49
|
const config: SyncHostConfig<Env> = {
|
|
41
50
|
hostVersion: 'upstream-ingest-harness',
|
|
42
51
|
schema,
|
|
43
|
-
mutateUrl: '/api/zero/push',
|
|
52
|
+
mutateUrl: '/api/zero/push?schema=feed_0&appID=feed',
|
|
53
|
+
mutateOrigin: 'https://app.internal',
|
|
44
54
|
mutateBinding: 'APP',
|
|
45
55
|
delegatedPushRetry: {
|
|
46
56
|
maxAttempts: 3,
|
|
@@ -50,9 +60,11 @@ const config: SyncHostConfig<Env> = {
|
|
|
50
60
|
},
|
|
51
61
|
upstream: {
|
|
52
62
|
binding: 'DATA',
|
|
53
|
-
namespacePath: (namespace) =>
|
|
63
|
+
namespacePath: (namespace) =>
|
|
64
|
+
namespace.startsWith('root-mount-') ? '/' : `/${namespace}`,
|
|
54
65
|
changeLimit: 2,
|
|
55
66
|
intervalMs: 1_000,
|
|
67
|
+
ingestBudgetRows: 600,
|
|
56
68
|
},
|
|
57
69
|
initialize(sql) {
|
|
58
70
|
sql.exec(
|
|
@@ -63,12 +75,23 @@ const config: SyncHostConfig<Env> = {
|
|
|
63
75
|
const userID = request.headers.get('authorization')?.match(/^Bearer token-(.+)$/)?.[1]
|
|
64
76
|
return userID ? { userID } : null
|
|
65
77
|
},
|
|
78
|
+
authorizeWake(request) {
|
|
79
|
+
return new URL(request.url).searchParams.get('wakeToken') === 'ingest-harness-wake'
|
|
80
|
+
},
|
|
81
|
+
authorizeNotify(request, env) {
|
|
82
|
+
return Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
|
|
83
|
+
},
|
|
66
84
|
namespace(request) {
|
|
67
85
|
return new URL(request.url).pathname.split('/')[1] || null
|
|
68
86
|
},
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
export const SyncDurableObject = createSyncDurableObject(config)
|
|
90
|
+
const cappedConfig: SyncHostConfig<Env> = {
|
|
91
|
+
...config,
|
|
92
|
+
caps: { maxChangeRows: 10_000, maxChangeBytes: 1 },
|
|
93
|
+
}
|
|
94
|
+
export const CappedSyncDurableObject = createSyncDurableObject(cappedConfig)
|
|
72
95
|
export { ZeroDO }
|
|
73
96
|
|
|
74
97
|
async function upstreamFetch(request: Request, env: Env): Promise<Response> {
|
|
@@ -113,6 +136,35 @@ export class DataService extends WorkerEntrypoint<Env> {
|
|
|
113
136
|
new Response('DATA route rejected non-feed request', { status: 418 })
|
|
114
137
|
)
|
|
115
138
|
}
|
|
139
|
+
if (pathname === '/changes') {
|
|
140
|
+
const cursor = Number(url.searchParams.get('watermark') ?? 0)
|
|
141
|
+
return Promise.resolve(
|
|
142
|
+
Response.json({
|
|
143
|
+
watermark: 1,
|
|
144
|
+
changes:
|
|
145
|
+
cursor >= 1
|
|
146
|
+
? []
|
|
147
|
+
: [
|
|
148
|
+
{
|
|
149
|
+
watermark: 1,
|
|
150
|
+
tableName: 'item',
|
|
151
|
+
op: 'INSERT',
|
|
152
|
+
rowData: {
|
|
153
|
+
id: 'root-feed-row',
|
|
154
|
+
label: 'root-mounted upstream feed',
|
|
155
|
+
rank: 1,
|
|
156
|
+
done: false,
|
|
157
|
+
meta: null,
|
|
158
|
+
},
|
|
159
|
+
oldData: null,
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
if (pathname === '/_orez/write-budget') {
|
|
166
|
+
return Promise.resolve(Response.json({ enabled: true, rootMount: true }))
|
|
167
|
+
}
|
|
116
168
|
const namespace = pathname.split('/')[1] ?? ''
|
|
117
169
|
if (pathname.endsWith('/changes') && jsonValueNamespaces.has(namespace)) {
|
|
118
170
|
const cursor = Number(new URL(request.url).searchParams.get('watermark') ?? 0)
|
|
@@ -210,11 +262,33 @@ export class DataService extends WorkerEntrypoint<Env> {
|
|
|
210
262
|
)
|
|
211
263
|
}
|
|
212
264
|
const response = await upstreamFetch(request, this.env)
|
|
265
|
+
if (pathname.endsWith('/changes') && heldChangeResponses.has(namespace)) {
|
|
266
|
+
activeHeldChangeResponses.add(namespace)
|
|
267
|
+
try {
|
|
268
|
+
while (heldChangeResponses.has(namespace)) await scheduler.wait(10)
|
|
269
|
+
} finally {
|
|
270
|
+
activeHeldChangeResponses.delete(namespace)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
213
273
|
if (pathname.endsWith('/snapshot')) {
|
|
214
|
-
|
|
274
|
+
const limit = Number(url.searchParams.get('limit'))
|
|
275
|
+
if (Number.isSafeInteger(limit)) {
|
|
276
|
+
const limits = snapshotLimits.get(namespace) ?? []
|
|
277
|
+
limits.push(limit)
|
|
278
|
+
snapshotLimits.set(namespace, limits)
|
|
279
|
+
}
|
|
280
|
+
const shouldHold =
|
|
281
|
+
heldSnapshots.has(namespace) &&
|
|
282
|
+
(!holdSnapshotsAfterCursor.has(namespace) || url.searchParams.has('cursor'))
|
|
283
|
+
if (shouldHold) {
|
|
215
284
|
activeSnapshots.add(namespace)
|
|
216
285
|
try {
|
|
217
|
-
while (
|
|
286
|
+
while (
|
|
287
|
+
heldSnapshots.has(namespace) &&
|
|
288
|
+
(!holdSnapshotsAfterCursor.has(namespace) || url.searchParams.has('cursor'))
|
|
289
|
+
) {
|
|
290
|
+
await scheduler.wait(10)
|
|
291
|
+
}
|
|
218
292
|
} finally {
|
|
219
293
|
activeSnapshots.delete(namespace)
|
|
220
294
|
}
|
|
@@ -226,7 +300,8 @@ export class DataService extends WorkerEntrypoint<Env> {
|
|
|
226
300
|
}
|
|
227
301
|
|
|
228
302
|
export class AppService extends WorkerEntrypoint<Env> {
|
|
229
|
-
fetch(request: Request): Promise<Response> {
|
|
303
|
+
async fetch(request: Request): Promise<Response> {
|
|
304
|
+
delegatedUrl = request.url
|
|
230
305
|
if (!new URL(request.url).pathname.endsWith('/api/zero/push')) {
|
|
231
306
|
return Promise.resolve(
|
|
232
307
|
new Response('APP route rejected non-push request', { status: 418 })
|
|
@@ -257,14 +332,81 @@ export class AppService extends WorkerEntrypoint<Env> {
|
|
|
257
332
|
Response.json({ error: 'synthetic delegated push failure' }, { status: 503 })
|
|
258
333
|
)
|
|
259
334
|
}
|
|
260
|
-
|
|
335
|
+
if (heldDelegatedPushes.has(namespace)) {
|
|
336
|
+
activeDelegatedPushes.add(namespace)
|
|
337
|
+
try {
|
|
338
|
+
while (heldDelegatedPushes.has(namespace)) await scheduler.wait(10)
|
|
339
|
+
} finally {
|
|
340
|
+
activeDelegatedPushes.delete(namespace)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const push = (await request.clone().json()) as {
|
|
344
|
+
mutations?: Array<{ clientID?: string; id?: number; name?: string }>
|
|
345
|
+
}
|
|
346
|
+
const cleanupIDs = new Set(
|
|
347
|
+
(push.mutations ?? [])
|
|
348
|
+
.filter((mutation) => mutation.name === '_zero_cleanupResults')
|
|
349
|
+
.map((mutation) => `${mutation.clientID}:${mutation.id}`)
|
|
350
|
+
)
|
|
351
|
+
const response = await upstreamFetch(request, this.env)
|
|
352
|
+
completedDelegatedPushes.add(namespace)
|
|
353
|
+
if (response.ok && cleanupIDs.size > 0) {
|
|
354
|
+
const body = (await response.json()) as {
|
|
355
|
+
mutations?: Array<{ id?: { clientID?: string; id?: number } }>
|
|
356
|
+
pushResponse?: {
|
|
357
|
+
mutations?: Array<{ id?: { clientID?: string; id?: number } }>
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const mutations = body.pushResponse?.mutations ?? body.mutations
|
|
361
|
+
if (Array.isArray(mutations)) {
|
|
362
|
+
const filtered = mutations.filter(
|
|
363
|
+
(mutation) => !cleanupIDs.has(`${mutation.id?.clientID}:${mutation.id?.id}`)
|
|
364
|
+
)
|
|
365
|
+
if (body.pushResponse) body.pushResponse.mutations = filtered
|
|
366
|
+
else body.mutations = filtered
|
|
367
|
+
}
|
|
368
|
+
return Response.json(body, { status: response.status })
|
|
369
|
+
}
|
|
370
|
+
return response
|
|
261
371
|
}
|
|
262
372
|
}
|
|
263
373
|
|
|
264
374
|
const syncWorker = createSyncWorker(config)
|
|
375
|
+
const cappedSyncWorker = createSyncWorker(cappedConfig)
|
|
265
376
|
export default {
|
|
266
377
|
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
267
378
|
const url = new URL(request.url)
|
|
379
|
+
if (url.pathname.startsWith('/delegated-ingest-control/')) {
|
|
380
|
+
const namespace = url.pathname.slice('/delegated-ingest-control/'.length)
|
|
381
|
+
if (request.method === 'GET') {
|
|
382
|
+
return Promise.resolve(
|
|
383
|
+
Response.json({
|
|
384
|
+
appHeld: heldDelegatedPushes.has(namespace),
|
|
385
|
+
appActive: activeDelegatedPushes.has(namespace),
|
|
386
|
+
appCompleted: completedDelegatedPushes.has(namespace),
|
|
387
|
+
changesHeld: heldChangeResponses.has(namespace),
|
|
388
|
+
changesActive: activeHeldChangeResponses.has(namespace),
|
|
389
|
+
})
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
return request
|
|
393
|
+
.json()
|
|
394
|
+
.catch(() => ({}))
|
|
395
|
+
.then((body) => {
|
|
396
|
+
if ((body as { appHeld?: unknown }).appHeld === true) {
|
|
397
|
+
heldDelegatedPushes.add(namespace)
|
|
398
|
+
completedDelegatedPushes.delete(namespace)
|
|
399
|
+
} else if ((body as { appHeld?: unknown }).appHeld === false) {
|
|
400
|
+
heldDelegatedPushes.delete(namespace)
|
|
401
|
+
}
|
|
402
|
+
if ((body as { changesHeld?: unknown }).changesHeld === true) {
|
|
403
|
+
heldChangeResponses.add(namespace)
|
|
404
|
+
} else if ((body as { changesHeld?: unknown }).changesHeld === false) {
|
|
405
|
+
heldChangeResponses.delete(namespace)
|
|
406
|
+
}
|
|
407
|
+
return Response.json({ ok: true, namespace })
|
|
408
|
+
})
|
|
409
|
+
}
|
|
268
410
|
if (url.pathname.startsWith('/snapshot-control/')) {
|
|
269
411
|
const namespace = url.pathname.slice('/snapshot-control/'.length)
|
|
270
412
|
if (request.method === 'GET') {
|
|
@@ -272,6 +414,8 @@ export default {
|
|
|
272
414
|
Response.json({
|
|
273
415
|
active: activeSnapshots.has(namespace),
|
|
274
416
|
held: heldSnapshots.has(namespace),
|
|
417
|
+
afterCursor: holdSnapshotsAfterCursor.has(namespace),
|
|
418
|
+
limits: snapshotLimits.get(namespace) ?? [],
|
|
275
419
|
})
|
|
276
420
|
)
|
|
277
421
|
}
|
|
@@ -281,12 +425,22 @@ export default {
|
|
|
281
425
|
.then((body) => {
|
|
282
426
|
if ((body as { hold?: unknown }).hold === true) {
|
|
283
427
|
heldSnapshots.add(namespace)
|
|
428
|
+
if ((body as { afterCursor?: unknown }).afterCursor === true) {
|
|
429
|
+
holdSnapshotsAfterCursor.add(namespace)
|
|
430
|
+
} else {
|
|
431
|
+
holdSnapshotsAfterCursor.delete(namespace)
|
|
432
|
+
}
|
|
284
433
|
} else {
|
|
285
434
|
heldSnapshots.delete(namespace)
|
|
435
|
+
holdSnapshotsAfterCursor.delete(namespace)
|
|
286
436
|
}
|
|
437
|
+
if ((body as { reset?: unknown }).reset === true)
|
|
438
|
+
snapshotLimits.set(namespace, [])
|
|
287
439
|
return Response.json({
|
|
288
440
|
active: activeSnapshots.has(namespace),
|
|
289
441
|
held: heldSnapshots.has(namespace),
|
|
442
|
+
afterCursor: holdSnapshotsAfterCursor.has(namespace),
|
|
443
|
+
limits: snapshotLimits.get(namespace) ?? [],
|
|
290
444
|
})
|
|
291
445
|
})
|
|
292
446
|
}
|
|
@@ -337,6 +491,7 @@ export default {
|
|
|
337
491
|
delegatedFailuresRemaining,
|
|
338
492
|
delegatedPushFailedRemaining,
|
|
339
493
|
delegatedAttempts,
|
|
494
|
+
delegatedUrl,
|
|
340
495
|
})
|
|
341
496
|
)
|
|
342
497
|
}
|
|
@@ -353,13 +508,24 @@ export default {
|
|
|
353
508
|
Number((body as { pushFailed?: unknown }).pushFailed) || 0
|
|
354
509
|
)
|
|
355
510
|
delegatedAttempts = 0
|
|
511
|
+
delegatedUrl = ''
|
|
356
512
|
return Response.json({
|
|
357
513
|
delegatedFailuresRemaining,
|
|
358
514
|
delegatedPushFailedRemaining,
|
|
359
515
|
delegatedAttempts,
|
|
516
|
+
delegatedUrl,
|
|
360
517
|
})
|
|
361
518
|
})
|
|
362
519
|
}
|
|
520
|
+
if (url.pathname.startsWith('/capped/')) {
|
|
521
|
+
url.pathname = url.pathname.slice('/capped'.length)
|
|
522
|
+
const cappedEnv = { ...env, SYNC_DO: env.CAPPED_SYNC_DO }
|
|
523
|
+
return cappedSyncWorker.fetch!(
|
|
524
|
+
new Request(url, request) as never,
|
|
525
|
+
cappedEnv,
|
|
526
|
+
ctx
|
|
527
|
+
) as Promise<Response>
|
|
528
|
+
}
|
|
363
529
|
if (url.pathname.startsWith('/upstream/')) {
|
|
364
530
|
url.pathname = url.pathname.slice('/upstream'.length)
|
|
365
531
|
return upstreamFetch(new Request(url, request), env)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// the app-rejection contract shared by every orez sync host and by
|
|
2
|
+
// protocol-neutral authoring layers (on-zero adapters). classification is
|
|
3
|
+
// STRUCTURAL, never instanceof: adapters must not depend on orez to reject a
|
|
4
|
+
// mutation, and instanceof breaks across package copies (dual-package
|
|
5
|
+
// installs, --into tarballs, workspace duplication). any error whose name is
|
|
6
|
+
// 'MutationApplicationError' with string details/message is claiming this
|
|
7
|
+
// contract deliberately.
|
|
8
|
+
export class MutationApplicationError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
readonly details: string,
|
|
11
|
+
message = details
|
|
12
|
+
) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'MutationApplicationError'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isMutationApplicationError(
|
|
19
|
+
error: unknown
|
|
20
|
+
): error is { name: string; message: string; details: string } {
|
|
21
|
+
if (typeof error !== 'object' || error === null) return false
|
|
22
|
+
const shaped = error as { name?: unknown; message?: unknown; details?: unknown }
|
|
23
|
+
return (
|
|
24
|
+
shaped.name === 'MutationApplicationError' &&
|
|
25
|
+
typeof shaped.message === 'string' &&
|
|
26
|
+
typeof shaped.details === 'string'
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import {
|
|
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 './
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|