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
This commit is contained in:
Johannes Millan 2026-05-23 15:41:05 +02:00
parent eca2994313
commit 84625be849
9 changed files with 564 additions and 363 deletions

View file

@ -1,216 +0,0 @@
# Document Mode — delta sync architecture
**Status:** proposal
**Date:** 2026-05-22
**Branch:** `feat/how-fat-is-data-model-for-sync-for-new-fbd044`
**Supersedes:** the "Future work — per-context sync entities" section of
[`2026-05-22-document-mode-sync-data-model.md`](./2026-05-22-document-mode-sync-data-model.md)
(that doc's Phase 1 — bare-atom chips — remains the immediate, separately-tracked fix)
## Problem
The document-mode plugin (`packages/plugin-dev/document-mode/`) is a TipTap
editor that persists **all** its data as a single opaque JSON blob via
`PluginAPI.persistDataSynced(string)`. The blob is:
```jsonc
{ "version": 1,
"docs": { "<ctxId>": <full ProseMirror JSON>, ... }, // one doc per project/tag/TODAY
"enabledCtxIds": ["..."] }
```
Host-side this becomes **one** `PluginUserData` entry with `entityId = pluginId`.
Every save is one op (`upsertPluginUserData`, `entityType: PLUGIN_USER_DATA`,
`opType: Update`) whose payload embeds the **entire blob string**. So the data
sent per change is the whole multi-context corpus (~2648 KB encrypted for a
typical 5-context user, measured) — **regardless of how tiny the edit was**.
Saves are throttled ~1 op / 2 s while typing (`SAVE_THROTTLE_MS` in
`src/ui/editor.ts`; the host additionally coalesces via
`MIN_PLUGIN_PERSIST_INTERVAL_MS`).
This is **not a delta**. Typing one character re-transmits, re-encrypts, and
re-stores every project and tag document. Phase 1 of the sibling doc shrinks the
blob ~46% by stripping redundant chip content, but the unit transmitted is still
"the whole corpus". The deeper question — *can a change send only the change?*
is what this doc addresses.
## Goals
1. Lay out the design space for transmitting **less than the whole corpus** per
edit, with honest effort/payoff for each option.
2. Identify which options are compatible with the op-log's partial ordering and
conflict model (a hard constraint — see below).
3. Recommend a staged path: what to do now, mid-term, and long-term.
## Non-goals
- Re-deriving the Phase 1 (bare-atom chip) work — already planned and tracked in
the sibling doc; it is orthogonal and complementary to everything here.
- Committing to an implementation in this branch. This is an architecture
proposal for discussion.
- Removing the in-tree `src/app/features/document-mode/` feature.
## Why naive deltas do not work here
The op-log is **partially ordered and conflict-resolved**. Vector clocks reorder
ops; `SYNC_IMPORT` / `BACKUP_IMPORT` deliberately drop concurrent ops
(`CONCURRENT` / `LESS_THAN` by vector clock — by design, per the sync model).
A position-dependent patch — "insert these 3 chars at offset 4012" — is only
valid against the exact base state it was computed from. Once another op is
interleaved ahead of it, the offset is wrong and the patch corrupts the
document.
This is precisely why the op-log replays **semantic action payloads**, never
text diffs: an action like "set task title to X" is replayable against any
state. So a *valid* delta for this system must be **either**:
- a **semantic operation** that is replayable against any document state, **or**
- a **commutative CRDT update** that composes correctly under any order.
A line/character/JSON-position patch is neither. This rules out the "obvious"
delta — diff the old and new blob — outright.
## The granularity spectrum
| Granularity | Delta unit | ~Per change sent | Effort |
| --- | --- | --- | --- |
| Whole blob (today) | entire corpus | ~2648 KB | — |
| Per-context entity | one document | ~69 KB | moderate (host API change) |
| Per-block entity | one paragraph | ~hundreds of B | high (stable block ids) |
| ProseMirror steps / Yjs CRDT | the actual edit | tens of B | major (new sync channel) |
Each row below the first is a real option. They are not mutually exclusive —
per-context is a stepping stone, not a dead end.
### Option A — per-context sync entities (document-level "delta")
Give each `(plugin, context)` pair its own sync entity, so editing project X's
document transmits only project X's document, not the whole corpus.
**What it needs:**
1. **Keyed plugin-persistence API.** `persistDataSynced` is currently
single-arg (`persistDataSynced(dataStr: string)` in
`packages/plugin-api/src/types.ts`, line 555; `loadSyncedData()` line 557 —
verified). Add an optional `key`: `persistDataSynced(data, key?)` /
`loadSyncedData(key?)`, threaded through the whole chain — `plugin-api/types.ts`,
`plugin-bridge.service.ts`, the iframe wrapper (`plugin-api.ts`), and the
iframe postMessage util (`plugin-iframe.util.ts`, which currently drops a
second argument).
2. **Composite entity id.** `PluginUserData.id` becomes `pluginId:key`, so
concurrent edits to *different* contexts stop colliding on one entity.
3. **Virtual-entity LWW support — required, not optional.** `PLUGIN_USER_DATA`
is registered as a **`virtual`** entity in
`src/app/op-log/core/entity-registry.ts` (lines 318322, verified), and
`ConflictResolutionService.getCurrentEntityState`
(`src/app/op-log/sync/conflict-resolution.service.ts`, lines 805873) has
branches for adapter / singleton / map / array entities but **no `virtual`
branch** — it falls through and returns `undefined`. So the LWW local-win
path (`_createLocalWinUpdateOp`) cannot read the entity and produces no
replacement op. **LWW conflict resolution cannot resolve plugin-data
conflicts at all today.** Per-context entity ids alone do not fix this; a
same-context concurrent edit still mis-resolves. Conflict resolution must
learn to read a virtual entity from `selectPluginUserDataFeatureState`.
**Payoff:** ~5x smaller payload per typical edit (~2648 KB → ~69 KB). This is
a document-level "delta" — only the *edited document* is transmitted, never
sub-document. Concurrent edits to different contexts stop conflicting entirely.
**Limits:** a single document is still sent whole on every keystroke-batch.
Concurrent edits to the *same* document still resolve whole-doc (LWW, once the
virtual branch exists). This is the realistic, moderate-effort win.
### Option B — per-block sync entities
Make each top-level block (paragraph, heading, list) its own keyed entity, so a
one-paragraph edit transmits one paragraph.
**What it needs:** stable per-node ids — a TipTap unique-id extension that
assigns and preserves an id across edits. Split, merge, and reorder of blocks
each touch multiple entities (a paragraph split = one update + one create; a
merge = one update + one delete), and the ordering of blocks becomes its own
synced structure.
**Assessment:** this is **essentially reinventing a block-level CRDT** — stable
identity, structural ops, ordering — but without the convergence guarantees a
real CRDT gives for free. The split/merge/reorder bookkeeping is exactly the
hard part of CRDTs, done by hand. **Not recommended.** If sub-document
granularity is wanted, go straight to Option C, which solves identity and
ordering correctly and gives finer granularity for the same conceptual cost.
### Option C — Yjs CRDT (true edit-level deltas)
Integrate `y-prosemirror`. Yjs models the document as a CRDT and emits a small
**binary update** per edit. These updates are **commutative** — they compose
correctly under *any* order — which is exactly the property the op-log's partial
ordering requires and that text/JSON patches lack.
**How it maps onto the op-log:**
- Each Yjs update becomes an **append-only** op (`opType: Create`) — never an
Update-that-replaces. The op-log already handles append-only creates well.
- **Conflict resolution becomes a no-op** for these ops: a CRDT converges by
construction, so there is no conflict to resolve. The partial-order /
`SYNC_IMPORT`-drops-concurrent problem disappears — Yjs updates are designed
to be applied in any order, including after gaps.
- **Op-log compaction** snapshots the document via `Y.encodeStateAsUpdate(doc)`
— a single binary blob that supersedes all prior update ops, the CRDT
equivalent of a state snapshot.
**Cost:**
- A new dependency (`yjs` + `y-prosemirror`).
- `persistDataSynced` is **replace-only**; Yjs needs an **append** primitive —
a new plugin API such as `appendSyncedDelta(bytes)`. This is a genuinely new
persistence channel, not a parameter addition.
- An op-log path that treats these ops as commutative create-only ops exempt
from conflict detection.
**Bonus:** Yjs also enables **real-time concurrent editing of the same
document** — currently an explicit Non-goal of the sibling doc. It is the *only*
option here that does.
**Assessment:** the correct long-term answer and the only path to true
edit-level deltas. But a CRDT sync channel is a significant architecture
initiative — disproportionate for an opt-in POC plugin *today*. It becomes the
right investment if/when real-time co-editing becomes a product goal, or the
plugin ships bundled-by-default and document sync volume matters at scale.
## Recommendation
A staged path, each stage independently shippable:
1. **Now — Phase 1 (bare-atom chips).** Already planned and being implemented
separately (sibling doc). ~46% smaller blob, no schema break, no host change.
2. **Mid-term — Option A (per-context entities).** The realistic document-level
"delta": moderate effort, ~5x payload reduction, and it *also* fixes the
currently-broken plugin-data conflict resolution (virtual-entity LWW). Pick
this up when document mode ships more widely or cross-context conflicts are
observed.
3. **Long-term / conditional — Option C (Yjs).** The only path to true
edit-level deltas; subsumes the conflict problem entirely and unlocks
real-time co-editing. Justified once co-editing is a goal or sync volume at
scale demands it. **Skip Option B** — it is Option C's hard parts without its
guarantees.
## Risks
| Risk | Mitigation |
| --- | --- |
| Option A's per-key rate-limit / size-cap weakens the per-plugin flood guard (`MIN_PLUGIN_PERSIST_INTERVAL_MS`, `MAX_PLUGIN_DATA_SIZE` become per-key) | Keep an additional per-*plugin* aggregate cap; see sibling doc's Future-work item 4. |
| Option A uninstall cleanup — `removePluginUserData(pluginId)` deletes only the exact id; `pluginId:*` entries leak | Make deletion prefix-aware (sibling doc Future-work item 5). |
| Option A migration — splitting the legacy single blob into per-key entities | One-time, idempotent, guarded on a meta key's existence (sibling doc Future-work item 7). |
| Option C dependency size / bundle weight | `yjs` + `y-prosemirror` are compact and tree-shakeable; the plugin is opt-in so it does not affect the core bundle for non-users. |
| Option C — exempting CRDT ops from conflict detection could mask a real bug if the wrong ops are routed there | Gate strictly on `entityType` + a dedicated `opType`/actionType; never a general "skip conflict detection" flag. |
| Option C op-log growth before compaction (one op per edit) | Compaction snapshots `Y.encodeStateAsUpdate`; tune `COMPACTION_THRESHOLD` for the higher op rate. |
## Open questions
1. Does Option A need to land before Option C, or can Option C replace the
blob entirely in one step? (Likely A first — it is lower-risk and the keyed
API it adds is reusable; but C does not strictly depend on A.)
2. Should the keyed plugin-persistence API (Option A item 1) be designed up
front to also accommodate Option C's `appendSyncedDelta`, so plugins get one
coherent persistence surface rather than two bolt-ons?
3. Is real-time co-editing a desired product direction at all? The answer
decides whether Option C is "long-term" or "never".

View file

@ -18,7 +18,7 @@ stored host-side as one `PluginUserData` entry keyed by **plugin id**:
```
Each save → `upsertPluginUserData`**one op** (`entityType: PLUGIN_USER_DATA`,
`entityId: pluginId`, `opType: Update`) whose payload embeds the *entire* `data`
`entityId: pluginId`, `opType: Update`) whose payload embeds the _entire_ `data`
string. Three problems compound:
1. **Every op carries every context.** Typing one character in TODAY's doc
@ -42,10 +42,10 @@ string. Three problems compound:
TODAY/TAG). So chips are reconstructable; only the **prose between them** is
plugin-owned.
3. **Concurrent edits do not resolve cleanly.** `entityId` is the *plugin id*,
3. **Concurrent edits do not resolve cleanly.** `entityId` is the _plugin id_,
so all N documents collapse into one sync entity: device A editing project X
and device B editing project Y produce `CONCURRENT` vector clocks on the
*same entity* → a conflict, even though they touched different documents.
_same entity_ → a conflict, even though they touched different documents.
Worse, `PLUGIN_USER_DATA` is registered as a **`virtual`** entity
(`entity-registry.ts`), and `ConflictResolutionService.getCurrentEntityState`
has **no `virtual` branch** — it returns `undefined`. So the LWW local-win
@ -53,7 +53,7 @@ string. Three problems compound:
replacement op. LWW does not function correctly for `PLUGIN_USER_DATA`;
concurrent edits lose data, and not by a predictable last-writer-wins rule.
*Note:* even today a conflict that drops the blob only loses **prose** — on
_Note:_ even today a conflict that drops the blob only loses **prose** — on
reload chips are rebuilt from the host regardless. Problem 3 is therefore a
correctness gap, separate from problems 12 (size).
@ -66,10 +66,10 @@ string. Three problems compound:
## Non-goals
- **Fixing problem 3 now.** It needs host-side work (per-context entities *and*
- **Fixing problem 3 now.** It needs host-side work (per-context entities _and_
virtual-entity LWW support) and is deferred — see Future work.
- **Fine-grained concurrent editing of the same doc.** Two devices editing the
*same* doc's prose will always resolve whole-doc; character-level merge needs
_same_ doc's prose will always resolve whole-doc; character-level merge needs
a CRDT (Yjs) and is out of scope.
- Removing the in-tree `src/app/features/document-mode/` feature.
@ -79,7 +79,7 @@ The smallest change that fixes problems 1 & 2: stop persisting the title text
and `isDone` attr on chips. Store each chip as a **bare identity atom**:
```jsonc
{ "type": "taskRef", "attrs": { "taskId": "<id>" } } // no content, no isDone
{ "type": "taskRef", "attrs": { "taskId": "<id>" } } // no content, no isDone
```
The persisted doc stays an ordinary ProseMirror doc (`type: "doc"`, chips +
@ -87,7 +87,7 @@ prose interleaved) — only the chip nodes get lighter.
### Why this needs no schema bump and no migration
`migrateStoredDoc` was *built* to load atom-shaped chips ("Older docs stored
`migrateStoredDoc` was _built_ to load atom-shaped chips ("Older docs stored
taskRef as an atom node (no `content` array)") — it backfills `content` from the
task cache and defaults `isDone`. `refreshChipContentFromCache` then overwrites
both unconditionally. So a bare-atom chip flows through the **existing,
@ -106,17 +106,17 @@ no `background.ts` change.
`stripChipContent` operates on a **copy**`editor.getJSON()` returns a fresh
object each call. The live `editor` document keeps its inline chip content, so
the title-editing path (`reconcileTitlesFromDoc`, which reads the *live* doc) is
the title-editing path (`reconcileTitlesFromDoc`, which reads the _live_ doc) is
untouched. Only the bytes written to storage shrink.
### Files
| File | Change |
| --- | --- |
| `src/doc-transform.ts` | Add pure `stripChipContent(doc): unknown` — walk content, replace each `taskRef`/`subTaskRef` node with `{ type, attrs: { taskId } }`, leave every other node (paragraphs, headings, lists — their text!) untouched. |
| `src/ui/editor.ts` | `flushSave` **and** `flushSaveSync` persist `stripChipContent(editor.getJSON())` instead of `editor.getJSON()`. No other change. |
| `src/doc-transform.spec.ts` | Test `stripChipContent` (chips emptied, prose intact); round-trip test (`stripChipContent``prepareStoredDoc` rebuilds full chips with titles); legacy content-bearing doc still loads. |
| `src/background.ts` | No change — it treats `docs` as opaque and only writes `enabledCtxIds`. |
| File | Change |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/doc-transform.ts` | Add pure `stripChipContent(doc): unknown` — walk content, replace each `taskRef`/`subTaskRef` node with `{ type, attrs: { taskId } }`, leave every other node (paragraphs, headings, lists — their text!) untouched. |
| `src/ui/editor.ts` | `flushSave` **and** `flushSaveSync` persist `stripChipContent(editor.getJSON())` instead of `editor.getJSON()`. No other change. |
| `src/doc-transform.spec.ts` | Test `stripChipContent` (chips emptied, prose intact); round-trip test (`stripChipContent``prepareStoredDoc` rebuilds full chips with titles); legacy content-bearing doc still loads. |
| `src/background.ts` | No change — it treats `docs` as opaque and only writes `enabledCtxIds`. |
### Expected reduction
@ -125,8 +125,8 @@ bare-atom chip is ~6065 bytes (`taskId` is a 21-char nanoid). For a task-heav
context (~60 chips) that is ~5 KB saved per doc; for a typical 5-context user
the per-op blob drops roughly 20 KB → ~12 KB. Multiplied across the op-log
retention window (up to 500 ops), that is a meaningful cut to IndexedDB volume
and sync transfer. Op *count* is unchanged (the throttles are untouched) — this
is purely a per-op *size* reduction.
and sync transfer. Op _count_ is unchanged (the throttles are untouched) — this
is purely a per-op _size_ reduction.
### Alternative considered — prose-only storage (rejected for now)
@ -143,10 +143,10 @@ if telemetry shows the blob is still too fat.
## Future work — per-context sync entities (host change, deferred)
> Expanded into a dedicated architecture proposal:
> [`2026-05-22-document-mode-delta-sync.md`](./2026-05-22-document-mode-delta-sync.md)
> — covers the full delta-sync granularity spectrum (per-context, per-block,
> Yjs CRDT). The summary below remains accurate for problem 3's host-side scope.
> Tracked as
> [super-productivity/super-productivity#7749](https://github.com/super-productivity/super-productivity/issues/7749) —
> Stage A (keyed plugin-persistence API for per-context sync entities).
> The summary below remains accurate for problem 3's host-side scope.
This is the fix for **problem 3**. Deferred, not scheduled: document mode is an
opt-in POC plugin, not bundled by default, so cross-context concurrent edits are
@ -156,14 +156,14 @@ It is **larger than "add a `key` parameter"** — the multi-review surfaced the
full scope:
1. **Plugin API** — add an optional `key` to `persistDataSynced` /
`loadSyncedData`, threaded through the *entire* chain: `plugin-api/types.ts`,
`loadSyncedData`, threaded through the _entire_ chain: `plugin-api/types.ts`,
`plugin-bridge.service.ts`, the iframe wrapper (`plugin-api.ts`), and the
iframe postMessage util (`plugin-iframe.util.ts`) — which currently drops a
second argument.
2. **Composite entity id**`PluginUserData.id` becomes `pluginId:key` so each
`(plugin, context)` is its own sync entity; concurrent edits to different
contexts stop conflicting.
3. **Virtual-entity LWW***required, not optional.* Per-context entity ids do
3. **Virtual-entity LWW**_required, not optional._ Per-context entity ids do
**not** by themselves fix problem 3: `getCurrentEntityState` still has no
`virtual` branch, so same-context conflicts still mis-resolve. Conflict
resolution must learn to read a virtual entity (`PLUGIN_USER_DATA`) from
@ -171,7 +171,7 @@ full scope:
4. **Rate-limit & size-cap semantics**`PluginUserPersistenceService` keys its
coalesce/throttle Maps by id; per-key keys weaken the per-plugin flood guard
(`MIN_PLUGIN_PERSIST_INTERVAL_MS`) and make `MAX_PLUGIN_DATA_SIZE` per-key.
Keep an additional per-*plugin* aggregate cap so a many-keyed plugin cannot
Keep an additional per-_plugin_ aggregate cap so a many-keyed plugin cannot
bypass the limits.
5. **Uninstall cleanup**`removePluginUserData(pluginId)` deletes only the
exact id; keyed entries `pluginId:*` would leak. Make deletion prefix-aware.
@ -187,11 +187,11 @@ whole-doc — acceptable per Non-goals.
## Risks
| Risk | Mitigation |
| --- | --- |
| `migrateStoredDoc` / `refreshChipContentFromCache` stop backfilling → stripped chips render empty | Both are existing load-pipeline invariants with spec coverage; add a round-trip test that a stripped doc rebuilds full chips. |
| Strip accidentally mutates the live editor doc or non-chip text | `stripChipContent` is pure and runs on the `getJSON()` copy; only `taskRef`/`subTaskRef` nodes are altered. Unit-test both. |
| A chip stripped to empty content is written back to the host as a title erasure | Cannot happen — write-back (`reconcileTitlesFromDoc`) reads the *live* editor doc, which always has refreshed content; only the storage copy is stripped. |
| Risk | Mitigation |
| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `migrateStoredDoc` / `refreshChipContentFromCache` stop backfilling → stripped chips render empty | Both are existing load-pipeline invariants with spec coverage; add a round-trip test that a stripped doc rebuilds full chips. |
| Strip accidentally mutates the live editor doc or non-chip text | `stripChipContent` is pure and runs on the `getJSON()` copy; only `taskRef`/`subTaskRef` nodes are altered. Unit-test both. |
| A chip stripped to empty content is written back to the host as a title erasure | Cannot happen — write-back (`reconcileTitlesFromDoc`) reads the _live_ editor doc, which always has refreshed content; only the storage copy is stripped. |
## Testing

View file

@ -34,14 +34,17 @@ import { createTaskRefNode, type TaskRefNodeDeps } from './task-ref-node';
declare const PluginAPI: PluginAPI;
// Save cadence. This is a *throttle*, not a debounce: the host tears the
// embed iframe down on every work-context switch, so a save deferred until
// the user goes idle (or until teardown) routinely never runs. Committing
// every SAVE_THROTTLE_MS while editing keeps the doc blob fresh in host
// storage while the iframe is still alive. Kept above the host's 1 s
// persist rate limit (MIN_PLUGIN_PERSIST_INTERVAL_MS) so saves aren't
// rejected.
const SAVE_THROTTLE_MS = 2_000;
// Save cadence. This is a *throttle*, not a debounce: a debounce would never
// fire for a continuous typist, and the host tears the embed iframe down on
// every work-context switch. The throttle bounds op-rate during typing; the
// real safety net against data loss is the set of flush triggers wired up in
// mount(): `visibilitychange`, `blur`, `pagehide`, `unload`, plus the
// explicit `flushSaveSync` on work-context change. Together those cover
// every non-crash "user is done editing for now" moment, so the throttle
// ceiling is only paid on actual process termination without any of those
// firing. Kept above the host's 1 s persist rate limit
// (MIN_PLUGIN_PERSIST_INTERVAL_MS) so saves aren't rejected.
const SAVE_THROTTLE_MS = 30_000;
const STORAGE_VERSION = 1;
// Action type the host emits for an in-place single-task update (NgRx
@ -67,6 +70,12 @@ let taskCache = new Map<string, Task>();
*/
const lookupTask: TaskLookup = (id) => taskCache.get(id);
let saveTimer: ReturnType<typeof setTimeout> | null = null;
// True from the moment the throttled `flushSave` setTimeout fires until its
// async readBlob+persist round-trip completes. The dirty signal moves from
// `saveTimer` to this flag for the duration, so a teardown that arrives
// mid-flight (pagehide/unload while `await readBlob()` is suspended) still
// triggers the sync safety-net write in `flushSaveSync`.
let saveInFlight = false;
let editor: Editor | null = null;
let isLoadingDoc = false;
// Set when the stored doc for the current ctx failed to parse and we fell
@ -272,6 +281,7 @@ const flushSave = async (): Promise<void> => {
saveTimer = null;
}
if (!currentCtx || !editor) return;
saveInFlight = true;
try {
const latest = await readBlob();
const merged: StoredState = {
@ -282,6 +292,8 @@ const flushSave = async (): Promise<void> => {
await PluginAPI.persistDataSynced(JSON.stringify(merged));
} catch (err) {
logErr('persistDataSynced failed', err);
} finally {
saveInFlight = false;
}
};
@ -297,7 +309,9 @@ const flushSave = async (): Promise<void> => {
*
* This variant skips the round-trip: it builds the blob from the in-memory
* `storedState` and dispatches `persistDataSynced` synchronously, so the
* postMessage leaves the iframe before it dies.
* postMessage leaves the iframe before it dies. The host transparently
* compresses the payload on its end (see `plugin-data-codec.ts`), so this
* path still benefits from the size win.
*
* Trade-off: an `enabledCtxIds` change made by background.ts since our last
* `readBlob` would be written back stale. That field only changes on an
@ -305,6 +319,12 @@ const flushSave = async (): Promise<void> => {
* window is effectively nil, and losing the whole doc is the worse outcome.
*/
const flushSaveSync = (): void => {
// Dirty signal: a timer is pending (edits queued for the throttle window)
// OR an async flushSave is mid-flight (its readBlob round-trip is awaiting
// a response that may never arrive if teardown is now). Skipping when
// neither is true avoids a full stringify + gzip on every blur (which
// fires on any focus shift inside the page).
if (saveTimer === null && !saveInFlight) return;
if (saveTimer !== null) {
clearTimeout(saveTimer);
saveTimer = null;
@ -2004,13 +2024,20 @@ const mount = async (): Promise<void> => {
onAnyTaskUpdate(payload as AnyTaskUpdatePayload);
});
// Best-effort teardown flush. The throttled save above is the real
// safety net (it commits while the iframe is unquestionably alive); this
// only catches edits made in the last < SAVE_THROTTLE_MS before the
// iframe is discarded. `pagehide` and `unload` are both wired up because
// browsers are inconsistent about which fires when an iframe element is
// removed from the DOM — flushSaveSync is idempotent, so double-firing
// is harmless.
// Flush triggers. `flushSaveSync` is idempotent, so overlap is harmless.
//
// - `visibilitychange` (on 'hidden') covers tab-switch, window-minimize,
// mobile-background, and screen-lock. The iframe's visibilityState
// mirrors the top-level document.
// - `blur` covers focus moving between iframes within the same page —
// which `visibilitychange` does not catch.
// - `pagehide` / `unload` cover iframe teardown; browsers are inconsistent
// about which fires when an iframe element is removed from the DOM, so
// both are wired.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushSaveSync();
});
window.addEventListener('blur', () => flushSaveSync());
window.addEventListener('pagehide', () => flushSaveSync());
window.addEventListener('unload', () => flushSaveSync());
};

View file

@ -8,7 +8,6 @@ import {
isSingletonEntity,
isMapEntity,
isArrayEntity,
isVirtualEntity,
EntityConfig,
} from './entity-registry';
@ -60,9 +59,12 @@ describe('entity-registry', () => {
const MAP_ENTITIES: EntityType[] = ['PLANNER'];
const ARRAY_ENTITIES: EntityType[] = ['BOARD', 'REMINDER'];
const VIRTUAL_ENTITIES: EntityType[] = ['PLUGIN_USER_DATA', 'PLUGIN_METADATA'];
const ARRAY_ENTITIES: EntityType[] = [
'BOARD',
'REMINDER',
'PLUGIN_USER_DATA',
'PLUGIN_METADATA',
];
describe('ENTITY_CONFIGS completeness', () => {
it('should have config for all regular entity types', () => {
@ -267,33 +269,10 @@ describe('entity-registry', () => {
});
});
describe('virtual entities', () => {
it('should have correct storagePattern', () => {
for (const entityType of VIRTUAL_ENTITIES) {
const config = ENTITY_CONFIGS[entityType];
expect(config?.storagePattern).toBe(
'virtual',
`${entityType} should have storagePattern 'virtual'`,
);
}
});
it('should have payloadKey', () => {
for (const entityType of VIRTUAL_ENTITIES) {
const config = ENTITY_CONFIGS[entityType];
expect(config?.payloadKey).toBeDefined(`${entityType} missing payloadKey`);
}
});
it('should NOT have featureName (not stored in NgRx)', () => {
for (const entityType of VIRTUAL_ENTITIES) {
const config = ENTITY_CONFIGS[entityType];
expect(config?.featureName).toBeUndefined(
`${entityType} should NOT have featureName (virtual)`,
);
}
});
});
// (No 'virtual entities' describe block — no entity in this registry uses
// the 'virtual' storagePattern. PLUGIN_USER_DATA / PLUGIN_METADATA
// migrated to 'array'. The pattern remains in @sp/sync-core for external
// consumers.)
describe('getEntityConfig', () => {
it('should return config for known entity types', () => {
@ -388,12 +367,14 @@ describe('entity-registry', () => {
expect(isArrayEntity(taskConfig)).toBe(false);
});
it('isVirtualEntity should correctly identify virtual entities', () => {
const pluginConfig = getEntityConfig('PLUGIN_USER_DATA') as EntityConfig;
const taskConfig = getEntityConfig('TASK') as EntityConfig;
expect(isVirtualEntity(pluginConfig)).toBe(true);
expect(isVirtualEntity(taskConfig)).toBe(false);
it('no currently-configured entity uses the virtual storagePattern', () => {
// The 'virtual' pattern remains in @sp/sync-core for external
// consumers, but no entity in this registry uses it after
// PLUGIN_USER_DATA / PLUGIN_METADATA migrated to 'array'.
for (const entityType of REGULAR_ENTITY_TYPES) {
const config = getEntityConfig(entityType) as EntityConfig;
expect(config.storagePattern).not.toBe('virtual');
}
});
});
@ -403,8 +384,7 @@ describe('entity-registry', () => {
ADAPTER_ENTITIES.length +
SINGLETON_ENTITIES.length +
MAP_ENTITIES.length +
ARRAY_ENTITIES.length +
VIRTUAL_ENTITIES.length;
ARRAY_ENTITIES.length;
expect(totalConfigured).toBe(REGULAR_ENTITY_TYPES.length);
});

View file

@ -19,7 +19,6 @@ import {
isArrayEntity as isArrayEntityFromCore,
isMapEntity as isMapEntityFromCore,
isSingletonEntity as isSingletonEntityFromCore,
isVirtualEntity as isVirtualEntityFromCore,
} from '@sp/sync-core';
import type { EntityRegistry as CoreEntityRegistry } from '@sp/sync-core';
import type {
@ -81,6 +80,14 @@ import { plannerFeatureKey } from '../../features/planner/store/planner.reducer'
import { BOARDS_FEATURE_NAME } from '../../features/boards/store/boards.reducer';
import { menuTreeFeatureKey } from '../../features/menu-tree/store/menu-tree.reducer';
import { REMINDER_FEATURE_NAME } from '../../features/reminder/store/reminder.reducer';
import {
PLUGIN_USER_DATA_FEATURE_NAME,
selectPluginUserDataFeatureState,
} from '../../plugins/store/plugin-user-data.reducer';
import {
PLUGIN_METADATA_FEATURE_NAME,
selectPluginMetadataFeatureState,
} from '../../plugins/store/plugin-metadata.reducer';
import {
SECTION_FEATURE_NAME,
adapter as sectionAdapter,
@ -315,15 +322,26 @@ export const buildEntityRegistry = (): EntityRegistry<EntityType> =>
arrayKey: null, // State IS the array
},
// ── VIRTUAL ENTITIES ───────────────────────────────────────────────────────
// PLUGIN_USER_DATA / PLUGIN_METADATA were registered as `'virtual'` before
// ConflictResolutionService grew a virtual branch. Their state shape is
// already Array<{ id, ... }> — identical to REMINDER's arrayKey:null —
// so the existing array branch in getCurrentEntityState (and elsewhere)
// resolves them correctly. Keeping them virtual silently disabled LWW
// conflict resolution for all plugin data.
PLUGIN_USER_DATA: {
storagePattern: 'virtual',
storagePattern: 'array',
featureName: PLUGIN_USER_DATA_FEATURE_NAME,
payloadKey: 'pluginUserData',
selectState: selectPluginUserDataFeatureState,
arrayKey: null,
},
PLUGIN_METADATA: {
storagePattern: 'virtual',
storagePattern: 'array',
featureName: PLUGIN_METADATA_FEATURE_NAME,
payloadKey: 'pluginMetadata',
selectState: selectPluginMetadataFeatureState,
arrayKey: null,
},
// Note: ALL, RECOVERY, MIGRATION are not configured - they're special operation types
@ -370,7 +388,10 @@ export const isMapEntity = isMapEntityFromCore;
export const isArrayEntity = isArrayEntityFromCore;
export const isVirtualEntity = isVirtualEntityFromCore;
// `isVirtualEntity` removed: no entity in this registry uses the `'virtual'`
// storagePattern after PLUGIN_USER_DATA / PLUGIN_METADATA migrated to
// `'array'`. The pattern still exists in `@sp/sync-core` for external
// consumers; re-export it here once we have a real user again.
/**
* Returns all payload keys from configured entities.

View file

@ -7,6 +7,17 @@ import {
} from './plugin-persistence.model';
import { upsertPluginUserData, deletePluginUserData } from './store/plugin.actions';
import { selectPluginUserDataFeatureState } from './store/plugin-user-data.reducer';
import { COMPRESS_THRESHOLD, SENTINEL, encodeForPersist } from './util/plugin-data-codec';
/**
* Drain enough microtasks for the per-plugin commit chain to settle. The
* chain is `Promise.resolve().catch().then(() => _encodeAndDispatch())` plus
* one `await` inside _encodeAndDispatch three turns max but a few extra
* yields are free and survive any future chain tweak.
*/
const drainAsync = async (): Promise<void> => {
for (let i = 0; i < 8; i++) await Promise.resolve();
};
describe('PluginUserPersistenceService', () => {
let service: PluginUserPersistenceService;
@ -33,37 +44,57 @@ describe('PluginUserPersistenceService', () => {
});
describe('persistPluginUserData', () => {
it('should dispatch upsertPluginUserData action with valid data', () => {
it('should dispatch upsertPluginUserData with the raw input (below codec threshold)', async () => {
const pluginId = 'test-plugin';
const data = 'test data';
service.persistPluginUserData(pluginId, data);
await service.persistPluginUserData(pluginId, data);
expect(dispatchSpy).toHaveBeenCalledWith(
upsertPluginUserData({ pluginUserData: { id: pluginId, data } }),
);
});
it('should dispatch compressed data when the payload is above the codec threshold', async () => {
const pluginId = 'test-plugin';
// ~2 KB of repetitive JSON — well above the 1 KB threshold, gzips small.
const data = JSON.stringify({
items: Array.from({ length: 40 }, (_, i) => ({
id: i,
name: `repeating-value-${i % 3}`,
})),
});
expect(data.length).toBeGreaterThan(COMPRESS_THRESHOLD);
await service.persistPluginUserData(pluginId, data);
const dispatched = dispatchSpy.calls.mostRecent().args[0] as ReturnType<
typeof upsertPluginUserData
>;
expect(dispatched.type).toBe(upsertPluginUserData.type);
const stored = dispatched.pluginUserData.data;
expect(stored.startsWith(SENTINEL)).toBe(true);
expect(stored.length).toBeLessThan(data.length);
});
it('should throw error when data exceeds MAX_PLUGIN_DATA_SIZE', () => {
const pluginId = 'test-plugin';
// Create a string larger than MAX_PLUGIN_DATA_SIZE (1MB)
const largeData = 'x'.repeat(MAX_PLUGIN_DATA_SIZE + 1000);
// Size check is synchronous: throws before any async work begins.
expect(() => service.persistPluginUserData(pluginId, largeData)).toThrowError(
/Plugin data exceeds maximum size/,
);
expect(dispatchSpy).not.toHaveBeenCalled();
});
it('should coalesce a rapid second call instead of dropping it', () => {
it('should coalesce a rapid second call instead of dropping it', async () => {
const baseTime = Date.now();
jasmine.clock().install();
jasmine.clock().mockDate(new Date(baseTime));
try {
const pluginId = 'test-plugin';
// First call commits immediately.
service.persistPluginUserData(pluginId, 'first');
await service.persistPluginUserData(pluginId, 'first');
expect(dispatchSpy).toHaveBeenCalledTimes(1);
// A call inside the rate-limit window must not throw and must not be
@ -71,8 +102,10 @@ describe('PluginUserPersistenceService', () => {
expect(() => service.persistPluginUserData(pluginId, 'second')).not.toThrow();
expect(dispatchSpy).toHaveBeenCalledTimes(1);
// Once the interval elapses the held write is committed.
jasmine.clock().tick(MIN_PLUGIN_PERSIST_INTERVAL_MS);
// setTimeout fires → _flushPendingData → _commit kicks off async
// compress+dispatch. Drain microtasks to let it settle.
await drainAsync();
expect(dispatchSpy).toHaveBeenCalledTimes(2);
expect(dispatchSpy).toHaveBeenCalledWith(
upsertPluginUserData({
@ -84,19 +117,20 @@ describe('PluginUserPersistenceService', () => {
}
});
it('should keep only the most recent of several coalesced calls', () => {
it('should keep only the most recent of several coalesced calls', async () => {
const baseTime = Date.now();
jasmine.clock().install();
jasmine.clock().mockDate(new Date(baseTime));
try {
const pluginId = 'test-plugin';
service.persistPluginUserData(pluginId, 'v1'); // committed
await service.persistPluginUserData(pluginId, 'v1');
service.persistPluginUserData(pluginId, 'v2'); // coalesced
service.persistPluginUserData(pluginId, 'v3'); // coalesced, replaces v2
expect(dispatchSpy).toHaveBeenCalledTimes(1);
jasmine.clock().tick(MIN_PLUGIN_PERSIST_INTERVAL_MS);
await drainAsync();
expect(dispatchSpy).toHaveBeenCalledTimes(2);
expect(dispatchSpy).toHaveBeenCalledWith(
upsertPluginUserData({ pluginUserData: { id: pluginId, data: 'v3' } }),
@ -106,40 +140,59 @@ describe('PluginUserPersistenceService', () => {
}
});
it('should allow different plugins to persist data without rate limiting each other', () => {
it('should allow different plugins to persist data without rate limiting each other', async () => {
const plugin1 = 'plugin-1';
const plugin2 = 'plugin-2';
const data = 'test data';
// Both plugins should be able to persist immediately
service.persistPluginUserData(plugin1, data);
service.persistPluginUserData(plugin2, data);
await Promise.all([
service.persistPluginUserData(plugin1, data),
service.persistPluginUserData(plugin2, data),
]);
expect(dispatchSpy).toHaveBeenCalledTimes(2);
});
it('should accept data at exactly MAX_PLUGIN_DATA_SIZE', () => {
const pluginId = 'test-plugin';
// Create a string exactly at the limit
const exactLimitData = 'x'.repeat(MAX_PLUGIN_DATA_SIZE - 10); // Slightly under to account for Blob overhead
const exactLimitData = 'x'.repeat(MAX_PLUGIN_DATA_SIZE - 10);
// This should not throw
// The sync size check is what we care about here; the async commit
// races with test teardown but is harmless.
expect(() => service.persistPluginUserData(pluginId, exactLimitData)).not.toThrow();
});
});
describe('loadPluginUserData', () => {
it('should return data for existing plugin', async () => {
it('should decompress and return data for an existing plugin', async () => {
const pluginId = 'test-plugin';
const testData = 'stored data';
const original = JSON.stringify({
items: Array.from({ length: 40 }, (_, i) => ({
id: i,
name: `repeating-value-${i % 3}`,
})),
});
const stored = await encodeForPersist(original);
expect(stored.startsWith(SENTINEL)).toBe(true);
store.overrideSelector(selectPluginUserDataFeatureState, [
{ id: pluginId, data: testData },
{ id: pluginId, data: stored },
]);
const result = await service.loadPluginUserData(pluginId);
expect(result).toBe(original);
});
expect(result).toBe(testData);
it('should pass through legacy uncompressed data', async () => {
const pluginId = 'test-plugin';
const legacy = 'stored data';
store.overrideSelector(selectPluginUserDataFeatureState, [
{ id: pluginId, data: legacy },
]);
const result = await service.loadPluginUserData(pluginId);
expect(result).toBe(legacy);
});
it('should return null for non-existent plugin', async () => {
@ -160,10 +213,9 @@ describe('PluginUserPersistenceService', () => {
{ id: pluginId, data: 'committed' },
]);
service.persistPluginUserData(pluginId, 'committed'); // commits
await service.persistPluginUserData(pluginId, 'committed');
service.persistPluginUserData(pluginId, 'pending'); // coalesced
// A read-modify-write cycle must see its own not-yet-committed write.
const result = await service.loadPluginUserData(pluginId);
expect(result).toBe('pending');
} finally {
@ -181,39 +233,49 @@ describe('PluginUserPersistenceService', () => {
expect(dispatchSpy).toHaveBeenCalledWith(deletePluginUserData({ pluginId }));
});
it('should cancel a pending coalesced write', () => {
it('should cancel a pending coalesced write', async () => {
const baseTime = Date.now();
jasmine.clock().install();
jasmine.clock().mockDate(new Date(baseTime));
try {
const pluginId = 'test-plugin';
service.persistPluginUserData(pluginId, 'first'); // commits
await service.persistPluginUserData(pluginId, 'first');
service.persistPluginUserData(pluginId, 'pending'); // coalesced
service.removePluginUserData(pluginId);
dispatchSpy.calls.reset();
// The cancelled flush must not resurrect the removed data.
jasmine.clock().tick(MIN_PLUGIN_PERSIST_INTERVAL_MS * 2);
await drainAsync();
expect(dispatchSpy).not.toHaveBeenCalled();
} finally {
jasmine.clock().uninstall();
}
});
});
describe('getAllPluginUserData', () => {
it('should return all plugin user data', async () => {
const testData = [
{ id: 'plugin-1', data: 'data-1' },
{ id: 'plugin-2', data: 'data-2' },
];
it('should not resurrect data when removed during an in-flight commit', async () => {
const pluginId = 'test-plugin';
// A payload above the codec threshold forces async compression, so
// the commit's dispatch happens after at least one microtask turn —
// wide enough for a synchronous remove to land in between.
const data = 'x'.repeat(COMPRESS_THRESHOLD + 100);
store.overrideSelector(selectPluginUserDataFeatureState, testData);
const persistPromise = service.persistPluginUserData(pluginId, data);
const result = await service.getAllPluginUserData();
// remove() runs synchronously while compression is still pending.
service.removePluginUserData(pluginId);
expect(result).toEqual(testData);
await persistPromise;
await drainAsync();
const upsertCalls = dispatchSpy.calls
.allArgs()
.filter(([action]) => action.type === upsertPluginUserData.type);
const deleteCalls = dispatchSpy.calls
.allArgs()
.filter(([action]) => action.type === deletePluginUserData.type);
expect(upsertCalls.length).toBe(0);
expect(deleteCalls.length).toBe(1);
});
});
});

View file

@ -8,11 +8,18 @@ import {
} from './plugin-persistence.model';
import { upsertPluginUserData, deletePluginUserData } from './store/plugin.actions';
import { selectPluginUserDataFeatureState } from './store/plugin-user-data.reducer';
import { decodeFromPersist, encodeForPersist } from './util/plugin-data-codec';
import { PluginLog } from '../core/log';
/**
* Service for persisting plugin user data using NgRx actions.
* Handles data that plugins store and retrieve via persistDataSynced/loadSyncedData.
* Includes rate limiting and size validation to prevent abuse.
*
* Data is transparently gzip-compressed at the persistence boundary (see
* `plugin-data-codec.ts`). Plugins only see their own raw strings; the
* compressed form lives in NgRx state, IndexedDB, the op-log, and on the
* sync server, shrinking per-op payloads ~45× for typical JSON.
*/
@Injectable({
providedIn: 'root',
@ -28,6 +35,7 @@ export class PluginUserPersistenceService {
/**
* Data that arrived inside the rate-limit window and is waiting to be
* committed. Coalesced only the most recent value per plugin is kept.
* Holds *uncompressed* input (compression happens at commit time).
*/
private _pendingData = new Map<string, string>();
@ -36,6 +44,32 @@ export class PluginUserPersistenceService {
*/
private _flushTimers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* The latest uncompressed input for a plugin whose `_commit` has started
* but whose async compression has not yet dispatched. A read in this
* window must see the latest write (read-your-writes), so we hold the
* raw input here until the dispatch lands.
*/
private _committing = new Map<string, string>();
/**
* Per-plugin commit chain. Compression is async (`CompressionStream`),
* so two `_commit` calls in quick succession could finish out of order
* and dispatch a stale write last. Chaining serializes
* compress-then-dispatch per plugin while leaving different plugins
* concurrent.
*/
private _commitChain = new Map<string, Promise<void>>();
/**
* Per-plugin generation counter. Bumped by `_cancelPending` so any
* in-flight `_commit` whose compression started under an older
* generation aborts before dispatching. Without this, a
* `removePluginUserData` issued during an async compress would be
* silently undone by the resurrecting upsert that lands afterwards.
*/
private _commitGeneration = new Map<string, number>();
/**
* Persist user data for a specific plugin (called by plugin via persistDataSynced).
*
@ -46,10 +80,27 @@ export class PluginUserPersistenceService {
* the caller's most recent data. Dropping it silently lost edits e.g. a
* plugin's final save on teardown landing just after a periodic save.
*
* Returns a Promise that resolves once the data has been compressed and
* dispatched (or queued for a future commit). The size-cap check is
* synchronous: callers that hit the limit get a thrown Error, not a
* rejected Promise, so the existing `try { persist(...) } catch` pattern
* keeps working.
*
* Read-modify-write contract: a `loadPluginUserData` whose result is then
* mutated and passed back via `persistPluginUserData` must not span a
* `removePluginUserData` for the same plugin. The remove invalidates any
* in-flight commit (via the generation counter), but a fresh `persist`
* issued from a stale `load` result *after* the remove is a new write and
* will resurrect the entry the codec cannot distinguish "user wants
* this back" from "stale read". Callers performing RMW after a delete
* must `loadPluginUserData` again first.
*
* @throws Error if data exceeds MAX_PLUGIN_DATA_SIZE
*/
persistPluginUserData(pluginId: string, data: string): void {
persistPluginUserData(pluginId: string, data: string): Promise<void> {
// Validate data size — applies whether the write commits now or later.
// Cap is on the uncompressed user input, so a plugin can't bypass by
// sending pre-compressed bytes.
const dataSize = new Blob([data]).size;
if (dataSize > MAX_PLUGIN_DATA_SIZE) {
throw new Error(
@ -71,37 +122,75 @@ export class PluginUserPersistenceService {
const delay = MIN_PLUGIN_PERSIST_INTERVAL_MS - timeSinceLastPersist;
this._flushTimers.set(
pluginId,
setTimeout(() => this._flushPendingData(pluginId), delay),
setTimeout(() => {
void this._flushPendingData(pluginId);
}, delay),
);
}
return;
return Promise.resolve();
}
this._commit(pluginId, data, now);
return this._commit(pluginId, data, now);
}
/**
* Commit a coalesced write once its rate-limit window has elapsed.
*/
private _flushPendingData(pluginId: string): void {
private async _flushPendingData(pluginId: string): Promise<void> {
this._flushTimers.delete(pluginId);
const data = this._pendingData.get(pluginId);
if (data === undefined) {
return;
}
this._pendingData.delete(pluginId);
this._commit(pluginId, data, Date.now());
await this._commit(pluginId, data, Date.now());
}
/**
* Dispatch the persist action and record the commit time for rate limiting.
* Compress and dispatch. Serialized per plugin via `_commitChain` to
* preserve write order even if compression times vary across calls.
*/
private _commit(pluginId: string, data: string, at: number): void {
private _commit(pluginId: string, data: string, at: number): Promise<void> {
this._lastPersistTime.set(pluginId, at);
const pluginUserData: PluginUserData = {
id: pluginId,
data,
};
this._committing.set(pluginId, data);
// Capture the generation at the moment we schedule. If a remove bumps
// it before this commit's compression finishes, _encodeAndDispatch
// bails and the upsert is suppressed — preventing post-delete
// resurrection.
const myGeneration = this._commitGeneration.get(pluginId) ?? 0;
const prev = this._commitChain.get(pluginId) ?? Promise.resolve();
// Swallow prior errors so the chain doesn't poison subsequent writes.
// The original failed call has already seen and surfaced its own error.
const next: Promise<void> = prev
.catch(() => undefined)
.then(() => this._encodeAndDispatch(pluginId, data, myGeneration));
this._commitChain.set(pluginId, next);
next.finally(() => {
if (this._committing.get(pluginId) === data) {
this._committing.delete(pluginId);
}
if (this._commitChain.get(pluginId) === next) {
this._commitChain.delete(pluginId);
}
});
return next;
}
private async _encodeAndDispatch(
pluginId: string,
data: string,
generation: number,
): Promise<void> {
const encoded = await encodeForPersist(data);
if ((this._commitGeneration.get(pluginId) ?? 0) !== generation) {
// A removePluginUserData / clearAllPluginUserData ran between
// schedule and dispatch. Dropping the upsert preserves the delete.
return;
}
const pluginUserData: PluginUserData = { id: pluginId, data: encoded };
this._store.dispatch(upsertPluginUserData({ pluginUserData }));
}
@ -109,18 +198,38 @@ export class PluginUserPersistenceService {
* Load user data for a specific plugin (called by plugin via loadSyncedData).
*
* Returns a coalesced-but-not-yet-committed write if one is pending, so a
* plugin's read-modify-write cycle always sees its own latest data.
* plugin's read-modify-write cycle always sees its own latest data. Same
* applies to a commit that has started compression but not yet dispatched.
*
* Otherwise reads from NgRx state and decompresses transparently.
*/
async loadPluginUserData(pluginId: string): Promise<string | null> {
const pending = this._pendingData.get(pluginId);
if (pending !== undefined) {
return pending;
}
const committing = this._committing.get(pluginId);
if (committing !== undefined) {
return committing;
}
const currentState = await firstValueFrom(
this._store.select(selectPluginUserDataFeatureState),
);
const pluginData = currentState.find((item) => item.id === pluginId);
return pluginData?.data || null;
if (!pluginData?.data) {
return null;
}
try {
return await decodeFromPersist(pluginData.data);
} catch (err) {
// Don't log `err` directly — gzip/atob messages can contain partial
// payload bytes from user content. Surface only the error class.
PluginLog.err('PluginUserPersistenceService: failed to decode stored data', {
pluginId,
errName: (err as Error)?.name,
});
return null;
}
}
/**
@ -133,7 +242,10 @@ export class PluginUserPersistenceService {
/**
* Drop any pending coalesced write (and its timer) for a plugin, so it
* cannot resurrect data that is being removed.
* cannot resurrect data that is being removed. Also bumps the commit
* generation so an *in-flight* `_commit` (already past its rate-limit
* check, currently awaiting compression) detects the cancel and skips
* its dispatch the upsert would otherwise undo this delete.
*/
private _cancelPending(pluginId: string): void {
const timer = this._flushTimers.get(pluginId);
@ -142,17 +254,16 @@ export class PluginUserPersistenceService {
this._flushTimers.delete(pluginId);
}
this._pendingData.delete(pluginId);
}
/**
* Get all plugin user data
*/
async getAllPluginUserData(): Promise<PluginUserData[]> {
return firstValueFrom(this._store.select(selectPluginUserDataFeatureState));
this._committing.delete(pluginId);
this._commitGeneration.set(pluginId, (this._commitGeneration.get(pluginId) ?? 0) + 1);
}
/**
* Clear all plugin user data (removes each one individually to create operations)
*
* Yields the event loop after the dispatch loop CLAUDE.md sync rule 6:
* rapid in-loop dispatches against an `array`-pattern entity can lose
* state without a microtask break.
*/
async clearAllPluginUserData(): Promise<void> {
const currentState = await firstValueFrom(
@ -162,5 +273,6 @@ export class PluginUserPersistenceService {
this._cancelPending(item.id);
this._store.dispatch(deletePluginUserData({ pluginId: item.id }));
}
await new Promise((r) => setTimeout(r, 0));
}
}

View file

@ -0,0 +1,65 @@
import {
COMPRESS_THRESHOLD,
SENTINEL,
decodeFromPersist,
encodeForPersist,
} from './plugin-data-codec';
import { compressWithGzipToString } from '../../op-log/encryption/compression-handler';
import { MAX_PLUGIN_DATA_SIZE } from '../plugin-persistence.model';
describe('plugin-data-codec', () => {
it('returns short input unchanged (below threshold)', async () => {
const input = JSON.stringify({ hello: 'world' });
expect(input.length).toBeLessThan(COMPRESS_THRESHOLD);
const encoded = await encodeForPersist(input);
expect(encoded).toBe(input);
expect(await decodeFromPersist(encoded)).toBe(input);
});
it('compresses and round-trips a large redundant JSON blob', async () => {
const blob = JSON.stringify({
docs: Object.fromEntries(
Array.from({ length: 5 }, (_outer, i) => [
`ctx-${i}`,
{
type: 'doc',
content: Array.from({ length: 40 }, (_inner, j) => ({
type: 'paragraph',
content: [{ type: 'text', text: `Paragraph ${j} in context ${i}.` }],
})),
},
]),
),
});
expect(blob.length).toBeGreaterThan(COMPRESS_THRESHOLD);
const encoded = await encodeForPersist(blob);
expect(encoded.startsWith(SENTINEL)).toBe(true);
expect(encoded.length).toBeLessThan(blob.length / 2);
expect(await decodeFromPersist(encoded)).toBe(blob);
});
it('passes through legacy uncompressed data (no sentinel)', async () => {
const legacy = JSON.stringify({ version: 1, payload: 'whatever' });
expect(await decodeFromPersist(legacy)).toBe(legacy);
});
it('rejects a malformed sentinel-prefixed blob', async () => {
await expectAsync(decodeFromPersist(SENTINEL + '!!!not-base64!!!')).toBeRejected();
});
it('aborts when the decompressed payload would exceed the size cap', async () => {
// A highly redundant string of 4× MAX_PLUGIN_DATA_SIZE compresses to a
// small base64 blob but decompresses past the codec's MAX_DECOMPRESSED_SIZE
// ceiling (2× MAX_PLUGIN_DATA_SIZE). The decode must throw before the full
// expansion is realised — a gzip-bomb defence.
const massive = 'A'.repeat(MAX_PLUGIN_DATA_SIZE * 4);
const compressed = await compressWithGzipToString(massive);
expect(compressed.length).toBeLessThan(massive.length / 10);
await expectAsync(decodeFromPersist(SENTINEL + compressed)).toBeRejectedWithError(
/decompressed size exceeded/,
);
});
});

View file

@ -0,0 +1,150 @@
import { compressWithGzipToString } from '../../op-log/encryption/compression-handler';
import { MAX_PLUGIN_DATA_SIZE } from '../plugin-persistence.model';
/**
* Transparent compression for plugin user data on the way to/from the
* op-log. Plugins call `persistDataSynced(string)` / `loadSyncedData(): string`
* and only ever see the string they themselves wrote; the host wraps the
* payload with gzip+base64 between the plugin API and `PluginUserData.data`,
* so every op (and every sync upload) carries the compressed form.
*
* Format: `GZ1:<base64-gzip-bytes>`. The `GZ1:` sentinel doubles as a version
* tag future format changes get a new prefix and stay decodable alongside.
*
* Below `COMPRESS_THRESHOLD` we emit the input unchanged. Gzip's framing
* (~18 bytes) plus base64's 4/3 overhead actively hurts very small payloads,
* and most plugin-metadata-style writes fall under the threshold.
*
* Backward compat on read: a string without the sentinel is treated as
* legacy uncompressed data and passed through.
*
* Compression itself delegates to `@sp/sync-core` via the host's
* `compression-handler` wrapper so we share the chunked base64 encoder,
* env-shim detection, and error reporting with the op-log path.
*
* Decompression is intentionally **bounded** via three cheap-to-expensive
* caps `MAX_RAW_LENGTH` (pre-`atob`), `MAX_COMPRESSED_SIZE` (post-`atob`,
* pre-stream), and `MAX_DECOMPRESSED_SIZE` (per-chunk in `gunzipBounded`)
* so a malicious gzip blob from a compromised sync server can't expand
* into gigabytes and OOM the renderer. The write-side `MAX_PLUGIN_DATA_SIZE`
* cap only constrains local input; remote data needs its own ceiling.
*/
export const SENTINEL = 'GZ1:';
export const COMPRESS_THRESHOLD = 1024;
/**
* Decompression output cap. Plugins are write-bounded by
* `MAX_PLUGIN_DATA_SIZE` (1 MB); allowing 2× on read covers any reasonable
* round-trip while still bounding a gzip-bomb to a few MB of allocation.
*/
const MAX_DECOMPRESSED_SIZE = MAX_PLUGIN_DATA_SIZE * 2;
/**
* Compressed input cap. Bounded at the same ceiling as the decompressed
* output (2× write cap, ~2 MB) generous enough to tolerate gzip framing
* on near-incompressible payloads, tight enough to reject multi-MB attacks
* before we hand bytes to `DecompressionStream`. Cheap defense-in-depth on
* top of the per-chunk size check inside `gunzipBounded`.
*/
const MAX_COMPRESSED_SIZE = MAX_DECOMPRESSED_SIZE;
/**
* Pre-decode cap on the raw (base64-encoded) string. base64 is 4 chars per
* 3 source bytes, so a string longer than `MAX_COMPRESSED_SIZE * 2` chars
* cannot fit under the byte cap. Checking before `atob` avoids allocating
* the decoded Uint8Array for oversized attacker payloads.
*/
const MAX_RAW_LENGTH = MAX_COMPRESSED_SIZE * 2;
const TEXT_DECODER = new TextDecoder();
const base64ToBytes = (b64: string): Uint8Array => {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
};
/**
* gzip-decompress a `Uint8Array` while enforcing an output-byte ceiling.
* Reads the `DecompressionStream` chunk-by-chunk and aborts if the running
* total exceeds `MAX_DECOMPRESSED_SIZE`, avoiding the gigabyte allocation
* an unbounded `Response.arrayBuffer()` would produce.
*/
const gunzipBounded = async (bytes: Uint8Array): Promise<string> => {
const stream = new Blob([bytes as BlobPart])
.stream()
.pipeThrough(new DecompressionStream('gzip'));
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
if (total > MAX_DECOMPRESSED_SIZE) {
try {
await reader.cancel();
} catch {
/* cancel is best-effort */
}
throw new Error(
`Plugin data decompressed size exceeded ${MAX_DECOMPRESSED_SIZE} bytes`,
);
}
chunks.push(value);
}
} finally {
try {
reader.releaseLock();
} catch {
/* releaseLock is best-effort after cancel */
}
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return TEXT_DECODER.decode(out);
};
/**
* Encode a plugin's persisted string for storage. Returns the input
* unchanged below the compression threshold; otherwise gzip+base64 with
* sentinel. Falls back to the raw input if compression throws persistence
* must never fail in this codec.
*/
export const encodeForPersist = async (input: string): Promise<string> => {
if (input.length < COMPRESS_THRESHOLD) return input;
try {
return SENTINEL + (await compressWithGzipToString(input));
} catch {
return input;
}
};
/**
* Decode a stored value back to the original plugin string. Falls back to
* pass-through for legacy uncompressed data (no sentinel).
*
* Throws on a malformed sentinel-prefixed blob *or* if the decompressed
* output would exceed the size ceiling. Callers (the persistence service)
* wrap and surface as a load failure.
*/
export const decodeFromPersist = async (raw: string): Promise<string> => {
if (!raw.startsWith(SENTINEL)) return raw;
const encoded = raw.slice(SENTINEL.length);
// Reject before `atob` allocates a ~0.75 × encoded.length byte array.
if (encoded.length > MAX_RAW_LENGTH) {
throw new Error(`Plugin data raw size exceeded ${MAX_RAW_LENGTH} chars`);
}
const compressed = base64ToBytes(encoded);
if (compressed.length > MAX_COMPRESSED_SIZE) {
throw new Error(`Plugin data compressed size exceeded ${MAX_COMPRESSED_SIZE} bytes`);
}
return gunzipBounded(compressed);
};