super-productivity/packages/plugin-dev/document-mode
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
..
scripts feat(document-mode): add TipTap-based document-mode plugin 2026-05-22 17:33:22 +02:00
src feat(plugins): Stage A keyed persistence with LWW (#7749) (#7763) 2026-05-23 22:23:17 +02:00
.gitignore feat(document-mode): add TipTap-based document-mode plugin 2026-05-22 17:33:22 +02:00
package-lock.json feat(document-mode): add TipTap-based document-mode plugin 2026-05-22 17:33:22 +02:00
package.json feat(document-mode): add TipTap-based document-mode plugin 2026-05-22 17:33:22 +02:00
tsconfig.json feat(document-mode): add TipTap-based document-mode plugin 2026-05-22 17:33:22 +02:00