165 lines
5.6 KiB
JavaScript
165 lines
5.6 KiB
JavaScript
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;
|