stackchain-dashboard/frontend/today-sync.js
timmy 8f7a836d28
All checks were successful
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: batch queued planning edits (#375)
2026-08-09 06:05:40 +00:00

236 lines
8.6 KiB
JavaScript

function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
const prefix = 'stackchain.today-sync.v1.';
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
let flushing = null;
let channel = null;
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
retryTimer = null;
}
function scheduleRetry(error, ownerKey) {
if (retryTimer !== null || !pending().length || !ownerKey) return;
const advised = Number(error?.retryAfter);
const delayMs = Number.isFinite(advised) && advised >= 0
? advised * 1000
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
retryAttempt += 1;
onStatus?.('retrying', { delayMs });
retryTimer = setTimer?.(async () => {
retryTimer = null;
if (key() !== ownerKey) return false;
return flush();
}, delayMs);
retryTimer?.unref?.();
}
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 adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
if (plan.revision < savedRevision()) return false;
try {
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
onRemoteIds?.(plan.ids);
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
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-today-' + 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 [];
const recordPrefix = storageKey + '.operation.';
try {
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
if (Array.isArray(legacy)) {
legacy.forEach((operation, index) => {
if (!operation?.operation_id) return;
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
.filter(record => record?.operation)
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id))
.map(record => record.operation)
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
}
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
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, direction = null) {
const operations = pending();
if (action === 'remove' && operations.some(operation =>
operation.action === 'remove' && operation.item_id === itemId
)) {
onStatus?.('pending');
return true;
}
const operation = { operation_id: operationId(), action, item_id: itemId, direction };
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
for (const id of ids || []) enqueue('add', id);
storage.setItem(marker, '1');
return true;
} catch (_error) {
return false;
}
}
async function run() {
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
try {
let operations = pending();
let plan;
let hadConflict = false;
if (!operations.length) plan = await fetchJson('api/v1/today');
while (operations.length) {
if (key() !== ownerKey) return false;
const batch = operations.slice(0, 50);
plan = await fetchJson('api/v1/today', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: batch }),
});
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
const received = hasReceipts ? [
...(plan.accepted_operation_ids || []),
...(plan.duplicate_operation_ids || []),
...(plan.rejected_operations || []).map(item => item.operation_id),
] : batch.map(item => item.operation_id);
hadConflict = hadConflict || Boolean(plan.rejected_operations?.length);
for (const operationId of received) {
if (pending().some(candidate => candidate.operation_id === operationId) &&
!removeOperation(operationId)) {
throw new Error('Could not persist Today delivery receipt');
}
}
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved');
retryAttempt = 0;
cancelRetry();
return !hadConflict;
} catch (error) {
if (pending().length) scheduleRetry(error, ownerKey);
else onStatus?.('error');
return false;
}
}
function flush() {
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
function startLifecycle({ window: windowObject, document: documentObject }) {
windowObject?.addEventListener?.('online', flush);
documentObject?.addEventListener?.('visibilitychange', () =>
documentObject.hidden ? false : flush()
);
}
coordinator?.subscribe(change => {
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;