super-productivity/docs
Johannes Millan 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
2026-05-23 22:23:17 +02:00
..
long-term-plans docs(sync): consolidate sync docs + enforce the contributor model 2026-05-15 16:51:50 +02:00
plans feat(plugins): Stage A keyed persistence with LWW (#7749) (#7763) 2026-05-23 22:23:17 +02:00
promotion build: better organize stuff 2025-06-27 09:03:16 +02:00
research docs(research): §18.7 — source-level mechanism for appendSwitch vs argv divergence 2026-04-21 15:03:42 +02:00
screens docs/wiki content v0.8 (#7068) 2026-04-01 12:40:28 +02:00
sync-and-op-log Improve on sync (#7736) 2026-05-22 17:49:25 +02:00
wiki feat(projects): polish archive flow per #7748 2026-05-23 16:36:04 +02:00
add-new-integration.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
build-and-publish-notes.md fix(ci): prevent duplicate unsigned exe files in GitHub Releases 2026-03-19 18:58:52 +01:00
documentation-guide.md Housekeeping for various docs, templates, and actions (#7451) 2026-05-02 13:58:25 +02:00
ENV_SETUP.md build: final approach 2025-07-14 20:52:51 +02:00
github-access-token-instructions.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
gitlab-access-token-instructions.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
how-to-rate.md feat(rate-dialog): action-aware prompt with feedback split 2026-05-04 17:27:11 +02:00
howto-refresh-snap-credentials.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
i18n-script-usage.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
legacy-webview-analysis.md 16.2.1 2025-11-01 13:22:58 +01:00
mac-app-store-code-signing-guide.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
performance-project-tag-report.md docs: add performance report 2025-11-13 18:23:50 +01:00
plugin-development.md fix(plugins): allow iframe-only plugin installs 2026-05-18 13:43:22 +02:00
styling-guide.md fix(theme): apply velvet sidenav blur on inner element, not host 2026-05-14 14:41:08 +02:00
theming-contract.md feat(theme): primitive token layer + theme contract validator 2026-05-08 22:31:00 +02:00
TRANSLATING.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
unused-translations-analysis.md chore(i18n): remove additional 86 orphan translation keys 2026-01-09 14:59:05 +01:00
update-android-app.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00
update-mac-certificates.md docs: clean up and organize project documentation 2026-03-10 10:22:56 +01:00