@bayonai/offline 0.11.0 → 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 +6 -0
- package/README.md +21 -0
- package/RECOVERY.md +13 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pendingChangeTracker.d.ts +71 -0
- package/dist/pendingChangeTracker.js +199 -0
- package/dist-cjs/index.d.ts +1 -0
- package/dist-cjs/index.js +1 -0
- package/dist-cjs/pendingChangeTracker.d.ts +71 -0
- package/dist-cjs/pendingChangeTracker.js +204 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.11.0
|
|
4
10
|
|
|
5
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.
|
package/README.md
CHANGED
|
@@ -464,3 +464,24 @@ cancel with superseded intent (for example, an award for a cancelled completion)
|
|
|
464
464
|
Independent effects can opt out of being barriers with `isBarrier`; they stay in
|
|
465
465
|
the queue. Claims and cancellation deadlines always fence both sides regardless
|
|
466
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`.
|
package/dist/index.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export * from "./readBoundary.js";
|
|
|
9
9
|
export * from "./recoveryPolicy.js";
|
|
10
10
|
export * from "./pendingWriteSupersession.js";
|
|
11
11
|
export { projectPendingWriteFinalState, type PendingWriteSupersessionKey, type PendingWriteConsolidationOptions, } from "./pendingWriteConsolidation.js";
|
|
12
|
+
export * from "./pendingChangeTracker.js";
|
package/dist/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist-cjs/index.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export * from "./readBoundary.js";
|
|
|
9
9
|
export * from "./recoveryPolicy.js";
|
|
10
10
|
export * from "./pendingWriteSupersession.js";
|
|
11
11
|
export { projectPendingWriteFinalState, type PendingWriteSupersessionKey, type PendingWriteConsolidationOptions, } from "./pendingWriteConsolidation.js";
|
|
12
|
+
export * from "./pendingChangeTracker.js";
|
package/dist-cjs/index.js
CHANGED
|
@@ -28,3 +28,4 @@ __exportStar(require("./recoveryPolicy"), exports);
|
|
|
28
28
|
__exportStar(require("./pendingWriteSupersession"), exports);
|
|
29
29
|
var pendingWriteConsolidation_1 = require("./pendingWriteConsolidation");
|
|
30
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
|
+
}
|