mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-21 02:20:12 +00:00
Delete 29 plan/design docs whose work has shipped or been superseded (SuperSync slices, sync-core extraction, encryption-at-rest drafts, document-mode/Stage-A persistence, calendar/CalDAV concepts, focus-mode time-tracking sync, etc.). Kept the still-forward-looking docs (e.g. supersync-encryption-at-rest, sync-core-simplification-roadmap, calendar-two-way-sync-technical-analysis). Source comments that cited deleted docs are rewritten into self-contained inline rationale so no "see docs/..." reference dangles.
187 lines
7.4 KiB
TypeScript
187 lines
7.4 KiB
TypeScript
/**
|
|
* Document-mode keyed persistence (Stage A Phase 4).
|
|
*
|
|
* Before Stage A, every context's editor doc + the enabled-context set were
|
|
* stuffed into one synced blob under the bare plugin id. Concurrent edits
|
|
* across contexts on different devices LWW-collided at the blob level — one
|
|
* side's whole-blob write wiped the other side's per-context changes.
|
|
*
|
|
* The keyed layout splits that into:
|
|
* - `meta` — `{ enabledCtxIds: string[] }`, owned by background.ts
|
|
* - `doc:${ctxId}` — the editor doc for one context, owned by editor.ts
|
|
* - `__meta__` — internal migration stamp `{ migrated: 1 }`
|
|
*
|
|
* Each entry has its own LWW timestamp on the host, so a concurrent edit in
|
|
* project A doesn't lose a concurrent edit in project B.
|
|
*
|
|
* The legacy single-blob entry is tombstoned (empty payload) after migration
|
|
* so an offline device that still writes the old shape loses cleanly on
|
|
* reconnect (its stale whole-blob write is LWW-dominated by the keyed entries).
|
|
*/
|
|
|
|
import type { PluginAPI } from '@super-productivity/plugin-api';
|
|
|
|
const META_KEY = 'meta';
|
|
const MIGRATION_STAMP_KEY = '__meta__';
|
|
const DOC_KEY_PREFIX = 'doc:';
|
|
|
|
/** Key for a single context's editor doc. Pure so tests can verify. */
|
|
export const docKey = (ctxId: string): string => `${DOC_KEY_PREFIX}${ctxId}`;
|
|
|
|
interface MetaEntry {
|
|
enabledCtxIds: string[];
|
|
}
|
|
|
|
interface MigrationStamp {
|
|
migrated: 0 | 1;
|
|
}
|
|
|
|
interface LegacyBlob {
|
|
docs?: Record<string, unknown>;
|
|
enabledCtxIds?: string[];
|
|
[extra: string]: unknown;
|
|
}
|
|
|
|
const safeParse = <T>(raw: string | null): T | null => {
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Read the enabled-context set from the keyed meta entry. Returns an empty
|
|
* array for a fresh install or a corrupt meta entry (the user can re-toggle).
|
|
*/
|
|
export const loadEnabledCtxIds = async (api: PluginAPI): Promise<string[]> => {
|
|
const raw = await api.loadSyncedData(META_KEY);
|
|
const parsed = safeParse<MetaEntry>(raw);
|
|
return Array.isArray(parsed?.enabledCtxIds) ? parsed!.enabledCtxIds : [];
|
|
};
|
|
|
|
export const saveEnabledCtxIds = async (
|
|
api: PluginAPI,
|
|
enabledCtxIds: string[],
|
|
): Promise<void> => {
|
|
await api.persistDataSynced(JSON.stringify({ enabledCtxIds }), META_KEY);
|
|
};
|
|
|
|
/**
|
|
* Read one context's editor doc. Returns both the raw stored string and the
|
|
* parsed value so callers can byte-compare against a `lastSeenDocBytes`
|
|
* snapshot (#7752) without round-tripping through `JSON.stringify` — which
|
|
* is not byte-stable across `prepareStoredDoc`'s task-cache reshaping.
|
|
*
|
|
* `raw` is null when the entry is missing; `parsed` is null when the entry
|
|
* is missing OR unparseable (caller falls back to a seed doc and, in
|
|
* editor.ts, gates saves so the fallback can't overwrite the original).
|
|
* The `raw !== null && parsed === null` shape is "corrupt entry present"
|
|
* — keep `raw` for the byte-compare invariant but don't try to render it.
|
|
*/
|
|
export const loadContextDoc = async (
|
|
api: PluginAPI,
|
|
ctxId: string,
|
|
): Promise<{ raw: string | null; parsed: unknown }> => {
|
|
const raw = await api.loadSyncedData(docKey(ctxId));
|
|
if (!raw) return { raw: null, parsed: null };
|
|
try {
|
|
return { raw, parsed: JSON.parse(raw) as unknown };
|
|
} catch {
|
|
return { raw, parsed: null };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Serialise an editor doc to the exact bytes that will land in storage.
|
|
* Extracted so `flushSave` and `flushSaveSync` produce byte-identical output
|
|
* — the self-echo baseline (#7752) compares raw against raw and silently
|
|
* desyncs if the two paths ever diverge on encoding. Also used by both flush
|
|
* paths to short-circuit no-op saves (#7815) by comparing the would-be-written
|
|
* bytes against `lastSeenDocBytes` before dispatching.
|
|
*/
|
|
export const serializeContextDoc = (doc: unknown): string => JSON.stringify(doc);
|
|
|
|
/**
|
|
* Persist a pre-serialised raw doc string under `doc:${ctxId}`. The caller
|
|
* is expected to have already computed the bytes via `serializeContextDoc`
|
|
* (so a no-op short-circuit against `lastSeenDocBytes` can run before this
|
|
* is invoked — see editor.ts `flushSave` for #7815).
|
|
*/
|
|
export const persistContextDocRaw = (
|
|
api: PluginAPI,
|
|
ctxId: string,
|
|
raw: string,
|
|
): Promise<void> => api.persistDataSynced(raw, docKey(ctxId));
|
|
|
|
/**
|
|
* One-shot migration from the legacy single-blob shape to keyed entries.
|
|
*
|
|
* Idempotent and crash-safe: a `__meta__` stamp guards re-runs after the
|
|
* full sweep completes. If a previous run wrote `migrated: 0` but never
|
|
* reached `migrated: 1` (process killed mid-way), the next call re-runs
|
|
* the loop — each upsert is content-idempotent (same context → same doc),
|
|
* so re-running costs op-log budget but doesn't corrupt state.
|
|
*
|
|
* Two devices migrating the same legacy data concurrently both write
|
|
* identical keyed entries; LWW resolves deterministically per entity.
|
|
*/
|
|
export const migrateToKeyedPersistence = async (api: PluginAPI): Promise<void> => {
|
|
const stampRaw = await api.loadSyncedData(MIGRATION_STAMP_KEY);
|
|
const stamp = safeParse<MigrationStamp>(stampRaw);
|
|
if (stamp?.migrated === 1) return;
|
|
|
|
const legacyRaw = await api.loadSyncedData();
|
|
if (!legacyRaw) {
|
|
// No legacy data — either a fresh install or someone already tombstoned
|
|
// the legacy entry. Either way, stamp success and exit.
|
|
await api.persistDataSynced(JSON.stringify({ migrated: 1 }), MIGRATION_STAMP_KEY);
|
|
return;
|
|
}
|
|
|
|
const parsed = safeParse<LegacyBlob>(legacyRaw);
|
|
if (!parsed) {
|
|
// Legacy blob is corrupt. Don't tombstone — that would destroy whatever
|
|
// bytes are there, and we'd rather a future build with a fix have
|
|
// something to recover from. Bail without stamping success so the
|
|
// migration retries next session.
|
|
return;
|
|
}
|
|
|
|
// Stamp the attempt FIRST so a crash before the split is detectable on
|
|
// resume — `migrated: 0` means "we tried, retry the loop." Recovery is
|
|
// simply re-running the loop; each upsert below is content-idempotent.
|
|
await api.persistDataSynced(JSON.stringify({ migrated: 0 }), MIGRATION_STAMP_KEY);
|
|
|
|
const docs = parsed.docs ?? {};
|
|
// One oversized legacy doc must not block migration of every other context.
|
|
// The host throws a synchronous Error from persistDataSynced when a payload
|
|
// exceeds MAX_PLUGIN_DATA_SIZE; catch per-doc and skip — the original bytes
|
|
// stay in the legacy blob (we suppress the tombstone below) so a future
|
|
// build with a larger cap (or the user pruning the doc) can recover them.
|
|
let anyDocSkipped = false;
|
|
for (const [ctxId, doc] of Object.entries(docs)) {
|
|
try {
|
|
await api.persistDataSynced(JSON.stringify(doc), docKey(ctxId));
|
|
} catch {
|
|
anyDocSkipped = true;
|
|
}
|
|
}
|
|
|
|
const enabledCtxIds = Array.isArray(parsed.enabledCtxIds) ? parsed.enabledCtxIds : [];
|
|
await api.persistDataSynced(JSON.stringify({ enabledCtxIds }), META_KEY);
|
|
|
|
if (!anyDocSkipped) {
|
|
// Tombstone the legacy entry: an empty payload is a plugin-side
|
|
// convention for "ignore". This gives LWW a winning side against any
|
|
// offline device that still writes the old shape. Skipped when any
|
|
// doc was too big to migrate — preserving the legacy bytes for recovery.
|
|
await api.persistDataSynced('');
|
|
await api.persistDataSynced(JSON.stringify({ migrated: 1 }), MIGRATION_STAMP_KEY);
|
|
}
|
|
// If anyDocSkipped: leave the stamp at {migrated: 0}. The next session
|
|
// will retry, which is a no-op for already-written docs (content-idempotent)
|
|
// and another skip for the oversized one. Stable failure mode without
|
|
// data loss.
|
|
};
|