orez-sync-cf-host 0.4.52
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 +143 -0
- package/package.json +30 -0
- package/src/config.ts +47 -0
- package/src/generated/package.json +16 -0
- package/src/generated/sync_wasm.d.ts +123 -0
- package/src/generated/sync_wasm.js +824 -0
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/generated/sync_wasm_bg.wasm.d.ts +2 -0
- package/src/harness-config.ts +384 -0
- package/src/harness-worker.ts +13 -0
- package/src/host.ts +1264 -0
- package/src/index.ts +22 -0
- package/src/ingest-harness-worker.ts +119 -0
- package/src/platform-probe-worker.ts +304 -0
- package/src/sql-storage-adapter.ts +168 -0
- package/src/types.ts +153 -0
package/src/host.ts
ADDED
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
2
|
+
|
|
3
|
+
import { validatePullCaps, validateSyncHostConfig } from './config.js'
|
|
4
|
+
import {
|
|
5
|
+
engine_apply_upstream,
|
|
6
|
+
engine_apply_upstream_snapshot,
|
|
7
|
+
engine_assemble_push_response,
|
|
8
|
+
engine_compile_query,
|
|
9
|
+
engine_finalize,
|
|
10
|
+
engine_handle_pull,
|
|
11
|
+
engine_handle_query_pull,
|
|
12
|
+
engine_init_query_schema,
|
|
13
|
+
engine_init_schema,
|
|
14
|
+
engine_invalidate,
|
|
15
|
+
engine_memory_bytes,
|
|
16
|
+
engine_preflight,
|
|
17
|
+
engine_prune,
|
|
18
|
+
engine_push_validate,
|
|
19
|
+
engine_record_app_error,
|
|
20
|
+
engine_state,
|
|
21
|
+
engine_version,
|
|
22
|
+
initSync,
|
|
23
|
+
} from './generated/sync_wasm.js'
|
|
24
|
+
import wasmModule from './generated/sync_wasm_bg.wasm'
|
|
25
|
+
import {
|
|
26
|
+
SqlStorageDirect,
|
|
27
|
+
SqlStorageMutatorTransaction,
|
|
28
|
+
SqlStorageSyncDb,
|
|
29
|
+
} from './sql-storage-adapter.js'
|
|
30
|
+
import { MutationApplicationError } from './types.js'
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
DeferredEffect,
|
|
34
|
+
JsonValue,
|
|
35
|
+
NormalizedClaims,
|
|
36
|
+
PullCaps,
|
|
37
|
+
SyncHostConfig,
|
|
38
|
+
SyncHostEnv,
|
|
39
|
+
} from './types.js'
|
|
40
|
+
|
|
41
|
+
initSync({ module: wasmModule })
|
|
42
|
+
|
|
43
|
+
const CLAIMS_HEADER = 'x-orez-sync-claims'
|
|
44
|
+
const NAMESPACE_HEADER = 'x-orez-sync-namespace'
|
|
45
|
+
const UPSTREAM_PATH_HEADER = 'x-orez-sync-upstream-path'
|
|
46
|
+
const DEFAULT_CAPS: PullCaps = {
|
|
47
|
+
maxChangeRows: 10_000,
|
|
48
|
+
maxChangeBytes: 2_000_000,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type PushMutation = {
|
|
52
|
+
id: string
|
|
53
|
+
clientID: string
|
|
54
|
+
name: string
|
|
55
|
+
args: JsonValue[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type PushPlan =
|
|
59
|
+
| { kind: 'respond'; response: unknown }
|
|
60
|
+
| { kind: 'process'; clientGroupID: string; mutations: PushMutation[] }
|
|
61
|
+
|
|
62
|
+
type Preflight = { kind: 'applied' } | { kind: 'replay'; expected: string }
|
|
63
|
+
|
|
64
|
+
type MutationResult = {
|
|
65
|
+
clientID: string
|
|
66
|
+
id: string
|
|
67
|
+
result: Record<string, unknown>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type EngineState = { watermark: string; floor: string; upstreamWatermark: string }
|
|
71
|
+
type UpstreamBatch = {
|
|
72
|
+
watermark: number
|
|
73
|
+
changes: Array<{
|
|
74
|
+
watermark: number
|
|
75
|
+
tableName: string
|
|
76
|
+
op: string
|
|
77
|
+
rowData: Record<string, unknown> | null
|
|
78
|
+
oldData: Record<string, unknown> | null
|
|
79
|
+
}>
|
|
80
|
+
}
|
|
81
|
+
type ApplyUpstreamResult = {
|
|
82
|
+
watermark: number | string
|
|
83
|
+
applied: number
|
|
84
|
+
caughtUp: boolean
|
|
85
|
+
}
|
|
86
|
+
type SocketAttachment = { clientID: string }
|
|
87
|
+
type FaultPoint =
|
|
88
|
+
| 'push_before_mutation'
|
|
89
|
+
| 'push_after_write_before_commit'
|
|
90
|
+
| 'push_after_commit_before_response'
|
|
91
|
+
| 'pull_during_tx'
|
|
92
|
+
| 'pull_after_commit'
|
|
93
|
+
type FaultKind = 'error' | 'quota'
|
|
94
|
+
|
|
95
|
+
type Counters = {
|
|
96
|
+
pulls: number
|
|
97
|
+
pushes: number
|
|
98
|
+
resets: number
|
|
99
|
+
applicationErrors: number
|
|
100
|
+
invariantFailures: number
|
|
101
|
+
retentionRuns: number
|
|
102
|
+
queryRecompilations: number
|
|
103
|
+
wasmBoundaryCalls: number
|
|
104
|
+
wakeFrames: number
|
|
105
|
+
wakeBatches: number
|
|
106
|
+
externalEffectFailures: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function freshCounters(): Counters {
|
|
110
|
+
return {
|
|
111
|
+
pulls: 0,
|
|
112
|
+
pushes: 0,
|
|
113
|
+
resets: 0,
|
|
114
|
+
applicationErrors: 0,
|
|
115
|
+
invariantFailures: 0,
|
|
116
|
+
retentionRuns: 0,
|
|
117
|
+
queryRecompilations: 0,
|
|
118
|
+
wasmBoundaryCalls: 0,
|
|
119
|
+
wakeFrames: 0,
|
|
120
|
+
wakeBatches: 0,
|
|
121
|
+
externalEffectFailures: 0,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function json(value: unknown, status = 200): Response {
|
|
126
|
+
return Response.json(value, {
|
|
127
|
+
status,
|
|
128
|
+
headers: { 'cache-control': 'no-store' },
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function statusOf(error: unknown): number {
|
|
133
|
+
if (typeof error === 'object' && error !== null && 'status' in error) {
|
|
134
|
+
const status = Number(error.status)
|
|
135
|
+
if (Number.isInteger(status) && status >= 400 && status <= 599) return status
|
|
136
|
+
}
|
|
137
|
+
return 500
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function errorMessage(error: unknown): string {
|
|
141
|
+
return error instanceof Error ? error.message : String(error)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function requestError(message: string, status = 400): Error & { status: number } {
|
|
145
|
+
return Object.assign(new Error(message), { status })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function requestJson(request: Request): Promise<unknown> {
|
|
149
|
+
try {
|
|
150
|
+
return await request.json()
|
|
151
|
+
} catch {
|
|
152
|
+
throw requestError('invalid JSON request body')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function requestObject(request: Request): Promise<Record<string, unknown>> {
|
|
157
|
+
const value = await requestJson(request)
|
|
158
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
159
|
+
throw requestError('request body must be a JSON object')
|
|
160
|
+
return value as Record<string, unknown>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function routeAfterNamespace(pathname: string): string {
|
|
164
|
+
const [, , ...parts] = pathname.split('/')
|
|
165
|
+
return `/${parts.join('/')}`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function claimsFromRequest(request: Request): NormalizedClaims | null {
|
|
169
|
+
const encoded = request.headers.get(CLAIMS_HEADER)
|
|
170
|
+
if (!encoded) return null
|
|
171
|
+
try {
|
|
172
|
+
const value = JSON.parse(decodeURIComponent(encoded)) as NormalizedClaims
|
|
173
|
+
return value && typeof value.userID === 'string' && value.userID.length > 0
|
|
174
|
+
? value
|
|
175
|
+
: null
|
|
176
|
+
} catch {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function namespaceHash(namespace: string): Promise<string> {
|
|
182
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(namespace))
|
|
183
|
+
return Array.from(new Uint8Array(bytes).slice(0, 8), (byte) =>
|
|
184
|
+
byte.toString(16).padStart(2, '0')
|
|
185
|
+
).join('')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function socketAttachment(socket: WebSocket): SocketAttachment | null {
|
|
189
|
+
const value = socket.deserializeAttachment() as SocketAttachment | null
|
|
190
|
+
return value && typeof value.clientID === 'string' ? value : null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Closing a socket that is already closed/closing throws, and a throw inside a
|
|
194
|
+
// hibernatable WebSocket handler aborts the DO. Swallow it: the socket is going
|
|
195
|
+
// away regardless.
|
|
196
|
+
function socketCloseQuietly(socket: WebSocket, code: number, reason: string): void {
|
|
197
|
+
try {
|
|
198
|
+
socket.close(code, reason)
|
|
199
|
+
} catch {
|
|
200
|
+
// already closing/closed, or workerd rejected the code — nothing to do
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create the consumer-facing Worker router. Authentication happens here; the
|
|
206
|
+
* Durable Object receives only normalized claims over a binding-private header.
|
|
207
|
+
*/
|
|
208
|
+
export function createSyncWorker<Env extends SyncHostEnv>(
|
|
209
|
+
config: SyncHostConfig<Env>
|
|
210
|
+
): ExportedHandler<Env> {
|
|
211
|
+
validateSyncHostConfig(config)
|
|
212
|
+
return {
|
|
213
|
+
async fetch(request, env): Promise<Response> {
|
|
214
|
+
const namespace = config.namespace(request)
|
|
215
|
+
if (!namespace) return new Response('orez sync-cf-host', { status: 200 })
|
|
216
|
+
|
|
217
|
+
const route = routeAfterNamespace(new URL(request.url).pathname)
|
|
218
|
+
const isAdmin = route.startsWith('/admin/')
|
|
219
|
+
if (isAdmin) {
|
|
220
|
+
const authorized = config.authorizeAdmin
|
|
221
|
+
? await config.authorizeAdmin(request, env)
|
|
222
|
+
: Boolean(env.ADMIN_KEY) && request.headers.get('x-admin-key') === env.ADMIN_KEY
|
|
223
|
+
if (!authorized) return json({ error: 'forbidden' }, 403)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const headers = new Headers(request.headers)
|
|
227
|
+
headers.delete(CLAIMS_HEADER)
|
|
228
|
+
headers.delete(NAMESPACE_HEADER)
|
|
229
|
+
headers.delete(UPSTREAM_PATH_HEADER)
|
|
230
|
+
// Wake sockets carry only a client ID and the literal "wake" hint; they
|
|
231
|
+
// expose no rows, claims, or mutation state. Keep this advisory channel
|
|
232
|
+
// compatible with the vendored transport, which cannot attach an HTTP
|
|
233
|
+
// Authorization header to its native WebSocket constructor.
|
|
234
|
+
if (!isAdmin && route !== '/wake' && route !== '/notify') {
|
|
235
|
+
const claims = await config.authenticate(request, env)
|
|
236
|
+
if (!claims) return json({ error: 'missing authentication' }, 401)
|
|
237
|
+
headers.set(CLAIMS_HEADER, encodeURIComponent(JSON.stringify(claims)))
|
|
238
|
+
}
|
|
239
|
+
headers.set(NAMESPACE_HEADER, await namespaceHash(namespace))
|
|
240
|
+
if (config.upstream) {
|
|
241
|
+
const namespacePath =
|
|
242
|
+
typeof config.upstream.namespacePath === 'function'
|
|
243
|
+
? config.upstream.namespacePath(namespace)
|
|
244
|
+
: config.upstream.namespacePath
|
|
245
|
+
if (!namespacePath.startsWith('/')) {
|
|
246
|
+
return json({ error: 'upstream namespacePath must be an absolute path' }, 500)
|
|
247
|
+
}
|
|
248
|
+
headers.set(UPSTREAM_PATH_HEADER, namespacePath.replace(/\/$/, ''))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const forwarded = new Request(request, { headers })
|
|
252
|
+
const id = env.SYNC_DO.idFromName(namespace)
|
|
253
|
+
return env.SYNC_DO.get(id).fetch(forwarded)
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Create the namespace Durable Object class for one bundled consumer config. */
|
|
259
|
+
export function createSyncDurableObject<Env extends SyncHostEnv>(
|
|
260
|
+
config: SyncHostConfig<Env>
|
|
261
|
+
) {
|
|
262
|
+
validateSyncHostConfig(config)
|
|
263
|
+
const defaultRetainChanges = String(config.retainChanges ?? 4_096)
|
|
264
|
+
const caps: PullCaps = validatePullCaps({ ...DEFAULT_CAPS, ...config.caps })
|
|
265
|
+
const idleTeardownMs = config.idleTeardownMs ?? 5_000
|
|
266
|
+
// A CF fan-out wakes every client into an HTTP pull. Give concurrent writer
|
|
267
|
+
// requests a real batching window so a storm burst creates one pull wave.
|
|
268
|
+
const wakeCoalesceMs = config.wakeCoalesceMs ?? 25
|
|
269
|
+
const upstreamIntervalMs = config.upstream?.intervalMs ?? 15_000
|
|
270
|
+
const upstreamLimit = config.upstream?.changeLimit ?? 1_000
|
|
271
|
+
|
|
272
|
+
return class SyncDurableObject extends DurableObject<Env> {
|
|
273
|
+
readonly #engineDb: SqlStorageSyncDb
|
|
274
|
+
readonly #directSql: SqlStorageDirect
|
|
275
|
+
readonly #mutatorSql: SqlStorageMutatorTransaction
|
|
276
|
+
#bootID = crypto.randomUUID()
|
|
277
|
+
#lastRequestAt = 0
|
|
278
|
+
#hibernations = 0
|
|
279
|
+
#dropNextPushResponse = false
|
|
280
|
+
#counters = freshCounters()
|
|
281
|
+
#pulling = new Set<string>()
|
|
282
|
+
#wakeOrigins = new Set<string>()
|
|
283
|
+
#wakeRecipients = new Set<WebSocket>()
|
|
284
|
+
#wakePromise: Promise<void> | null = null
|
|
285
|
+
#ingestPromise: Promise<number> | null = null
|
|
286
|
+
|
|
287
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
288
|
+
super(ctx, env)
|
|
289
|
+
this.#engineDb = new SqlStorageSyncDb(ctx.storage.sql)
|
|
290
|
+
this.#directSql = new SqlStorageDirect(ctx.storage.sql)
|
|
291
|
+
this.#mutatorSql = new SqlStorageMutatorTransaction(this.#directSql, (ast) =>
|
|
292
|
+
this.#wasm(() => engine_compile_query(config.schema, ast))
|
|
293
|
+
)
|
|
294
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
295
|
+
ctx.storage.transactionSync(() => {
|
|
296
|
+
config.initialize(this.#directSql)
|
|
297
|
+
this.#directSql.exec(`CREATE TABLE IF NOT EXISTS _zsync_host_control (
|
|
298
|
+
key TEXT PRIMARY KEY,
|
|
299
|
+
value TEXT NOT NULL
|
|
300
|
+
)`)
|
|
301
|
+
this.#directSql.exec(
|
|
302
|
+
"INSERT OR IGNORE INTO _zsync_host_control (key, value) VALUES ('writerEnabled', '1')"
|
|
303
|
+
)
|
|
304
|
+
this.#wasm(() => engine_init_schema(this.#engineDb, config.schema))
|
|
305
|
+
if (config.queryAware || config.resolveQuery)
|
|
306
|
+
this.#wasm(() => engine_init_query_schema(this.#engineDb))
|
|
307
|
+
})
|
|
308
|
+
if (config.upstream && (await ctx.storage.getAlarm()) === null) {
|
|
309
|
+
await ctx.storage.setAlarm(Date.now() + upstreamIntervalMs)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#wasm<T>(call: () => T): T {
|
|
315
|
+
this.#counters.wasmBoundaryCalls++
|
|
316
|
+
return call()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#simulateIdleTeardown(now: number): void {
|
|
320
|
+
if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= idleTeardownMs) {
|
|
321
|
+
this.#bootID = crypto.randomUUID()
|
|
322
|
+
this.#hibernations++
|
|
323
|
+
this.#counters = freshCounters()
|
|
324
|
+
this.#pulling.clear()
|
|
325
|
+
this.#wakeOrigins.clear()
|
|
326
|
+
this.#wakeRecipients.clear()
|
|
327
|
+
this.#wakePromise = null
|
|
328
|
+
}
|
|
329
|
+
this.#lastRequestAt = now
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#visibility(claims: NormalizedClaims): unknown {
|
|
333
|
+
if (!config.visibility || !this.#visibilityEnabled()) return null
|
|
334
|
+
return {
|
|
335
|
+
rowLocal:
|
|
336
|
+
typeof config.visibility.rowLocal === 'function'
|
|
337
|
+
? config.visibility.rowLocal(claims)
|
|
338
|
+
: config.visibility.rowLocal,
|
|
339
|
+
filters: Object.keys(config.schema.tables).flatMap((table) => {
|
|
340
|
+
const filter = config.visibility?.filter(table, claims)
|
|
341
|
+
return filter
|
|
342
|
+
? [{ table, sql: filter.sql, params: [...(filter.params ?? [])] }]
|
|
343
|
+
: []
|
|
344
|
+
}),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// the admin-set namespace knobs (writer, visibility, query-aware,
|
|
349
|
+
// retention) live in _zsync_host_control, NOT in instance fields: a real
|
|
350
|
+
// eviction recreates this instance, and an in-memory override silently
|
|
351
|
+
// reverting to the config default mid-run turns a query-aware namespace
|
|
352
|
+
// back into a baseline one — the client keeps re-sending its desired-query
|
|
353
|
+
// patch, the host ignores it, and every pull answers {unchanged:true}
|
|
354
|
+
// forever.
|
|
355
|
+
#controlGet(key: string): string | null {
|
|
356
|
+
const row = this.#directSql.query<{ value: string }>(
|
|
357
|
+
'SELECT value FROM _zsync_host_control WHERE key = ?',
|
|
358
|
+
[key]
|
|
359
|
+
)[0]
|
|
360
|
+
return row?.value ?? null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#controlSet(key: string, value: string): void {
|
|
364
|
+
this.#directSql.exec(
|
|
365
|
+
'INSERT INTO _zsync_host_control (key, value) VALUES (?, ?) ' +
|
|
366
|
+
'ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
|
367
|
+
[key, value]
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#writerEnabled(): boolean {
|
|
372
|
+
return this.#controlGet('writerEnabled') === '1'
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#visibilityEnabled(): boolean {
|
|
376
|
+
const value = this.#controlGet('visibilityEnabled')
|
|
377
|
+
return value === null ? (config.visibilityEnabled ?? false) : value === '1'
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#queryAwareOverride(): boolean | null {
|
|
381
|
+
const value = this.#controlGet('queryAwareOverride')
|
|
382
|
+
return value === null ? null : value === '1'
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#retainChanges(): string {
|
|
386
|
+
return this.#controlGet('retainChanges') ?? defaultRetainChanges
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#takeFault(point: FaultPoint): FaultKind | null {
|
|
390
|
+
if (this.#controlGet('faultPoint') !== point) return null
|
|
391
|
+
const kind = this.#controlGet('faultKind')
|
|
392
|
+
this.#directSql.exec(
|
|
393
|
+
"DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')"
|
|
394
|
+
)
|
|
395
|
+
return kind === 'quota' ? 'quota' : 'error'
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#faultError(kind: FaultKind, point: FaultPoint): Error & { status: number } {
|
|
399
|
+
return requestError(
|
|
400
|
+
`injected ${kind} fault at ${point}`,
|
|
401
|
+
kind === 'quota' ? 507 : 500
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#engineState(): EngineState | null {
|
|
406
|
+
try {
|
|
407
|
+
return this.#wasm(() => engine_state(this.#engineDb)) as EngineState
|
|
408
|
+
} catch {
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#serviceBinding(name = config.upstream?.binding): {
|
|
414
|
+
fetch(input: string | Request, init?: RequestInit): Promise<Response>
|
|
415
|
+
} {
|
|
416
|
+
const value = name
|
|
417
|
+
? (this.env as unknown as Record<string, unknown>)[name]
|
|
418
|
+
: undefined
|
|
419
|
+
if (!value || typeof (value as { fetch?: unknown }).fetch !== 'function') {
|
|
420
|
+
throw requestError(
|
|
421
|
+
`missing upstream service binding: ${name ?? '(not configured)'}`,
|
|
422
|
+
500
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
return value as {
|
|
426
|
+
fetch(input: string | Request, init?: RequestInit): Promise<Response>
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#rememberUpstreamPath(request: Request): string | null {
|
|
431
|
+
if (!config.upstream) return null
|
|
432
|
+
const path = request.headers.get(UPSTREAM_PATH_HEADER)
|
|
433
|
+
if (path) {
|
|
434
|
+
this.#controlSet('upstreamPath', path)
|
|
435
|
+
return path
|
|
436
|
+
}
|
|
437
|
+
return this.#controlGet('upstreamPath')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#ingest(upstreamPath?: string | null): Promise<number> {
|
|
441
|
+
if (!config.upstream) return Promise.resolve(0)
|
|
442
|
+
if (this.#ingestPromise) return this.#ingestPromise
|
|
443
|
+
const path = upstreamPath ?? this.#controlGet('upstreamPath')
|
|
444
|
+
if (!path) return Promise.resolve(0)
|
|
445
|
+
this.#ingestPromise = (async () => {
|
|
446
|
+
let total = 0
|
|
447
|
+
for (;;) {
|
|
448
|
+
const cursor = this.#engineState()?.upstreamWatermark ?? '0'
|
|
449
|
+
const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
|
|
450
|
+
endpoint.searchParams.set('watermark', cursor)
|
|
451
|
+
endpoint.searchParams.set('limit', String(upstreamLimit))
|
|
452
|
+
const response = await this.#serviceBinding().fetch(endpoint.toString(), {
|
|
453
|
+
headers: { host: endpoint.host },
|
|
454
|
+
})
|
|
455
|
+
if (response.status === 410) {
|
|
456
|
+
const snapshotEndpoint = new URL(`${path}/snapshot`, endpoint)
|
|
457
|
+
const snapshotResponse = await this.#serviceBinding().fetch(
|
|
458
|
+
snapshotEndpoint.toString(),
|
|
459
|
+
{ headers: { host: snapshotEndpoint.host } }
|
|
460
|
+
)
|
|
461
|
+
if (!snapshotResponse.ok) {
|
|
462
|
+
throw new Error(`upstream snapshot returned ${snapshotResponse.status}`)
|
|
463
|
+
}
|
|
464
|
+
const snapshot = await snapshotResponse.json()
|
|
465
|
+
const rebuilt = this.ctx.storage.transactionSync(() =>
|
|
466
|
+
this.#wasm(() =>
|
|
467
|
+
engine_apply_upstream_snapshot(this.#engineDb, config.schema, snapshot)
|
|
468
|
+
)
|
|
469
|
+
) as ApplyUpstreamResult
|
|
470
|
+
total += rebuilt.applied
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
if (!response.ok) {
|
|
474
|
+
throw new Error(`upstream changes returned ${response.status}`)
|
|
475
|
+
}
|
|
476
|
+
const batch = (await response.json()) as UpstreamBatch
|
|
477
|
+
if (!Number.isSafeInteger(batch.watermark) || !Array.isArray(batch.changes)) {
|
|
478
|
+
throw new Error('invalid upstream changes response')
|
|
479
|
+
}
|
|
480
|
+
const result = this.ctx.storage.transactionSync(() =>
|
|
481
|
+
this.#wasm(() => engine_apply_upstream(this.#engineDb, config.schema, batch))
|
|
482
|
+
) as ApplyUpstreamResult
|
|
483
|
+
total += result.applied
|
|
484
|
+
if (result.caughtUp) break
|
|
485
|
+
if (result.applied === 0) {
|
|
486
|
+
throw new Error('upstream change cursor made no progress')
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (total > 0) await this.#enqueueWake('__upstream__')
|
|
490
|
+
return total
|
|
491
|
+
})().finally(() => {
|
|
492
|
+
this.#ingestPromise = null
|
|
493
|
+
})
|
|
494
|
+
return this.#ingestPromise
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#log(fields: Record<string, unknown>): void {
|
|
498
|
+
console.log(
|
|
499
|
+
JSON.stringify({
|
|
500
|
+
event: 'sync_request',
|
|
501
|
+
hostVersion: config.hostVersion,
|
|
502
|
+
engineVersion: engine_version(),
|
|
503
|
+
...fields,
|
|
504
|
+
})
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async #runEffects(effects: DeferredEffect[]): Promise<void> {
|
|
509
|
+
for (const effect of effects) {
|
|
510
|
+
try {
|
|
511
|
+
await effect()
|
|
512
|
+
} catch (error) {
|
|
513
|
+
this.#counters.externalEffectFailures++
|
|
514
|
+
console.error(
|
|
515
|
+
JSON.stringify({
|
|
516
|
+
event: 'sync_external_effect_error',
|
|
517
|
+
hostVersion: config.hostVersion,
|
|
518
|
+
error: errorMessage(error),
|
|
519
|
+
})
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#enqueueWake(originClientID: string): Promise<void> {
|
|
526
|
+
this.#wakeOrigins.add(originClientID)
|
|
527
|
+
for (const socket of this.ctx.getWebSockets()) {
|
|
528
|
+
const attachment = socketAttachment(socket)
|
|
529
|
+
if (
|
|
530
|
+
!attachment ||
|
|
531
|
+
attachment.clientID === originClientID ||
|
|
532
|
+
this.#pulling.has(attachment.clientID)
|
|
533
|
+
) {
|
|
534
|
+
continue
|
|
535
|
+
}
|
|
536
|
+
this.#wakeRecipients.add(socket)
|
|
537
|
+
}
|
|
538
|
+
return this.#scheduleWake()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#scheduleWake(): Promise<void> {
|
|
542
|
+
if (!this.#wakePromise) {
|
|
543
|
+
const queuedAt = performance.now()
|
|
544
|
+
this.#wakePromise = (async () => {
|
|
545
|
+
await scheduler.wait(wakeCoalesceMs)
|
|
546
|
+
const fanoutStarted = performance.now()
|
|
547
|
+
const origins = this.#wakeOrigins
|
|
548
|
+
this.#wakeOrigins = new Set()
|
|
549
|
+
const recipients = this.#wakeRecipients
|
|
550
|
+
this.#wakeRecipients = new Set()
|
|
551
|
+
this.#counters.wakeBatches++
|
|
552
|
+
let sent = 0
|
|
553
|
+
const sockets = this.ctx.getWebSockets()
|
|
554
|
+
for (const socket of recipients) {
|
|
555
|
+
try {
|
|
556
|
+
socket.send('wake')
|
|
557
|
+
sent++
|
|
558
|
+
this.#counters.wakeFrames++
|
|
559
|
+
} catch {
|
|
560
|
+
// A closing hibernating socket disappears from getWebSockets;
|
|
561
|
+
// a race here is advisory and carries no correctness weight.
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
console.log(
|
|
565
|
+
JSON.stringify({
|
|
566
|
+
event: 'sync_wake',
|
|
567
|
+
hostVersion: config.hostVersion,
|
|
568
|
+
socketCount: sockets.length,
|
|
569
|
+
originCount: origins.size,
|
|
570
|
+
sent,
|
|
571
|
+
eligibleRecipients: recipients.size,
|
|
572
|
+
coalesceMs: fanoutStarted - queuedAt,
|
|
573
|
+
fanoutMs: performance.now() - fanoutStarted,
|
|
574
|
+
})
|
|
575
|
+
)
|
|
576
|
+
})().finally(() => {
|
|
577
|
+
this.#wakePromise = null
|
|
578
|
+
if (this.#wakeOrigins.size > 0) void this.#scheduleWake()
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
return this.#wakePromise
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async #pull(
|
|
585
|
+
request: Request,
|
|
586
|
+
claims: NormalizedClaims,
|
|
587
|
+
namespace: string
|
|
588
|
+
): Promise<Response> {
|
|
589
|
+
this.#counters.pulls++
|
|
590
|
+
this.#engineDb.resetStats()
|
|
591
|
+
const started = performance.now()
|
|
592
|
+
let transactionMs = 0
|
|
593
|
+
let body: Record<string, unknown> | undefined
|
|
594
|
+
try {
|
|
595
|
+
body = await requestObject(request)
|
|
596
|
+
const queryAware =
|
|
597
|
+
this.#queryAwareOverride() ??
|
|
598
|
+
(typeof config.queryAware === 'function'
|
|
599
|
+
? config.queryAware(claims)
|
|
600
|
+
: (config.queryAware ?? Boolean(config.resolveQuery)))
|
|
601
|
+
if (queryAware && body.queries) {
|
|
602
|
+
const queries = body.queries as {
|
|
603
|
+
version?: unknown
|
|
604
|
+
patch?: unknown
|
|
605
|
+
}
|
|
606
|
+
if (Array.isArray(queries.patch)) {
|
|
607
|
+
const patch = []
|
|
608
|
+
for (const operation of queries.patch) {
|
|
609
|
+
if (!operation || typeof operation !== 'object') {
|
|
610
|
+
patch.push(operation)
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
const op = operation as Record<string, unknown>
|
|
614
|
+
if (op.op === 'put') {
|
|
615
|
+
if (!config.resolveQuery || typeof op.name !== 'string') {
|
|
616
|
+
throw requestError('query put requires a server-resolved named query')
|
|
617
|
+
}
|
|
618
|
+
if (!Array.isArray(op.args)) {
|
|
619
|
+
throw requestError('named query args must be an array')
|
|
620
|
+
}
|
|
621
|
+
const args = op.args as JsonValue[]
|
|
622
|
+
let ast: JsonValue
|
|
623
|
+
try {
|
|
624
|
+
// resolveQuery may be async and needs `env` (a consumer can
|
|
625
|
+
// delegate the transform to its app's real synced-queries
|
|
626
|
+
// endpoint over an app service binding — authenticate runs in the
|
|
627
|
+
// worker isolate, but the query loop runs here in the DO, so the
|
|
628
|
+
// binding must come from the DO's own env, not a shared global).
|
|
629
|
+
ast = await config.resolveQuery(op.name, args, claims, this.env)
|
|
630
|
+
} catch (error) {
|
|
631
|
+
throw requestError(`unknown or unsupported named query: ${op.name}`)
|
|
632
|
+
}
|
|
633
|
+
const transformVersion =
|
|
634
|
+
typeof config.queryTransformVersion === 'function'
|
|
635
|
+
? config.queryTransformVersion(claims)
|
|
636
|
+
: (config.queryTransformVersion ?? 0)
|
|
637
|
+
if (!Number.isSafeInteger(transformVersion) || transformVersion < 0) {
|
|
638
|
+
throw new TypeError(
|
|
639
|
+
'queryTransformVersion must be a non-negative safe integer'
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
patch.push({
|
|
643
|
+
op: 'put',
|
|
644
|
+
hash: op.hash,
|
|
645
|
+
ast,
|
|
646
|
+
transformVersion,
|
|
647
|
+
})
|
|
648
|
+
} else patch.push(operation)
|
|
649
|
+
}
|
|
650
|
+
body = { ...body, queries: { ...queries, patch } }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const clientID = typeof body.clientID === 'string' ? body.clientID : ''
|
|
654
|
+
this.#pulling.add(clientID)
|
|
655
|
+
let response: Record<string, unknown>
|
|
656
|
+
try {
|
|
657
|
+
const txStarted = performance.now()
|
|
658
|
+
const duringFault = this.#takeFault('pull_during_tx')
|
|
659
|
+
response = this.ctx.storage.transactionSync(() => {
|
|
660
|
+
const result = this.#wasm(() =>
|
|
661
|
+
queryAware
|
|
662
|
+
? engine_handle_query_pull(
|
|
663
|
+
this.#engineDb,
|
|
664
|
+
config.schema,
|
|
665
|
+
this.#retainChanges(),
|
|
666
|
+
body,
|
|
667
|
+
claims.userID
|
|
668
|
+
)
|
|
669
|
+
: engine_handle_pull(
|
|
670
|
+
this.#engineDb,
|
|
671
|
+
config.schema,
|
|
672
|
+
this.#visibility(claims),
|
|
673
|
+
caps,
|
|
674
|
+
this.#retainChanges(),
|
|
675
|
+
body,
|
|
676
|
+
claims.userID
|
|
677
|
+
)
|
|
678
|
+
) as Record<string, unknown>
|
|
679
|
+
if (duringFault) throw this.#faultError(duringFault, 'pull_during_tx')
|
|
680
|
+
return result
|
|
681
|
+
})
|
|
682
|
+
transactionMs = performance.now() - txStarted
|
|
683
|
+
} finally {
|
|
684
|
+
this.#pulling.delete(clientID)
|
|
685
|
+
}
|
|
686
|
+
const afterPullFault = this.#takeFault('pull_after_commit')
|
|
687
|
+
if (afterPullFault) throw this.#faultError(afterPullFault, 'pull_after_commit')
|
|
688
|
+
const patch = Array.isArray(response.rowsPatch) ? response.rowsPatch : []
|
|
689
|
+
const queriesBody = body.queries as { patch?: unknown[] } | undefined
|
|
690
|
+
const queryPuts =
|
|
691
|
+
queryAware && Array.isArray(queriesBody?.patch)
|
|
692
|
+
? queriesBody.patch.filter(
|
|
693
|
+
(entry) => (entry as { op?: unknown } | null)?.op === 'put'
|
|
694
|
+
).length
|
|
695
|
+
: 0
|
|
696
|
+
this.#counters.queryRecompilations += queryPuts
|
|
697
|
+
const state = this.#engineState()
|
|
698
|
+
this.#log({
|
|
699
|
+
namespaceHash: namespace,
|
|
700
|
+
requestKind: 'pull',
|
|
701
|
+
resultClass: response.unchanged === true ? 'unchanged' : 'success',
|
|
702
|
+
inputCookie: body.cookie ?? null,
|
|
703
|
+
outputCookie: response.cookie ?? null,
|
|
704
|
+
retainedFloor: state?.floor ?? null,
|
|
705
|
+
currentWatermark: state?.watermark ?? null,
|
|
706
|
+
changeRowsScanned: null,
|
|
707
|
+
changeRowsIncluded: null,
|
|
708
|
+
queriesRecomputed: queryPuts,
|
|
709
|
+
rowPuts: patch.filter((entry) => entry?.op === 'put').length,
|
|
710
|
+
rowDeletes: patch.filter((entry) => entry?.op === 'del').length,
|
|
711
|
+
lmidAdvances: 0,
|
|
712
|
+
transactionMs,
|
|
713
|
+
totalMs: performance.now() - started,
|
|
714
|
+
resetReason: null,
|
|
715
|
+
wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
|
|
716
|
+
sql: this.#engineDb.stats,
|
|
717
|
+
})
|
|
718
|
+
return json(response)
|
|
719
|
+
} catch (error) {
|
|
720
|
+
const status = statusOf(error)
|
|
721
|
+
if (status === 409) this.#counters.resets++
|
|
722
|
+
if (status === 500) this.#counters.invariantFailures++
|
|
723
|
+
this.#log({
|
|
724
|
+
namespaceHash: namespace,
|
|
725
|
+
requestKind: 'pull',
|
|
726
|
+
resultClass: status === 409 ? 'reset' : 'error',
|
|
727
|
+
inputCookie: body?.cookie ?? null,
|
|
728
|
+
outputCookie: null,
|
|
729
|
+
retainedFloor: this.#engineState()?.floor ?? null,
|
|
730
|
+
currentWatermark: this.#engineState()?.watermark ?? null,
|
|
731
|
+
changeRowsScanned: null,
|
|
732
|
+
changeRowsIncluded: 0,
|
|
733
|
+
queriesRecomputed: 0,
|
|
734
|
+
rowPuts: 0,
|
|
735
|
+
rowDeletes: 0,
|
|
736
|
+
lmidAdvances: 0,
|
|
737
|
+
transactionMs,
|
|
738
|
+
totalMs: performance.now() - started,
|
|
739
|
+
resetReason: status === 409 ? errorMessage(error) : null,
|
|
740
|
+
})
|
|
741
|
+
return json({ error: errorMessage(error) }, status)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async #push(
|
|
746
|
+
request: Request,
|
|
747
|
+
claims: NormalizedClaims,
|
|
748
|
+
namespace: string,
|
|
749
|
+
upstreamPath: string | null
|
|
750
|
+
): Promise<Response> {
|
|
751
|
+
this.#counters.pushes++
|
|
752
|
+
this.#engineDb.resetStats()
|
|
753
|
+
const started = performance.now()
|
|
754
|
+
let transactionMs = 0
|
|
755
|
+
let lmidAdvances = 0
|
|
756
|
+
let resultClass = 'success'
|
|
757
|
+
if (!this.#writerEnabled()) {
|
|
758
|
+
// Workerd requires the request stream to be consumed before the DO
|
|
759
|
+
// returns a response. Discard it without parsing or logging payloads.
|
|
760
|
+
await request.arrayBuffer()
|
|
761
|
+
const state = this.#engineState()
|
|
762
|
+
this.#log({
|
|
763
|
+
namespaceHash: namespace,
|
|
764
|
+
requestKind: 'push',
|
|
765
|
+
resultClass: 'writer_disabled',
|
|
766
|
+
inputCookie: null,
|
|
767
|
+
outputCookie: null,
|
|
768
|
+
retainedFloor: state?.floor ?? null,
|
|
769
|
+
currentWatermark: state?.watermark ?? null,
|
|
770
|
+
changeRowsScanned: 0,
|
|
771
|
+
changeRowsIncluded: 0,
|
|
772
|
+
queriesRecomputed: 0,
|
|
773
|
+
rowPuts: 0,
|
|
774
|
+
rowDeletes: 0,
|
|
775
|
+
lmidAdvances: 0,
|
|
776
|
+
transactionMs: 0,
|
|
777
|
+
totalMs: performance.now() - started,
|
|
778
|
+
resetReason: 'writer disabled by operator',
|
|
779
|
+
wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
|
|
780
|
+
sql: this.#engineDb.stats,
|
|
781
|
+
})
|
|
782
|
+
return json({ error: 'writer disabled by operator' }, 503)
|
|
783
|
+
}
|
|
784
|
+
if (config.mutateUrl) {
|
|
785
|
+
try {
|
|
786
|
+
const bytes = await request.arrayBuffer()
|
|
787
|
+
const body = JSON.parse(new TextDecoder().decode(bytes)) as Record<
|
|
788
|
+
string,
|
|
789
|
+
unknown
|
|
790
|
+
>
|
|
791
|
+
const plan = this.#wasm(() => engine_push_validate(body)) as PushPlan
|
|
792
|
+
if (plan.kind === 'respond') return json(plan.response)
|
|
793
|
+
|
|
794
|
+
const endpoint = new URL(
|
|
795
|
+
`${upstreamPath ?? ''}${config.mutateUrl}`,
|
|
796
|
+
'https://upstream.invalid'
|
|
797
|
+
)
|
|
798
|
+
const headers = new Headers(request.headers)
|
|
799
|
+
headers.delete(CLAIMS_HEADER)
|
|
800
|
+
headers.delete(NAMESPACE_HEADER)
|
|
801
|
+
headers.delete(UPSTREAM_PATH_HEADER)
|
|
802
|
+
headers.set('host', endpoint.host)
|
|
803
|
+
const upstreamResponse = await this.#serviceBinding(
|
|
804
|
+
config.mutateBinding ?? config.upstream?.binding
|
|
805
|
+
).fetch(endpoint.toString(), {
|
|
806
|
+
method: 'POST',
|
|
807
|
+
headers,
|
|
808
|
+
body: bytes,
|
|
809
|
+
})
|
|
810
|
+
if (!upstreamResponse.ok) {
|
|
811
|
+
return new Response(upstreamResponse.body, upstreamResponse)
|
|
812
|
+
}
|
|
813
|
+
const upstreamBody = (await upstreamResponse.json()) as {
|
|
814
|
+
mutations?: Array<{ id?: { clientID?: unknown; id?: unknown } }>
|
|
815
|
+
pushResponse?: {
|
|
816
|
+
mutations?: Array<{ id?: { clientID?: unknown; id?: unknown } }>
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const acknowledged =
|
|
820
|
+
upstreamBody.pushResponse?.mutations ?? upstreamBody.mutations
|
|
821
|
+
if (!Array.isArray(acknowledged)) {
|
|
822
|
+
throw new Error('delegated push returned no mutation results')
|
|
823
|
+
}
|
|
824
|
+
for (const mutation of plan.mutations) {
|
|
825
|
+
const ack = acknowledged.some(
|
|
826
|
+
(result) =>
|
|
827
|
+
result.id?.clientID === mutation.clientID &&
|
|
828
|
+
String(result.id?.id) === mutation.id
|
|
829
|
+
)
|
|
830
|
+
if (!ack) continue
|
|
831
|
+
this.ctx.storage.transactionSync(() => {
|
|
832
|
+
const decision = this.#wasm(() =>
|
|
833
|
+
engine_preflight(
|
|
834
|
+
this.#engineDb,
|
|
835
|
+
plan.clientGroupID,
|
|
836
|
+
mutation.clientID,
|
|
837
|
+
mutation.id,
|
|
838
|
+
claims.userID
|
|
839
|
+
)
|
|
840
|
+
) as Preflight
|
|
841
|
+
if (decision.kind === 'applied') {
|
|
842
|
+
this.#wasm(() =>
|
|
843
|
+
engine_finalize(
|
|
844
|
+
this.#engineDb,
|
|
845
|
+
plan.clientGroupID,
|
|
846
|
+
mutation.clientID,
|
|
847
|
+
mutation.id
|
|
848
|
+
)
|
|
849
|
+
)
|
|
850
|
+
lmidAdvances++
|
|
851
|
+
}
|
|
852
|
+
})
|
|
853
|
+
}
|
|
854
|
+
if (lmidAdvances > 0) {
|
|
855
|
+
this.ctx.storage.transactionSync(() =>
|
|
856
|
+
this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
await this.#ingest(upstreamPath)
|
|
860
|
+
return json({ pushResponse: upstreamBody.pushResponse ?? upstreamBody })
|
|
861
|
+
} catch (error) {
|
|
862
|
+
const status = statusOf(error)
|
|
863
|
+
return json({ error: errorMessage(error) }, status)
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
try {
|
|
867
|
+
const body = await requestObject(request)
|
|
868
|
+
const beforeMutationFault = this.#takeFault('push_before_mutation')
|
|
869
|
+
if (beforeMutationFault)
|
|
870
|
+
throw this.#faultError(beforeMutationFault, 'push_before_mutation')
|
|
871
|
+
const plan = this.#wasm(() => engine_push_validate(body)) as PushPlan
|
|
872
|
+
if (plan.kind === 'respond') return json(plan.response)
|
|
873
|
+
|
|
874
|
+
const results: MutationResult[] = []
|
|
875
|
+
for (const mutation of plan.mutations) {
|
|
876
|
+
const deferred: DeferredEffect[] = []
|
|
877
|
+
try {
|
|
878
|
+
const txStarted = performance.now()
|
|
879
|
+
// consume the before-commit fault OUTSIDE the transaction it is
|
|
880
|
+
// about to abort: taken inside, the control-table delete rolls
|
|
881
|
+
// back with the abort and the fault re-fires on every retry
|
|
882
|
+
// instead of being one-shot.
|
|
883
|
+
const beforeCommitFault = this.#takeFault('push_after_write_before_commit')
|
|
884
|
+
const preflight = await this.ctx.storage.transaction(async () => {
|
|
885
|
+
// Storage transactions may retry their closure. Never carry a
|
|
886
|
+
// deferred effect from an abandoned attempt into the commit.
|
|
887
|
+
deferred.length = 0
|
|
888
|
+
const decision = this.#wasm(() =>
|
|
889
|
+
engine_preflight(
|
|
890
|
+
this.#engineDb,
|
|
891
|
+
plan.clientGroupID,
|
|
892
|
+
mutation.clientID,
|
|
893
|
+
mutation.id,
|
|
894
|
+
claims.userID
|
|
895
|
+
)
|
|
896
|
+
) as Preflight
|
|
897
|
+
if (decision.kind === 'replay') return decision
|
|
898
|
+
const mutator = config.mutators?.[mutation.name]
|
|
899
|
+
if (!mutator) throw new Error(`unknown mutator: ${mutation.name}`)
|
|
900
|
+
await mutator(this.#mutatorSql, mutation.args[0] ?? null, {
|
|
901
|
+
claims,
|
|
902
|
+
clientID: mutation.clientID,
|
|
903
|
+
mutationID: mutation.id,
|
|
904
|
+
defer(effect) {
|
|
905
|
+
deferred.push(effect)
|
|
906
|
+
},
|
|
907
|
+
})
|
|
908
|
+
if (beforeCommitFault)
|
|
909
|
+
throw this.#faultError(
|
|
910
|
+
beforeCommitFault,
|
|
911
|
+
'push_after_write_before_commit'
|
|
912
|
+
)
|
|
913
|
+
this.#wasm(() =>
|
|
914
|
+
engine_finalize(
|
|
915
|
+
this.#engineDb,
|
|
916
|
+
plan.clientGroupID,
|
|
917
|
+
mutation.clientID,
|
|
918
|
+
mutation.id
|
|
919
|
+
)
|
|
920
|
+
)
|
|
921
|
+
return decision
|
|
922
|
+
})
|
|
923
|
+
transactionMs += performance.now() - txStarted
|
|
924
|
+
|
|
925
|
+
if ((preflight as Preflight).kind === 'replay') {
|
|
926
|
+
const expected = (preflight as Extract<Preflight, { kind: 'replay' }>)
|
|
927
|
+
.expected
|
|
928
|
+
results.push({
|
|
929
|
+
clientID: mutation.clientID,
|
|
930
|
+
id: mutation.id,
|
|
931
|
+
result: {
|
|
932
|
+
error: 'alreadyProcessed',
|
|
933
|
+
details: `Ignoring mutation from ${mutation.clientID} with ID ${mutation.id} as it was already processed. Expected: ${expected}`,
|
|
934
|
+
},
|
|
935
|
+
})
|
|
936
|
+
continue
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
lmidAdvances++
|
|
940
|
+
results.push({
|
|
941
|
+
clientID: mutation.clientID,
|
|
942
|
+
id: mutation.id,
|
|
943
|
+
result: {},
|
|
944
|
+
})
|
|
945
|
+
// Keep the advisory fan-out off the push response's critical path.
|
|
946
|
+
// waitUntil anchors the coalescing timer across request completion,
|
|
947
|
+
// while the next serialized client push can join the same batch.
|
|
948
|
+
this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
|
|
949
|
+
await this.#runEffects(deferred)
|
|
950
|
+
} catch (error) {
|
|
951
|
+
const isAppError = error instanceof MutationApplicationError
|
|
952
|
+
if (!isAppError) throw error
|
|
953
|
+
this.#counters.applicationErrors++
|
|
954
|
+
resultClass = 'application_error'
|
|
955
|
+
const appError = error as Error & { details?: string }
|
|
956
|
+
const txStarted = performance.now()
|
|
957
|
+
await this.ctx.storage.transaction(async () => {
|
|
958
|
+
this.#wasm(() =>
|
|
959
|
+
engine_record_app_error(
|
|
960
|
+
this.#engineDb,
|
|
961
|
+
plan.clientGroupID,
|
|
962
|
+
mutation.clientID,
|
|
963
|
+
mutation.id,
|
|
964
|
+
claims.userID
|
|
965
|
+
)
|
|
966
|
+
)
|
|
967
|
+
})
|
|
968
|
+
transactionMs += performance.now() - txStarted
|
|
969
|
+
lmidAdvances++
|
|
970
|
+
results.push({
|
|
971
|
+
clientID: mutation.clientID,
|
|
972
|
+
id: mutation.id,
|
|
973
|
+
result: {
|
|
974
|
+
error: 'app',
|
|
975
|
+
message: appError.message,
|
|
976
|
+
details: appError.details ?? appError.message,
|
|
977
|
+
},
|
|
978
|
+
})
|
|
979
|
+
this.ctx.waitUntil(this.#enqueueWake(mutation.clientID))
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (plan.mutations.length > 0) {
|
|
984
|
+
const txStarted = performance.now()
|
|
985
|
+
await this.ctx.storage.transaction(async () => {
|
|
986
|
+
this.#wasm(() => engine_prune(this.#engineDb, this.#retainChanges()))
|
|
987
|
+
})
|
|
988
|
+
transactionMs += performance.now() - txStarted
|
|
989
|
+
this.#counters.retentionRuns++
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const afterCommitFault = this.#takeFault('push_after_commit_before_response')
|
|
993
|
+
if (afterCommitFault)
|
|
994
|
+
throw this.#faultError(afterCommitFault, 'push_after_commit_before_response')
|
|
995
|
+
|
|
996
|
+
const response = this.#wasm(() => engine_assemble_push_response(results))
|
|
997
|
+
const state = this.#engineState()
|
|
998
|
+
this.#log({
|
|
999
|
+
namespaceHash: namespace,
|
|
1000
|
+
requestKind: 'push',
|
|
1001
|
+
resultClass,
|
|
1002
|
+
inputCookie: null,
|
|
1003
|
+
outputCookie: null,
|
|
1004
|
+
retainedFloor: state?.floor ?? null,
|
|
1005
|
+
currentWatermark: state?.watermark ?? null,
|
|
1006
|
+
changeRowsScanned: 0,
|
|
1007
|
+
changeRowsIncluded: 0,
|
|
1008
|
+
queriesRecomputed: 0,
|
|
1009
|
+
rowPuts: 0,
|
|
1010
|
+
rowDeletes: 0,
|
|
1011
|
+
lmidAdvances,
|
|
1012
|
+
transactionMs,
|
|
1013
|
+
totalMs: performance.now() - started,
|
|
1014
|
+
resetReason: null,
|
|
1015
|
+
wasmBoundaryCalls: this.#counters.wasmBoundaryCalls,
|
|
1016
|
+
sql: this.#engineDb.stats,
|
|
1017
|
+
})
|
|
1018
|
+
if (this.#dropNextPushResponse) {
|
|
1019
|
+
this.#dropNextPushResponse = false
|
|
1020
|
+
return json({ error: 'intentionally dropped push response' }, 503)
|
|
1021
|
+
}
|
|
1022
|
+
return json(response)
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
const status = statusOf(error)
|
|
1025
|
+
if (status === 500) this.#counters.invariantFailures++
|
|
1026
|
+
this.#log({
|
|
1027
|
+
namespaceHash: namespace,
|
|
1028
|
+
requestKind: 'push',
|
|
1029
|
+
resultClass: 'error',
|
|
1030
|
+
inputCookie: null,
|
|
1031
|
+
outputCookie: null,
|
|
1032
|
+
retainedFloor: this.#engineState()?.floor ?? null,
|
|
1033
|
+
currentWatermark: this.#engineState()?.watermark ?? null,
|
|
1034
|
+
changeRowsScanned: 0,
|
|
1035
|
+
changeRowsIncluded: 0,
|
|
1036
|
+
queriesRecomputed: 0,
|
|
1037
|
+
rowPuts: 0,
|
|
1038
|
+
rowDeletes: 0,
|
|
1039
|
+
lmidAdvances,
|
|
1040
|
+
transactionMs,
|
|
1041
|
+
totalMs: performance.now() - started,
|
|
1042
|
+
resetReason: null,
|
|
1043
|
+
})
|
|
1044
|
+
return json({ error: errorMessage(error) }, status)
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
#wake(request: Request): Response {
|
|
1049
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
1050
|
+
return json({ error: 'websocket upgrade required' }, 426)
|
|
1051
|
+
}
|
|
1052
|
+
const clientID = new URL(request.url).searchParams.get('clientID')
|
|
1053
|
+
if (!clientID) return json({ error: 'clientID is required' }, 400)
|
|
1054
|
+
const pair = new WebSocketPair()
|
|
1055
|
+
const [client, server] = Object.values(pair)
|
|
1056
|
+
server.serializeAttachment({ clientID } satisfies SocketAttachment)
|
|
1057
|
+
this.ctx.acceptWebSocket(server, [`client:${clientID}`])
|
|
1058
|
+
return new Response(null, { status: 101, webSocket: client })
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
#admin(route: string, request: Request): Promise<Response> | Response {
|
|
1062
|
+
if (route === '/admin/health') return json({ ok: true })
|
|
1063
|
+
if (route === '/admin/status') {
|
|
1064
|
+
const heap = (
|
|
1065
|
+
performance as Performance & {
|
|
1066
|
+
memory?: {
|
|
1067
|
+
usedJSHeapSize: number
|
|
1068
|
+
totalJSHeapSize: number
|
|
1069
|
+
jsHeapSizeLimit: number
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
).memory
|
|
1073
|
+
return json({
|
|
1074
|
+
bootID: this.#bootID,
|
|
1075
|
+
idleTeardownMs,
|
|
1076
|
+
hibernations: this.#hibernations,
|
|
1077
|
+
databaseSizeBytes: this.ctx.storage.sql.databaseSize,
|
|
1078
|
+
connectedWakeSockets: this.ctx.getWebSockets().length,
|
|
1079
|
+
writerEnabled: this.#writerEnabled(),
|
|
1080
|
+
wasmMemoryBytes: engine_memory_bytes(),
|
|
1081
|
+
heapUsedBytes: heap?.usedJSHeapSize ?? null,
|
|
1082
|
+
heapTotalBytes: heap?.totalJSHeapSize ?? null,
|
|
1083
|
+
heapLimitBytes: heap?.jsHeapSizeLimit ?? null,
|
|
1084
|
+
engine: this.#engineState(),
|
|
1085
|
+
counters: this.#counters,
|
|
1086
|
+
})
|
|
1087
|
+
}
|
|
1088
|
+
if (route === '/admin/sql') {
|
|
1089
|
+
return request.json().then((body) => {
|
|
1090
|
+
const { query } = body as { query?: string }
|
|
1091
|
+
if (typeof query !== 'string') return json({ error: 'query is required' }, 400)
|
|
1092
|
+
return json({ rows: this.#directSql.query(query) })
|
|
1093
|
+
})
|
|
1094
|
+
}
|
|
1095
|
+
if (route === '/admin/invalidate') {
|
|
1096
|
+
this.ctx.storage.transactionSync(() =>
|
|
1097
|
+
this.#wasm(() => engine_invalidate(this.#engineDb))
|
|
1098
|
+
)
|
|
1099
|
+
return json({ ok: true, engine: this.#engineState() })
|
|
1100
|
+
}
|
|
1101
|
+
if (route === '/admin/drop-next-push-response') {
|
|
1102
|
+
this.#dropNextPushResponse = true
|
|
1103
|
+
return json({ ok: true })
|
|
1104
|
+
}
|
|
1105
|
+
if (route === '/admin/restart') {
|
|
1106
|
+
this.#bootID = crypto.randomUUID()
|
|
1107
|
+
this.#hibernations++
|
|
1108
|
+
this.#counters = freshCounters()
|
|
1109
|
+
this.#pulling.clear()
|
|
1110
|
+
this.#wakeOrigins.clear()
|
|
1111
|
+
this.#wakeRecipients.clear()
|
|
1112
|
+
return json({ ok: true, bootID: this.#bootID })
|
|
1113
|
+
}
|
|
1114
|
+
if (route === '/admin/visibility') {
|
|
1115
|
+
return request.json().then((body) => {
|
|
1116
|
+
const enabled = Boolean((body as { enabled?: unknown }).enabled)
|
|
1117
|
+
this.#controlSet('visibilityEnabled', enabled ? '1' : '0')
|
|
1118
|
+
return json({ ok: true, enabled })
|
|
1119
|
+
})
|
|
1120
|
+
}
|
|
1121
|
+
if (route === '/admin/query-aware') {
|
|
1122
|
+
return request.json().then((body) => {
|
|
1123
|
+
const enabled = Boolean((body as { enabled?: unknown }).enabled)
|
|
1124
|
+
this.#controlSet('queryAwareOverride', enabled ? '1' : '0')
|
|
1125
|
+
return json({ ok: true, enabled })
|
|
1126
|
+
})
|
|
1127
|
+
}
|
|
1128
|
+
if (route === '/admin/retention') {
|
|
1129
|
+
return request
|
|
1130
|
+
.json()
|
|
1131
|
+
.catch(() => ({}))
|
|
1132
|
+
.then((body) => {
|
|
1133
|
+
const value = Number((body as { retainChanges?: unknown }).retainChanges)
|
|
1134
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
1135
|
+
return json({ error: 'invalid retainChanges' }, 400)
|
|
1136
|
+
this.#controlSet('retainChanges', String(value))
|
|
1137
|
+
return json({ ok: true, retainChanges: value })
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
if (route === '/admin/writer') {
|
|
1141
|
+
if (request.method === 'GET')
|
|
1142
|
+
return json({ writerEnabled: this.#writerEnabled() })
|
|
1143
|
+
return request
|
|
1144
|
+
.json()
|
|
1145
|
+
.catch(() => ({}))
|
|
1146
|
+
.then((body) => {
|
|
1147
|
+
const enabled = (body as { enabled?: unknown }).enabled
|
|
1148
|
+
if (typeof enabled !== 'boolean')
|
|
1149
|
+
return json({ error: 'enabled must be a boolean' }, 400)
|
|
1150
|
+
this.#controlSet('writerEnabled', enabled ? '1' : '0')
|
|
1151
|
+
return json({ ok: true, writerEnabled: enabled })
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
if (route === '/admin/fault') {
|
|
1155
|
+
return request
|
|
1156
|
+
.json()
|
|
1157
|
+
.catch(() => ({}))
|
|
1158
|
+
.then((body) => {
|
|
1159
|
+
const value = body as {
|
|
1160
|
+
clear?: unknown
|
|
1161
|
+
point?: unknown
|
|
1162
|
+
kind?: unknown
|
|
1163
|
+
}
|
|
1164
|
+
if (value.clear === true) {
|
|
1165
|
+
this.#directSql.exec(
|
|
1166
|
+
"DELETE FROM _zsync_host_control WHERE key IN ('faultPoint', 'faultKind')"
|
|
1167
|
+
)
|
|
1168
|
+
return json({ ok: true, armed: null })
|
|
1169
|
+
}
|
|
1170
|
+
const points: FaultPoint[] = [
|
|
1171
|
+
'push_before_mutation',
|
|
1172
|
+
'push_after_write_before_commit',
|
|
1173
|
+
'push_after_commit_before_response',
|
|
1174
|
+
'pull_during_tx',
|
|
1175
|
+
'pull_after_commit',
|
|
1176
|
+
]
|
|
1177
|
+
if (!points.includes(value.point as FaultPoint))
|
|
1178
|
+
return json({ error: 'invalid fault point' }, 400)
|
|
1179
|
+
if (value.kind !== 'error' && value.kind !== 'quota')
|
|
1180
|
+
return json({ error: 'invalid fault kind' }, 400)
|
|
1181
|
+
this.#controlSet('faultPoint', value.point as string)
|
|
1182
|
+
this.#controlSet('faultKind', value.kind)
|
|
1183
|
+
return json({
|
|
1184
|
+
ok: true,
|
|
1185
|
+
armed: { point: value.point, kind: value.kind },
|
|
1186
|
+
})
|
|
1187
|
+
})
|
|
1188
|
+
}
|
|
1189
|
+
return json({ error: 'not found' }, 404)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async fetch(request: Request): Promise<Response> {
|
|
1193
|
+
this.#simulateIdleTeardown(Date.now())
|
|
1194
|
+
const route = routeAfterNamespace(new URL(request.url).pathname)
|
|
1195
|
+
const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown'
|
|
1196
|
+
const upstreamPath = this.#rememberUpstreamPath(request)
|
|
1197
|
+
if (route.startsWith('/admin/')) return this.#admin(route, request)
|
|
1198
|
+
|
|
1199
|
+
if (route === '/wake' && request.method === 'GET') return this.#wake(request)
|
|
1200
|
+
if (route === '/notify' && request.method === 'POST') {
|
|
1201
|
+
try {
|
|
1202
|
+
const applied = await this.#ingest(upstreamPath)
|
|
1203
|
+
return json({ ok: true, applied })
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
return json({ error: errorMessage(error) }, statusOf(error))
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const claims = claimsFromRequest(request)
|
|
1210
|
+
if (!claims) return json({ error: 'missing normalized claims' }, 401)
|
|
1211
|
+
if (route === '/pull' && request.method === 'POST') {
|
|
1212
|
+
try {
|
|
1213
|
+
await this.#ingest(upstreamPath)
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
return json({ error: errorMessage(error) }, statusOf(error))
|
|
1216
|
+
}
|
|
1217
|
+
return this.#pull(request, claims, namespace)
|
|
1218
|
+
}
|
|
1219
|
+
if (route === '/push' && request.method === 'POST') {
|
|
1220
|
+
return this.#push(request, claims, namespace, upstreamPath)
|
|
1221
|
+
}
|
|
1222
|
+
return json({ error: 'not found' }, 404)
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async alarm(): Promise<void> {
|
|
1226
|
+
try {
|
|
1227
|
+
await this.#ingest()
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
console.error(
|
|
1230
|
+
JSON.stringify({
|
|
1231
|
+
event: 'sync_upstream_ingest_error',
|
|
1232
|
+
status: statusOf(error),
|
|
1233
|
+
error: errorMessage(error),
|
|
1234
|
+
})
|
|
1235
|
+
)
|
|
1236
|
+
} finally {
|
|
1237
|
+
await this.ctx.storage.setAlarm(Date.now() + upstreamIntervalMs)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): void {
|
|
1242
|
+
if (message === 'ping') socket.send('pong')
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
webSocketClose(
|
|
1246
|
+
socket: WebSocket,
|
|
1247
|
+
code: number,
|
|
1248
|
+
reason: string,
|
|
1249
|
+
_wasClean: boolean
|
|
1250
|
+
): void {
|
|
1251
|
+
// The peer already closed, so echo the close to release the socket — but
|
|
1252
|
+
// WebSocket.close() rejects reserved/absent codes (1005 "no status", 1006
|
|
1253
|
+
// abnormal, 1015) with InvalidAccessError, and a real browser routinely
|
|
1254
|
+
// closes with 1001/1005. An uncaught throw here aborts the DO, so only
|
|
1255
|
+
// echo an application-permitted code and otherwise close cleanly.
|
|
1256
|
+
const echoable = code === 1000 || (code >= 3000 && code <= 4999)
|
|
1257
|
+
socketCloseQuietly(socket, echoable ? code : 1000, echoable ? reason : '')
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
webSocketError(socket: WebSocket, _error: unknown): void {
|
|
1261
|
+
socketCloseQuietly(socket, 1011, 'wake socket error')
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
}
|