orez-sync-cf-host 0.5.4 → 0.5.5

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 CHANGED
@@ -12,9 +12,10 @@ to a per-namespace Durable Object, and never logs tokens, mutation arguments, or
12
12
  row contents. Pull runs `engine_handle_pull` inside `transactionSync`; push runs
13
13
  Rust `push_validate`/`preflight`/`finalize`/`record_app_error` steps around the
14
14
  registered asynchronous TypeScript mutator inside `ctx.storage.transaction`.
15
- Every SQL cursor is materialized before an await. Application effects are
16
- collected and run only after their transaction commits; application failures use
17
- the required second transaction to advance the LMID marker.
15
+ Every SQL cursor is materialized before an await. Mutators may await only their
16
+ `MutatorSql` operations; timers, fetches, and other external work belong in
17
+ `context.defer`, which runs only after commit. Application failures use the
18
+ required second transaction to advance the LMID marker.
18
19
 
19
20
  ## Wake channel and eviction
20
21
 
@@ -54,6 +55,14 @@ The runner accepts only loopback or the `lslcf.workers.dev` test worker and does
54
55
  not mutate a production route. These controls are mechanisms, not authorization
55
56
  to perform a production cutover.
56
57
 
58
+ `POST /admin/resnapshot` is available only when the consumer configured an
59
+ upstream data service. It reads that namespace's authoritative `/snapshot`,
60
+ atomically replaces the derived application tables, then consumes `/changes`
61
+ until caught up. Engine metadata, client last-mutation IDs, operator controls,
62
+ and the authoritative upstream database are preserved. The JSON response
63
+ includes before/after upstream watermarks and the number of snapshot plus
64
+ catch-up rows applied.
65
+
57
66
  ## Counter and HTTP wire representation
58
67
 
59
68
  Inside the wasm/JavaScript engine boundary, cookies, watermarks, and LMIDs are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez-sync-cf-host",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
Binary file
@@ -325,7 +325,6 @@ const harnessMutators = registerMutators({
325
325
  value.id,
326
326
  ])
327
327
  )
328
- await scheduler.wait(1)
329
328
  throw new MutationApplicationError('intentional-rollback')
330
329
  },
331
330
  })
package/src/host.ts CHANGED
@@ -609,16 +609,67 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
609
609
  }
610
610
  }
611
611
 
612
- #ingest(upstreamPath?: string | null): Promise<number> {
613
- if (!config.upstream) return Promise.resolve(0)
614
- if (this.#ingestPromise) return this.#ingestPromise
612
+ async #applyUpstreamSnapshot(
613
+ path: string,
614
+ cursor: string,
615
+ allowSameCursor: boolean
616
+ ): Promise<ApplyUpstreamResult> {
617
+ const endpoint = new URL(`${path}/snapshot`, 'https://upstream.invalid')
618
+ const response = await this.#serviceBinding().fetch(endpoint.toString(), {
619
+ headers: { host: endpoint.host },
620
+ })
621
+ if (!response.ok) {
622
+ throw new Error(`upstream snapshot returned ${response.status}`)
623
+ }
624
+ const snapshot = await response.json()
625
+ const rebuilt = this.#withIngestBilling({ phase: 'snapshot', cursor }, () =>
626
+ this.ctx.storage.transactionSync(() =>
627
+ this.#wasm(() =>
628
+ engine_apply_upstream_snapshot(this.#engineDb, config.schema, snapshot)
629
+ )
630
+ )
631
+ ) as ApplyUpstreamResult
632
+ this.#recordIngestLogicalRows(rebuilt.applied)
633
+ const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
634
+ if (!allowSameCursor && String(nextCursor) === String(cursor)) {
635
+ this.#tripIngest('ingestCursorStalled', {
636
+ phase: 'snapshot',
637
+ cursor,
638
+ resultWatermark: rebuilt.watermark,
639
+ applied: rebuilt.applied,
640
+ })
641
+ }
642
+ return rebuilt
643
+ }
644
+
645
+ #ingest(upstreamPath?: string | null, forceSnapshot = false): Promise<number> {
646
+ if (!config.upstream) {
647
+ return forceSnapshot
648
+ ? Promise.reject(requestError('upstream is not configured'))
649
+ : Promise.resolve(0)
650
+ }
651
+ if (this.#ingestPromise) {
652
+ return forceSnapshot
653
+ ? this.#ingestPromise.then(() => this.#ingest(upstreamPath, true))
654
+ : this.#ingestPromise
655
+ }
615
656
  this.#ingestBreaker.assertReady()
616
657
  const path = upstreamPath ?? this.#controlGet('upstreamPath')
617
- if (!path) return Promise.resolve(0)
658
+ if (!path) {
659
+ return forceSnapshot
660
+ ? Promise.reject(requestError('upstream path is not available'))
661
+ : Promise.resolve(0)
662
+ }
618
663
  this.#ingestPromise = (async () => {
619
664
  let total = 0
620
665
  for (;;) {
621
666
  const cursor = this.#engineState()?.upstreamWatermark ?? '0'
667
+ if (forceSnapshot) {
668
+ forceSnapshot = false
669
+ const rebuilt = await this.#applyUpstreamSnapshot(path, cursor, true)
670
+ total += rebuilt.applied
671
+ continue
672
+ }
622
673
  const endpoint = new URL(`${path}/changes`, 'https://upstream.invalid')
623
674
  endpoint.searchParams.set('watermark', cursor)
624
675
  endpoint.searchParams.set('limit', String(upstreamLimit))
@@ -626,39 +677,8 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
626
677
  headers: { host: endpoint.host },
627
678
  })
628
679
  if (response.status === 410) {
629
- const snapshotEndpoint = new URL(`${path}/snapshot`, endpoint)
630
- const snapshotResponse = await this.#serviceBinding().fetch(
631
- snapshotEndpoint.toString(),
632
- { headers: { host: snapshotEndpoint.host } }
633
- )
634
- if (!snapshotResponse.ok) {
635
- throw new Error(`upstream snapshot returned ${snapshotResponse.status}`)
636
- }
637
- const snapshot = await snapshotResponse.json()
638
- const rebuilt = this.#withIngestBilling(
639
- { phase: 'snapshot', cursor },
640
- () =>
641
- this.ctx.storage.transactionSync(() =>
642
- this.#wasm(() =>
643
- engine_apply_upstream_snapshot(
644
- this.#engineDb,
645
- config.schema,
646
- snapshot
647
- )
648
- )
649
- ) as ApplyUpstreamResult
650
- )
680
+ const rebuilt = await this.#applyUpstreamSnapshot(path, cursor, false)
651
681
  total += rebuilt.applied
652
- this.#recordIngestLogicalRows(rebuilt.applied)
653
- const nextCursor = this.#engineState()?.upstreamWatermark ?? cursor
654
- if (String(nextCursor) === String(cursor)) {
655
- this.#tripIngest('ingestCursorStalled', {
656
- phase: 'snapshot',
657
- cursor,
658
- resultWatermark: rebuilt.watermark,
659
- applied: rebuilt.applied,
660
- })
661
- }
662
682
  continue
663
683
  }
664
684
  if (!response.ok) {
@@ -1345,7 +1365,11 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1345
1365
  return new Response(null, { status: 101, webSocket: client })
1346
1366
  }
1347
1367
 
1348
- #admin(route: string, request: Request): Promise<Response> | Response {
1368
+ #admin(
1369
+ route: string,
1370
+ request: Request,
1371
+ upstreamPath: string | null
1372
+ ): Promise<Response> | Response {
1349
1373
  if (route === '/admin/health') return json({ ok: true })
1350
1374
  if (route === '/admin/upstream-write-budget' && request.method === 'GET')
1351
1375
  return this.#upstreamWriteBudgetStatus()
@@ -1391,6 +1415,27 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1391
1415
  )
1392
1416
  return json({ ok: true, engine: this.#engineState() })
1393
1417
  }
1418
+ if (route === '/admin/resnapshot') {
1419
+ if (request.method !== 'POST') {
1420
+ return json({ error: 'method not allowed' }, 405)
1421
+ }
1422
+ return (async () => {
1423
+ try {
1424
+ const beforeUpstreamWatermark = this.#engineState()?.upstreamWatermark ?? null
1425
+ const applied = await this.#ingest(upstreamPath, true)
1426
+ const engine = this.#engineState()
1427
+ return json({
1428
+ ok: true,
1429
+ applied,
1430
+ beforeUpstreamWatermark,
1431
+ afterUpstreamWatermark: engine?.upstreamWatermark ?? null,
1432
+ engine,
1433
+ })
1434
+ } catch (error) {
1435
+ return json(errorBody(error), statusOf(error))
1436
+ }
1437
+ })()
1438
+ }
1394
1439
  if (route === '/admin/drop-next-push-response') {
1395
1440
  this.#dropNextPushResponse = true
1396
1441
  return json({ ok: true })
@@ -1498,7 +1543,7 @@ export function createSyncDurableObject<Env extends SyncHostEnv>(
1498
1543
  const route = routeAfterNamespace(new URL(request.url).pathname)
1499
1544
  const namespace = request.headers.get(NAMESPACE_HEADER) ?? 'unknown'
1500
1545
  const upstreamPath = this.#rememberUpstreamPath(request)
1501
- if (route.startsWith('/admin/')) return this.#admin(route, request)
1546
+ if (route.startsWith('/admin/')) return this.#admin(route, request, upstreamPath)
1502
1547
 
1503
1548
  if (route === '/wake' && request.method === 'GET') return this.#wake(request)
1504
1549
  if (route === '/notify' && request.method === 'POST') {
@@ -31,6 +31,8 @@ const runawayNamespaces = new Set<string>()
31
31
  const numericTextNamespaces = new Set<string>()
32
32
  const jsonValueNamespaces = new Set<string>()
33
33
  const hydratedNamespaces = new Set<string>()
34
+ const heldSnapshots = new Set<string>()
35
+ const activeSnapshots = new Set<string>()
34
36
  let delegatedFailuresRemaining = 0
35
37
  let delegatedAttempts = 0
36
38
  let delegatedPushFailedRemaining = 0
@@ -208,6 +210,16 @@ export class DataService extends WorkerEntrypoint<Env> {
208
210
  )
209
211
  }
210
212
  const response = await upstreamFetch(request, this.env)
213
+ if (pathname.endsWith('/snapshot')) {
214
+ if (heldSnapshots.has(namespace)) {
215
+ activeSnapshots.add(namespace)
216
+ try {
217
+ while (heldSnapshots.has(namespace)) await scheduler.wait(10)
218
+ } finally {
219
+ activeSnapshots.delete(namespace)
220
+ }
221
+ }
222
+ }
211
223
  if (pathname.endsWith('/changes') && response.ok) hydratedNamespaces.add(namespace)
212
224
  return response
213
225
  }
@@ -253,6 +265,31 @@ const syncWorker = createSyncWorker(config)
253
265
  export default {
254
266
  fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
255
267
  const url = new URL(request.url)
268
+ if (url.pathname.startsWith('/snapshot-control/')) {
269
+ const namespace = url.pathname.slice('/snapshot-control/'.length)
270
+ if (request.method === 'GET') {
271
+ return Promise.resolve(
272
+ Response.json({
273
+ active: activeSnapshots.has(namespace),
274
+ held: heldSnapshots.has(namespace),
275
+ })
276
+ )
277
+ }
278
+ return request
279
+ .json()
280
+ .catch(() => ({}))
281
+ .then((body) => {
282
+ if ((body as { hold?: unknown }).hold === true) {
283
+ heldSnapshots.add(namespace)
284
+ } else {
285
+ heldSnapshots.delete(namespace)
286
+ }
287
+ return Response.json({
288
+ active: activeSnapshots.has(namespace),
289
+ held: heldSnapshots.has(namespace),
290
+ })
291
+ })
292
+ }
256
293
  if (url.pathname.startsWith('/json-values-control/')) {
257
294
  const namespace = url.pathname.slice('/json-values-control/'.length)
258
295
  return request
@@ -79,7 +79,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
79
79
  const { balance } = this.ctx.storage.sql
80
80
  .exec("SELECT balance FROM accounts WHERE id = 'primary'")
81
81
  .one() as { balance: number }
82
- await scheduler.wait(1)
82
+ await Promise.resolve()
83
83
  this.ctx.storage.sql.exec(
84
84
  "UPDATE accounts SET balance = ? WHERE id = 'primary'",
85
85
  balance + 10
@@ -98,7 +98,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
98
98
  this.ctx.storage.sql.exec(
99
99
  "UPDATE accounts SET balance = balance - 5 WHERE id = 'primary'"
100
100
  )
101
- await scheduler.wait(1)
101
+ await Promise.resolve()
102
102
  this.ctx.storage.sql.exec(
103
103
  'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
104
104
  'primary',
@@ -121,7 +121,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
121
121
  "UPDATE accounts SET balance = ? WHERE id = 'primary'",
122
122
  balance + 777
123
123
  )
124
- await scheduler.wait(1)
124
+ await Promise.resolve()
125
125
  this.ctx.storage.sql.exec(
126
126
  'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
127
127
  'primary',
@@ -144,9 +144,10 @@ export class ProbeDurableObject extends DurableObject<Env> {
144
144
  const expected = push_preflight(this.#db, mutationID)
145
145
  wasmMs += performance.now() - wasmStarted
146
146
 
147
- // This await is deliberately inside the storage transaction and before
148
- // the remaining SQL operations. M0 exists to prove this contract.
149
- await scheduler.wait(1)
147
+ // MutatorSql methods resolve through the microtask queue. Cross that
148
+ // same boundary here without admitting timers or other external work
149
+ // into the storage transaction.
150
+ await Promise.resolve()
150
151
  await this.#mutate(name, deferred)
151
152
 
152
153
  wasmStarted = performance.now()
@@ -236,7 +237,7 @@ export class ProbeDurableObject extends DurableObject<Env> {
236
237
  this.ctx.storage.sql.exec(
237
238
  "UPDATE accounts SET balance = balance + 123 WHERE id = 'primary'"
238
239
  )
239
- await scheduler.wait(1)
240
+ await Promise.resolve()
240
241
  this.ctx.storage.sql.exec(
241
242
  'INSERT INTO outbox (topic, payload) VALUES (?, ?)',
242
243
  'must.rollback',