mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-25 08:53:50 +00:00
6 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6d81dde043
|
feat(plugins): adopt PERSISTED_DATA_CHANGED in document-mode (#7752) (#7812)
* feat(plugins): adopt PERSISTED_DATA_CHANGED in document-mode (#7752) Document-mode's iframe editor went stale when another device edited the same context's doc — the editor kept typing on in-memory `storedState` and the next throttled save merged-on-stale-base, partially clobbering the remote edit. Background's `enabledIds` set had the same gap for remote toggles. Adopts the host-side `PERSISTED_DATA_CHANGED` hook (shipped in #7805, made multi-handler-safe in #7811) in both surfaces: - `background.ts` — on fire: re-read `enabledIds`, diff, and call `showInWorkContext`/`closeWorkContextView` only when the active context's membership flipped. Idempotent on no-op re-fires; the hook also fires for `doc:` writes which background ignores via the set-unchanged short-circuit. - `ui/editor.ts` — on fire: load the current ctx's raw `doc:` bytes via the new `loadContextDoc` `{ raw, parsed }` shape, byte-compare to `lastSeenRemoteData`. Equal → noop (self-echo or another key's fire). Different → show a one-button "reload" banner with a distinct `doc-banner--remote-update` modifier class. Clicking Reload re-runs `setActiveContext` which already re-reads + setContent's. `isDocCorrupt === true` short-circuits to a direct reload (the corruption banner already promises saved data is untouched). The load-bearing piece is `lastSeenRemoteData` — captured as the raw `loadSyncedData()` string on both load (`setActiveContext`) and write (`flushSave` / `flushSaveSync`). The editor's own `JSON.stringify(getJSON())` is NOT byte-stable across the load path because `prepareStoredDoc` reshapes chip content against the local task cache, so comparing against editor output would mis-flag every load as remote. Raw-against-raw keeps the host's deterministic encoding the only thing that matters. Acceptance criteria from #7752: all met except the E2E append, which needs iframe-level state injection that the existing spec helpers don't cover; covered by manual verification + unit specs instead. What this doesn't fix: - No cross-context notification (editor catches up silently on switch). - Same-context concurrent edits still resolve whole-doc LWW. - No silent swap or selection preservation. - No "Keep mine" force-flush — dismiss-and-keep-typing already wins LWW. Tests: 84/84 plugin specs pass (+6 background hook membership branches, +1 corrupt-entry raw-bytes case, +1 saveContextDoc-returns-raw case). Plan v6 changelog documents the v5/v6 multi-review iterations and the cuts (pre-reload backup, pure-fn file, coalesce timer, "Keep mine"). * refactor(plugins): apply doc-mode review findings (#7752) Multi-review of the prior commit surfaced two real and one likely-real bugs in the reconciler and a few cleanup wins. Apply all that survived verification. W1 — wrap getActiveWorkContext in try/catch. The prior handler only caught loadEnabledCtxIds rejections; a getActiveWorkContext throw would escape via the void onPersistedDataChanged() registration as an unhandled rejection and leave enabledIds stale across every subsequent fire. W2 — snapshot enabledIds at entry. The prior handler read the closure variable across multiple awaits before assigning at the end; two interleaved fires could mis-compute wasActiveEnabled and drop a close/show call. Pass-by-parameter to reconcileEnabledIds eliminates the in-function race; concurrent fires now race only on the final caller assignment, which is at least eventual-consistent. W3 — extract reconcileEnabledIds to its own file and import the real function from the spec (no more local copy). reconcile-enabled.ts sidesteps the testability problem at its root: background.ts has top-level PluginAPI side-effects (registerWorkContextHeaderButton, registerHook) that crash in node, so the spec can't import from there. The new file holds only the pure reconciler + try/catch hardening. W4 — extract serializeContextDoc(doc) helper. flushSave and flushSaveSync now produce byte-identical output via the same function, so a future encoding change can't silently desync the sync path from the async one. W5 — fix the inline comment on showRemoteUpdateBanner re-entrancy to match what the code actually does (early-return because text is invariant, not "replace text in place" as the v6 plan implied). S1 — rename lastSeenRemoteData → lastSeenDocBytes. The variable also holds local-write bytes, not only "remote" ones; the new name follows the existing lastSeenTaskIds convention. S2 — drop the try/catch around loadSyncedData in onRemotePersistedDataChanged. The hook dispatcher already catches + logs handler rejections (PluginHooksService._invokeWithTimeout), and loadContextDoc elsewhere in this file follows the no-wrap convention. S3 — add primitive-JSON case to persistence.spec.ts. loadContextDoc's new {raw, parsed} shape silently forwards parsed=123/null/"hi" to callers; the editor's truthy guard is the safety net, but the persistence contract is now locked. S5 — explain why isDocCorrupt short-circuits to a direct reload (auto-recovery without user click; alternative would leave the user stuck on the corruption banner even when the remote already fixed the entry). Tests: 86/86 plugin specs pass (+7 reconciler error/membership cases, +1 persistence primitive-JSON). Bundle redeployed. * docs(plugins): update stale lastSeenRemoteData refs in JSDoc/comments Two comments referenced the pre-rename variable name. |
||
|
|
8f274582e2
|
feat(plugins): Stage A keyed persistence with LWW (#7749) (#7763)
* feat(plugins): keyed persistence API for per-context LWW (Stage A Phase 1+3) Add an optional `key` argument to `persistDataSynced` / `loadSyncedData`, composed at the bridge transport boundary into `pluginId:key` entity ids. Distinct keys now produce distinct ops that LWW-resolve per-entity, enabling document-mode-style plugins to avoid cross-context blob overwrites without changing existing keyless callers. Phase 3: `removePluginUserData(pluginId)` now sweeps the full prefix (legacy entry + every keyed entry), dispatching one delete per match with the rule-6 setTimeout(0) trailer so remote replicas don't keep keyed entries after uninstall. The reducer-only "smart prefix match" shortcut is wrong (one op for the prefix only, remote keyed entries leak) — see docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md Phase 3. Phase 4 (document-mode plugin-side migration of the legacy single-blob entry) is left as a separate follow-up so the host change can be reviewed in isolation. Issue #7749 * feat(document-mode): migrate to keyed persistence (Stage A Phase 4) Move from one synced blob under the bare plugin id to per-entity keyed entries: - meta — { enabledCtxIds: string[] }, owned by background.ts - doc:${ctxId} — one entry per context, owned by the editor iframe - __meta__ — migration stamp Each entry has its own LWW timestamp on the host, so a concurrent edit in project A on Device 1 and project B on Device 2 no longer whole-blob-collide. The migration runs idempotently from both background.ts and editor.ts (stamp-guarded), splits the legacy single blob into keyed entries, then tombstones the legacy entry with an empty payload — giving LWW a winning side against any offline device that still writes the old shape. flushSave / flushSaveSync no longer need to read+merge sibling state, since each context's entry stands alone. The future-version blob guard (isStorageUnreadable) is dropped — it referenced the wrapping blob's version, which no longer exists; per-doc corruption still falls back via isDocCorrupt. Issue #7749 * fix(plugins): tighten Stage A keyspace at the boundaries Multi-review surfaced three small gaps in the keyed-persistence rollout: - The synchronous composeId throw covers the bridge's iframe and direct-API entry points, but three in-process callers (plugin-config.service, plugin.service, plugin-config-dialog) bypass the bridge and route directly into the persistence service. A user-installed plugin with `id: "evil:plugin"` passed manifest validation and would have collided with the legitimate `evil` plugin's keyed namespace — `removePluginUserData('evil')` would have over-matched the sweep. Reject the colon at install time in `validatePluginManifest`; keep the bridge throw as defense-in-depth. - The new `key` arg at the bridge was typia-asserted on `data` but unchecked itself. A compromised iframe could pass a multi-megabyte string or a non-string value via postMessage. `data` is capped at 1 MB, but the entity id composed from `key` would be stored verbatim in NgRx state, IndexedDB, the op-log, and on the sync wire — bypassing the data cap. Add `assertPluginPersistenceKey` with a 256-char cap. - `_loadPersistedData` silently returned `null` when composeId threw, while `_persistDataSynced` rethrew. The asymmetry made a malformed pluginId look like "no data yet" on the load side, indistinguishable from a fresh install. Hoist composeId + key validation out of the load try/catch so it throws symmetrically. Issue #7749 * fix(plugins): lower per-write cap to 256 KB The pre-Stage-A 1 MB cap was sized for the old single-blob shape, where one entry held every context's data. With the keyed split, each entity gets its own write budget — 1 MB per write is wildly over-provisioned for the realistic upper bound of plugin payloads (heavy document-mode docs ~30–100 KB, configs and automations KB-scale). 256 KB keeps 2–5× headroom over realistic payloads while bounding the per-plugin storage growth more tightly. Document-mode's migration loop now skips oversized legacy docs instead of aborting the whole run: a user whose legacy blob holds one ~500 KB doc (legal under the old cap) keeps the other contexts migrated and the original bytes preserved in the legacy entry. The success stamp stays at migrated:0 in that case so a future build (or pruning of the doc) can complete the migration without data loss. Issue #7749 * test(plugins): e2e migration of legacy single-blob to keyed entries The migration logic in document-mode is unit-tested against a mock PluginAPI, which can't catch real-iframe quirks (postMessage handling of undefined second args, commit-chain timing under the host's per-entity rate limiter, hydration ordering against the op-log). Add two end-to-end scenarios: - Fresh install: enable the plugin, verify the __meta__ stamp lands at migrated:1 (the migration's final write — observing it implies every earlier step completed). - Legacy blob: seed a pre-Stage-A single-blob entry via the e2e helper store, enable the plugin, verify the legacy entry is tombstoned, meta carries the enabledCtxIds, and each doc landed under its own doc:${ctxId} key. Issue #7749 * chore(plugins): drop dead code and review-driven polish Four small follow-ups from the multi-review pass: - Don't log the plugin-supplied `key` value. Plugins may use user content (search queries, doc titles) as keys; the log history is exportable. Log `keyLen` instead, per CLAUDE.md rule 9. - Delete `detectStaleLegacyWrite` and its 3 specs. Exported and fully tested, but zero non-test callers — banner UI is forbidden by project convention for transient-only messaging. If the need resurfaces, the implementation is four lines. - Drop the `attemptedAt` field from `MigrationStamp`. It was written but never read; the success stamp is the only re-entry gate, and the resume path is just "re-run the loop" — re-writes are content- idempotent. Saves one rate-limited write per fresh migration. - Update `docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md` with an implementation-status table referencing the shipping commits, so future readers don't have to dig through git. Issue #7749 |
||
|
|
196e50b906
|
test: strengthen unit test assertions and revive disabled plugin specs (#7755)
* chore(plugins): re-bundle document-mode and document Stage A path Reverts the unbundling from |
||
|
|
0c2420eeb3 |
feat(plugin): improve sync data size for plugins
- docs(plugins): remove delta-sync plan; track Stage A as GH issue - fix(plugins): preserve teardown safety net + earlier base64 size gate - fix(plugins): address second-round multi-review findings - refactor(sync): drop unused isVirtualEntity host re-export - fix(plugins): address multi-review findings in plugin persistence - perf(plugins): delegate plugin-data codec to sync-core helpers, cap decompression - docs(sync): mark delta-sync plan implementation status - perf(plugins): gzip plugin user data at the persistence boundary - perf(document-mode): raise save throttle and add focus-loss flush triggers - fix(sync): resolve plugin-data LWW conflicts via array storagePattern |
||
|
|
69b1cb2877 |
perf(document-mode): strip redundant chip content from synced data
The document-mode plugin persisted each taskRef/subTaskRef chip with its task title as inline content plus an isDone attr. Both are re-derived from the host task cache on load (migrateStoredDoc backfills content, refreshChipContentFromCache overwrites both), so storing them only inflates the synced blob -- and the title is its byte-heavy part.
Add a pure stripChipContent() that collapses every chip to a bare identity atom { type, attrs: { taskId } }, and apply it in flushSave / flushSaveSync before serializing. The live editor document keeps its inline content, so title write-back is unaffected; only the persisted copy shrinks. A bare-atom chip loads through the existing unchanged pipeline, so no schema bump or migration is needed. Roughly halves the per-change sync payload.
|
||
|
|
1c10ff67dd |
feat(document-mode): add TipTap-based document-mode plugin
A document-mode plugin under packages/plugin-dev/document-mode/ that renders the active work context's tasks as an editable TipTap document. Per-context doc state is persisted as a last-writer-wins JSON blob via PluginAPI.persistDataSynced; task identity (title, done state, hierarchy) stays in NgRx and is reached through PluginAPI.updateTask and the ANY_TASK_UPDATE hook. It registers a work-context header button and embeds itself into the work-view embed slot added in the preceding commit. See docs/plans/2026-05-21-document-mode-tiptap-plugin.md for the full design. Squashed from the feat/doc-mode-v4 work; full per-step history is preserved in the archive/doc-mode-v4-full-history branch and the doc-mode-v4-pre-squash tag. |