super-productivity/packages/plugin-dev/doc-mode/src/persistence.ts
Johannes Millan 2580ea6eb1
perf(plugins): skip no-op doc-mode saves + rename to doc-mode (#7825)
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality

Closes #7815.

flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.

The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.

Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.

Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).

* refactor(plugins): apply doc-mode no-op-save review findings (#7815)

Multi-review follow-ups for #7815:

- Tighten serializeContextDoc determinism test: previously asserted
  equality between a doc and its `{...doc}` spread, which preserves V8
  insertion order — the test would pass even against a future encoder
  that randomised iteration over Map-backed nodes (the real failure
  mode for the byte-compare). Now asserts repeated-call determinism on
  the same input directly, plus type+non-empty shape so a future return-
  type change (e.g. Uint8Array) breaks here, not in the editor.

- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
  lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
  AFTER `await`. Both are correct (the sync path is fire-and-forget and
  the iframe can be torn down mid-call, so a post-dispatch stamp would
  silently drop on teardown) but the divergence wasn't called out in
  the inline comments.

* refactor(plugins): rename document-mode to doc-mode

Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.

Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
  plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry

Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).

The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.

* refactor(plugins): apply doc-mode rename review findings

Multi-review follow-ups for the rename in 3443bcacd0:

- editor.ts:1920: missed runtime log string ('Document mode:' →
  'doc-mode:') — leaked into the dev console with inconsistent brand
  because the rename sweep matched on the kebab `document-mode` or the
  PascalCase `Document Mode`, not the bare-space `Document mode`.
- editor.ts + background.ts header JSDoc: `Document-Mode editor` /
  `Document-Mode background script` → `Doc-Mode …`. Cosmetic but the
  only remaining branded references in the source.
- scripts/build.js: esbuild iife `globalName: 'DocumentModePlugin'` →
  `'DocModePlugin'`. Internal global, no consumers, but worth keeping
  consistent with the new id.
- src/app/plugins/plugin.service.ts: add a header comment to
  BUNDLED_PLUGIN_PATHS noting that pluginIds become entityId prefixes in
  IDB / op-log / sync wire and must be treated as permanent for any
  plugin that has ever shipped. Records the rule we relied on being
  absent for this rename.

DOM-document references (`installDocumentDragHandlers`,
`Document-status banner`, `Document-level pointer handlers`) left
unchanged — those refer to the browser document object, not the brand.
Historical plan docs in `docs/plans/2026-05-2[123]-*.md` also left
unchanged per the rename commit's intent.
2026-05-27 17:30:38 +02:00

188 lines
7.5 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 — see docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md
* Phase 4 for the LWW rationale.
*/
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.
};