339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
|
|
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
|
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
|
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;
|
|
let expiredCount = 0;
|
|
let discardedCount = 0;
|
|
let recoveryNotice = { discarded: 0, until: 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;
|
|
const snapshot = {
|
|
revision: plan.revision, ids: plan.ids,
|
|
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
|
};
|
|
if (plan.plan_date) {
|
|
snapshot.plan_date = plan.plan_date;
|
|
snapshot.timezone = plan.timezone || null;
|
|
}
|
|
try {
|
|
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
|
} catch (_error) {
|
|
// A storage quota failure must not prevent the current tab from using server truth.
|
|
}
|
|
onRemoteIds?.(plan.ids);
|
|
onRemotePlan?.(plan);
|
|
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-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);
|
|
}
|
|
const records = [];
|
|
for (const recordKey of keys) {
|
|
let record;
|
|
try {
|
|
record = JSON.parse(storage.getItem(recordKey) || 'null');
|
|
} catch (_error) {
|
|
storage.removeItem(recordKey);
|
|
knownOperationKeys.delete(recordKey);
|
|
discardedCount += 1;
|
|
continue;
|
|
}
|
|
const operation = record?.operation;
|
|
const valid = operation && typeof operation.operation_id === 'string' &&
|
|
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
|
|
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
|
if (valid) records.push({ ...record, recordKey });
|
|
else {
|
|
storage.removeItem(recordKey);
|
|
knownOperationKeys.delete(recordKey);
|
|
discardedCount += 1;
|
|
}
|
|
}
|
|
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
|
now() - Number(record.queued_at) > maxOfflineMs);
|
|
expired.forEach(record => {
|
|
storage.removeItem(record.recordKey);
|
|
knownOperationKeys.delete(record.recordKey);
|
|
});
|
|
if (expired.length) {
|
|
expiredCount += expired.length;
|
|
onStatus?.('expired', { count: expiredCount });
|
|
}
|
|
return records.filter(record => !expired.includes(record))
|
|
.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,
|
|
base_revision: Number.isInteger(record.operation.base_revision)
|
|
? record.operation.base_revision : Math.max(0, savedRevision()),
|
|
}))
|
|
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
|
['add', 'remove', 'move', 'configure', 'rollover'].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,
|
|
base_revision: Math.max(0, savedRevision()),
|
|
};
|
|
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: now() + operations.length }));
|
|
knownOperationKeys.add(recordKey);
|
|
saved = true;
|
|
coordinator?.notify('today');
|
|
} catch (_error) { /* Report the persistence failure below. */ }
|
|
onStatus?.(saved ? 'pending' : 'error');
|
|
return saved;
|
|
}
|
|
|
|
function enqueueConfiguration(capacityMinutes, estimates) {
|
|
const operation = {
|
|
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
|
|
capacity_minutes: capacityMinutes, estimates: estimates || {},
|
|
base_revision: Math.max(0, savedRevision()),
|
|
};
|
|
const storageKey = key();
|
|
if (!storageKey || !storage) return false;
|
|
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
|
try {
|
|
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
|
knownOperationKeys.add(recordKey);
|
|
coordinator?.notify('today');
|
|
onStatus?.('pending');
|
|
return true;
|
|
} catch (_error) {
|
|
onStatus?.('error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function enqueueRollover(proposed) {
|
|
if (!proposed || proposed.action !== 'rollover') return false;
|
|
const operation = {
|
|
...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()),
|
|
};
|
|
const storageKey = key();
|
|
if (!storageKey || !storage) return false;
|
|
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
|
try {
|
|
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
|
knownOperationKeys.add(recordKey);
|
|
coordinator?.notify('today');
|
|
onStatus?.('pending');
|
|
return true;
|
|
} catch (_error) {
|
|
onStatus?.('error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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();
|
|
expiredCount = 0;
|
|
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);
|
|
const stillPending = pending().length;
|
|
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
|
|
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
|
|
onStatus?.(stillPending ? 'pending' : hadConflict ? 'full' :
|
|
expiredCount ? 'expired' : recovered ? 'recovered' : 'saved',
|
|
expiredCount ? { count: expiredCount } : recovered ? { discarded: recovered } : {});
|
|
if (!stillPending) discardedCount = 0;
|
|
retryAttempt = 0;
|
|
cancelRetry();
|
|
return !hadConflict;
|
|
} catch (error) {
|
|
const stillPending = pending().length;
|
|
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
|
|
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
|
|
if (stillPending) scheduleRetry(error, ownerKey);
|
|
else onStatus?.(recovered ? 'recovered' : 'error', recovered ? { discarded: recovered } : {});
|
|
discardedCount = 0;
|
|
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, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|