function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel }) { const prefix = 'stackchain.later-sync.v1.'; const migrationPrefix = 'stackchain.later-sync-migrated.v1.'; const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.'; let flushing = null; let channel = null; let channelKey = ''; function key() { const login = String(getLogin?.() || '').trim().toLowerCase(); return login ? prefix + encodeURIComponent(login) : ''; } function snapshotKey() { const storageKey = key(); return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : ''; } function savedRevision() { try { const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null'); return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1; } catch (_error) { return -1; } } function validRecords(records) { return records && typeof records === 'object' && !Array.isArray(records); } function adopt(plan, broadcast = true) { if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false; if (plan.revision < savedRevision()) return false; const snapshot = { revision: plan.revision, records: plan.records }; try { storage?.setItem(snapshotKey(), JSON.stringify(snapshot)); } catch (_error) { // Server truth remains usable in this tab when storage is unavailable. } onRemoteRecords?.(plan.records); if (broadcast) channel?.postMessage(snapshot); return true; } function ensureChannel() { const storageKey = key(); if (!storageKey || channelKey === storageKey) return; channel?.close?.(); const factory = createChannel || (globalThis.window?.BroadcastChannel ? name => new globalThis.window.BroadcastChannel(name) : null); channelKey = storageKey; channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null; channel?.addEventListener?.('message', event => { if (key() === storageKey) adopt(event.data, false); }); } function pending() { const storageKey = key(); if (!storageKey || !storage) return []; try { const value = JSON.parse(storage.getItem(storageKey) || '[]'); return Array.isArray(value) ? value.filter(operation => operation && typeof operation.operation_id === 'string' && ['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' && (operation.action === 'restore' || typeof operation.wake_at === 'string') ) : []; } catch (_error) { return []; } } function save(operations) { const storageKey = key(); if (!storageKey || !storage) return false; try { if (operations.length) storage.setItem(storageKey, JSON.stringify(operations)); else storage.removeItem(storageKey); return true; } catch (_error) { return false; } } function operationId() { if (createOperationId) return createOperationId(); if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); } function enqueue(action, itemId, wakeAt = null) { if (!['defer', 'restore'].includes(action) || !itemId || (action === 'defer' && typeof wakeAt !== 'string')) return false; const operations = pending().filter(operation => operation.item_id !== itemId); operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt }); const saved = save(operations); onStatus?.(saved ? 'pending' : 'error'); return saved; } function migrate(records) { const storageKey = key(); if (!storageKey || !storage) return false; const marker = migrationPrefix + storageKey.slice(prefix.length); try { if (storage.getItem(marker)) return false; Object.entries(records || {}).forEach(([itemId, wakeAt]) => enqueue('defer', itemId, wakeAt) ); storage.setItem(marker, '1'); return true; } catch (_error) { return false; } } async function run() { if (!key()) return false; ensureChannel(); try { let plan = await fetchJson('api/v1/later'); let operations = pending(); while (operations.length) { const operation = operations[0]; plan = await fetchJson('api/v1/later', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(operation), }); const remaining = pending(); const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id); if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) { throw new Error('Could not persist Later delivery receipt'); } operations = pending(); } adopt(plan); onStatus?.(pending().length ? 'pending' : 'saved'); return true; } catch (_error) { onStatus?.(pending().length ? 'pending' : 'error'); return false; } } function flush() { if (!flushing) flushing = run().finally(() => { flushing = null; }); return flushing; } function startLifecycle({ window: windowObject, document: documentObject }) { windowObject?.addEventListener?.('online', flush); documentObject?.addEventListener?.('visibilitychange', () => documentObject.hidden ? false : flush() ); } return { enqueue, migrate, flush, pending, startLifecycle }; } if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;