feat: make Today completion durably atomic (Closes #1150)
This commit is contained in:
1 parent
f785134dfe
commit
d0780a72a7
17 files changed
+214
-45
No files matched your search
@@ -102,7 +102,6 @@
|
||||
function completeOutcome() {
|
||||
if (state() !== 'coaching') return false;
|
||||
store('complete');
|
||||
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
|
||||
renderCoach();
|
||||
if (options.receipt) {
|
||||
options.receipt.hidden = false;
|
||||
@@ -128,5 +127,6 @@
|
||||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
|
||||
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v123';
|
||||
const CACHE = 'stackchain-dashboard-shell-v124';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
|
||||
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
|
||||
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
|
||||
undo = [globalThis.document?.getElementById('today-completion-undo'),
|
||||
@@ -23,7 +24,18 @@ function createTodayCompletion({ todayWork, todaySync, workSession, refresh, war
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
const operations = [
|
||||
{action:'remove', item_id:identity},
|
||||
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
|
||||
];
|
||||
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
|
||||
todaySync.enqueue('remove', identity);
|
||||
if (!admitted) {
|
||||
todayWork.restore?.(snapshot);
|
||||
announce(options.admissionFailureMessage ||
|
||||
'Device storage is full. Free space and try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
|
||||
+42
-18
@@ -13,6 +13,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
let discardedCount = 0;
|
||||
let recoveryNotice = { discarded: 0, until: 0 };
|
||||
const knownOperationKeys = new Set();
|
||||
const operationRecordKeys = new Map();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
@@ -131,11 +132,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
discardedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const operation = record?.operation;
|
||||
const valid = operation && typeof operation.operation_id === 'string' &&
|
||||
const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
|
||||
const valid = operations.length && operations.every(operation => operation &&
|
||||
typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) records.push({ ...record, recordKey });
|
||||
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) operations.forEach((operation, index) => {
|
||||
operationRecordKeys.set(operation.operation_id, recordKey);
|
||||
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, recordKey});
|
||||
});
|
||||
else {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
@@ -170,10 +175,12 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
const recordKey = operationRecordKeys.get(operationId) ||
|
||||
storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
operationRecordKeys.delete(operationId);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
@@ -186,6 +193,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function saveOperations(operations, queuedAt = now()) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const batched = operations.length > 1;
|
||||
const recordKey = storageKey + '.operation.' + (batched ? 'batch-' : '') +
|
||||
encodeURIComponent(operations[0].operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify(batched ?
|
||||
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
|
||||
knownOperationKeys.add(recordKey);
|
||||
operations.forEach(operation => operationRecordKeys.set(operation.operation_id, recordKey));
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null, fields = {}) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
@@ -198,18 +225,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()), ...fields,
|
||||
};
|
||||
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;
|
||||
return saveOperations([operation], now() + operations.length);
|
||||
}
|
||||
|
||||
function enqueueBatch(specifications) {
|
||||
const operations = specifications.map(specification => ({
|
||||
operation_id: operationId(), direction: specification.direction ?? null,
|
||||
base_revision: Math.max(0, savedRevision()), ...specification,
|
||||
}));
|
||||
return saveOperations(operations);
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
@@ -354,7 +378,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
Reference in new issue
Block a user