@beyondwork/docx-react-component 1.0.42 → 1.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -0,0 +1,544 @@
1
+ import type {
2
+ EditorStateBlob,
3
+ EditorStateLocation,
4
+ EditorStateNamespace,
5
+ EditorStatePartLoadFailure,
6
+ EditorStatePartPersistFailure,
7
+ EditorStatePersister,
8
+ EditorStatePolicy,
9
+ EditorStatePolicyEntry,
10
+ EditorStatePolicyMigration,
11
+ EditorStateResolver,
12
+ EditorStateResolveErrorMode,
13
+ } from "../api/editor-state-types.ts";
14
+
15
+ /**
16
+ * Default policy entry for a namespace not explicitly configured.
17
+ * Locks current behavior: in-document, block-on-resolve-failure,
18
+ * 250ms debounce.
19
+ */
20
+ export const DEFAULT_POLICY_ENTRY: Readonly<Required<EditorStatePolicyEntry>> = {
21
+ location: "in-document",
22
+ key: "",
23
+ onResolveError: "block",
24
+ debounceMs: 250,
25
+ } as const;
26
+
27
+ export const DEFAULT_DEBOUNCE_MS = 250;
28
+
29
+ /**
30
+ * Internal channel event surface. Task D maps these to
31
+ * WordReviewEditorEvent variants. Pure runtime emission; no
32
+ * dependency on the editor event union.
33
+ */
34
+ export type EditorStateChannelEvent =
35
+ | { kind: "load_failed"; failure: EditorStatePartLoadFailure }
36
+ | { kind: "persist_failed"; failure: EditorStatePartPersistFailure }
37
+ | { kind: "policy_migrated"; migration: EditorStatePolicyMigration }
38
+ | { kind: "unknown_namespace"; namespace: string };
39
+
40
+ export type EditorStateChannelListener = (event: EditorStateChannelEvent) => void;
41
+
42
+ /**
43
+ * Parser-captured payload for an unknown namespace. Stored verbatim
44
+ * so the serializer can re-emit the original XML fragment on save,
45
+ * preserving forward-compat with schemas the current build doesn't know.
46
+ */
47
+ export interface UnknownNamespaceEntry {
48
+ rawXml?: string;
49
+ inline?: EditorStateBlob;
50
+ storageRef?: import("../api/editor-state-types.ts").EditorStateStorageRef;
51
+ schemaVersion?: string;
52
+ }
53
+
54
+ export interface EditorStateChannelOptions {
55
+ /** Injectable timer for tests. Defaults to setTimeout/clearTimeout. */
56
+ scheduler?: {
57
+ setTimeout: (fn: () => void, ms: number) => unknown;
58
+ clearTimeout: (handle: unknown) => void;
59
+ };
60
+ /** UUID factory for editor-generated keys. Defaults to crypto.randomUUID() when available. */
61
+ randomUuid?: () => string;
62
+ }
63
+
64
+ export interface EditorStateChannel {
65
+ // Policy ---------------------------------------------------------
66
+ setPolicy(partial: EditorStatePolicy): void;
67
+ getPolicyEntry(ns: EditorStateNamespace): Required<EditorStatePolicyEntry>;
68
+ getKey(ns: EditorStateNamespace): string | undefined;
69
+ /** Set the effective key for a namespace (e.g. from a loaded docx). */
70
+ setKey(ns: EditorStateNamespace, key: string): void;
71
+
72
+ // Callbacks ------------------------------------------------------
73
+ setResolver(resolver: EditorStateResolver | null): void;
74
+ setPersister(persister: EditorStatePersister | null): void;
75
+ getResolver(): EditorStateResolver | null;
76
+ getPersister(): EditorStatePersister | null;
77
+
78
+ // Mutation flow --------------------------------------------------
79
+ /**
80
+ * Record a subsystem mutation. Schedules a debounced persist when
81
+ * the namespace is under keyed policy. Pure state update + timer
82
+ * registration; the actual persister call happens on debounce-fire.
83
+ */
84
+ recordMutation(ns: EditorStateNamespace, blob: EditorStateBlob): void;
85
+
86
+ /**
87
+ * Flush any pending debounced persist for a namespace (or all).
88
+ * Returns a promise that resolves when all flushes complete or
89
+ * fail via the retry queue.
90
+ */
91
+ flush(ns?: EditorStateNamespace): Promise<void>;
92
+
93
+ /**
94
+ * Re-attempt persists sitting in the retry queue.
95
+ */
96
+ retry(ns?: EditorStateNamespace): Promise<void>;
97
+
98
+ // Load-path hooks ------------------------------------------------
99
+ /**
100
+ * Resolve a keyed namespace's blob via the registered resolver,
101
+ * applying onResolveError policy. Returns the blob on success,
102
+ * undefined when the resolver missed or the failure mode is
103
+ * "empty" / "retain-last-known".
104
+ *
105
+ * Caller applies the returned blob to the subsystem store. If the
106
+ * failure mode is "block", throws — caller should fail the load.
107
+ */
108
+ resolve(
109
+ ns: EditorStateNamespace,
110
+ entryKey: string,
111
+ ): Promise<{ blob: EditorStateBlob | null; appliedFallback: boolean }>;
112
+
113
+ /**
114
+ * Record a policy migration detected during load. Caller diff'd
115
+ * the payload-written location vs. current policy location. Emits
116
+ * policy_migrated event.
117
+ */
118
+ recordPolicyMigration(migration: EditorStatePolicyMigration): void;
119
+
120
+ /**
121
+ * Record an unknown-namespace finding from the parser. Emits the
122
+ * warning event AND stores the raw entry so it round-trips verbatim
123
+ * on the next save. `entry` carries the parser-captured rawXml.
124
+ */
125
+ recordUnknownNamespace(namespace: string, entry?: UnknownNamespaceEntry): void;
126
+
127
+ /**
128
+ * Return the preserved unknown-namespace entries captured during load,
129
+ * for the serializer to re-emit verbatim on save.
130
+ */
131
+ getUnknownEntries(): ReadonlyArray<UnknownNamespaceEntry & { name: string }>;
132
+
133
+ /**
134
+ * Record a load failure detected outside the resolver path (e.g.
135
+ * malformed inline JSON discovered by the parser). Emits
136
+ * `load_failed` so hosts are notified through the same channel.
137
+ */
138
+ recordLoadFailure(failure: EditorStatePartLoadFailure): void;
139
+
140
+ /**
141
+ * Record a blob that was successfully loaded/applied (inline apply
142
+ * during hydration or post-resolve apply). Updates `lastKnown` so
143
+ * the `retain-last-known` fallback has something to return on a
144
+ * subsequent resolver failure. No event, no debounce.
145
+ */
146
+ recordLoaded(namespace: EditorStateNamespace, blob: EditorStateBlob): void;
147
+
148
+ // Subscription ---------------------------------------------------
149
+ addListener(listener: EditorStateChannelListener): () => void;
150
+
151
+ // Snapshot -------------------------------------------------------
152
+ /** Read-side snapshot used by the serializer. */
153
+ snapshot(): EditorStateChannelSnapshot;
154
+ }
155
+
156
+ export interface EditorStateChannelSnapshot {
157
+ policy: EditorStatePolicy;
158
+ keys: ReadonlyMap<EditorStateNamespace, string>;
159
+ /** Namespaces with a pending debounced persist. */
160
+ dirtyNamespaces: ReadonlySet<EditorStateNamespace>;
161
+ /** Retry queue size per namespace (0 when clean). */
162
+ retryCounts: ReadonlyMap<EditorStateNamespace, number>;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Implementation
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export function createEditorStateChannel(
170
+ options: EditorStateChannelOptions = {},
171
+ ): EditorStateChannel {
172
+ const scheduler = options.scheduler ?? {
173
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
174
+ clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
175
+ };
176
+ const randomUuid =
177
+ options.randomUuid ??
178
+ (() => {
179
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
180
+ return crypto.randomUUID();
181
+ }
182
+ // Fallback: simple UUIDv4 via Math.random — not cryptographic but ok for correlation keys.
183
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
184
+ const r = (Math.random() * 16) | 0;
185
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
186
+ return v.toString(16);
187
+ });
188
+ });
189
+
190
+ const policy: EditorStatePolicy = {};
191
+ const keys = new Map<EditorStateNamespace, string>();
192
+ let resolver: EditorStateResolver | null = null;
193
+ let persister: EditorStatePersister | null = null;
194
+
195
+ // Debounce queue: namespace → { blob, timer }
196
+ interface DebounceEntry {
197
+ blob: EditorStateBlob;
198
+ timer: unknown;
199
+ }
200
+ const debounced = new Map<EditorStateNamespace, DebounceEntry>();
201
+
202
+ // Retry queue: namespace → latest blob that failed to persist + attempt count
203
+ interface RetryEntry {
204
+ blob: EditorStateBlob;
205
+ attemptCount: number;
206
+ }
207
+ const retries = new Map<EditorStateNamespace, RetryEntry>();
208
+
209
+ /**
210
+ * Last-known-good blob per namespace. Populated on successful
211
+ * resolve, recordMutation, and inline apply during load. Used to
212
+ * honor `retain-last-known` onResolveError: when the resolver fails
213
+ * we return this blob instead of falling through to empty.
214
+ */
215
+ const lastKnown = new Map<EditorStateNamespace, EditorStateBlob>();
216
+
217
+ /**
218
+ * Unknown-namespace entries captured at load; re-emitted verbatim on
219
+ * the next save so forward-compat namespaces survive round-trip.
220
+ */
221
+ const unknownEntries = new Map<string, UnknownNamespaceEntry>();
222
+
223
+ const listeners = new Set<EditorStateChannelListener>();
224
+
225
+ function emit(event: EditorStateChannelEvent): void {
226
+ for (const listener of listeners) {
227
+ try {
228
+ listener(event);
229
+ } catch {
230
+ // Listeners are host-supplied; they must not break the channel.
231
+ }
232
+ }
233
+ }
234
+
235
+ function effectiveEntry(ns: EditorStateNamespace): Required<EditorStatePolicyEntry> {
236
+ const configured = policy[ns];
237
+ if (!configured) return { ...DEFAULT_POLICY_ENTRY };
238
+ return {
239
+ location: configured.location,
240
+ key: configured.key ?? "",
241
+ onResolveError: configured.onResolveError ?? DEFAULT_POLICY_ENTRY.onResolveError,
242
+ debounceMs: configured.debounceMs ?? DEFAULT_DEBOUNCE_MS,
243
+ };
244
+ }
245
+
246
+ async function runPersist(
247
+ ns: EditorStateNamespace,
248
+ blob: EditorStateBlob,
249
+ priorAttempts: number,
250
+ ): Promise<void> {
251
+ const entry = effectiveEntry(ns);
252
+ if (entry.location === "in-document") {
253
+ // Nothing to persist externally — next serialize inlines.
254
+ return;
255
+ }
256
+ if (!persister) {
257
+ retries.set(ns, { blob, attemptCount: priorAttempts + 1 });
258
+ emit({
259
+ kind: "persist_failed",
260
+ failure: {
261
+ namespace: ns,
262
+ entryKey: keys.get(ns) ?? entry.key,
263
+ error: new Error("No persister registered"),
264
+ attemptCount: priorAttempts + 1,
265
+ },
266
+ });
267
+ return;
268
+ }
269
+ const key = keys.get(ns) ?? entry.key;
270
+ if (!key) {
271
+ retries.set(ns, { blob, attemptCount: priorAttempts + 1 });
272
+ emit({
273
+ kind: "persist_failed",
274
+ failure: {
275
+ namespace: ns,
276
+ entryKey: "",
277
+ error: new Error("No entry key assigned"),
278
+ attemptCount: priorAttempts + 1,
279
+ },
280
+ });
281
+ return;
282
+ }
283
+ try {
284
+ await persister(ns, key, blob);
285
+ retries.delete(ns);
286
+ } catch (err) {
287
+ const error = err instanceof Error ? err : new Error(String(err));
288
+ retries.set(ns, { blob, attemptCount: priorAttempts + 1 });
289
+ emit({
290
+ kind: "persist_failed",
291
+ failure: {
292
+ namespace: ns,
293
+ entryKey: key,
294
+ error,
295
+ attemptCount: priorAttempts + 1,
296
+ },
297
+ });
298
+ }
299
+ }
300
+
301
+ function cancelDebounce(ns: EditorStateNamespace): DebounceEntry | undefined {
302
+ const existing = debounced.get(ns);
303
+ if (existing) {
304
+ scheduler.clearTimeout(existing.timer);
305
+ debounced.delete(ns);
306
+ }
307
+ return existing;
308
+ }
309
+
310
+ function scheduleDebounce(
311
+ ns: EditorStateNamespace,
312
+ blob: EditorStateBlob,
313
+ debounceMs: number,
314
+ ): void {
315
+ cancelDebounce(ns);
316
+ const handle = scheduler.setTimeout(() => {
317
+ debounced.delete(ns);
318
+ void runPersist(ns, blob, 0);
319
+ }, debounceMs);
320
+ debounced.set(ns, { blob, timer: handle });
321
+ }
322
+
323
+ function validatePolicyChange(partial: EditorStatePolicy): void {
324
+ for (const [ns, entry] of Object.entries(partial) as Array<[
325
+ EditorStateNamespace,
326
+ EditorStatePolicyEntry,
327
+ ]>) {
328
+ if (entry.location === "rowstore" || entry.location === "key-only") {
329
+ if (!resolver || !persister) {
330
+ throw new Error(
331
+ `Keyed policy for "${ns}" requires both a resolver and persister to be registered first`,
332
+ );
333
+ }
334
+ }
335
+ if (entry.location === "key-only" && !entry.key) {
336
+ throw new Error(
337
+ `key-only policy for "${ns}" requires an explicit key`,
338
+ );
339
+ }
340
+ }
341
+ }
342
+
343
+ return {
344
+ setPolicy(partial) {
345
+ validatePolicyChange(partial);
346
+ for (const [ns, entry] of Object.entries(partial) as Array<[
347
+ EditorStateNamespace,
348
+ EditorStatePolicyEntry,
349
+ ]>) {
350
+ const previous = policy[ns];
351
+ // Effective prior location (defaults to in-document when the
352
+ // namespace has no explicit policy yet, per DEFAULT_POLICY_ENTRY).
353
+ const prevLocation: EditorStateLocation = previous?.location ?? "in-document";
354
+ policy[ns] = entry;
355
+ if (entry.key) {
356
+ keys.set(ns, entry.key);
357
+ } else if (!keys.has(ns) && entry.location !== "in-document") {
358
+ keys.set(ns, randomUuid());
359
+ }
360
+ if (prevLocation !== entry.location) {
361
+ // Only emit the migrated event when we actually had a prior
362
+ // explicit policy — first-time configuration is not a migration.
363
+ if (previous) {
364
+ emit({
365
+ kind: "policy_migrated",
366
+ migration: {
367
+ namespace: ns,
368
+ from: previous.location,
369
+ to: entry.location,
370
+ key: keys.get(ns),
371
+ },
372
+ });
373
+ }
374
+
375
+ // Migrate data across the location change so the next save/flush
376
+ // writes the in-memory state to the new destination. The source
377
+ // blob is `lastKnown` (populated by recordMutation + inline apply
378
+ // + successful resolve) — authoritative across locations.
379
+ const retained = lastKnown.get(ns);
380
+ if (retained) {
381
+ if (prevLocation === "in-document" && entry.location !== "in-document") {
382
+ // in-document → keyed: queue for persist.
383
+ cancelDebounce(ns);
384
+ scheduleDebounce(ns, retained, entry.debounceMs ?? DEFAULT_DEBOUNCE_MS);
385
+ } else if (prevLocation !== "in-document" && entry.location === "in-document") {
386
+ // keyed → in-document: seed the inline dirty slot so the next
387
+ // serialize inlines the retained blob.
388
+ cancelDebounce(ns);
389
+ debounced.set(ns, { blob: retained, timer: null });
390
+ }
391
+ }
392
+ }
393
+ }
394
+ },
395
+
396
+ getPolicyEntry(ns) {
397
+ return effectiveEntry(ns);
398
+ },
399
+
400
+ getKey(ns) {
401
+ return keys.get(ns);
402
+ },
403
+
404
+ setKey(ns, key) {
405
+ keys.set(ns, key);
406
+ },
407
+
408
+ setResolver(next) {
409
+ resolver = next;
410
+ },
411
+
412
+ setPersister(next) {
413
+ persister = next;
414
+ },
415
+
416
+ getResolver() {
417
+ return resolver;
418
+ },
419
+
420
+ getPersister() {
421
+ return persister;
422
+ },
423
+
424
+ recordMutation(ns, blob) {
425
+ lastKnown.set(ns, blob);
426
+ const entry = effectiveEntry(ns);
427
+ if (entry.location === "in-document") {
428
+ // Mark dirty for the serializer — no external debounce needed.
429
+ // Use null timer to distinguish from a scheduled debounce.
430
+ debounced.set(ns, { blob, timer: null });
431
+ return;
432
+ }
433
+ scheduleDebounce(ns, blob, entry.debounceMs);
434
+ },
435
+
436
+ async flush(ns) {
437
+ const targets = ns ? [ns] : Array.from(debounced.keys());
438
+ for (const target of targets) {
439
+ const pending = cancelDebounce(target);
440
+ if (!pending) continue;
441
+ await runPersist(target, pending.blob, 0);
442
+ }
443
+ },
444
+
445
+ async retry(ns) {
446
+ const targets = ns ? [ns] : Array.from(retries.keys());
447
+ for (const target of targets) {
448
+ const entry = retries.get(target);
449
+ if (!entry) continue;
450
+ retries.delete(target);
451
+ await runPersist(target, entry.blob, entry.attemptCount);
452
+ }
453
+ },
454
+
455
+ async resolve(ns, entryKey) {
456
+ const entry = effectiveEntry(ns);
457
+ const retainBlob = (): EditorStateBlob | null => {
458
+ if (entry.onResolveError !== "retain-last-known") return null;
459
+ return lastKnown.get(ns) ?? null;
460
+ };
461
+ if (!resolver) {
462
+ const failure: EditorStatePartLoadFailure = {
463
+ namespace: ns,
464
+ entryKey,
465
+ error: new Error("No resolver registered"),
466
+ fallback: entry.onResolveError === "block" ? "empty" : entry.onResolveError,
467
+ };
468
+ emit({ kind: "load_failed", failure });
469
+ if (entry.onResolveError === "block") {
470
+ throw failure.error;
471
+ }
472
+ return { blob: retainBlob(), appliedFallback: true };
473
+ }
474
+ try {
475
+ const blob = await resolver(ns, entryKey);
476
+ if (blob === null) {
477
+ const failure: EditorStatePartLoadFailure = {
478
+ namespace: ns,
479
+ entryKey,
480
+ error: new Error(`Resolver returned null for ${ns}:${entryKey}`),
481
+ fallback: entry.onResolveError === "block" ? "empty" : entry.onResolveError,
482
+ };
483
+ emit({ kind: "load_failed", failure });
484
+ if (entry.onResolveError === "block") throw failure.error;
485
+ return { blob: retainBlob(), appliedFallback: true };
486
+ }
487
+ lastKnown.set(ns, blob);
488
+ return { blob, appliedFallback: false };
489
+ } catch (err) {
490
+ const error = err instanceof Error ? err : new Error(String(err));
491
+ const failure: EditorStatePartLoadFailure = {
492
+ namespace: ns,
493
+ entryKey,
494
+ error,
495
+ fallback: entry.onResolveError === "block" ? "empty" : entry.onResolveError,
496
+ };
497
+ emit({ kind: "load_failed", failure });
498
+ if (entry.onResolveError === "block") throw error;
499
+ return { blob: retainBlob(), appliedFallback: true };
500
+ }
501
+ },
502
+
503
+ recordPolicyMigration(migration) {
504
+ emit({ kind: "policy_migrated", migration });
505
+ },
506
+
507
+ recordUnknownNamespace(namespace, entry) {
508
+ if (entry) {
509
+ unknownEntries.set(namespace, entry);
510
+ }
511
+ emit({ kind: "unknown_namespace", namespace });
512
+ },
513
+
514
+ getUnknownEntries() {
515
+ return Array.from(unknownEntries.entries()).map(([name, entry]) => ({ name, ...entry }));
516
+ },
517
+
518
+ recordLoadFailure(failure) {
519
+ emit({ kind: "load_failed", failure });
520
+ },
521
+
522
+ recordLoaded(ns, blob) {
523
+ lastKnown.set(ns, blob);
524
+ },
525
+
526
+ addListener(listener) {
527
+ listeners.add(listener);
528
+ return () => {
529
+ listeners.delete(listener);
530
+ };
531
+ },
532
+
533
+ snapshot() {
534
+ return {
535
+ policy: { ...policy },
536
+ keys: new Map(keys),
537
+ dirtyNamespaces: new Set(debounced.keys()),
538
+ retryCounts: new Map(
539
+ Array.from(retries.entries()).map(([ns, entry]) => [ns, entry.attemptCount]),
540
+ ),
541
+ };
542
+ },
543
+ };
544
+ }