1818 *
1919 * Factory returns:
2020 * ensureSyncFreshness(db, ctx, now) — O(1) hot-path hook
21+ * claimPoll(now) → {sql, apply} | null — the due poll as a statement + result
22+ * step, to send with a query the request makes anyway
23+ * runPoll(db, poll) — run a claimed poll on its own
2124 * handleStatus(request, db, ctx) — pre-encoded /status handler (needs statusHeaders)
2225 * getEntityVersion(tag) — returns last_modified_at for L2 key versioning
2326 * refresh(db) — force a synchronous poll (cold boot / tests)
@@ -40,6 +43,8 @@ import { encoder, jsonError } from './http.js';
4043 * @returns {{
4144 * refresh: (db: D1Session) => Promise<void>,
4245 * ensureSyncFreshness: (db: D1Session, ctx: ExecutionContext, now: number) => void,
46+ * claimPoll: (now: number) => ({ sql: string, apply: (results: Record<string, any>[]) => void } | null),
47+ * runPoll: (db: D1Session, poll: { sql: string, apply: (results: Record<string, any>[]) => void }) => Promise<void>,
4348 * handleStatus: (request: Request, db: D1Session, ctx: ExecutionContext) => Promise<Response>,
4449 * getEntityVersion: (tag: string) => number,
4550 * }}
@@ -61,11 +66,12 @@ export function createSyncState({ entityTags, onEntityChange, statusHeaders, che
6166 /** Pre-encoded JSON response for /status. @type {Uint8Array|null} */
6267 let statusPayload = null ;
6368
69+ /** The poll statement (tiny: one row per entity). */
70+ const POLL_SQL = 'SELECT entity, last_sync, row_count, updated_at, last_modified_at FROM "_sync_meta" ORDER BY entity' ;
71+
6472 /**
65- * Background task: queries _sync_meta via D1 read-replication.
66- * Compares each entity's last_modified_at against the in-memory snapshot
67- * and calls onEntityChange for the ones that moved. Rebuilds the
68- * pre-encoded /status payload when statusHeaders is configured.
73+ * Background task: queries _sync_meta via D1 read-replication and
74+ * applies the result (see applyPoll).
6975 *
7076 * Runs inside ctx.waitUntil() — allocations here do not affect the
7177 * response path.
@@ -75,17 +81,30 @@ export function createSyncState({ entityTags, onEntityChange, statusHeaders, che
7581 */
7682 async function refresh ( db ) {
7783 try {
78- const rows = await db . prepare (
79- 'SELECT entity, last_sync, row_count, updated_at, last_modified_at FROM "_sync_meta" ORDER BY entity'
80- ) . all ( ) ;
81-
82- if ( ! rows || ! rows . results ) return ;
84+ const rows = await db . prepare ( POLL_SQL ) . all ( ) ;
85+ if ( rows && rows . results ) applyPoll ( rows . results ) ;
86+ } catch ( err ) {
87+ // Background D1 errors must not crash the isolate.
88+ // The previous statusPayload and knownModifiedAt remain valid.
89+ console . error ( '[sync] background poll failed:' , err ) ;
90+ }
91+ }
8392
93+ /**
94+ * Applies a _sync_meta result: compares each entity's last_modified_at
95+ * against the in-memory snapshot and calls onEntityChange for the ones
96+ * that moved; rebuilds the pre-encoded /status payload when statusHeaders
97+ * is configured.
98+ *
99+ * @param {Record<string, any>[] } results - _sync_meta rows.
100+ */
101+ function applyPoll ( results ) {
102+ try {
84103 // Build a lookup from the D1 results for fast access.
85104 // Use a Map to avoid dynamic-key objects.
86105 /** @type {Map<string, {last_sync: number, row_count: number, updated_at: string, last_modified_at: number}> } */
87106 const dbState = new Map ( ) ;
88- for ( const row of rows . results ) {
107+ for ( const row of results ) {
89108 dbState . set (
90109 /** @type {string } */ ( row . entity ) ,
91110 {
@@ -133,8 +152,38 @@ export function createSyncState({ entityTags, onEntityChange, statusHeaders, che
133152 }
134153
135154 } catch ( err ) {
136- // Background D1 errors must not crash the isolate.
137- // The previous statusPayload and knownModifiedAt remain valid.
155+ // A malformed result must not crash the isolate.
156+ console . error ( '[sync] applying poll result failed:' , err ) ;
157+ }
158+ }
159+
160+ /**
161+ * Claims the poll if checkIntervalMs has elapsed (and marks it taken):
162+ * the statement and its result step, for the caller to send in the same
163+ * D1 call as a query it makes anyway — no extra round trip. null when
164+ * not due. A claimed poll the caller never sends must go to runPoll.
165+ *
166+ * @param {number } now
167+ * @returns {{ sql: string, apply: (results: Record<string, any>[]) => void } | null }
168+ */
169+ function claimPoll ( now ) {
170+ if ( now - lastCheck <= checkIntervalMs ) return null ;
171+ lastCheck = now ;
172+ return { sql : POLL_SQL , apply : applyPoll } ;
173+ }
174+
175+ /**
176+ * Runs a claimed poll on its own (when the request made no D1 call).
177+ *
178+ * @param {D1Session } db
179+ * @param {{ sql: string, apply: (results: Record<string, any>[]) => void } } poll
180+ * @returns {Promise<void> }
181+ */
182+ async function runPoll ( db , poll ) {
183+ try {
184+ const rows = await db . prepare ( poll . sql ) . all ( ) ;
185+ if ( rows && rows . results ) poll . apply ( rows . results ) ;
186+ } catch ( err ) {
138187 console . error ( '[sync] background poll failed:' , err ) ;
139188 }
140189 }
@@ -206,5 +255,5 @@ export function createSyncState({ entityTags, onEntityChange, statusHeaders, che
206255 return knownModifiedAt . get ( tag ) || 0 ;
207256 }
208257
209- return { refresh, ensureSyncFreshness, handleStatus, getEntityVersion } ;
258+ return { refresh, ensureSyncFreshness, claimPoll , runPoll , handleStatus, getEntityVersion } ;
210259}
0 commit comments