feat: bound planning sync history (#381)
CI / lint (pull_request) Successful in 39s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-09 07:43:53 +00:00
1 parent 51278bc86f
commit 4e13f5482b
16 files changed
+390 -57

No files matched your search

+5 -3
View File
@@ -155,8 +155,9 @@
status.textContent = state === 'saved' ? 'Today saved to account.' :
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Today saved on this device · sync pending.' :
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
'Today sync unavailable · changes stay on this device.')));
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Today sync unavailable · changes stay on this device.'))));
},
});
todaySync.startLifecycle({ window, document });
@@ -189,7 +190,8 @@
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` :
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Later saved on this device · sync pending.' :
'Later sync unavailable · changes stay on this device.')));
(state === 'expired' ? `Later edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
'Later sync unavailable · changes stay on this device.'))));
},
});
laterSync.startLifecycle({ window, document });
+25 -10
View File
@@ -1,5 +1,6 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
const prefix = 'stackchain.later-sync.v1.';
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
@@ -8,6 +9,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
let expiredCount = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
@@ -105,16 +107,27 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, 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));
const latestByItem = new Map();
records.forEach(record => latestByItem.set(record.operation.item_id, record));
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
}).filter(record => record?.operation);
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);
});
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
if (expired.length) {
expiredCount += expired.length;
onStatus?.('expired', { count: expiredCount });
}
const activeRecords = 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));
const latestByItem = new Map();
activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record));
activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record)
.map(record => ({
...record.operation,
base_revision: Number.isInteger(record.operation.base_revision)
@@ -161,7 +174,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('later');
@@ -190,6 +203,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
expiredCount = 0;
try {
let operations = pending();
let plan;
@@ -222,7 +236,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
}
adopt(plan);
onStatus?.(pending().length ? 'pending' :
(conflictCount ? 'conflict' : 'saved'), conflictCount ? { count: conflictCount } : {});
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
retryAttempt = 0;
cancelRetry();
return true;
+1 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v55';
const CACHE = 'stackchain-dashboard-shell-v56';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
+31 -7
View File
@@ -1,5 +1,6 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
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.';
@@ -8,6 +9,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
let expiredCount = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
@@ -97,11 +99,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
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)
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation);
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)
.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'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
@@ -136,13 +155,16 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
onStatus?.('pending');
return true;
}
const operation = { operation_id: operationId(), action, item_id: itemId, direction };
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: Date.now() }));
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
@@ -169,6 +191,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
expiredCount = 0;
try {
let operations = pending();
let plan;
@@ -199,7 +222,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved');
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
retryAttempt = 0;
cancelRetry();
return !hadConflict;