feat: bound planning sync history (#381)
This commit is contained in:
parent
51278bc86f
commit
4e13f5482b
|
|
@ -40,7 +40,11 @@ another worker replays a confirmed result instead of posting duplicate content.
|
||||||
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
|
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
|
||||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||||
offline operations. After a healthy, fully paginated
|
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||||
|
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||||
|
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||||
|
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||||
|
changing a newer Today or Later plan. After a healthy, fully paginated
|
||||||
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||||
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||||
leave the plan unchanged, and offline retirements replay after reconnect. When
|
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,9 @@
|
||||||
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||||||
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
(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 === 'pending' ? 'Today saved on this device · sync pending.' :
|
||||||
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||||||
'Today sync unavailable · changes stay on this device.')));
|
(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 });
|
todaySync.startLifecycle({ window, document });
|
||||||
|
|
@ -189,7 +190,8 @@
|
||||||
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
(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 === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||||||
(state === 'pending' ? 'Later saved on this device · sync pending.' :
|
(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 });
|
laterSync.startLifecycle({ window, document });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
|
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 prefix = 'stackchain.later-sync.v1.';
|
||||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||||
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||||
|
|
@ -8,6 +9,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||||
let channelKey = '';
|
let channelKey = '';
|
||||||
let retryTimer = null;
|
let retryTimer = null;
|
||||||
let retryAttempt = 0;
|
let retryAttempt = 0;
|
||||||
|
let expiredCount = 0;
|
||||||
const knownOperationKeys = new Set();
|
const knownOperationKeys = new Set();
|
||||||
|
|
||||||
function cancelRetry() {
|
function cancelRetry() {
|
||||||
|
|
@ -105,16 +107,27 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||||
const records = [...keys].map(recordKey => {
|
const records = [...keys].map(recordKey => {
|
||||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||||
return record ? { ...record, recordKey } : null;
|
return record ? { ...record, recordKey } : null;
|
||||||
}).filter(record => record?.operation)
|
}).filter(record => record?.operation);
|
||||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
now() - Number(record.queued_at) > maxOfflineMs);
|
||||||
const latestByItem = new Map();
|
expired.forEach(record => {
|
||||||
records.forEach(record => latestByItem.set(record.operation.item_id, record));
|
|
||||||
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
|
||||||
storage.removeItem(record.recordKey);
|
storage.removeItem(record.recordKey);
|
||||||
knownOperationKeys.delete(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 => ({
|
.map(record => ({
|
||||||
...record.operation,
|
...record.operation,
|
||||||
base_revision: Number.isInteger(record.operation.base_revision)
|
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);
|
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||||
let saved = false;
|
let saved = false;
|
||||||
try {
|
try {
|
||||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||||
knownOperationKeys.add(recordKey);
|
knownOperationKeys.add(recordKey);
|
||||||
saved = true;
|
saved = true;
|
||||||
coordinator?.notify('later');
|
coordinator?.notify('later');
|
||||||
|
|
@ -190,6 +203,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||||
const ownerKey = key();
|
const ownerKey = key();
|
||||||
if (!ownerKey) return false;
|
if (!ownerKey) return false;
|
||||||
ensureChannel();
|
ensureChannel();
|
||||||
|
expiredCount = 0;
|
||||||
try {
|
try {
|
||||||
let operations = pending();
|
let operations = pending();
|
||||||
let plan;
|
let plan;
|
||||||
|
|
@ -222,7 +236,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||||
}
|
}
|
||||||
adopt(plan);
|
adopt(plan);
|
||||||
onStatus?.(pending().length ? 'pending' :
|
onStatus?.(pending().length ? 'pending' :
|
||||||
(conflictCount ? 'conflict' : 'saved'), conflictCount ? { count: conflictCount } : {});
|
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
|
||||||
|
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
|
||||||
retryAttempt = 0;
|
retryAttempt = 0;
|
||||||
cancelRetry();
|
cancelRetry();
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
|
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 prefix = 'stackchain.today-sync.v1.';
|
||||||
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||||
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
||||||
|
|
@ -8,6 +9,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
let channelKey = '';
|
let channelKey = '';
|
||||||
let retryTimer = null;
|
let retryTimer = null;
|
||||||
let retryAttempt = 0;
|
let retryAttempt = 0;
|
||||||
|
let expiredCount = 0;
|
||||||
const knownOperationKeys = new Set();
|
const knownOperationKeys = new Set();
|
||||||
|
|
||||||
function cancelRetry() {
|
function cancelRetry() {
|
||||||
|
|
@ -97,11 +99,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
const candidate = storage.key?.(index);
|
const candidate = storage.key?.(index);
|
||||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||||
}
|
}
|
||||||
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
|
const records = [...keys].map(recordKey => {
|
||||||
.filter(record => record?.operation)
|
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) ||
|
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||||
left.operation.operation_id.localeCompare(right.operation.operation_id))
|
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' &&
|
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||||
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
|
@ -136,13 +155,16 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
onStatus?.('pending');
|
onStatus?.('pending');
|
||||||
return true;
|
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();
|
const storageKey = key();
|
||||||
if (!storageKey || !storage) return false;
|
if (!storageKey || !storage) return false;
|
||||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||||
let saved = false;
|
let saved = false;
|
||||||
try {
|
try {
|
||||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||||
knownOperationKeys.add(recordKey);
|
knownOperationKeys.add(recordKey);
|
||||||
saved = true;
|
saved = true;
|
||||||
coordinator?.notify('today');
|
coordinator?.notify('today');
|
||||||
|
|
@ -169,6 +191,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
const ownerKey = key();
|
const ownerKey = key();
|
||||||
if (!ownerKey) return false;
|
if (!ownerKey) return false;
|
||||||
ensureChannel();
|
ensureChannel();
|
||||||
|
expiredCount = 0;
|
||||||
try {
|
try {
|
||||||
let operations = pending();
|
let operations = pending();
|
||||||
let plan;
|
let plan;
|
||||||
|
|
@ -199,7 +222,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
operations = pending();
|
operations = pending();
|
||||||
}
|
}
|
||||||
adopt(plan);
|
adopt(plan);
|
||||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved');
|
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||||
|
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||||
retryAttempt = 0;
|
retryAttempt = 0;
|
||||||
cancelRetry();
|
cancelRetry();
|
||||||
return !hadConflict;
|
return !hadConflict;
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,26 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
class LaterStore:
|
class LaterStore:
|
||||||
def __init__(self, path: str | Path, *, timeout: float = 1.0):
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
operation_limit: int = 4096,
|
||||||
|
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||||
|
clock=time.time,
|
||||||
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.operation_limit = operation_limit
|
||||||
|
self.operation_retention_seconds = operation_retention_seconds
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -29,6 +41,7 @@ class LaterStore:
|
||||||
CREATE TABLE IF NOT EXISTS later_operations (
|
CREATE TABLE IF NOT EXISTS later_operations (
|
||||||
login TEXT NOT NULL,
|
login TEXT NOT NULL,
|
||||||
operation_id TEXT NOT NULL,
|
operation_id TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
PRIMARY KEY (login, operation_id)
|
PRIMARY KEY (login, operation_id)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
@ -43,8 +56,33 @@ class LaterStore:
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||||
|
if "created_at" not in columns:
|
||||||
|
connection.execute("ALTER TABLE later_operations ADD COLUMN created_at REAL")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||||
|
(self.clock(),),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
return connection
|
return connection
|
||||||
|
|
||||||
|
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||||
|
now = self.clock()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||||
|
(login, operation_id, now),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM later_operations WHERE login = ? AND created_at < ?",
|
||||||
|
(login, now - self.operation_retention_seconds),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM later_operations WHERE login = ? AND rowid NOT IN "
|
||||||
|
"(SELECT rowid FROM later_operations WHERE login = ? "
|
||||||
|
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||||
|
(login, login, self.operation_limit),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_login(login: str) -> str:
|
def _normalize_login(login: str) -> str:
|
||||||
normalized = login.strip().lower()
|
normalized = login.strip().lower()
|
||||||
|
|
@ -148,10 +186,7 @@ class LaterStore:
|
||||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||||
raise ValueError("base_revision must be a non-negative integer")
|
raise ValueError("base_revision must be a non-negative integer")
|
||||||
if batch_start_item_revisions.get(item_id, 0) > base_revision:
|
if batch_start_item_revisions.get(item_id, 0) > base_revision:
|
||||||
connection.execute(
|
self._record_operation(connection, login, operation_id)
|
||||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
|
||||||
(login, operation_id),
|
|
||||||
)
|
|
||||||
rejected.append({
|
rejected.append({
|
||||||
"operation_id": operation_id,
|
"operation_id": operation_id,
|
||||||
"reason": "stale_intent",
|
"reason": "stale_intent",
|
||||||
|
|
@ -173,10 +208,7 @@ class LaterStore:
|
||||||
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
|
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
|
||||||
(login, item_id, revision),
|
(login, item_id, revision),
|
||||||
)
|
)
|
||||||
connection.execute(
|
self._record_operation(connection, login, operation_id)
|
||||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
|
||||||
(login, operation_id),
|
|
||||||
)
|
|
||||||
accepted.append(operation_id)
|
accepted.append(operation_id)
|
||||||
|
|
||||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ class TodayOperation(BaseModel):
|
||||||
action: Literal["add", "remove", "move"]
|
action: Literal["add", "remove", "move"]
|
||||||
item_id: str = Field(min_length=1, max_length=500)
|
item_id: str = Field(min_length=1, max_length=500)
|
||||||
direction: Literal["up", "down"] | None = None
|
direction: Literal["up", "down"] | None = None
|
||||||
|
base_revision: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def require_move_direction(self):
|
def require_move_direction(self):
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10,10 +11,22 @@ class TodayPlanFull(ValueError):
|
||||||
|
|
||||||
|
|
||||||
class TodayStore:
|
class TodayStore:
|
||||||
def __init__(self, path: str | Path, *, limit: int = 5, timeout: float = 1.0):
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
limit: int = 5,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
operation_limit: int = 4096,
|
||||||
|
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||||
|
clock=time.time,
|
||||||
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.operation_limit = operation_limit
|
||||||
|
self.operation_retention_seconds = operation_retention_seconds
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -33,12 +46,38 @@ class TodayStore:
|
||||||
CREATE TABLE IF NOT EXISTS today_operations (
|
CREATE TABLE IF NOT EXISTS today_operations (
|
||||||
login TEXT NOT NULL,
|
login TEXT NOT NULL,
|
||||||
operation_id TEXT NOT NULL,
|
operation_id TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
PRIMARY KEY (login, operation_id)
|
PRIMARY KEY (login, operation_id)
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||||
|
if "created_at" not in columns:
|
||||||
|
connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||||
|
(self.clock(),),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
return connection
|
return connection
|
||||||
|
|
||||||
|
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||||
|
now = self.clock()
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||||
|
(login, operation_id, now),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM today_operations WHERE login = ? AND created_at < ?",
|
||||||
|
(login, now - self.operation_retention_seconds),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM today_operations WHERE login = ? AND rowid NOT IN "
|
||||||
|
"(SELECT rowid FROM today_operations WHERE login = ? "
|
||||||
|
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||||
|
(login, login, self.operation_limit),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_login(login: str) -> str:
|
def _normalize_login(login: str) -> str:
|
||||||
normalized = login.strip().lower()
|
normalized = login.strip().lower()
|
||||||
|
|
@ -123,10 +162,7 @@ class TodayStore:
|
||||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||||
(revision, json.dumps(ids, separators=(",", ":")), login),
|
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||||
)
|
)
|
||||||
connection.execute(
|
self._record_operation(connection, login, operation_id)
|
||||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
|
||||||
(login, operation_id),
|
|
||||||
)
|
|
||||||
return {"revision": revision, "ids": ids}
|
return {"revision": revision, "ids": ids}
|
||||||
|
|
||||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||||
|
|
@ -163,6 +199,16 @@ class TodayStore:
|
||||||
duplicates.append(operation_id)
|
duplicates.append(operation_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
base_revision = operation.get("base_revision")
|
||||||
|
if base_revision is None:
|
||||||
|
base_revision = snapshot["revision"]
|
||||||
|
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||||
|
raise ValueError("base_revision must be a non-negative integer")
|
||||||
|
if base_revision < snapshot["revision"]:
|
||||||
|
self._record_operation(connection, login, operation_id)
|
||||||
|
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
||||||
|
continue
|
||||||
|
|
||||||
changed = False
|
changed = False
|
||||||
if action == "add":
|
if action == "add":
|
||||||
if item_id not in ids:
|
if item_id not in ids:
|
||||||
|
|
@ -185,10 +231,7 @@ class TodayStore:
|
||||||
ids[index], ids[target] = ids[target], ids[index]
|
ids[index], ids[target] = ids[target], ids[index]
|
||||||
changed = True
|
changed = True
|
||||||
revision += 1 if changed else 0
|
revision += 1 if changed else 0
|
||||||
connection.execute(
|
self._record_operation(connection, login, operation_id)
|
||||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
|
||||||
(login, operation_id),
|
|
||||||
)
|
|
||||||
accepted.append(operation_id)
|
accepted.append(operation_id)
|
||||||
|
|
||||||
serialized = json.dumps(ids, separators=(",", ":"))
|
serialized = json.dumps(ids, separators=(",", ":"))
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,63 @@ def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_p
|
||||||
assert duplicate["records"] == replay["records"]
|
assert duplicate["records"] == replay["records"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||||
|
path = tmp_path / "later.sqlite3"
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"CREATE TABLE later_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||||
|
"PRIMARY KEY (login, operation_id))"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO later_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||||
|
)
|
||||||
|
|
||||||
|
store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
|
||||||
|
for index in range(4):
|
||||||
|
store.apply(
|
||||||
|
"timmy", f"op-{index}", "defer", f"issue:r:{index}:",
|
||||||
|
wake_at="2026-08-10T09:00:00.000Z",
|
||||||
|
)
|
||||||
|
store.apply(
|
||||||
|
"alexander", "other", "defer", "issue:r:99:",
|
||||||
|
wake_at="2026-08-10T09:00:00.000Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||||
|
timmy = connection.execute(
|
||||||
|
"SELECT operation_id FROM later_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||||
|
).fetchall()
|
||||||
|
alexander = connection.execute(
|
||||||
|
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert "created_at" in columns
|
||||||
|
assert timmy == [("op-2",), ("op-3",)]
|
||||||
|
assert alexander == [("other",)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||||
|
clock = [1_000.0]
|
||||||
|
path = tmp_path / "later.sqlite3"
|
||||||
|
store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
|
||||||
|
store.apply("timmy", "old", "restore", "issue:r:1:")
|
||||||
|
store.apply("alexander", "other-old", "restore", "issue:r:2:")
|
||||||
|
clock[0] += 61
|
||||||
|
store.apply("timmy", "fresh", "restore", "issue:r:3:")
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
timmy = connection.execute(
|
||||||
|
"SELECT operation_id FROM later_operations WHERE login = 'timmy'"
|
||||||
|
).fetchall()
|
||||||
|
alexander = connection.execute(
|
||||||
|
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert timmy == [("fresh",)]
|
||||||
|
assert alexander == [("other-old",)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,29 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||||
assert result["pending"] == []
|
assert result["pending"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_queue_expires_ancient_edits_before_replay():
|
||||||
|
script = f"""
|
||||||
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||||
|
const values=new Map();const statuses=[];const requests=[];
|
||||||
|
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||||
|
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const sync=createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-later',
|
||||||
|
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||||
|
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:4,records:{{}}}}}},
|
||||||
|
onRemoteRecords:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||||
|
(async()=>{{await sync.flush();sync.enqueue('restore','issue:r:2:');
|
||||||
|
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||||
|
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||||
|
await sync.flush();process.stdout.write(JSON.stringify({{requests,statuses,pending:sync.pending()}}));}})();
|
||||||
|
"""
|
||||||
|
result = run_node(script)
|
||||||
|
|
||||||
|
assert result["requests"] == ["GET", "GET"]
|
||||||
|
assert ["expired", 1] in result["statuses"]
|
||||||
|
assert result["statuses"][-1] == ["expired", 1]
|
||||||
|
assert result["pending"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
|
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||||
|
|
@ -317,10 +340,12 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
assert "laterSync.flush();" in html
|
assert "laterSync.flush();" in html
|
||||||
assert "Later saved to account." in html
|
assert "Later saved to account." in html
|
||||||
assert "Another device changed this Later item" in html
|
assert "Another device changed this Later item" in html
|
||||||
|
assert "Today edit expired after 30 days offline" in html
|
||||||
|
assert "Later edit expired after 30 days offline" in html
|
||||||
|
|
||||||
|
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v55" in worker
|
assert "stackchain-dashboard-shell-v56" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v55" in worker
|
assert "stackchain-dashboard-shell-v56" in worker
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,12 @@ def test_readme_documents_offline_unread_update_conversations_and_boundaries():
|
||||||
assert "previously opened unread update" in text
|
assert "previously opened unread update" in text
|
||||||
assert "replies enter the account-bound durable outbox" in text
|
assert "replies enter the account-bound durable outbox" in text
|
||||||
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
|
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_planning_sync_retention_contract():
|
||||||
|
text = " ".join(README.read_text().split())
|
||||||
|
|
||||||
|
assert "30 days" in text
|
||||||
|
assert "4,096 operation receipts per account" in text
|
||||||
|
assert "expired edit" in text
|
||||||
|
assert "account plan is kept" in text
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -132,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,96 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
|
||||||
assert replay["duplicate_operation_ids"] == ["two", "three"]
|
assert replay["duplicate_operation_ids"] == ["two", "three"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||||
|
path = tmp_path / "today.sqlite3"
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"CREATE TABLE today_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||||
|
"PRIMARY KEY (login, operation_id))"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO today_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||||
|
)
|
||||||
|
|
||||||
|
store = TodayStore(path, limit=20, operation_limit=3, clock=lambda: 1_000)
|
||||||
|
for index in range(5):
|
||||||
|
store.apply("timmy", f"op-{index}", "add", f"issue:r:{index}:")
|
||||||
|
store.apply("alexander", "other", "add", "issue:r:99:")
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||||
|
timmy = connection.execute(
|
||||||
|
"SELECT operation_id FROM today_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||||
|
).fetchall()
|
||||||
|
alexander = connection.execute(
|
||||||
|
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert "created_at" in columns
|
||||||
|
assert timmy == [("op-2",), ("op-3",), ("op-4",)]
|
||||||
|
assert alexander == [("other",)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruned_stale_today_operation_cannot_reorder_a_newer_plan(tmp_path):
|
||||||
|
store = TodayStore(tmp_path / "today.sqlite3", limit=5, operation_limit=1)
|
||||||
|
store.apply_batch("timmy", [
|
||||||
|
{"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:", "base_revision": 0},
|
||||||
|
{"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:", "base_revision": 0},
|
||||||
|
])
|
||||||
|
store.apply_batch("timmy", [{
|
||||||
|
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||||
|
"direction": "up", "base_revision": 2,
|
||||||
|
}])
|
||||||
|
newer = store.apply_batch("timmy", [{
|
||||||
|
"operation_id": "newer-move", "action": "move", "item_id": "issue:r:1:",
|
||||||
|
"direction": "up", "base_revision": 3,
|
||||||
|
}])
|
||||||
|
|
||||||
|
replay = store.apply_batch("timmy", [{
|
||||||
|
"operation_id": "offline-move", "action": "move", "item_id": "issue:r:2:",
|
||||||
|
"direction": "up", "base_revision": 2,
|
||||||
|
}])
|
||||||
|
|
||||||
|
assert replay["ids"] == newer["ids"] == ["issue:r:1:", "issue:r:2:"]
|
||||||
|
assert replay["revision"] == 4
|
||||||
|
assert replay["accepted_operation_ids"] == []
|
||||||
|
assert replay["rejected_operations"] == [
|
||||||
|
{"operation_id": "offline-move", "reason": "stale_intent"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||||
|
clock = [1_000.0]
|
||||||
|
path = tmp_path / "today.sqlite3"
|
||||||
|
store = TodayStore(
|
||||||
|
path, limit=5, operation_retention_seconds=60, clock=lambda: clock[0]
|
||||||
|
)
|
||||||
|
store.apply("timmy", "old", "add", "issue:r:1:")
|
||||||
|
store.apply("alexander", "other-old", "add", "issue:r:2:")
|
||||||
|
clock[0] += 61
|
||||||
|
store.apply("timmy", "fresh", "remove", "issue:r:1:")
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
timmy = connection.execute(
|
||||||
|
"SELECT operation_id FROM today_operations WHERE login = 'timmy'"
|
||||||
|
).fetchall()
|
||||||
|
alexander = connection.execute(
|
||||||
|
"SELECT operation_id FROM today_operations WHERE login = 'alexander'"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
assert timmy == [("fresh",)]
|
||||||
|
assert alexander == [("other-old",)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_api_model_preserves_client_base_revision():
|
||||||
|
operation = main.TodayOperation(
|
||||||
|
operation_id="offline", action="move", item_id="issue:r:2:",
|
||||||
|
direction="up", base_revision=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert operation.model_dump()["base_revision"] == 7
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
assert result == {
|
assert result == {
|
||||||
"sharesInflight": True,
|
"sharesInflight": True,
|
||||||
"patches": [
|
"patches": [
|
||||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||||
],
|
],
|
||||||
"pending": [],
|
"pending": [],
|
||||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||||
|
|
@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v55" in source
|
assert "stackchain-dashboard-shell-v56" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -137,12 +137,41 @@ sync.enqueue('add', 'issue:r:2:');
|
||||||
"action": "add",
|
"action": "add",
|
||||||
"item_id": "issue:r:2:",
|
"item_id": "issue:r:2:",
|
||||||
"direction": None,
|
"direction": None,
|
||||||
|
"base_revision": 0,
|
||||||
}]}
|
}]}
|
||||||
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
||||||
assert result["status"] == "saved"
|
assert result["status"] == "saved"
|
||||||
assert result["pending"] == []
|
assert result["pending"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_operations_capture_server_revision_and_expire_before_replay():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map(); const statuses=[]; const requests=[];
|
||||||
|
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||||
|
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const sync=createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-op',
|
||||||
|
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||||
|
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:7,ids:['server']}}}},
|
||||||
|
onRemoteIds:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||||
|
(async()=>{{await sync.flush();sync.enqueue('move','issue:r:2:','up');
|
||||||
|
const pendingBefore=sync.pending();
|
||||||
|
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||||
|
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||||
|
await sync.flush();process.stdout.write(JSON.stringify({{pendingBefore,requests,statuses,pendingAfter:sync.pending()}}));}})();
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||||
|
|
||||||
|
assert result["pendingBefore"] == [{
|
||||||
|
"operation_id": "old-op", "action": "move", "item_id": "issue:r:2:",
|
||||||
|
"direction": "up", "base_revision": 7,
|
||||||
|
}]
|
||||||
|
assert result["requests"] == ["GET", "GET"]
|
||||||
|
assert ["expired", 1] in result["statuses"]
|
||||||
|
assert result["statuses"][-1] == ["expired", 1]
|
||||||
|
assert result["pendingAfter"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
|
def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
|
@ -184,6 +213,7 @@ sync.enqueue('move', 'issue:r:2:', 'up');
|
||||||
"action": "move",
|
"action": "move",
|
||||||
"item_id": "issue:r:2:",
|
"item_id": "issue:r:2:",
|
||||||
"direction": "up",
|
"direction": "up",
|
||||||
|
"base_revision": 0,
|
||||||
}],
|
}],
|
||||||
"status": "retrying",
|
"status": "retrying",
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +251,7 @@ sync.enqueue('add','issue:r:2:');
|
||||||
assert result["scheduled"] == [2000]
|
assert result["scheduled"] == [2000]
|
||||||
assert result["attempts"] == 2
|
assert result["attempts"] == 2
|
||||||
assert result["patches"] == [{
|
assert result["patches"] == [{
|
||||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
|
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0,
|
||||||
}]
|
}]
|
||||||
assert result["pending"] == []
|
assert result["pending"] == []
|
||||||
assert ["retrying", 2000] in result["statuses"]
|
assert ["retrying", 2000] in result["statuses"]
|
||||||
|
|
@ -254,6 +284,7 @@ process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(),
|
||||||
"action": "remove",
|
"action": "remove",
|
||||||
"item_id": "issue:r:1:",
|
"item_id": "issue:r:1:",
|
||||||
"direction": None,
|
"direction": None,
|
||||||
|
"base_revision": 0,
|
||||||
}],
|
}],
|
||||||
"statuses": ["pending", "pending"],
|
"statuses": ["pending", "pending"],
|
||||||
}
|
}
|
||||||
|
|
@ -280,8 +311,8 @@ process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
|
||||||
"first": True,
|
"first": True,
|
||||||
"second": False,
|
"second": False,
|
||||||
"pending": [
|
"pending": [
|
||||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None, "base_revision": 0},
|
||||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None, "base_revision": 0},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,8 +377,8 @@ sync.enqueue('remove', '5');
|
||||||
assert result == {
|
assert result == {
|
||||||
"result": False,
|
"result": False,
|
||||||
"patches": [
|
"patches": [
|
||||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None},
|
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None, "base_revision": 0},
|
||||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None},
|
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None, "base_revision": 0},
|
||||||
],
|
],
|
||||||
"pending": [],
|
"pending": [],
|
||||||
"ids": ["1", "2", "3", "4"],
|
"ids": ["1", "2", "3", "4"],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user