@bayonai/offline 0.10.1 → 0.12.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 candidate - 2026-11-05
4
+
5
+ - Add framework-neutral pending-change projection and observable tracker, with host-supplied existing queues/drafts, account and stale-read fencing, stable external-store snapshots, grouping/counts/guidance, and retained last known records on read failures.
6
+
7
+ - Keep durable read failures visible throughout a retry, independently of capture failures; only current durable proof clears the failed read.
8
+
9
+ ## 0.11.0
10
+
11
+ - Add opt-in `projectPendingWriteFinalState` and store `consolidate` for final absolute setters. IndexedDB and memory retain winners and remove competing records atomically. Unknown effects, claims and cancellation deadlines remain ordering barriers. Legacy custom drivers retain records safely.
12
+
3
13
  ## 0.10.1 - 2026-11-04
4
14
 
5
15
  - Recognize two exact Firestore server-only cache diagnostics as transient while retaining terminal-code precedence.
package/README.md CHANGED
@@ -414,3 +414,74 @@ no TypeScript parser, Firebase SDK, or host-domain module. Bounded's real catalo
414
414
  read-family recipe lives in `src/lib/lists/catalogReadBoundary.test.ts` and works
415
415
  with its existing cache behavior tests. This locally added export requires an
416
416
  intentional future package release before registry consumers can adopt it.
417
+
418
+ ## Consolidate unsent absolute state
419
+
420
+ `store.consolidate(getSupersessionKey)` atomically retires earlier unclaimed
421
+ absolute setters with the same explicit account/entity/field key. Call after
422
+ acceptance and before recovery to compact historical queue records too. Keep the
423
+ latest setter even when the remote baseline is unknown. This capability is
424
+ optional on custom stores; built-in stores always expose it. Legacy custom
425
+ drivers without atomic `deleteSelected` retain records and return zero.
426
+
427
+ ```ts
428
+ await store.set(nextIntent);
429
+ await store.consolidate?.((record) =>
430
+ record.payload.kind === "setCompleted"
431
+ ? JSON.stringify([
432
+ record.payload.accountId,
433
+ record.payload.taskId,
434
+ "completed",
435
+ ])
436
+ : null,
437
+ );
438
+ ```
439
+
440
+ Return null for effects, partial patches, overlapping fields, moves/access changes,
441
+ assignments with notifications and commands that observe earlier state. Null keys,
442
+ syncing records (including expired claims), explicit `syncingAt` and any
443
+ `notBefore` deadline fence both sides of the ordered lane. Distinct recurrence
444
+ occurrences must not share a setter key. Hosts own the keys and their proof of
445
+ independence. All ordinary setters between barriers must commute with the omitted
446
+ older setters; do not key commands merely because they target the same entity.
447
+
448
+ `projectPendingWriteFinalState(records, getSupersessionKey)` provides the same
449
+ pure ordered view for display/capture overlays. It does not establish durability
450
+ and must not justify dropping live optimistic receipts before storage succeeds.
451
+ Consolidation emits clear events only after the deletion transaction commits.
452
+ No envelope schema or database upgrade is required. Driver implementations of
453
+ `deleteSelected` must select and delete in one atomic transaction and propagate
454
+ failures without partial mutation. Policy callbacks are synchronous and pure.
455
+
456
+ Related effects may explicitly depend on an unsent setter: supply
457
+ `getDependencyKey(effect)` returning that setter's key and optionally
458
+ `isDependencySatisfied(effect, state)`. When a later setter supersedes the
459
+ prerequisite, its pending dependent effects retire with it. A false dependency
460
+ check retires an effect attached to a cancelling latest setter. Without an
461
+ unclaimed prerequisite in the same barrier-bounded lane, effects remain ordered.
462
+ Use this only for dependent consequences that the host explicitly wants to
463
+ cancel with superseded intent (for example, an award for a cancelled completion).
464
+ Independent effects can opt out of being barriers with `isBarrier`; they stay in
465
+ the queue. Claims and cancellation deadlines always fence both sides regardless
466
+ of these callbacks. All callbacks are synchronous, pure host policy.
467
+
468
+ ### Observe pending changes without owning another queue
469
+
470
+ `createPendingChangeTracker<T>()` observes host-supplied `load(accountKey)` and
471
+ optional synchronous `capture(accountKey)` sources. Supply `subscribe(notify)`
472
+ for existing command-store, draft and browser source notifications. It refreshes
473
+ immediately, exposes stable `getSnapshot`/`subscribe` methods for external-store
474
+ consumers, and releases its source subscription with `close()`.
475
+
476
+ Hosts project real rows into `PendingChangeItem` (account, group, label, status
477
+ and attention/storage/sending flags); generic host fields survive projection.
478
+ `createPendingChangeSnapshot` also supports standalone synchronous consumers.
479
+ Captured rows replace same-ID durable rows, groups preserve host order, and all
480
+ counts and guidance derive from the resulting account-scoped rows. IDs must be
481
+ unique across the host's sources unless replacement is intended.
482
+
483
+ `setAccountKey` clears the previous account immediately and fences late reads.
484
+ Read failures retain the current account's last known rows and expose `readError`;
485
+ provide `onError` for host diagnostics (otherwise the tracker logs to console).
486
+ Call `setOffline` when connectivity changes. The tracker never persists, executes,
487
+ consolidates or retires operations, and adds no schema, jobs or provider imports.
package/RECOVERY.md CHANGED
@@ -63,5 +63,17 @@ No queue record is discarded or reclassified merely because the app reopened.
63
63
  - Additional regression coverage: `src/recoveryRemediation.test.ts` exercises the
64
64
  four audited failure compositions with real IndexedDB reads and safe cleanup.
65
65
 
66
-
67
66
  Firestore server-only reads may report either exact “Failed to get documents from server.” or the exact SDK cache-retrieval guidance. These known messages classify as transient only when no structured code is present; terminal and unknown structured codes remain authoritative. Arbitrary messages containing connectivity words do not qualify.
67
+
68
+ ### Shared inspection source uncertainty
69
+
70
+ `pendingChangeTracker` retains a failed durable read throughout a deferred retry,
71
+ including when synchronous capture succeeds. Only current-generation durable
72
+ proof clears that error. Capture failures remain independent and survive a
73
+ successful durable read until capture itself succeeds. Last known account rows
74
+ stay inspectable; account changes fence prior rows/errors and stale completions.
75
+ Hosts must also project command-provider loading/hydration failures separately:
76
+ an independent successful draft read does not establish an empty command queue.
77
+ Inspection never acquires replay ownership. Coverage:
78
+ `src/pendingChangeTracker.test.ts` and host
79
+ `src/components/offline/PendingChangesIndicator.providers.test.tsx`.
@@ -1,4 +1,11 @@
1
+ export type KeyValueEntry = {
2
+ key: string;
3
+ value: unknown;
4
+ };
5
+ export type KeyValueDeletionSelector = (entries: KeyValueEntry[]) => readonly string[];
1
6
  export type BrowserKeyValueDriver = {
7
+ /** Select and delete within one transaction, including the winning-record read. */
8
+ deleteSelected?: (select: KeyValueDeletionSelector) => Promise<string[]>;
2
9
  clear: () => Promise<void>;
3
10
  close: () => Promise<void>;
4
11
  delete: (key: string) => Promise<void>;
package/dist/idbDriver.js CHANGED
@@ -43,6 +43,48 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
43
43
  });
44
44
  }
45
45
  return {
46
+ async deleteSelected(select) {
47
+ return runTransactionalStorageOperation({
48
+ failureMode: "throw",
49
+ fallback: [],
50
+ operationName: "consolidate pending keys",
51
+ storeName,
52
+ timeoutMs: operationTimeoutMs,
53
+ operation: async (setTransaction, isCancelled) => {
54
+ const database = await getDatabase();
55
+ if (isCancelled())
56
+ return [];
57
+ const transaction = database.transaction(storeName, "readwrite");
58
+ setTransaction(transaction);
59
+ try {
60
+ const objectStore = transaction.objectStore(storeName);
61
+ const [keys, values] = await Promise.all([
62
+ objectStore.getAllKeys(),
63
+ objectStore.getAll(),
64
+ ]);
65
+ const entries = keys.map((key, index) => ({
66
+ key: String(key),
67
+ value: values[index],
68
+ }));
69
+ const existingKeys = new Set(entries.map((entry) => entry.key));
70
+ const selected = [...new Set(select(entries))].filter((key) => existingKeys.has(key));
71
+ await Promise.all(selected.map((key) => objectStore.delete(key)));
72
+ await transaction.done;
73
+ return selected;
74
+ }
75
+ catch (error) {
76
+ // A policy failure must roll back, never commit a partial selection.
77
+ try {
78
+ transaction.abort();
79
+ }
80
+ catch {
81
+ /* Already aborted or settled. */
82
+ }
83
+ throw error;
84
+ }
85
+ },
86
+ });
87
+ },
46
88
  async clear() {
47
89
  await runMutation("clear object store", (objectStore) => objectStore.clear());
48
90
  },
package/dist/index.d.ts CHANGED
@@ -8,3 +8,5 @@ export { OfflineStorageError } from "./idbDriver.js";
8
8
  export * from "./readBoundary.js";
9
9
  export * from "./recoveryPolicy.js";
10
10
  export * from "./pendingWriteSupersession.js";
11
+ export { projectPendingWriteFinalState, type PendingWriteSupersessionKey, type PendingWriteConsolidationOptions, } from "./pendingWriteConsolidation.js";
12
+ export * from "./pendingChangeTracker.js";
package/dist/index.js CHANGED
@@ -8,3 +8,5 @@ export { OfflineStorageError } from "./idbDriver.js";
8
8
  export * from "./readBoundary.js";
9
9
  export * from "./recoveryPolicy.js";
10
10
  export * from "./pendingWriteSupersession.js";
11
+ export { projectPendingWriteFinalState, } from "./pendingWriteConsolidation.js";
12
+ export * from "./pendingChangeTracker.js";
@@ -0,0 +1,71 @@
1
+ export type PendingChangeStatus = "draft" | "waiting" | "sending" | "attention" | "storage-error";
2
+ /** Hosts own domain labels and recovery actions; this is only a queue projection. */
3
+ export type PendingChangeItem = {
4
+ id: string;
5
+ accountKey: string;
6
+ groupKey: string;
7
+ groupTitle: string;
8
+ label: string;
9
+ status: PendingChangeStatus;
10
+ attemptedText?: string;
11
+ needsAttention: boolean;
12
+ storageError: boolean;
13
+ sending: boolean;
14
+ };
15
+ export type PendingChangeGroup<T extends PendingChangeItem = PendingChangeItem> = {
16
+ key: string;
17
+ title: string;
18
+ items: readonly T[];
19
+ };
20
+ export type PendingChangeSnapshot<T extends PendingChangeItem = PendingChangeItem> = {
21
+ accountKey: string | null;
22
+ items: readonly T[];
23
+ groups: readonly PendingChangeGroup<T>[];
24
+ count: number;
25
+ draftCount: number;
26
+ readyCount: number;
27
+ attentionCount: number;
28
+ storageErrorCount: number;
29
+ sendingCount: number;
30
+ guidance: string;
31
+ loading: boolean;
32
+ readError: unknown | null;
33
+ };
34
+ /** Groups host-scoped rows without imposing account or domain semantics. */
35
+ export declare function groupPendingChangeItems<T extends {
36
+ groupKey: string;
37
+ groupTitle: string;
38
+ }>(items: readonly T[]): Array<{
39
+ key: string;
40
+ title: string;
41
+ items: T[];
42
+ }>;
43
+ /** Same-ID captured edits replace durable rows; insertion order remains stable. */
44
+ export declare function createPendingChangeSnapshot<T extends PendingChangeItem>({ accountKey, items, isOffline, loading, readError, }: {
45
+ accountKey: string | null;
46
+ items: readonly T[];
47
+ isOffline?: boolean;
48
+ loading?: boolean;
49
+ readError?: unknown;
50
+ }): PendingChangeSnapshot<T>;
51
+ export type PendingChangeTracker<T extends PendingChangeItem = PendingChangeItem> = {
52
+ getSnapshot: () => PendingChangeSnapshot<T>;
53
+ subscribe: (listener: () => void) => () => void;
54
+ refresh: () => Promise<void>;
55
+ setAccountKey: (accountKey: string | null) => void;
56
+ setOffline: (isOffline: boolean) => void;
57
+ close: () => void;
58
+ };
59
+ /**
60
+ * Observes existing host queues, never persists or replays changes. Reads fail
61
+ * closed across accounts but retain the current account's last known rows.
62
+ * Hosts must notify for draft, command-store and connectivity source changes.
63
+ */
64
+ export declare function createPendingChangeTracker<T extends PendingChangeItem>(options: {
65
+ accountKey?: string | null;
66
+ isOffline?: boolean;
67
+ load: (accountKey: string) => Promise<readonly T[]>;
68
+ capture?: (accountKey: string) => readonly T[];
69
+ subscribe?: (onChange: () => void) => () => void;
70
+ onError?: (error: unknown) => void;
71
+ }): PendingChangeTracker<T>;
@@ -0,0 +1,199 @@
1
+ import { getPendingChangesGuidance } from "./recoveryPolicy.js";
2
+ /** Groups host-scoped rows without imposing account or domain semantics. */
3
+ export function groupPendingChangeItems(items) {
4
+ const groups = new Map();
5
+ for (const item of items) {
6
+ let group = groups.get(item.groupKey);
7
+ if (!group) {
8
+ group = { key: item.groupKey, title: item.groupTitle, items: [] };
9
+ groups.set(item.groupKey, group);
10
+ }
11
+ group.items.push(item);
12
+ }
13
+ return [...groups.values()];
14
+ }
15
+ /** Same-ID captured edits replace durable rows; insertion order remains stable. */
16
+ export function createPendingChangeSnapshot({ accountKey, items, isOffline = false, loading = false, readError = null, }) {
17
+ const unique = new Map();
18
+ for (const item of items) {
19
+ if (accountKey !== null && item.accountKey === accountKey)
20
+ unique.set(item.id, { ...item });
21
+ }
22
+ const rows = [...unique.values()];
23
+ const groups = groupPendingChangeItems(rows);
24
+ const draftCount = rows.filter((item) => item.status === "draft").length;
25
+ const readyCount = rows.filter((item) => item.status === "waiting" || item.status === "sending").length;
26
+ const attentionCount = rows.filter((item) => item.needsAttention || item.status === "attention").length;
27
+ const storageErrorCount = rows.filter((item) => item.storageError || item.status === "storage-error").length;
28
+ return {
29
+ accountKey,
30
+ items: rows,
31
+ groups,
32
+ count: rows.length,
33
+ draftCount,
34
+ readyCount,
35
+ attentionCount,
36
+ storageErrorCount,
37
+ sendingCount: rows.filter((item) => item.sending || item.status === "sending").length,
38
+ guidance: readError !== null
39
+ ? "Pending changes could not be read. Your last known changes are preserved."
40
+ : getPendingChangesGuidance({
41
+ hasReadyChanges: readyCount > 0,
42
+ hasDrafts: draftCount > 0,
43
+ hasErrors: attentionCount > 0 || storageErrorCount > 0 || readError !== null,
44
+ isOffline,
45
+ }),
46
+ loading,
47
+ readError,
48
+ };
49
+ }
50
+ /**
51
+ * Observes existing host queues, never persists or replays changes. Reads fail
52
+ * closed across accounts but retain the current account's last known rows.
53
+ * Hosts must notify for draft, command-store and connectivity source changes.
54
+ */
55
+ export function createPendingChangeTracker(options) {
56
+ let accountKey = options.accountKey ?? null;
57
+ let isOffline = options.isOffline ?? false;
58
+ let durable = [];
59
+ let captured = [];
60
+ let durableReadError = null;
61
+ let captureReadError = null;
62
+ let loading = false;
63
+ let generation = 0;
64
+ let closed = false;
65
+ const listeners = new Set();
66
+ let snapshot = createPendingChangeSnapshot({
67
+ accountKey,
68
+ items: [],
69
+ isOffline,
70
+ });
71
+ const report = (error) => {
72
+ try {
73
+ if (options.onError)
74
+ options.onError(error);
75
+ else
76
+ console.error("Pending-change tracker could not read host changes; retaining the last known account snapshot.", error);
77
+ }
78
+ catch (callbackError) {
79
+ console.error("Pending-change tracker error reporter failed while reporting a host source failure.", callbackError, error);
80
+ }
81
+ };
82
+ const publish = () => {
83
+ const publishingGeneration = generation;
84
+ snapshot = createPendingChangeSnapshot({
85
+ accountKey,
86
+ items: [...durable, ...captured],
87
+ isOffline,
88
+ loading,
89
+ readError: durableReadError ?? captureReadError,
90
+ });
91
+ for (const listener of [...listeners]) {
92
+ if (closed || publishingGeneration !== generation)
93
+ return;
94
+ try {
95
+ listener();
96
+ }
97
+ catch (error) {
98
+ console.error("Pending-change tracker subscriber failed; other subscribers will still receive updates.", error);
99
+ }
100
+ }
101
+ };
102
+ const refresh = async () => {
103
+ if (closed)
104
+ return;
105
+ const request = ++generation;
106
+ const requestedAccount = accountKey;
107
+ const current = () => !closed && request === generation && requestedAccount === accountKey;
108
+ if (requestedAccount === null)
109
+ return;
110
+ // Capture callbacks may notify sources or switch accounts synchronously.
111
+ const capture = () => {
112
+ try {
113
+ const rows = options.capture?.(requestedAccount) ?? [];
114
+ if (!current())
115
+ return;
116
+ captured = rows;
117
+ captureReadError = null;
118
+ }
119
+ catch (error) {
120
+ if (!current())
121
+ return;
122
+ captureReadError = error;
123
+ report(error);
124
+ }
125
+ };
126
+ capture();
127
+ if (!current())
128
+ return;
129
+ loading = true;
130
+ publish();
131
+ if (!current())
132
+ return;
133
+ try {
134
+ const rows = await options.load(requestedAccount);
135
+ if (!current())
136
+ return;
137
+ // A successful capture cannot prove durable storage recovered.
138
+ durableReadError = null;
139
+ durable = rows;
140
+ // A durable read may retire host acceptance receipts without emitting an
141
+ // event. Recapture after proof so drained receipts cannot become ghosts.
142
+ capture();
143
+ }
144
+ catch (error) {
145
+ if (!current())
146
+ return;
147
+ durableReadError = error;
148
+ report(error);
149
+ }
150
+ if (!current())
151
+ return;
152
+ loading = false;
153
+ publish();
154
+ };
155
+ const unsubscribe = options.subscribe?.(() => {
156
+ void refresh();
157
+ });
158
+ const tracker = {
159
+ getSnapshot: () => snapshot,
160
+ subscribe: (listener) => {
161
+ if (closed)
162
+ return () => { };
163
+ listeners.add(listener);
164
+ return () => {
165
+ listeners.delete(listener);
166
+ };
167
+ },
168
+ refresh,
169
+ setAccountKey: (next) => {
170
+ if (closed || next === accountKey)
171
+ return;
172
+ ++generation;
173
+ accountKey = next;
174
+ durable = [];
175
+ captured = [];
176
+ durableReadError = null;
177
+ captureReadError = null;
178
+ loading = false;
179
+ publish();
180
+ void refresh();
181
+ },
182
+ setOffline: (next) => {
183
+ if (closed || next === isOffline)
184
+ return;
185
+ isOffline = next;
186
+ publish();
187
+ },
188
+ close: () => {
189
+ if (closed)
190
+ return;
191
+ closed = true;
192
+ ++generation;
193
+ unsubscribe?.();
194
+ listeners.clear();
195
+ },
196
+ };
197
+ void refresh();
198
+ return tracker;
199
+ }
@@ -0,0 +1,17 @@
1
+ import type { PendingWriteRecord } from "./pendingWrites.js";
2
+ /** Host keys must identify account, entity and one independent absolute field. */
3
+ export type PendingWriteSupersessionKey<Payload> = (record: PendingWriteRecord<Payload>) => string | null;
4
+ export type PendingWriteConsolidationOptions<Payload> = {
5
+ /** Optional effects that belong to the latest unsent keyed setter. */
6
+ getDependencyKey?: PendingWriteSupersessionKey<Payload>;
7
+ /** False retires a dependent effect whose latest unsent prerequisite cancels it. */
8
+ isDependencySatisfied?: (effect: PendingWriteRecord<Payload>, state: PendingWriteRecord<Payload>) => boolean;
9
+ /** A host may prove an unkeyed effect independent; it remains in the queue. */
10
+ isBarrier?: (record: PendingWriteRecord<Payload>) => boolean;
11
+ };
12
+ /**
13
+ * Keep final absolute state, never infer cancellation of effects. Unknown commands,
14
+ * claims (even expired ones) and Undo deadlines fence both sides of the lane.
15
+ * This pure view does not establish durability; stores compact atomically.
16
+ */
17
+ export declare function projectPendingWriteFinalState<Payload>(records: readonly PendingWriteRecord<Payload>[], getKey: PendingWriteSupersessionKey<Payload>, { isBarrier, getDependencyKey, isDependencySatisfied, }?: PendingWriteConsolidationOptions<Payload>): PendingWriteRecord<Payload>[];
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keep final absolute state, never infer cancellation of effects. Unknown commands,
3
+ * claims (even expired ones) and Undo deadlines fence both sides of the lane.
4
+ * This pure view does not establish durability; stores compact atomically.
5
+ */
6
+ export function projectPendingWriteFinalState(records, getKey, { isBarrier = () => true, getDependencyKey, isDependencySatisfied, } = {}) {
7
+ const ordered = [...records].sort((a, b) => a.savedAt.localeCompare(b.savedAt) || a.id.localeCompare(b.id));
8
+ const latest = new Map();
9
+ const dependents = new Map();
10
+ const superseded = new Set();
11
+ for (const record of ordered) {
12
+ const key = getKey(record);
13
+ if (record.syncStatus === "syncing" ||
14
+ record.syncingAt ||
15
+ record.notBefore) {
16
+ latest.clear();
17
+ dependents.clear();
18
+ continue;
19
+ }
20
+ if (key === null) {
21
+ const dependencyKey = getDependencyKey?.(record);
22
+ const prerequisite = dependencyKey == null ? undefined : latest.get(dependencyKey);
23
+ if (prerequisite && dependencyKey != null) {
24
+ if (isDependencySatisfied?.(record, prerequisite) === false)
25
+ superseded.add(record);
26
+ else
27
+ dependents.get(dependencyKey).push(record);
28
+ }
29
+ else if (isBarrier(record)) {
30
+ latest.clear();
31
+ dependents.clear();
32
+ }
33
+ continue;
34
+ }
35
+ const previous = latest.get(key);
36
+ if (previous) {
37
+ superseded.add(previous);
38
+ for (const effect of dependents.get(key) ?? [])
39
+ superseded.add(effect);
40
+ }
41
+ latest.set(key, record);
42
+ dependents.set(key, []);
43
+ }
44
+ return ordered.filter((record) => !superseded.has(record));
45
+ }
@@ -1,4 +1,5 @@
1
- import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
1
+ import { type PendingWriteSupersessionKey, type PendingWriteConsolidationOptions } from "./pendingWriteConsolidation.js";
2
+ import { type BrowserKeyValueDriverOptions, type KeyValueDeletionSelector } from "./idbDriver.js";
2
3
  export declare const pendingWriteSchemaVersion = 1;
3
4
  export declare const pendingWriteOperationTimeoutMs = 2000;
4
5
  export declare const pendingWriteSyncLeaseTimeoutMs = 60000;
@@ -20,6 +21,8 @@ export type PendingWriteRecordInput<Payload = unknown> = Omit<PendingWriteRecord
20
21
  };
21
22
  export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown) => Payload | null;
22
23
  export type PendingWriteDriver = {
24
+ /** Optional for legacy drivers; must read and delete atomically. */
25
+ deleteSelected?: (select: KeyValueDeletionSelector) => Promise<string[]>;
23
26
  clear: (key: string) => Promise<void>;
24
27
  clearIf: (key: string, predicate: PendingWritePredicate) => Promise<boolean>;
25
28
  clearAll?: () => Promise<void>;
@@ -48,6 +51,8 @@ export type PendingWriteStoreChange<Payload = unknown> = {
48
51
  };
49
52
  export type PendingWriteStoreChangeListener<Payload = unknown> = (change: PendingWriteStoreChange<Payload>) => void;
50
53
  export type PendingWriteStore<Payload = unknown> = {
54
+ /** Retire competing absolute setters atomically; custom stores may omit it. */
55
+ consolidate?: (getKey: PendingWriteSupersessionKey<Payload>, options?: PendingWriteConsolidationOptions<Payload>) => Promise<number>;
51
56
  claimIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>, now: string) => Promise<boolean>;
52
57
  clear: (id: string) => Promise<void>;
53
58
  clearAll: () => Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { projectPendingWriteFinalState, } from "./pendingWriteConsolidation.js";
1
2
  import { createIdbKeyValueDriver, OfflineStorageError, } from "./idbDriver.js";
2
3
  export const pendingWriteSchemaVersion = 1;
3
4
  export const pendingWriteOperationTimeoutMs = 2_000;
@@ -41,6 +42,36 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
41
42
  return records.filter((record) => Boolean(record));
42
43
  }
43
44
  return {
45
+ async consolidate(getKey, options) {
46
+ if (!driver.deleteSelected)
47
+ return 0;
48
+ const removedIds = new Map();
49
+ const deleted = await driver.deleteSelected((entries) => {
50
+ const scoped = entries.filter(({ key }) => key.startsWith(`${keyPrefix}:`));
51
+ const records = scoped.map(({ value }) => toPendingWriteEnvelope(value, schemaVersion, {
52
+ normalizeLegacyRecord,
53
+ normalizePayload,
54
+ })?.record);
55
+ // An unreadable envelope may be an ordering barrier from a newer host.
56
+ // Never infer that skipping it makes its neighbours commute.
57
+ if (records.some((record) => !record))
58
+ return [];
59
+ const valid = records;
60
+ const retained = new Set(projectPendingWriteFinalState(valid, getKey, options));
61
+ return scoped
62
+ .filter((entry, index) => {
63
+ const record = valid[index];
64
+ if (retained.has(record))
65
+ return false;
66
+ removedIds.set(entry.key, record.id);
67
+ return true;
68
+ })
69
+ .map((entry) => entry.key);
70
+ });
71
+ for (const key of deleted)
72
+ notify({ id: removedIds.get(key), type: "clear" });
73
+ return deleted.length;
74
+ },
44
75
  async claimIfSnapshotId(id, snapshotId, record, now) {
45
76
  const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
46
77
  const current = toPendingWriteEnvelope(value, schemaVersion, {
@@ -127,6 +158,13 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
127
158
  export function createMemoryPendingWriteDriver() {
128
159
  const records = new Map();
129
160
  return {
161
+ async deleteSelected(select) {
162
+ // No await between observing winners and deleting obsolete records.
163
+ const selected = [
164
+ ...new Set(select([...records].map(([key, value]) => ({ key, value })))),
165
+ ];
166
+ return selected.filter((key) => records.delete(key));
167
+ },
130
168
  async clear(key) {
131
169
  records.delete(key);
132
170
  },
@@ -425,6 +463,7 @@ function createUnavailablePendingWriteDriver() {
425
463
  }
426
464
  function toPendingWriteDriver(driver) {
427
465
  return {
466
+ deleteSelected: driver.deleteSelected,
428
467
  clear: driver.delete,
429
468
  clearIf: driver.deleteIf,
430
469
  clearAll: driver.clear,
@@ -1,4 +1,11 @@
1
+ export type KeyValueEntry = {
2
+ key: string;
3
+ value: unknown;
4
+ };
5
+ export type KeyValueDeletionSelector = (entries: KeyValueEntry[]) => readonly string[];
1
6
  export type BrowserKeyValueDriver = {
7
+ /** Select and delete within one transaction, including the winning-record read. */
8
+ deleteSelected?: (select: KeyValueDeletionSelector) => Promise<string[]>;
2
9
  clear: () => Promise<void>;
3
10
  close: () => Promise<void>;
4
11
  delete: (key: string) => Promise<void>;
@@ -48,6 +48,48 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
48
48
  });
49
49
  }
50
50
  return {
51
+ async deleteSelected(select) {
52
+ return runTransactionalStorageOperation({
53
+ failureMode: "throw",
54
+ fallback: [],
55
+ operationName: "consolidate pending keys",
56
+ storeName,
57
+ timeoutMs: operationTimeoutMs,
58
+ operation: async (setTransaction, isCancelled) => {
59
+ const database = await getDatabase();
60
+ if (isCancelled())
61
+ return [];
62
+ const transaction = database.transaction(storeName, "readwrite");
63
+ setTransaction(transaction);
64
+ try {
65
+ const objectStore = transaction.objectStore(storeName);
66
+ const [keys, values] = await Promise.all([
67
+ objectStore.getAllKeys(),
68
+ objectStore.getAll(),
69
+ ]);
70
+ const entries = keys.map((key, index) => ({
71
+ key: String(key),
72
+ value: values[index],
73
+ }));
74
+ const existingKeys = new Set(entries.map((entry) => entry.key));
75
+ const selected = [...new Set(select(entries))].filter((key) => existingKeys.has(key));
76
+ await Promise.all(selected.map((key) => objectStore.delete(key)));
77
+ await transaction.done;
78
+ return selected;
79
+ }
80
+ catch (error) {
81
+ // A policy failure must roll back, never commit a partial selection.
82
+ try {
83
+ transaction.abort();
84
+ }
85
+ catch {
86
+ /* Already aborted or settled. */
87
+ }
88
+ throw error;
89
+ }
90
+ },
91
+ });
92
+ },
51
93
  async clear() {
52
94
  await runMutation("clear object store", (objectStore) => objectStore.clear());
53
95
  },
@@ -8,3 +8,5 @@ export { OfflineStorageError } from "./idbDriver.js";
8
8
  export * from "./readBoundary.js";
9
9
  export * from "./recoveryPolicy.js";
10
10
  export * from "./pendingWriteSupersession.js";
11
+ export { projectPendingWriteFinalState, type PendingWriteSupersessionKey, type PendingWriteConsolidationOptions, } from "./pendingWriteConsolidation.js";
12
+ export * from "./pendingChangeTracker.js";
package/dist-cjs/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.OfflineStorageError = void 0;
17
+ exports.projectPendingWriteFinalState = exports.OfflineStorageError = void 0;
18
18
  __exportStar(require("./readCache"), exports);
19
19
  __exportStar(require("./pendingWrites"), exports);
20
20
  __exportStar(require("./browserRecovery"), exports);
@@ -26,3 +26,6 @@ Object.defineProperty(exports, "OfflineStorageError", { enumerable: true, get: f
26
26
  __exportStar(require("./readBoundary"), exports);
27
27
  __exportStar(require("./recoveryPolicy"), exports);
28
28
  __exportStar(require("./pendingWriteSupersession"), exports);
29
+ var pendingWriteConsolidation_1 = require("./pendingWriteConsolidation");
30
+ Object.defineProperty(exports, "projectPendingWriteFinalState", { enumerable: true, get: function () { return pendingWriteConsolidation_1.projectPendingWriteFinalState; } });
31
+ __exportStar(require("./pendingChangeTracker"), exports);
@@ -0,0 +1,71 @@
1
+ export type PendingChangeStatus = "draft" | "waiting" | "sending" | "attention" | "storage-error";
2
+ /** Hosts own domain labels and recovery actions; this is only a queue projection. */
3
+ export type PendingChangeItem = {
4
+ id: string;
5
+ accountKey: string;
6
+ groupKey: string;
7
+ groupTitle: string;
8
+ label: string;
9
+ status: PendingChangeStatus;
10
+ attemptedText?: string;
11
+ needsAttention: boolean;
12
+ storageError: boolean;
13
+ sending: boolean;
14
+ };
15
+ export type PendingChangeGroup<T extends PendingChangeItem = PendingChangeItem> = {
16
+ key: string;
17
+ title: string;
18
+ items: readonly T[];
19
+ };
20
+ export type PendingChangeSnapshot<T extends PendingChangeItem = PendingChangeItem> = {
21
+ accountKey: string | null;
22
+ items: readonly T[];
23
+ groups: readonly PendingChangeGroup<T>[];
24
+ count: number;
25
+ draftCount: number;
26
+ readyCount: number;
27
+ attentionCount: number;
28
+ storageErrorCount: number;
29
+ sendingCount: number;
30
+ guidance: string;
31
+ loading: boolean;
32
+ readError: unknown | null;
33
+ };
34
+ /** Groups host-scoped rows without imposing account or domain semantics. */
35
+ export declare function groupPendingChangeItems<T extends {
36
+ groupKey: string;
37
+ groupTitle: string;
38
+ }>(items: readonly T[]): Array<{
39
+ key: string;
40
+ title: string;
41
+ items: T[];
42
+ }>;
43
+ /** Same-ID captured edits replace durable rows; insertion order remains stable. */
44
+ export declare function createPendingChangeSnapshot<T extends PendingChangeItem>({ accountKey, items, isOffline, loading, readError, }: {
45
+ accountKey: string | null;
46
+ items: readonly T[];
47
+ isOffline?: boolean;
48
+ loading?: boolean;
49
+ readError?: unknown;
50
+ }): PendingChangeSnapshot<T>;
51
+ export type PendingChangeTracker<T extends PendingChangeItem = PendingChangeItem> = {
52
+ getSnapshot: () => PendingChangeSnapshot<T>;
53
+ subscribe: (listener: () => void) => () => void;
54
+ refresh: () => Promise<void>;
55
+ setAccountKey: (accountKey: string | null) => void;
56
+ setOffline: (isOffline: boolean) => void;
57
+ close: () => void;
58
+ };
59
+ /**
60
+ * Observes existing host queues, never persists or replays changes. Reads fail
61
+ * closed across accounts but retain the current account's last known rows.
62
+ * Hosts must notify for draft, command-store and connectivity source changes.
63
+ */
64
+ export declare function createPendingChangeTracker<T extends PendingChangeItem>(options: {
65
+ accountKey?: string | null;
66
+ isOffline?: boolean;
67
+ load: (accountKey: string) => Promise<readonly T[]>;
68
+ capture?: (accountKey: string) => readonly T[];
69
+ subscribe?: (onChange: () => void) => () => void;
70
+ onError?: (error: unknown) => void;
71
+ }): PendingChangeTracker<T>;
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.groupPendingChangeItems = groupPendingChangeItems;
4
+ exports.createPendingChangeSnapshot = createPendingChangeSnapshot;
5
+ exports.createPendingChangeTracker = createPendingChangeTracker;
6
+ const recoveryPolicy_1 = require("./recoveryPolicy");
7
+ /** Groups host-scoped rows without imposing account or domain semantics. */
8
+ function groupPendingChangeItems(items) {
9
+ const groups = new Map();
10
+ for (const item of items) {
11
+ let group = groups.get(item.groupKey);
12
+ if (!group) {
13
+ group = { key: item.groupKey, title: item.groupTitle, items: [] };
14
+ groups.set(item.groupKey, group);
15
+ }
16
+ group.items.push(item);
17
+ }
18
+ return [...groups.values()];
19
+ }
20
+ /** Same-ID captured edits replace durable rows; insertion order remains stable. */
21
+ function createPendingChangeSnapshot({ accountKey, items, isOffline = false, loading = false, readError = null, }) {
22
+ const unique = new Map();
23
+ for (const item of items) {
24
+ if (accountKey !== null && item.accountKey === accountKey)
25
+ unique.set(item.id, { ...item });
26
+ }
27
+ const rows = [...unique.values()];
28
+ const groups = groupPendingChangeItems(rows);
29
+ const draftCount = rows.filter((item) => item.status === "draft").length;
30
+ const readyCount = rows.filter((item) => item.status === "waiting" || item.status === "sending").length;
31
+ const attentionCount = rows.filter((item) => item.needsAttention || item.status === "attention").length;
32
+ const storageErrorCount = rows.filter((item) => item.storageError || item.status === "storage-error").length;
33
+ return {
34
+ accountKey,
35
+ items: rows,
36
+ groups,
37
+ count: rows.length,
38
+ draftCount,
39
+ readyCount,
40
+ attentionCount,
41
+ storageErrorCount,
42
+ sendingCount: rows.filter((item) => item.sending || item.status === "sending").length,
43
+ guidance: readError !== null
44
+ ? "Pending changes could not be read. Your last known changes are preserved."
45
+ : (0, recoveryPolicy_1.getPendingChangesGuidance)({
46
+ hasReadyChanges: readyCount > 0,
47
+ hasDrafts: draftCount > 0,
48
+ hasErrors: attentionCount > 0 || storageErrorCount > 0 || readError !== null,
49
+ isOffline,
50
+ }),
51
+ loading,
52
+ readError,
53
+ };
54
+ }
55
+ /**
56
+ * Observes existing host queues, never persists or replays changes. Reads fail
57
+ * closed across accounts but retain the current account's last known rows.
58
+ * Hosts must notify for draft, command-store and connectivity source changes.
59
+ */
60
+ function createPendingChangeTracker(options) {
61
+ let accountKey = options.accountKey ?? null;
62
+ let isOffline = options.isOffline ?? false;
63
+ let durable = [];
64
+ let captured = [];
65
+ let durableReadError = null;
66
+ let captureReadError = null;
67
+ let loading = false;
68
+ let generation = 0;
69
+ let closed = false;
70
+ const listeners = new Set();
71
+ let snapshot = createPendingChangeSnapshot({
72
+ accountKey,
73
+ items: [],
74
+ isOffline,
75
+ });
76
+ const report = (error) => {
77
+ try {
78
+ if (options.onError)
79
+ options.onError(error);
80
+ else
81
+ console.error("Pending-change tracker could not read host changes; retaining the last known account snapshot.", error);
82
+ }
83
+ catch (callbackError) {
84
+ console.error("Pending-change tracker error reporter failed while reporting a host source failure.", callbackError, error);
85
+ }
86
+ };
87
+ const publish = () => {
88
+ const publishingGeneration = generation;
89
+ snapshot = createPendingChangeSnapshot({
90
+ accountKey,
91
+ items: [...durable, ...captured],
92
+ isOffline,
93
+ loading,
94
+ readError: durableReadError ?? captureReadError,
95
+ });
96
+ for (const listener of [...listeners]) {
97
+ if (closed || publishingGeneration !== generation)
98
+ return;
99
+ try {
100
+ listener();
101
+ }
102
+ catch (error) {
103
+ console.error("Pending-change tracker subscriber failed; other subscribers will still receive updates.", error);
104
+ }
105
+ }
106
+ };
107
+ const refresh = async () => {
108
+ if (closed)
109
+ return;
110
+ const request = ++generation;
111
+ const requestedAccount = accountKey;
112
+ const current = () => !closed && request === generation && requestedAccount === accountKey;
113
+ if (requestedAccount === null)
114
+ return;
115
+ // Capture callbacks may notify sources or switch accounts synchronously.
116
+ const capture = () => {
117
+ try {
118
+ const rows = options.capture?.(requestedAccount) ?? [];
119
+ if (!current())
120
+ return;
121
+ captured = rows;
122
+ captureReadError = null;
123
+ }
124
+ catch (error) {
125
+ if (!current())
126
+ return;
127
+ captureReadError = error;
128
+ report(error);
129
+ }
130
+ };
131
+ capture();
132
+ if (!current())
133
+ return;
134
+ loading = true;
135
+ publish();
136
+ if (!current())
137
+ return;
138
+ try {
139
+ const rows = await options.load(requestedAccount);
140
+ if (!current())
141
+ return;
142
+ // A successful capture cannot prove durable storage recovered.
143
+ durableReadError = null;
144
+ durable = rows;
145
+ // A durable read may retire host acceptance receipts without emitting an
146
+ // event. Recapture after proof so drained receipts cannot become ghosts.
147
+ capture();
148
+ }
149
+ catch (error) {
150
+ if (!current())
151
+ return;
152
+ durableReadError = error;
153
+ report(error);
154
+ }
155
+ if (!current())
156
+ return;
157
+ loading = false;
158
+ publish();
159
+ };
160
+ const unsubscribe = options.subscribe?.(() => {
161
+ void refresh();
162
+ });
163
+ const tracker = {
164
+ getSnapshot: () => snapshot,
165
+ subscribe: (listener) => {
166
+ if (closed)
167
+ return () => { };
168
+ listeners.add(listener);
169
+ return () => {
170
+ listeners.delete(listener);
171
+ };
172
+ },
173
+ refresh,
174
+ setAccountKey: (next) => {
175
+ if (closed || next === accountKey)
176
+ return;
177
+ ++generation;
178
+ accountKey = next;
179
+ durable = [];
180
+ captured = [];
181
+ durableReadError = null;
182
+ captureReadError = null;
183
+ loading = false;
184
+ publish();
185
+ void refresh();
186
+ },
187
+ setOffline: (next) => {
188
+ if (closed || next === isOffline)
189
+ return;
190
+ isOffline = next;
191
+ publish();
192
+ },
193
+ close: () => {
194
+ if (closed)
195
+ return;
196
+ closed = true;
197
+ ++generation;
198
+ unsubscribe?.();
199
+ listeners.clear();
200
+ },
201
+ };
202
+ void refresh();
203
+ return tracker;
204
+ }
@@ -0,0 +1,17 @@
1
+ import type { PendingWriteRecord } from "./pendingWrites.js";
2
+ /** Host keys must identify account, entity and one independent absolute field. */
3
+ export type PendingWriteSupersessionKey<Payload> = (record: PendingWriteRecord<Payload>) => string | null;
4
+ export type PendingWriteConsolidationOptions<Payload> = {
5
+ /** Optional effects that belong to the latest unsent keyed setter. */
6
+ getDependencyKey?: PendingWriteSupersessionKey<Payload>;
7
+ /** False retires a dependent effect whose latest unsent prerequisite cancels it. */
8
+ isDependencySatisfied?: (effect: PendingWriteRecord<Payload>, state: PendingWriteRecord<Payload>) => boolean;
9
+ /** A host may prove an unkeyed effect independent; it remains in the queue. */
10
+ isBarrier?: (record: PendingWriteRecord<Payload>) => boolean;
11
+ };
12
+ /**
13
+ * Keep final absolute state, never infer cancellation of effects. Unknown commands,
14
+ * claims (even expired ones) and Undo deadlines fence both sides of the lane.
15
+ * This pure view does not establish durability; stores compact atomically.
16
+ */
17
+ export declare function projectPendingWriteFinalState<Payload>(records: readonly PendingWriteRecord<Payload>[], getKey: PendingWriteSupersessionKey<Payload>, { isBarrier, getDependencyKey, isDependencySatisfied, }?: PendingWriteConsolidationOptions<Payload>): PendingWriteRecord<Payload>[];
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.projectPendingWriteFinalState = projectPendingWriteFinalState;
4
+ /**
5
+ * Keep final absolute state, never infer cancellation of effects. Unknown commands,
6
+ * claims (even expired ones) and Undo deadlines fence both sides of the lane.
7
+ * This pure view does not establish durability; stores compact atomically.
8
+ */
9
+ function projectPendingWriteFinalState(records, getKey, { isBarrier = () => true, getDependencyKey, isDependencySatisfied, } = {}) {
10
+ const ordered = [...records].sort((a, b) => a.savedAt.localeCompare(b.savedAt) || a.id.localeCompare(b.id));
11
+ const latest = new Map();
12
+ const dependents = new Map();
13
+ const superseded = new Set();
14
+ for (const record of ordered) {
15
+ const key = getKey(record);
16
+ if (record.syncStatus === "syncing" ||
17
+ record.syncingAt ||
18
+ record.notBefore) {
19
+ latest.clear();
20
+ dependents.clear();
21
+ continue;
22
+ }
23
+ if (key === null) {
24
+ const dependencyKey = getDependencyKey?.(record);
25
+ const prerequisite = dependencyKey == null ? undefined : latest.get(dependencyKey);
26
+ if (prerequisite && dependencyKey != null) {
27
+ if (isDependencySatisfied?.(record, prerequisite) === false)
28
+ superseded.add(record);
29
+ else
30
+ dependents.get(dependencyKey).push(record);
31
+ }
32
+ else if (isBarrier(record)) {
33
+ latest.clear();
34
+ dependents.clear();
35
+ }
36
+ continue;
37
+ }
38
+ const previous = latest.get(key);
39
+ if (previous) {
40
+ superseded.add(previous);
41
+ for (const effect of dependents.get(key) ?? [])
42
+ superseded.add(effect);
43
+ }
44
+ latest.set(key, record);
45
+ dependents.set(key, []);
46
+ }
47
+ return ordered.filter((record) => !superseded.has(record));
48
+ }
@@ -1,4 +1,5 @@
1
- import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
1
+ import { type PendingWriteSupersessionKey, type PendingWriteConsolidationOptions } from "./pendingWriteConsolidation.js";
2
+ import { type BrowserKeyValueDriverOptions, type KeyValueDeletionSelector } from "./idbDriver.js";
2
3
  export declare const pendingWriteSchemaVersion = 1;
3
4
  export declare const pendingWriteOperationTimeoutMs = 2000;
4
5
  export declare const pendingWriteSyncLeaseTimeoutMs = 60000;
@@ -20,6 +21,8 @@ export type PendingWriteRecordInput<Payload = unknown> = Omit<PendingWriteRecord
20
21
  };
21
22
  export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown) => Payload | null;
22
23
  export type PendingWriteDriver = {
24
+ /** Optional for legacy drivers; must read and delete atomically. */
25
+ deleteSelected?: (select: KeyValueDeletionSelector) => Promise<string[]>;
23
26
  clear: (key: string) => Promise<void>;
24
27
  clearIf: (key: string, predicate: PendingWritePredicate) => Promise<boolean>;
25
28
  clearAll?: () => Promise<void>;
@@ -48,6 +51,8 @@ export type PendingWriteStoreChange<Payload = unknown> = {
48
51
  };
49
52
  export type PendingWriteStoreChangeListener<Payload = unknown> = (change: PendingWriteStoreChange<Payload>) => void;
50
53
  export type PendingWriteStore<Payload = unknown> = {
54
+ /** Retire competing absolute setters atomically; custom stores may omit it. */
55
+ consolidate?: (getKey: PendingWriteSupersessionKey<Payload>, options?: PendingWriteConsolidationOptions<Payload>) => Promise<number>;
51
56
  claimIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>, now: string) => Promise<boolean>;
52
57
  clear: (id: string) => Promise<void>;
53
58
  clearAll: () => Promise<void>;
@@ -12,6 +12,7 @@ exports.syncPendingWrites = syncPendingWrites;
12
12
  exports.runPendingWrite = runPendingWrite;
13
13
  exports.summarizePendingWrites = summarizePendingWrites;
14
14
  exports.createOrderedPendingWriteExecutor = createOrderedPendingWriteExecutor;
15
+ const pendingWriteConsolidation_1 = require("./pendingWriteConsolidation");
15
16
  const idbDriver_1 = require("./idbDriver");
16
17
  exports.pendingWriteSchemaVersion = 1;
17
18
  exports.pendingWriteOperationTimeoutMs = 2_000;
@@ -55,6 +56,36 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
55
56
  return records.filter((record) => Boolean(record));
56
57
  }
57
58
  return {
59
+ async consolidate(getKey, options) {
60
+ if (!driver.deleteSelected)
61
+ return 0;
62
+ const removedIds = new Map();
63
+ const deleted = await driver.deleteSelected((entries) => {
64
+ const scoped = entries.filter(({ key }) => key.startsWith(`${keyPrefix}:`));
65
+ const records = scoped.map(({ value }) => toPendingWriteEnvelope(value, schemaVersion, {
66
+ normalizeLegacyRecord,
67
+ normalizePayload,
68
+ })?.record);
69
+ // An unreadable envelope may be an ordering barrier from a newer host.
70
+ // Never infer that skipping it makes its neighbours commute.
71
+ if (records.some((record) => !record))
72
+ return [];
73
+ const valid = records;
74
+ const retained = new Set((0, pendingWriteConsolidation_1.projectPendingWriteFinalState)(valid, getKey, options));
75
+ return scoped
76
+ .filter((entry, index) => {
77
+ const record = valid[index];
78
+ if (retained.has(record))
79
+ return false;
80
+ removedIds.set(entry.key, record.id);
81
+ return true;
82
+ })
83
+ .map((entry) => entry.key);
84
+ });
85
+ for (const key of deleted)
86
+ notify({ id: removedIds.get(key), type: "clear" });
87
+ return deleted.length;
88
+ },
58
89
  async claimIfSnapshotId(id, snapshotId, record, now) {
59
90
  const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
60
91
  const current = toPendingWriteEnvelope(value, schemaVersion, {
@@ -141,6 +172,13 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
141
172
  function createMemoryPendingWriteDriver() {
142
173
  const records = new Map();
143
174
  return {
175
+ async deleteSelected(select) {
176
+ // No await between observing winners and deleting obsolete records.
177
+ const selected = [
178
+ ...new Set(select([...records].map(([key, value]) => ({ key, value })))),
179
+ ];
180
+ return selected.filter((key) => records.delete(key));
181
+ },
144
182
  async clear(key) {
145
183
  records.delete(key);
146
184
  },
@@ -439,6 +477,7 @@ function createUnavailablePendingWriteDriver() {
439
477
  }
440
478
  function toPendingWriteDriver(driver) {
441
479
  return {
480
+ deleteSelected: driver.deleteSelected,
442
481
  clear: driver.delete,
443
482
  clearIf: driver.deleteIf,
444
483
  clearAll: driver.clear,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",