@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 +10 -0
- package/README.md +71 -0
- package/RECOVERY.md +13 -1
- package/dist/idbDriver.d.ts +7 -0
- package/dist/idbDriver.js +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pendingChangeTracker.d.ts +71 -0
- package/dist/pendingChangeTracker.js +199 -0
- package/dist/pendingWriteConsolidation.d.ts +17 -0
- package/dist/pendingWriteConsolidation.js +45 -0
- package/dist/pendingWrites.d.ts +6 -1
- package/dist/pendingWrites.js +39 -0
- package/dist-cjs/idbDriver.d.ts +7 -0
- package/dist-cjs/idbDriver.js +42 -0
- package/dist-cjs/index.d.ts +2 -0
- package/dist-cjs/index.js +4 -1
- package/dist-cjs/pendingChangeTracker.d.ts +71 -0
- package/dist-cjs/pendingChangeTracker.js +204 -0
- package/dist-cjs/pendingWriteConsolidation.d.ts +17 -0
- package/dist-cjs/pendingWriteConsolidation.js +48 -0
- package/dist-cjs/pendingWrites.d.ts +6 -1
- package/dist-cjs/pendingWrites.js +39 -0
- package/package.json +1 -1
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`.
|
package/dist/idbDriver.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/pendingWrites.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type
|
|
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>;
|
package/dist/pendingWrites.js
CHANGED
|
@@ -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,
|
package/dist-cjs/idbDriver.d.ts
CHANGED
|
@@ -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-cjs/idbDriver.js
CHANGED
|
@@ -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
|
},
|
package/dist-cjs/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-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
|
|
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,
|