mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-25 00:47:39 +00:00
* 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.
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
/**
|
|
* Tests for the keyed-persistence helpers and the legacy → keyed migration.
|
|
* Run with `npm test` (see scripts/test.js) — esbuild transpiles + bundles
|
|
* each spec, and `node --test` executes it.
|
|
*/
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import type { PluginAPI } from '@super-productivity/plugin-api';
|
|
import {
|
|
docKey,
|
|
loadContextDoc,
|
|
loadEnabledCtxIds,
|
|
migrateToKeyedPersistence,
|
|
persistContextDocRaw,
|
|
saveEnabledCtxIds,
|
|
serializeContextDoc,
|
|
} from './persistence';
|
|
|
|
/**
|
|
* In-memory mock of the host's plugin storage. Mirrors the keyed semantics:
|
|
* each (pluginId, key?) tuple is a distinct entry, an empty payload
|
|
* tombstones an entry (treated as missing on read).
|
|
*/
|
|
const createMockApi = (): {
|
|
api: PluginAPI;
|
|
store: Map<string, string>;
|
|
writes: { key: string | undefined; data: string }[];
|
|
} => {
|
|
// Single entityId space (pluginId-scoped — the test plugin id is implied).
|
|
// Key "" means the legacy keyless entry.
|
|
const store = new Map<string, string>();
|
|
const writes: { key: string | undefined; data: string }[] = [];
|
|
const api = {
|
|
persistDataSynced: async (data: string, key?: string): Promise<void> => {
|
|
writes.push({ key, data });
|
|
const k = key ?? '';
|
|
// Plugin-side tombstone convention: an empty payload means "ignore".
|
|
// We model this on the host by storing the empty string; the read
|
|
// side returns null for empty entries so callers see "no data".
|
|
store.set(k, data);
|
|
},
|
|
loadSyncedData: async (key?: string): Promise<string | null> => {
|
|
const k = key ?? '';
|
|
const v = store.get(k);
|
|
if (v === undefined) return null;
|
|
if (v === '') return ''; // explicit tombstone — still readable as empty
|
|
return v;
|
|
},
|
|
} as unknown as PluginAPI;
|
|
return { api, store, writes };
|
|
};
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Read/write helpers */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('docKey: composes "doc:<ctxId>"', () => {
|
|
assert.equal(docKey('project-1'), 'doc:project-1');
|
|
assert.equal(docKey('TODAY'), 'doc:TODAY');
|
|
});
|
|
|
|
test('loadEnabledCtxIds: returns [] when meta is missing', async () => {
|
|
const { api } = createMockApi();
|
|
assert.deepEqual(await loadEnabledCtxIds(api), []);
|
|
});
|
|
|
|
test('loadEnabledCtxIds: returns the saved ids', async () => {
|
|
const { api } = createMockApi();
|
|
await saveEnabledCtxIds(api, ['p1', 'p2']);
|
|
assert.deepEqual(await loadEnabledCtxIds(api), ['p1', 'p2']);
|
|
});
|
|
|
|
test('loadEnabledCtxIds: tolerates corrupt meta (returns [])', async () => {
|
|
const { api, store } = createMockApi();
|
|
store.set('meta', '{not json');
|
|
assert.deepEqual(await loadEnabledCtxIds(api), []);
|
|
});
|
|
|
|
test('loadContextDoc: returns {raw:null, parsed:null} when the entry is missing', async () => {
|
|
const { api } = createMockApi();
|
|
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: null, parsed: null });
|
|
});
|
|
|
|
test('loadContextDoc: returns both the raw string and parsed value', async () => {
|
|
const { api } = createMockApi();
|
|
const doc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
|
await persistContextDocRaw(api, 'p1', serializeContextDoc(doc));
|
|
const loaded = await loadContextDoc(api, 'p1');
|
|
// raw is what the editor needs for the lastSeenDocBytes byte-compare (#7752)
|
|
assert.equal(loaded.raw, JSON.stringify(doc));
|
|
assert.deepEqual(loaded.parsed, doc);
|
|
});
|
|
|
|
test('loadContextDoc: corrupt entry returns raw bytes with parsed:null', async () => {
|
|
// The editor's hook handler still needs the raw bytes for the self-echo
|
|
// baseline even when JSON.parse fails — otherwise a remote write that
|
|
// happens to also be corrupt would self-echo as "different" forever.
|
|
const { api, store } = createMockApi();
|
|
store.set('doc:p1', '{not valid json');
|
|
const loaded = await loadContextDoc(api, 'p1');
|
|
assert.equal(loaded.raw, '{not valid json');
|
|
assert.equal(loaded.parsed, null);
|
|
});
|
|
|
|
test('loadContextDoc: primitive JSON values pass through parsed as the primitive', async () => {
|
|
// `parsed` is typed `unknown` so the editor's truthy guard
|
|
// (`stored ? prepareStoredDoc(stored, ...) : buildSeedDoc(...)`) is the
|
|
// only thing keeping a non-object from reaching the doc transformer.
|
|
// Lock the persistence-side behaviour so a future refactor (e.g.
|
|
// "validate it's an object here") surfaces here, not in the editor.
|
|
const { api, store } = createMockApi();
|
|
store.set('doc:p1', '123');
|
|
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: '123', parsed: 123 });
|
|
store.set('doc:p1', 'null');
|
|
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: 'null', parsed: null });
|
|
store.set('doc:p1', '"hi"');
|
|
assert.deepEqual(await loadContextDoc(api, 'p1'), { raw: '"hi"', parsed: 'hi' });
|
|
});
|
|
|
|
test('persistContextDocRaw: writes the exact raw string under doc:<ctxId>', async () => {
|
|
const { api, store, writes } = createMockApi();
|
|
// Pre-computed raw (mirrors the flushSave path: serialize once, byte-compare,
|
|
// then persist). The helper must not re-stringify — that would defeat the
|
|
// self-echo baseline (#7752) and the no-op skip (#7815).
|
|
const raw = serializeContextDoc({ type: 'doc' });
|
|
await persistContextDocRaw(api, 'TODAY', raw);
|
|
assert.equal(store.get('doc:TODAY'), raw);
|
|
assert.deepEqual(writes, [{ key: 'doc:TODAY', data: raw }]);
|
|
});
|
|
|
|
test('serializeContextDoc: deterministic across repeated calls on the same input', () => {
|
|
// The no-op save short-circuit (#7815) in flushSave / flushSaveSync compares
|
|
// the freshly-serialised bytes against the `lastSeenDocBytes` baseline. That
|
|
// comparison is only meaningful if the encoder is deterministic — same input
|
|
// must always produce byte-identical output, otherwise a "real revert" cycle
|
|
// would compute different bytes each call and the skip would never fire (or,
|
|
// worse, would fire when bytes actually differ if the comparator hashed).
|
|
// Lock this in so a future swap to a non-deterministic encoder (e.g. one
|
|
// that randomises iteration order over Map-backed nodes) breaks here, not
|
|
// silently in the editor.
|
|
const doc = {
|
|
type: 'doc',
|
|
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
|
};
|
|
const first = serializeContextDoc(doc);
|
|
const second = serializeContextDoc(doc);
|
|
assert.equal(first, second);
|
|
// Also asserts the obvious shape so a future signature change (e.g.
|
|
// returning a Uint8Array) fails compile + runtime, not just structurally.
|
|
assert.equal(typeof first, 'string');
|
|
assert.ok(first.length > 0);
|
|
});
|
|
|
|
test('persistContextDocRaw: re-persisting identical bytes is a no-op at the store level', async () => {
|
|
// The optimization premise: when the editor's no-op short-circuit fires, the
|
|
// persist call never reaches here. But IF a caller did call this twice with
|
|
// the same raw, the result is byte-identical state — confirming the skip is
|
|
// safe (no information is lost by not making the second write).
|
|
const { api, store } = createMockApi();
|
|
const raw = serializeContextDoc({ type: 'doc', content: [] });
|
|
await persistContextDocRaw(api, 'p1', raw);
|
|
const after1 = store.get('doc:p1');
|
|
await persistContextDocRaw(api, 'p1', raw);
|
|
assert.equal(store.get('doc:p1'), after1);
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Migration */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
test('migration: no-op when already stamped', async () => {
|
|
const { api, store, writes } = createMockApi();
|
|
store.set('__meta__', JSON.stringify({ migrated: 1 }));
|
|
// Even if a legacy blob exists, we don't touch it once the stamp says we're
|
|
// done — a re-migration could overwrite later edits to the keyed entries.
|
|
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } } }));
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
assert.deepEqual(writes, []);
|
|
assert.equal(store.has('doc:p1'), false);
|
|
});
|
|
|
|
test('migration: stamps success on a fresh install (no legacy data)', async () => {
|
|
const { api, store } = createMockApi();
|
|
await migrateToKeyedPersistence(api);
|
|
assert.equal(store.get('__meta__'), JSON.stringify({ migrated: 1 }));
|
|
assert.equal(store.has('meta'), false);
|
|
assert.equal(store.has(''), false);
|
|
});
|
|
|
|
test('migration: splits legacy blob into keyed entries + meta', async () => {
|
|
const { api, store } = createMockApi();
|
|
const legacy = {
|
|
version: 1,
|
|
docs: {
|
|
p1: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
TODAY: { type: 'doc', content: [{ type: 'heading' }] },
|
|
},
|
|
enabledCtxIds: ['p1'],
|
|
};
|
|
store.set('', JSON.stringify(legacy));
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
assert.equal(store.get('doc:p1'), JSON.stringify(legacy.docs.p1));
|
|
assert.equal(store.get('doc:TODAY'), JSON.stringify(legacy.docs.TODAY));
|
|
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: ['p1'] }));
|
|
// Legacy entry tombstoned (empty payload).
|
|
assert.equal(store.get(''), '');
|
|
// Final stamp.
|
|
assert.equal(store.get('__meta__'), JSON.stringify({ migrated: 1 }));
|
|
});
|
|
|
|
test('migration: handles a legacy blob with no enabledCtxIds', async () => {
|
|
const { api, store } = createMockApi();
|
|
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } } }));
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: [] }));
|
|
assert.equal(store.get('doc:p1'), JSON.stringify({ type: 'doc' }));
|
|
});
|
|
|
|
test('migration: refuses to tombstone a corrupt legacy blob (preserves data for recovery)', async () => {
|
|
const { api, store, writes } = createMockApi();
|
|
store.set('', '{not json');
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
// Corrupt legacy stays in place; no keyed entries; no success stamp.
|
|
assert.equal(store.get(''), '{not json');
|
|
assert.equal(store.has('__meta__'), false);
|
|
assert.equal(store.has('meta'), false);
|
|
// We attempted (intent stamp) but bailed before tombstoning.
|
|
const sawAttemptedStamp = writes.some(
|
|
(w) =>
|
|
w.key === '__meta__' &&
|
|
typeof w.data === 'string' &&
|
|
w.data.includes('"migrated":0'),
|
|
);
|
|
// The attempt stamp wasn't written either — we bailed BEFORE stamping
|
|
// attempt, because parsing failed.
|
|
assert.equal(sawAttemptedStamp, false);
|
|
});
|
|
|
|
test('migration: skips an oversized legacy doc and leaves legacy intact for recovery', async () => {
|
|
// One context's stored doc is so big that the keyed write throws (mock
|
|
// simulates the host's MAX_PLUGIN_DATA_SIZE check). The migration must
|
|
// not abort the whole run — the other docs must still land — and must
|
|
// NOT tombstone or stamp success, so a future build with a larger cap
|
|
// (or after the user prunes the doc) can recover.
|
|
const { store, writes } = createMockApi();
|
|
const api = {
|
|
persistDataSynced: async (data: string, key?: string): Promise<void> => {
|
|
// Simulate host-side cap: throw for the oversized doc.
|
|
if (data.length > 10_000) {
|
|
throw new Error('Plugin data exceeds maximum size');
|
|
}
|
|
writes.push({ key, data });
|
|
store.set(key ?? '', data);
|
|
},
|
|
loadSyncedData: async (key?: string): Promise<string | null> => {
|
|
const v = store.get(key ?? '');
|
|
return v === undefined ? null : v;
|
|
},
|
|
} as unknown as PluginAPI;
|
|
const big = 'x'.repeat(20_000);
|
|
store.set(
|
|
'',
|
|
JSON.stringify({
|
|
docs: { small: { type: 'doc' }, big: { type: 'doc', text: big } },
|
|
enabledCtxIds: ['small'],
|
|
}),
|
|
);
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
// Small doc migrated; big doc absent.
|
|
assert.equal(store.get('doc:small'), JSON.stringify({ type: 'doc' }));
|
|
assert.equal(store.has('doc:big'), false);
|
|
// Meta lands (it's small).
|
|
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: ['small'] }));
|
|
// Legacy NOT tombstoned — original bytes preserved.
|
|
assert.equal(
|
|
store.get(''),
|
|
JSON.stringify({
|
|
docs: { small: { type: 'doc' }, big: { type: 'doc', text: big } },
|
|
enabledCtxIds: ['small'],
|
|
}),
|
|
);
|
|
// Migration NOT stamped successful — next session retries.
|
|
const stampRaw = store.get('__meta__');
|
|
const stamp = stampRaw ? (JSON.parse(stampRaw) as { migrated: number }) : null;
|
|
assert.notEqual(stamp?.migrated, 1);
|
|
});
|
|
|
|
test('migration: idempotent re-run after crash mid-loop', async () => {
|
|
const { api, store } = createMockApi();
|
|
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } }, enabledCtxIds: ['p1'] }));
|
|
// Simulate a previous attempt that stamped intent but never reached
|
|
// success — e.g. the iframe was torn down mid-loop.
|
|
store.set('__meta__', JSON.stringify({ migrated: 0 }));
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
assert.equal(store.get('doc:p1'), JSON.stringify({ type: 'doc' }));
|
|
assert.equal(store.get('meta'), JSON.stringify({ enabledCtxIds: ['p1'] }));
|
|
assert.equal(store.get(''), '');
|
|
assert.equal(store.get('__meta__'), JSON.stringify({ migrated: 1 }));
|
|
});
|
|
|
|
test('migration: re-running after success is a no-op even with replayed legacy data', async () => {
|
|
// Simulate: A migrates, B (offline, old build) replays a legacy edit on top
|
|
// of A's tombstone, A's local stamp still says migrated:1. A must NOT
|
|
// re-migrate (it would overwrite its keyed entries with B's older shape).
|
|
const { api, store, writes } = createMockApi();
|
|
store.set('__meta__', JSON.stringify({ migrated: 1 }));
|
|
store.set('', JSON.stringify({ docs: { p1: { type: 'paragraph' } } }));
|
|
store.set('doc:p1', JSON.stringify({ type: 'doc', content: [{ type: 'heading' }] }));
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
assert.deepEqual(writes, []);
|
|
assert.equal(
|
|
store.get('doc:p1'),
|
|
JSON.stringify({ type: 'doc', content: [{ type: 'heading' }] }),
|
|
);
|
|
});
|
|
|
|
test('migration: stamps intent FIRST, then splits, then tombstones, then stamps success', async () => {
|
|
// Order matters for crash recovery: the intent stamp must be the first
|
|
// write so that a crash between "stamp" and "split" still leaves the
|
|
// marker that says "we tried; retry on resume."
|
|
const { api, store, writes } = createMockApi();
|
|
store.set('', JSON.stringify({ docs: { p1: { type: 'doc' } }, enabledCtxIds: [] }));
|
|
|
|
await migrateToKeyedPersistence(api);
|
|
|
|
const order = writes.map((w) => w.key ?? '<legacy>');
|
|
// First write: intent stamp.
|
|
assert.equal(order[0], '__meta__');
|
|
assert.match(writes[0].data, /"migrated":0/);
|
|
// Last write: success stamp.
|
|
assert.equal(order[order.length - 1], '__meta__');
|
|
assert.match(writes[writes.length - 1].data, /"migrated":1/);
|
|
// Tombstone happens before the success stamp.
|
|
const tombstoneIdx = writes.findIndex((w) => w.key === undefined && w.data === '');
|
|
const successIdx = writes.findIndex(
|
|
(w, i) => w.key === '__meta__' && i > 0 && w.data.includes('"migrated":1'),
|
|
);
|
|
assert.ok(tombstoneIdx > 0, 'expected a tombstone write');
|
|
assert.ok(successIdx > tombstoneIdx, 'success stamp must come after tombstone');
|
|
});
|