feat: sync unfiled drafts across devices (Closes #851)
This commit is contained in:
parent
871e35c10b
commit
3dd0ba2024
11
README.md
11
README.md
|
|
@ -62,9 +62,14 @@ repository milestone and due date; the dashboard validates both and sends them w
|
||||||
self-assignment in the single create request, so planned work appears in its release
|
self-assignment in the single create request, so planned work appears in its release
|
||||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||||
account-bound title/description captures without selecting a repository or entering the
|
account-bound title/description captures without selecting a repository or entering the
|
||||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
|
||||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
|
||||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
description, blockers, and ordered screenshot evidence; another signed-in device can then use
|
||||||
|
**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report
|
||||||
|
whether they are synced or still pending locally. Concurrent changes surface a conflict, and
|
||||||
|
successful discard or outbox admission propagates deletion so a stale device cannot resurrect the
|
||||||
|
draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure
|
||||||
|
never blocks local capture. A different or unconfirmed account can only copy or discard
|
||||||
the private content. Issue capture and authored mobile actions (issue
|
the private content. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,7 @@
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
|
|
||||||
error.code = payload.detail?.code;
|
error.code = payload.detail?.code;
|
||||||
const retryAfter = response.headers.get('Retry-After');
|
const retryAfter = response.headers.get('Retry-After');
|
||||||
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
|
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
|
||||||
|
|
@ -530,6 +531,9 @@
|
||||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||||
getCurrentLogin: () => activeFlushLogin,
|
getCurrentLogin: () => activeFlushLogin,
|
||||||
});
|
});
|
||||||
|
createUnfiledDraftSync(
|
||||||
|
unfiledCaptures, fetchReviewJson
|
||||||
|
);
|
||||||
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
|
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
|
||||||
dFS.attach(qs, {
|
dFS.attach(qs, {
|
||||||
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
|
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
|
||||||
|
|
@ -2782,7 +2786,9 @@
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
|
if (item?.kind === 'unfiled-issue') {
|
||||||
|
await unfiledCaptures.discard(item.capture_id);
|
||||||
|
}
|
||||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||||
else if (item) draftInbox.discard(item.id);
|
else if (item) draftInbox.discard(item.id);
|
||||||
|
|
|
||||||
|
|
@ -1222,6 +1222,7 @@
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/drafts.js"></script>
|
<script src="static/drafts.js"></script>
|
||||||
<script src="static/unfiled-captures.js"></script>
|
<script src="static/unfiled-captures.js"></script>
|
||||||
|
<script src="static/unfiled-draft-sync.js"></script>
|
||||||
<script src="static/shared-image-capture.js"></script>
|
<script src="static/shared-image-capture.js"></script>
|
||||||
<script src="static/draft-filing-session.js"></script>
|
<script src="static/draft-filing-session.js"></script>
|
||||||
<script src="static/draft-capacity-dialog.js"></script>
|
<script src="static/draft-capacity-dialog.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const SHELL = [
|
||||||
BASE + 'static/widgets.js',
|
BASE + 'static/widgets.js',
|
||||||
BASE + 'static/drafts.js',
|
BASE + 'static/drafts.js',
|
||||||
BASE + 'static/unfiled-captures.js',
|
BASE + 'static/unfiled-captures.js',
|
||||||
|
BASE + 'static/unfiled-draft-sync.js',
|
||||||
BASE + 'static/shared-image-capture.js',
|
BASE + 'static/shared-image-capture.js',
|
||||||
BASE + 'static/draft-filing-session.js',
|
BASE + 'static/draft-filing-session.js',
|
||||||
BASE + 'static/draft-capacity-dialog.js',
|
BASE + 'static/draft-capacity-dialog.js',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ function createUnfiledCaptures({
|
||||||
maxItems = 20,
|
maxItems = 20,
|
||||||
}) {
|
}) {
|
||||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||||
|
const listeners = new Set();
|
||||||
|
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
|
||||||
|
|
||||||
function read() {
|
function read() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -94,6 +96,7 @@ function createUnfiledCaptures({
|
||||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
onChange(item.id, false);
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
const stage = prepared.hasAttachment
|
const stage = prepared.hasAttachment
|
||||||
|
|
@ -150,6 +153,7 @@ function createUnfiledCaptures({
|
||||||
const removed = items.find(item => item.id === id);
|
const removed = items.find(item => item.id === id);
|
||||||
if (!removed?.hasAttachment) {
|
if (!removed?.hasAttachment) {
|
||||||
write(remaining);
|
write(remaining);
|
||||||
|
onChange(id, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||||
|
|
@ -159,6 +163,7 @@ function createUnfiledCaptures({
|
||||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
onChange(id, true);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -191,7 +196,86 @@ function createUnfiledCaptures({
|
||||||
|
|
||||||
function completeResume(id) { return discard(id); }
|
function completeResume(id) { return discard(id); }
|
||||||
|
|
||||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
function encodeBytes(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBytes(value, contentType) {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||||
|
return new Blob([bytes], {type:contentType});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportOwned(login) {
|
||||||
|
const owner = String(login || '').trim();
|
||||||
|
const exported = [];
|
||||||
|
for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
|
||||||
|
let evidence = [];
|
||||||
|
if (item.hasAttachment) {
|
||||||
|
const stored = await attachmentStore?.get(item.id);
|
||||||
|
const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
|
||||||
|
(stored?.blob ? [stored] : []);
|
||||||
|
if (attachments.length !== Number(item.attachmentCount || 1)) {
|
||||||
|
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
|
||||||
|
}
|
||||||
|
evidence = await Promise.all(attachments.map(async attachment => ({
|
||||||
|
filename:attachment.filename,
|
||||||
|
content_type:attachment.contentType,
|
||||||
|
...(attachment.note ? {note:attachment.note} : {}),
|
||||||
|
data:encodeBytes(await attachment.blob.arrayBuffer()),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
exported.push({
|
||||||
|
id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
|
||||||
|
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
|
||||||
|
...(evidence.length ? {evidence} : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return exported;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeRemote(drafts, login) {
|
||||||
|
const ownerLogin = String(login || '').trim();
|
||||||
|
if (!ownerLogin || !Array.isArray(drafts)) return 0;
|
||||||
|
const existing = read();
|
||||||
|
const known = new Set(existing.map(item => item.id));
|
||||||
|
const imported = [];
|
||||||
|
for (const remote of drafts) {
|
||||||
|
if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
|
||||||
|
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
|
||||||
|
if (evidence.length && !attachmentStore) continue;
|
||||||
|
const item = {
|
||||||
|
id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
|
||||||
|
savedAt:Number(remote.saved_at),
|
||||||
|
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
|
||||||
|
blockers:remote.blockers, blockerCount:remote.blockers.length,
|
||||||
|
} : {}),
|
||||||
|
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||||
|
};
|
||||||
|
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
|
||||||
|
if (evidence.length) {
|
||||||
|
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
|
||||||
|
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||||
|
blob:decodeBytes(String(entry.data), String(entry.content_type)),
|
||||||
|
...(entry.note ? {note:String(entry.note)} : {}),
|
||||||
|
}))});
|
||||||
|
}
|
||||||
|
known.add(item.id);
|
||||||
|
imported.push(item);
|
||||||
|
}
|
||||||
|
if (imported.length) write(imported.concat(existing));
|
||||||
|
return imported.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, capacity, save, replaceOldest, discard, resume, completeResume,
|
||||||
|
exportOwned, mergeRemote, currentLogin:getCurrentLogin,
|
||||||
|
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||||
|
|
|
||||||
139
frontend/unfiled-draft-sync.js
Normal file
139
frontend/unfiled-draft-sync.js
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const createUnfiledDraftSync = factory();
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
|
||||||
|
if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
function createUnfiledDraftSync(options, mountedFetch) {
|
||||||
|
if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
|
||||||
|
const {captures, fetchJson, storage, getLogin = () => '', onState = () => {}} = options;
|
||||||
|
const key = 'stackchain.unfiled-draft-sync.v1';
|
||||||
|
let inFlight = null;
|
||||||
|
let rerun = false;
|
||||||
|
|
||||||
|
function read(login) {
|
||||||
|
try {
|
||||||
|
const all = JSON.parse(storage?.getItem(key) || '{}');
|
||||||
|
const state = all?.[login];
|
||||||
|
return {
|
||||||
|
all:all && typeof all === 'object' ? all : {},
|
||||||
|
known:new Set(Array.isArray(state?.known) ? state.known : []),
|
||||||
|
deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return {all:{}, known:new Set(), deleted:new Set()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(login, state) {
|
||||||
|
state.all[login] = {known:[...state.known], deleted:[...state.deleted]};
|
||||||
|
storage?.setItem(key, JSON.stringify(state.all));
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(status, message) {
|
||||||
|
onState({status, ...(message ? {message} : {})});
|
||||||
|
const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
|
||||||
|
if (message && visibleStatus) visibleStatus.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDrafts(left, right) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSync(login) {
|
||||||
|
const owner = String(login || '').trim();
|
||||||
|
if (!owner) return false;
|
||||||
|
publish('syncing', 'Syncing Drafts…');
|
||||||
|
const state = read(owner);
|
||||||
|
try {
|
||||||
|
const remote = await fetchJson('api/v1/unfiled-drafts');
|
||||||
|
const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
|
||||||
|
const remoteIds = new Set(remoteDrafts.map(item => item.id));
|
||||||
|
const before = await captures.exportOwned(owner);
|
||||||
|
|
||||||
|
for (const local of before) {
|
||||||
|
if (state.known.has(local.id) && !remoteIds.has(local.id) && !state.deleted.has(local.id)) {
|
||||||
|
await captures.discard(local.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await captures.mergeRemote(
|
||||||
|
remoteDrafts.filter(item => !state.deleted.has(item.id)), owner
|
||||||
|
);
|
||||||
|
const local = await captures.exportOwned(owner);
|
||||||
|
const merged = [];
|
||||||
|
const byId = new Map();
|
||||||
|
for (const item of remoteDrafts) if (!state.deleted.has(item.id)) byId.set(item.id, item);
|
||||||
|
for (const item of local) if (!state.deleted.has(item.id)) byId.set(item.id, item);
|
||||||
|
for (const item of byId.values()) merged.push(item);
|
||||||
|
merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
|
||||||
|
|
||||||
|
let finalSnapshot = remote;
|
||||||
|
if (!sameDrafts(merged, remoteDrafts)) {
|
||||||
|
finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
|
||||||
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
|
||||||
|
state.deleted.clear();
|
||||||
|
write(owner, state);
|
||||||
|
publish('ready', 'Drafts synced across devices.');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 409) {
|
||||||
|
publish('conflict', 'Drafts changed on another device. Sync again to combine them.');
|
||||||
|
} else {
|
||||||
|
publish('pending', 'Saved on this device · sync pending');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync(login) {
|
||||||
|
const owner = String(login || '').trim();
|
||||||
|
if (!owner) return Promise.resolve(false);
|
||||||
|
if (inFlight) {
|
||||||
|
rerun = true;
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
inFlight = (async () => {
|
||||||
|
let result = false;
|
||||||
|
do {
|
||||||
|
rerun = false;
|
||||||
|
result = await runSync(owner);
|
||||||
|
} while (rerun);
|
||||||
|
return result;
|
||||||
|
})().finally(() => { inFlight = null; });
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id, login) {
|
||||||
|
const owner = String(login || '').trim();
|
||||||
|
if (!owner) return false;
|
||||||
|
const state = read(owner);
|
||||||
|
state.deleted.add(String(id));
|
||||||
|
write(owner, state);
|
||||||
|
await captures.discard(String(id));
|
||||||
|
return sync(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
captures.subscribe?.((id, removed) => {
|
||||||
|
const login = getLogin();
|
||||||
|
if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {sync, remove};
|
||||||
|
}
|
||||||
|
createUnfiledDraftSync.mount = (captures, fetchJson) => {
|
||||||
|
const getLogin = captures.currentLogin;
|
||||||
|
const controller = createUnfiledDraftSync({captures, fetchJson, storage:globalThis.localStorage, getLogin});
|
||||||
|
const connect = () => {
|
||||||
|
const login = getLogin();
|
||||||
|
if (login) void controller.sync(login).catch(() => {});
|
||||||
|
else globalThis.setTimeout(connect, 250);
|
||||||
|
};
|
||||||
|
globalThis.setTimeout(connect, 0);
|
||||||
|
globalThis.addEventListener?.('online', connect);
|
||||||
|
return controller;
|
||||||
|
};
|
||||||
|
return createUnfiledDraftSync;
|
||||||
|
});
|
||||||
|
|
@ -31,7 +31,7 @@ FEATURE_SOURCES = {
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
"static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
|
|
|
||||||
77
src/main.py
77
src/main.py
|
|
@ -58,6 +58,7 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
|
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
from src.later_store import LaterStore
|
from src.later_store import LaterStore
|
||||||
|
|
@ -632,6 +633,33 @@ class SavedSearchCollection(BaseModel):
|
||||||
views: list[SavedSearchView] = Field(max_length=20)
|
views: list[SavedSearchView] = Field(max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class UnfiledDraftBlocker(BaseModel):
|
||||||
|
repository: str = Field(min_length=3, max_length=200)
|
||||||
|
number: int = Field(ge=1)
|
||||||
|
title: str = Field(default="", max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class UnfiledDraftEvidence(BaseModel):
|
||||||
|
filename: str = Field(min_length=1, max_length=255)
|
||||||
|
content_type: Literal["image/png", "image/jpeg", "image/webp"]
|
||||||
|
note: str = Field(default="", max_length=240)
|
||||||
|
data: str = Field(max_length=14_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class UnfiledDraft(BaseModel):
|
||||||
|
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
body: str = Field(default="", max_length=10_000)
|
||||||
|
saved_at: int = Field(ge=0)
|
||||||
|
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
|
||||||
|
evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
|
||||||
|
|
||||||
|
|
||||||
|
class UnfiledDraftCollection(BaseModel):
|
||||||
|
revision: int = Field(ge=0)
|
||||||
|
drafts: list[UnfiledDraft] = Field(max_length=20)
|
||||||
|
|
||||||
|
|
||||||
class NotificationLaterRequest(BaseModel):
|
class NotificationLaterRequest(BaseModel):
|
||||||
wake_at: str = Field(min_length=1, max_length=100)
|
wake_at: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
@ -2138,6 +2166,12 @@ def _saved_search_store() -> SavedSearchStore:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||||
|
return UnfiledDraftStore(
|
||||||
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _confirmed_login() -> str:
|
async def _confirmed_login() -> str:
|
||||||
try:
|
try:
|
||||||
user = await asyncio.wait_for(
|
user = await asyncio.wait_for(
|
||||||
|
|
@ -2196,6 +2230,49 @@ async def replace_saved_searches(payload: SavedSearchCollection):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/unfiled-drafts")
|
||||||
|
async def get_unfiled_drafts(response: Response):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Draft synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/unfiled-drafts")
|
||||||
|
async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_unfiled_draft_store().replace,
|
||||||
|
login,
|
||||||
|
payload.revision,
|
||||||
|
[draft.model_dump() for draft in payload.drafts],
|
||||||
|
)
|
||||||
|
except UnfiledDraftConflict as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Drafts changed on another device.",
|
||||||
|
"snapshot": exc.snapshot,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Draft synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/today")
|
@app.get("/api/v1/today")
|
||||||
async def get_today_plan():
|
async def get_today_plan():
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ SESSION_BODY_LIMIT = 16 * 1024
|
||||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||||
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
||||||
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||||
|
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
|
||||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,6 +16,8 @@ def request_body_limit(method: str, path: str) -> int | None:
|
||||||
normalized_method = method.upper()
|
normalized_method = method.upper()
|
||||||
if normalized_method == "POST" and path == "/api/v1/session":
|
if normalized_method == "POST" and path == "/api/v1/session":
|
||||||
return SESSION_BODY_LIMIT
|
return SESSION_BODY_LIMIT
|
||||||
|
if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
|
||||||
|
return UNFILED_DRAFT_SYNC_BODY_LIMIT
|
||||||
if (
|
if (
|
||||||
normalized_method == "POST"
|
normalized_method == "POST"
|
||||||
and (
|
and (
|
||||||
|
|
|
||||||
177
src/unfiled_draft_store.py
Normal file
177
src/unfiled_draft_store.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""Durable, account-scoped unfiled issue drafts and ordered evidence."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||||
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||||
|
|
||||||
|
|
||||||
|
class UnfiledDraftConflict(ValueError):
|
||||||
|
"""Raised when a client attempts to replace a stale draft collection."""
|
||||||
|
|
||||||
|
def __init__(self, snapshot: dict):
|
||||||
|
super().__init__("unfiled drafts changed on another device")
|
||||||
|
self.snapshot = snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class UnfiledDraftStore:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
max_total_bytes: int = 12 * 1024 * 1024,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.limit = limit
|
||||||
|
self.max_total_bytes = max_total_bytes
|
||||||
|
self.timeout = timeout
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS unfiled_drafts (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
drafts TEXT NOT NULL
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _login(login: str) -> str:
|
||||||
|
normalized = login.strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("login is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot(row) -> dict:
|
||||||
|
return {"revision": 0, "drafts": []} if row is None else {
|
||||||
|
"revision": int(row[0]), "drafts": json.loads(row[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
||||||
|
(self._login(login),),
|
||||||
|
).fetchone()
|
||||||
|
return self._snapshot(row)
|
||||||
|
|
||||||
|
def _normalize(self, drafts: list[dict]) -> list[dict]:
|
||||||
|
if not isinstance(drafts, list):
|
||||||
|
raise ValueError("drafts must be a list")
|
||||||
|
if len(drafts) > self.limit:
|
||||||
|
raise ValueError(f"unfiled drafts are limited to {self.limit}")
|
||||||
|
normalized = []
|
||||||
|
seen = set()
|
||||||
|
decoded_total = 0
|
||||||
|
for raw in drafts:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("draft must be an object")
|
||||||
|
draft_id = raw.get("id")
|
||||||
|
if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id):
|
||||||
|
raise ValueError("draft id is invalid")
|
||||||
|
if draft_id in seen:
|
||||||
|
raise ValueError("draft ids must be unique")
|
||||||
|
title = raw.get("title")
|
||||||
|
body = raw.get("body", "")
|
||||||
|
saved_at = raw.get("saved_at")
|
||||||
|
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
|
||||||
|
raise ValueError("title is invalid")
|
||||||
|
if not isinstance(body, str) or len(body) > 10_000:
|
||||||
|
raise ValueError("body is invalid")
|
||||||
|
if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0:
|
||||||
|
raise ValueError("saved_at is invalid")
|
||||||
|
blockers = raw.get("blockers", [])
|
||||||
|
if not isinstance(blockers, list) or len(blockers) > 5:
|
||||||
|
raise ValueError("blockers are invalid")
|
||||||
|
clean_blockers = []
|
||||||
|
for blocker in blockers:
|
||||||
|
repository = blocker.get("repository") if isinstance(blocker, dict) else None
|
||||||
|
number = blocker.get("number") if isinstance(blocker, dict) else None
|
||||||
|
blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else ""
|
||||||
|
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||||
|
raise ValueError("blocker repository is invalid")
|
||||||
|
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||||
|
raise ValueError("blocker number is invalid")
|
||||||
|
if not isinstance(blocker_title, str) or len(blocker_title) > 255:
|
||||||
|
raise ValueError("blocker title is invalid")
|
||||||
|
clean_blockers.append({"repository": repository, "number": number, "title": blocker_title})
|
||||||
|
evidence = raw.get("evidence", [])
|
||||||
|
if not isinstance(evidence, list) or len(evidence) > 5:
|
||||||
|
raise ValueError("evidence is invalid")
|
||||||
|
clean_evidence = []
|
||||||
|
for item in evidence:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("evidence is invalid")
|
||||||
|
filename = item.get("filename")
|
||||||
|
content_type = item.get("content_type")
|
||||||
|
note = item.get("note", "")
|
||||||
|
data = item.get("data")
|
||||||
|
if not isinstance(filename, str) or not filename or len(filename) > 255:
|
||||||
|
raise ValueError("evidence filename is invalid")
|
||||||
|
if content_type not in _CONTENT_TYPES:
|
||||||
|
raise ValueError("evidence content type is invalid")
|
||||||
|
if not isinstance(note, str) or len(note) > 240:
|
||||||
|
raise ValueError("evidence note is invalid")
|
||||||
|
if not isinstance(data, str):
|
||||||
|
raise ValueError("evidence data is invalid")
|
||||||
|
try:
|
||||||
|
decoded_total += len(base64.b64decode(data, validate=True))
|
||||||
|
except (binascii.Error, ValueError) as error:
|
||||||
|
raise ValueError("evidence data must be valid base64") from error
|
||||||
|
if decoded_total > self.max_total_bytes:
|
||||||
|
raise ValueError("synchronized evidence is too large")
|
||||||
|
clean_evidence.append({
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": content_type,
|
||||||
|
**({"note": note} if note else {}),
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
seen.add(draft_id)
|
||||||
|
normalized.append({
|
||||||
|
"id": draft_id,
|
||||||
|
"title": title.strip(),
|
||||||
|
"body": body,
|
||||||
|
"saved_at": saved_at,
|
||||||
|
**({"blockers": clean_blockers} if clean_blockers else {}),
|
||||||
|
**({"evidence": clean_evidence} if clean_evidence else {}),
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict:
|
||||||
|
login = self._login(login)
|
||||||
|
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||||
|
raise ValueError("revision is invalid")
|
||||||
|
normalized = self._normalize(drafts)
|
||||||
|
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
current = self._snapshot(row)
|
||||||
|
if current["revision"] != expected_revision:
|
||||||
|
raise UnfiledDraftConflict(current)
|
||||||
|
revision = expected_revision + 1
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
||||||
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
||||||
|
(login, revision, serialized),
|
||||||
|
)
|
||||||
|
return {"revision": revision, "drafts": normalized}
|
||||||
|
|
@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||||
== 64 * 1024
|
== 64 * 1024
|
||||||
)
|
)
|
||||||
assert main.request_body_limit("GET", "/api/v1/context") is None
|
assert main.request_body_limit("GET", "/api/v1/context") is None
|
||||||
|
assert main.request_body_limit("PUT", "/api/v1/unfiled-drafts") == 17 * 1024 * 1024
|
||||||
assert (
|
assert (
|
||||||
main.request_body_limit(
|
main.request_body_limit(
|
||||||
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
|
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/widgets.js",
|
"/dashboard/static/widgets.js",
|
||||||
"/dashboard/static/drafts.js",
|
"/dashboard/static/drafts.js",
|
||||||
"/dashboard/static/unfiled-captures.js",
|
"/dashboard/static/unfiled-captures.js",
|
||||||
|
"/dashboard/static/unfiled-draft-sync.js",
|
||||||
"/dashboard/static/shared-image-capture.js",
|
"/dashboard/static/shared-image-capture.js",
|
||||||
"/dashboard/static/draft-filing-session.js",
|
"/dashboard/static/draft-filing-session.js",
|
||||||
"/dashboard/static/draft-capacity-dialog.js",
|
"/dashboard/static/draft-capacity-dialog.js",
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,47 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
|
||||||
assert output["stored"] == output["notes"]
|
assert output["stored"] == output["notes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
|
||||||
|
script = f"""
|
||||||
|
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||||
|
function device() {{
|
||||||
|
const values=new Map(), blobs=new Map();
|
||||||
|
return {{
|
||||||
|
captures:createUnfiledCaptures({{
|
||||||
|
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}},
|
||||||
|
attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}},
|
||||||
|
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42,
|
||||||
|
}}), blobs,
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
(async()=>{{
|
||||||
|
const phone=device(), desktop=device();
|
||||||
|
const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}});
|
||||||
|
await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}});
|
||||||
|
const exported=await phone.captures.exportOwned('timmy');
|
||||||
|
const imported=await desktop.captures.mergeRemote(exported,'timmy');
|
||||||
|
const resumed=await desktop.captures.resume('phone-draft','timmy');
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
exported:exported[0], imported,
|
||||||
|
names:resumed.attachments.map(item=>item.filename),
|
||||||
|
notes:resumed.attachments.map(item=>item.note),
|
||||||
|
bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())),
|
||||||
|
blockers:resumed.blockers,
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["exported"]["id"] == "phone-draft"
|
||||||
|
assert output["exported"]["saved_at"] == 42
|
||||||
|
assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"]
|
||||||
|
assert output["imported"] == 1
|
||||||
|
assert output["names"] == ["one.png", "two.png"]
|
||||||
|
assert output["notes"] == ["First", "Second"]
|
||||||
|
assert output["bytes"] == ["one.png", "two.png"]
|
||||||
|
assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
|
||||||
|
|
||||||
|
|
||||||
def test_unfiled_capture_restores_selected_blockers():
|
def test_unfiled_capture_restores_selected_blockers():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||||
|
|
|
||||||
21
tests/test_unfiled_draft_integration.py
Normal file
21
tests/test_unfiled_draft_integration.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.frontend_bundle import FEATURE_SOURCES, build_frontend
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_device_unfiled_draft_sync_is_shipped_and_wired_to_user_flow():
|
||||||
|
html = (ROOT / "frontend" / "index.html").read_text()
|
||||||
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
assert '<script src="static/unfiled-draft-sync.js"></script>' in html
|
||||||
|
assert "static/unfiled-draft-sync.js" in FEATURE_SOURCES["today-timer"]
|
||||||
|
assert "createUnfiledDraftSync(" in dashboard
|
||||||
|
assert "unfiledCaptures, fetchReviewJson" in dashboard
|
||||||
|
assert "Saved on this device · sync pending" in (ROOT / "frontend" / "unfiled-draft-sync.js").read_text()
|
||||||
|
|
||||||
|
built = build_frontend(ROOT / "frontend")
|
||||||
|
assert "unfiled-draft-sync.js" not in built.dashboard_html
|
||||||
|
assert b"stackchain.unfiled-draft-sync.v1" in built.feature_bundles["today-timer"].runtime_bytes
|
||||||
92
tests/test_unfiled_draft_store.py
Normal file
92
tests/test_unfiled_draft_store.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||||
|
|
||||||
|
|
||||||
|
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
||||||
|
return {
|
||||||
|
"id": draft_id,
|
||||||
|
"title": title,
|
||||||
|
"body": "Steps from the field",
|
||||||
|
"saved_at": 1_723_600_000_000,
|
||||||
|
"blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}],
|
||||||
|
"evidence": (evidence if evidence is not None else [
|
||||||
|
{
|
||||||
|
"filename": "checkout.png",
|
||||||
|
"content_type": "image/png",
|
||||||
|
"note": "Error after tapping Pay",
|
||||||
|
"data": "cG5nLWJ5dGVz",
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||||
|
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||||
|
|
||||||
|
created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
|
||||||
|
|
||||||
|
assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]}
|
||||||
|
assert store.get("timmy") == created
|
||||||
|
assert store.get("alexander") == {"revision": 0, "drafts": []}
|
||||||
|
with pytest.raises(UnfiledDraftConflict) as conflict:
|
||||||
|
store.replace("timmy", 0, [draft(title="Stale overwrite")])
|
||||||
|
assert conflict.value.snapshot == created
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
||||||
|
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="limited to 2"):
|
||||||
|
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
|
||||||
|
with pytest.raises(ValueError, match="evidence is too large"):
|
||||||
|
store.replace("timmy", 0, [draft(evidence=[{
|
||||||
|
"filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=",
|
||||||
|
}])])
|
||||||
|
with pytest.raises(ValueError, match="valid base64"):
|
||||||
|
store.replace("timmy", 0, [draft(evidence=[{
|
||||||
|
"filename": "bad.png", "content_type": "image/png", "data": "not base64!",
|
||||||
|
}])])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||||
|
"a-separate-session-signing-secret-with-enough-entropy",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3"))
|
||||||
|
identity = {"login": "Timmy"}
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": identity["login"]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
payload = {"revision": 0, "drafts": [draft(evidence=[])]}
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||||
|
forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
|
||||||
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||||
|
saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
|
||||||
|
stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
|
||||||
|
fetched = await client.get("/api/v1/unfiled-drafts")
|
||||||
|
identity["login"] = "Alexander"
|
||||||
|
isolated = await client.get("/api/v1/unfiled-drafts")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert saved.status_code == 200
|
||||||
|
expected = draft(evidence=[])
|
||||||
|
expected.pop("evidence")
|
||||||
|
assert saved.json() == {"revision": 1, "drafts": [expected]}
|
||||||
|
assert stale.status_code == 409
|
||||||
|
assert stale.json()["detail"]["snapshot"] == saved.json()
|
||||||
|
assert fetched.json() == saved.json()
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
|
assert isolated.json() == {"revision": 0, "drafts": []}
|
||||||
104
tests/test_unfiled_draft_sync.py
Normal file
104
tests/test_unfiled_draft_sync.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str):
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete():
|
||||||
|
script = f"""
|
||||||
|
const createSync=require({json.dumps(str(SYNC))});
|
||||||
|
let remote={{revision:0,drafts:[]}};
|
||||||
|
function api(url,init={{}}) {{
|
||||||
|
if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote)));
|
||||||
|
const body=JSON.parse(init.body);
|
||||||
|
if (body.revision!==remote.revision) {{
|
||||||
|
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error);
|
||||||
|
}}
|
||||||
|
remote={{revision:remote.revision+1,drafts:body.drafts}};
|
||||||
|
return Promise.resolve(JSON.parse(JSON.stringify(remote)));
|
||||||
|
}}
|
||||||
|
function device(initial=[]) {{
|
||||||
|
let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[];
|
||||||
|
const captures={{
|
||||||
|
exportOwned:async()=>drafts.map(item=>({{...item}})),
|
||||||
|
mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}},
|
||||||
|
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
|
||||||
|
}};
|
||||||
|
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||||
|
const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}});
|
||||||
|
return {{sync,states,drafts:()=>drafts}};
|
||||||
|
}}
|
||||||
|
(async()=>{{
|
||||||
|
const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]);
|
||||||
|
await phone.sync.sync('timmy');
|
||||||
|
const desktop=device();
|
||||||
|
await desktop.sync.sync('timmy');
|
||||||
|
await desktop.sync.remove('field','timmy');
|
||||||
|
await phone.sync.sync('timmy');
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
remote,desktop:desktop.drafts(),phone:phone.drafts(),
|
||||||
|
phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status),
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["remote"]["revision"] == 2
|
||||||
|
assert output["remote"]["drafts"] == []
|
||||||
|
assert output["desktop"] == []
|
||||||
|
assert output["phone"] == []
|
||||||
|
assert output["phoneStates"][-1] == "ready"
|
||||||
|
assert output["desktopStates"][-1] == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_draft_sync_surfaces_revision_conflict_without_losing_local_draft():
|
||||||
|
script = f"""
|
||||||
|
const createSync=require({json.dumps(str(SYNC))});
|
||||||
|
let calls=0;const states=[];let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}];
|
||||||
|
const conflict={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}};
|
||||||
|
const sync=createSync({{
|
||||||
|
captures:{{exportOwned:async()=>drafts,mergeRemote:async()=>0,discard:async()=>true}},
|
||||||
|
storage:{{getItem:()=>null,setItem:()=>{{}}}},
|
||||||
|
fetchJson:async(_url,init)=>{{calls++;if(!init)return {{revision:1,drafts:[]}};const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:conflict}}}};throw error}},
|
||||||
|
onState:state=>states.push(state),
|
||||||
|
}});
|
||||||
|
(async()=>{{let message='';try{{await sync.sync('timmy')}}catch(error){{message=error.message}}process.stdout.write(JSON.stringify({{calls,states,message,drafts}}))}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["calls"] == 2
|
||||||
|
assert output["states"][-1]["status"] == "conflict"
|
||||||
|
assert output["states"][-1]["message"] == "Drafts changed on another device. Sync again to combine them."
|
||||||
|
assert output["drafts"][0]["id"] == "local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict():
|
||||||
|
script = f"""
|
||||||
|
const createSync=require({json.dumps(str(SYNC))});
|
||||||
|
let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release;
|
||||||
|
const gate=new Promise(resolve=>release=resolve);
|
||||||
|
const api=async(_url,init)=>{{
|
||||||
|
if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}}
|
||||||
|
puts++;const body=JSON.parse(init.body);
|
||||||
|
if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}}
|
||||||
|
remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
|
||||||
|
}};
|
||||||
|
const draft={{id:'local',title:'Local',body:'',saved_at:1}};
|
||||||
|
const sync=createSync({{
|
||||||
|
captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}},
|
||||||
|
storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api,
|
||||||
|
}});
|
||||||
|
(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["puts"] == 1
|
||||||
|
assert output["remote"]["drafts"][0]["id"] == "local"
|
||||||
|
assert output["results"] == [True, True]
|
||||||
Loading…
Reference in New Issue
Block a user