@bayonai/offline 0.11.0 → 0.13.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/dist/readCache.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createIdbKeyValueDriver, } from "./idbDriver.js";
2
+ import { createPendingWriteSnapshotId } from "./pendingWrites.js";
2
3
  export const readCacheSchemaVersion = 1;
3
4
  export const readCacheFreshnessMaxAgeMs = 30_000;
4
5
  export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
@@ -13,6 +14,28 @@ export function createReadCache(driver) {
13
14
  const keyVersions = new Map();
14
15
  let operationQueue = Promise.resolve();
15
16
  const pendingLoads = new Map();
17
+ const capturedSnapshots = new WeakMap();
18
+ function ownsSnapshot(current, snapshot) {
19
+ if (!snapshot)
20
+ return current === null || toReadCacheEnvelope(current) === null;
21
+ const captured = capturedSnapshots.get(snapshot);
22
+ const envelope = toReadCacheEnvelope(current);
23
+ if (!captured || !envelope)
24
+ return false;
25
+ if (captured.snapshotId)
26
+ return envelope.snapshotId === captured.snapshotId;
27
+ if (envelope.snapshotId || envelope.savedAt !== captured.savedAt)
28
+ return false;
29
+ try {
30
+ if (!isComparableLegacyValue(envelope.value) ||
31
+ !isComparableLegacyValue(captured.value))
32
+ return false;
33
+ return JSON.stringify(envelope.value) === JSON.stringify(captured.value);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
16
39
  function getVersion(key) {
17
40
  return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
18
41
  }
@@ -25,6 +48,30 @@ export function createReadCache(driver) {
25
48
  operationQueue = next.then(() => undefined, () => undefined);
26
49
  return next;
27
50
  }
51
+ async function writeIfVersion(key, value, version, snapshot, savedAt) {
52
+ if (getVersion(key) !== version)
53
+ return false;
54
+ return enqueue(async () => {
55
+ if (getVersion(key) !== version)
56
+ return false;
57
+ const next = {
58
+ savedAt,
59
+ schemaVersion: readCacheSchemaVersion,
60
+ snapshotId: createPendingWriteSnapshotId(),
61
+ value,
62
+ };
63
+ if (snapshot !== undefined && driver.setIf) {
64
+ const written = await driver.setIf(key, next, (current) => getVersion(key) === version && ownsSnapshot(current, snapshot));
65
+ if (!written)
66
+ return false;
67
+ }
68
+ else {
69
+ await driver.set(key, next);
70
+ }
71
+ invalidate(key);
72
+ return true;
73
+ });
74
+ }
28
75
  const cache = {
29
76
  async clear() {
30
77
  pendingLoads.clear();
@@ -39,40 +86,50 @@ export function createReadCache(driver) {
39
86
  invalidate(key);
40
87
  await enqueue(() => driver.delete(key));
41
88
  },
89
+ async deleteIfVersion(key, version, snapshot) {
90
+ return enqueue(async () => {
91
+ if (getVersion(key) !== version)
92
+ return false;
93
+ if (!capturedSnapshots.has(snapshot) || !driver.deleteIf) {
94
+ console.error("[Bayon offline] Cannot safely retire a read-cache snapshot: atomic deletion is unavailable.", { key });
95
+ return false;
96
+ }
97
+ const deleted = await driver.deleteIf(key, (current) => {
98
+ return getVersion(key) === version && ownsSnapshot(current, snapshot);
99
+ });
100
+ if (deleted)
101
+ invalidate(key);
102
+ return deleted;
103
+ });
104
+ },
42
105
  invalidate,
43
106
  async get(key) {
44
107
  const envelope = toReadCacheEnvelope(await driver.get(key));
45
- return envelope
46
- ? {
47
- fromCache: true,
48
- savedAt: envelope.savedAt,
49
- value: envelope.value,
50
- }
51
- : null;
108
+ if (!envelope)
109
+ return null;
110
+ const hit = {
111
+ fromCache: true,
112
+ savedAt: envelope.savedAt,
113
+ value: envelope.value,
114
+ };
115
+ capturedSnapshots.set(hit, envelope);
116
+ return hit;
52
117
  },
53
118
  getVersion,
54
119
  async set(key, value) {
120
+ invalidate(key);
55
121
  await enqueue(() => driver.set(key, {
56
122
  savedAt: new Date().toISOString(),
57
123
  schemaVersion: readCacheSchemaVersion,
124
+ snapshotId: createPendingWriteSnapshotId(),
58
125
  value,
59
126
  }));
60
127
  },
61
- async setIfVersion(key, value, version) {
62
- if (getVersion(key) !== version) {
63
- return false;
64
- }
65
- return enqueue(async () => {
66
- if (getVersion(key) !== version) {
67
- return false;
68
- }
69
- await driver.set(key, {
70
- savedAt: new Date().toISOString(),
71
- schemaVersion: readCacheSchemaVersion,
72
- value,
73
- });
74
- return true;
75
- });
128
+ async setIfVersion(key, value, version, snapshot, confirmedAt) {
129
+ return writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString());
130
+ },
131
+ async setIfVersionAt(key, value, version, snapshot, confirmedAt) {
132
+ return writeIfVersion(key, value, version, snapshot, confirmedAt);
76
133
  },
77
134
  };
78
135
  pendingFreshLoads.set(cache, pendingLoads);
@@ -103,6 +160,12 @@ export function createMemoryReadCacheDriver() {
103
160
  async delete(key) {
104
161
  records.delete(key);
105
162
  },
163
+ async deleteIf(key, predicate) {
164
+ if (!predicate(records.get(key) ?? null))
165
+ return false;
166
+ records.delete(key);
167
+ return true;
168
+ },
106
169
  async get(key) {
107
170
  return records.get(key) ?? null;
108
171
  },
@@ -112,6 +175,12 @@ export function createMemoryReadCacheDriver() {
112
175
  async set(key, value) {
113
176
  records.set(key, value);
114
177
  },
178
+ async setIf(key, value, predicate) {
179
+ if (!predicate(records.get(key) ?? null))
180
+ return false;
181
+ records.set(key, value);
182
+ return true;
183
+ },
115
184
  };
116
185
  }
117
186
  export function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = browserReadCacheOperationTimeoutMs, storeName, }) {
@@ -127,9 +196,11 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
127
196
  clear: driver.clear,
128
197
  close: driver.close,
129
198
  delete: driver.delete,
199
+ deleteIf: driver.deleteIf,
130
200
  get: driver.get,
131
201
  listEntries: driver.listEntries,
132
202
  set: driver.set,
203
+ setIf: driver.setIf,
133
204
  }
134
205
  : createNoopReadCacheDriver());
135
206
  }
@@ -156,10 +227,12 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
156
227
  .filter(Boolean)
157
228
  .join(":");
158
229
  }
159
- export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
230
+ export async function readThroughCache({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource = false, }) {
160
231
  const resourceCache = cache.capture?.() ?? cache;
161
232
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
233
+ const capturedVersion = resourceCache.getVersion(key);
162
234
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
235
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
163
236
  if (forceFresh) {
164
237
  reportReadCacheOutcome(onOutcome, "forced-refresh");
165
238
  // Built-in caches advance generation without deleting fallback data.
@@ -180,9 +253,13 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
180
253
  }
181
254
  }
182
255
  const version = resourceCache.getVersion(key);
256
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
183
257
  const cached = forcedFallback ?? (await resourceCache.get(key));
184
258
  const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
185
- if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
259
+ if (!forceFresh &&
260
+ !requireSource &&
261
+ retained &&
262
+ isCacheFresh(cached.savedAt, resolvedPolicy)) {
186
263
  reportReadCacheOutcome(onOutcome, "fresh-hit");
187
264
  return {
188
265
  data: cached.value,
@@ -190,14 +267,33 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
190
267
  fromCache: true,
191
268
  stale: false,
192
269
  status: "fresh",
270
+ ...(includeProvenance
271
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
272
+ : {}),
193
273
  };
194
274
  }
195
275
  reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
196
276
  try {
197
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
277
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
278
+ if (source.provenance !== "source-confirmed") {
279
+ if (requireSource)
280
+ throw (source.originalError ??
281
+ source.error ??
282
+ new Error("[Bayon offline] Source confirmation was required."));
283
+ return includeProvenance
284
+ ? source
285
+ : {
286
+ data: source.data,
287
+ error: source.error,
288
+ fromCache: source.fromCache,
289
+ stale: source.stale,
290
+ status: source.status,
291
+ };
292
+ }
293
+ const fresh = source.data;
198
294
  reportReadCacheOutcome(onOutcome, "loader-success");
199
295
  try {
200
- await resourceCache.setIfVersion(key, fresh, version);
296
+ await writeConfirmedResource(resourceCache, key, fresh, version, cached, source.confirmedAt, Boolean(loadFreshResult));
201
297
  }
202
298
  catch (error) {
203
299
  console.error("[Bayon offline] Unable to refresh the read cache.", {
@@ -211,26 +307,44 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
211
307
  fromCache: false,
212
308
  stale: false,
213
309
  status: retained ? "fresh" : "miss",
310
+ ...(includeProvenance
311
+ ? {
312
+ provenance: "source-confirmed",
313
+ confirmedAt: source.confirmedAt,
314
+ }
315
+ : {}),
214
316
  };
215
317
  }
216
318
  catch (error) {
217
319
  reportReadCacheOutcome(onOutcome, "loader-failure");
218
- if (retained && resolvedPolicy.refreshFailure === "use-retained") {
320
+ if (error instanceof ReadCacheLoaderContractError)
321
+ throw error;
322
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
323
+ if (!requireSource && retained && failure === "use-retained") {
219
324
  return {
220
325
  data: cached.value,
221
326
  error: normalizeError(error),
222
327
  fromCache: true,
223
328
  stale: true,
224
329
  status: "stale",
330
+ ...(includeProvenance
331
+ ? {
332
+ provenance: "retained-fallback",
333
+ savedAt: cached.savedAt,
334
+ originalError: error,
335
+ }
336
+ : {}),
225
337
  };
226
338
  }
227
339
  throw error;
228
340
  }
229
341
  }
230
- export async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
342
+ export async function loadReadCacheResource({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
231
343
  const resourceCache = cache.capture?.() ?? cache;
232
344
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
345
+ const capturedVersion = resourceCache.getVersion(key);
233
346
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
347
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
234
348
  if (forceFresh) {
235
349
  reportReadCacheOutcome(onOutcome, "forced-refresh");
236
350
  try {
@@ -249,19 +363,23 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
249
363
  }
250
364
  }
251
365
  const version = resourceCache.getVersion(key);
252
- const cached = skipCache
253
- ? null
254
- : (forcedFallback ?? (await resourceCache.get(key)));
366
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
367
+ // Capture ownership even when presentation skips the cached value.
368
+ const writeSnapshot = forcedFallback ?? (await resourceCache.get(key));
369
+ const cached = skipCache ? null : writeSnapshot;
255
370
  const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
256
- if (retained) {
371
+ if (retained && !requireSource) {
257
372
  const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
258
373
  reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
259
374
  onUpdate({
260
375
  data: cached.value,
261
376
  fromCache: true,
262
377
  status: fresh && !forceFresh ? "fresh" : "stale",
378
+ ...(includeProvenance
379
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
380
+ : {}),
263
381
  });
264
- if (fresh && !forceFresh) {
382
+ if (fresh && !forceFresh && !requireSource) {
265
383
  return cached.value;
266
384
  }
267
385
  }
@@ -269,16 +387,44 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
269
387
  reportReadCacheOutcome(onOutcome, "cache-miss");
270
388
  }
271
389
  try {
272
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
390
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
391
+ if (source.provenance !== "source-confirmed") {
392
+ if (requireSource)
393
+ throw (source.originalError ??
394
+ source.error ??
395
+ new Error("[Bayon offline] Source confirmation was required."));
396
+ if (resourceCache.getVersion(key) === version)
397
+ onUpdate({
398
+ data: source.data,
399
+ fromCache: source.fromCache,
400
+ status: source.status,
401
+ ...(source.error ? { error: source.error } : {}),
402
+ ...(includeProvenance
403
+ ? {
404
+ provenance: source.provenance,
405
+ savedAt: source.savedAt,
406
+ originalError: source.originalError,
407
+ }
408
+ : {}),
409
+ });
410
+ return source.data;
411
+ }
412
+ const fresh = source.data;
273
413
  reportReadCacheOutcome(onOutcome, "loader-success");
274
414
  if (resourceCache.getVersion(key) === version) {
275
415
  onUpdate({
276
416
  data: fresh,
277
417
  fromCache: false,
278
418
  status: retained ? "fresh" : "miss",
419
+ ...(includeProvenance
420
+ ? {
421
+ provenance: "source-confirmed",
422
+ confirmedAt: source.confirmedAt,
423
+ }
424
+ : {}),
279
425
  });
280
426
  }
281
- void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
427
+ void writeConfirmedResource(resourceCache, key, fresh, version, writeSnapshot, source.confirmedAt, Boolean(loadFreshResult)).catch((error) => {
282
428
  const normalizedError = normalizeError(error);
283
429
  console.error("[Bayon offline] Unable to refresh the read cache.", {
284
430
  error: normalizedError,
@@ -290,7 +436,10 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
290
436
  }
291
437
  catch (error) {
292
438
  reportReadCacheOutcome(onOutcome, "loader-failure");
293
- if (!retained || resolvedPolicy.refreshFailure === "throw") {
439
+ if (error instanceof ReadCacheLoaderContractError)
440
+ throw error;
441
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
442
+ if (requireSource || !retained || failure !== "use-retained") {
294
443
  throw error;
295
444
  }
296
445
  onUpdate({
@@ -298,10 +447,112 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
298
447
  error: normalizeError(error),
299
448
  fromCache: true,
300
449
  status: "stale",
450
+ ...(includeProvenance
451
+ ? {
452
+ provenance: "retained-fallback",
453
+ savedAt: cached.savedAt,
454
+ originalError: error,
455
+ }
456
+ : {}),
301
457
  });
302
458
  return cached.value;
303
459
  }
304
460
  }
461
+ class ReadCacheLoaderContractError extends TypeError {
462
+ }
463
+ // Older envelopes have no identity token. Avoid comparisons that JSON would
464
+ // collapse, such as distinct Maps, non-finite numbers, or cyclic values.
465
+ function isComparableLegacyValue(value, seen = new Set()) {
466
+ if (value === null || typeof value === "string" || typeof value === "boolean")
467
+ return true;
468
+ if (typeof value === "number")
469
+ return Number.isFinite(value);
470
+ if (typeof value !== "object" || seen.has(value))
471
+ return false;
472
+ if (value instanceof Date)
473
+ return Number.isFinite(value.getTime());
474
+ if (Array.isArray(value) &&
475
+ (Object.keys(value).length !== value.length ||
476
+ Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).some((present) => !present)))
477
+ return false;
478
+ if (!Array.isArray(value) &&
479
+ Object.getPrototypeOf(value) !== Object.prototype)
480
+ return false;
481
+ seen.add(value);
482
+ const comparable = Object.values(value).every((item) => isComparableLegacyValue(item, seen));
483
+ seen.delete(value);
484
+ return comparable;
485
+ }
486
+ async function resolveFreshLoad(loadFresh, loadFreshResult) {
487
+ if (!!loadFresh === !!loadFreshResult)
488
+ throw new ReadCacheLoaderContractError("[Bayon offline] Provide exactly one read loader.");
489
+ if (loadFreshResult) {
490
+ const result = await loadFreshResult();
491
+ if (!result ||
492
+ !["source-confirmed", "cache-hit", "retained-fallback"].includes(result.provenance ?? ""))
493
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read must report valid provenance.");
494
+ const cached = result.provenance !== "source-confirmed";
495
+ if (result.confirmedAt !== undefined &&
496
+ (typeof result.confirmedAt !== "string" ||
497
+ !Number.isFinite(Date.parse(result.confirmedAt)))) {
498
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has an invalid confirmedAt timestamp.");
499
+ }
500
+ if (result.fromCache !== cached ||
501
+ (cached &&
502
+ (typeof result.savedAt !== "string" ||
503
+ !Number.isFinite(Date.parse(result.savedAt)))) ||
504
+ (result.provenance === "retained-fallback"
505
+ ? !result.error || !result.stale
506
+ : result.error !== null)) {
507
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has inconsistent provenance metadata.");
508
+ }
509
+ return result;
510
+ }
511
+ const data = await loadFresh();
512
+ return {
513
+ data,
514
+ error: null,
515
+ fromCache: false,
516
+ stale: false,
517
+ status: "miss",
518
+ provenance: "source-confirmed",
519
+ confirmedAt: new Date().toISOString(),
520
+ };
521
+ }
522
+ function writeConfirmedResource(cache, key, value, version, snapshot, confirmedAt, layered) {
523
+ // Preserve the established write hook for ordinary, value-only loaders.
524
+ if (!layered)
525
+ return cache.setIfVersion(key, value, version, snapshot, confirmedAt);
526
+ if (confirmedAt && cache.setIfVersionAt)
527
+ return cache.setIfVersionAt(key, value, version, snapshot, confirmedAt);
528
+ // A legacy adapter cannot preserve the age of a layered confirmation.
529
+ return Promise.resolve(false);
530
+ }
531
+ async function handleReadFailure(cache, key, version, snapshot, policy, error) {
532
+ let failure;
533
+ try {
534
+ failure =
535
+ typeof policy.refreshFailure === "function"
536
+ ? policy.refreshFailure(error)
537
+ : policy.refreshFailure;
538
+ }
539
+ catch (classificationError) {
540
+ console.error("[Bayon offline] Unable to classify the read failure; preserving the source error.", { error: normalizeError(classificationError), key });
541
+ return "throw";
542
+ }
543
+ if (failure === "retire" && snapshot) {
544
+ try {
545
+ if (cache.deleteIfVersion)
546
+ await cache.deleteIfVersion(key, version, snapshot);
547
+ else
548
+ console.error("[Bayon offline] Cannot safely retire a read-cache entry: adapter lacks deleteIfVersion.", { key });
549
+ }
550
+ catch (retirementError) {
551
+ console.error("[Bayon offline] Unable to retire the denied read-cache entry.", { key, error: normalizeError(retirementError) });
552
+ }
553
+ }
554
+ return failure;
555
+ }
305
556
  function reportReadCacheOutcome(onOutcome, outcome) {
306
557
  if (!onOutcome)
307
558
  return;
@@ -342,7 +593,9 @@ function resolvePolicy(policy, maxAgeMs) {
342
593
  policy.freshForMs < 0 ||
343
594
  !Number.isFinite(policy.retainForMs) ||
344
595
  policy.retainForMs < policy.freshForMs ||
345
- !["use-retained", "throw"].includes(policy.refreshFailure)) {
596
+ !(typeof policy.refreshFailure === "function" ||
597
+ policy.refreshFailure === "use-retained" ||
598
+ policy.refreshFailure === "throw")) {
346
599
  throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
347
600
  }
348
601
  return policy;
@@ -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>;