@chipmobilesdk/rn-notification 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +174 -166
  2. package/README.md +414 -410
  3. package/package.json +58 -75
  4. package/skills/sdk-rn-notification/SKILL.md +172 -173
  5. package/src/channel/migrate.ts +285 -289
  6. package/src/channel/tone-map.ts +130 -130
  7. package/src/channel/validate-tone.ts +61 -61
  8. package/src/contract/channel.ts +192 -188
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +134 -134
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +55 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +129 -129
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +67 -64
  21. package/src/engine/adapter.ts +172 -172
  22. package/src/engine/codec.ts +89 -89
  23. package/src/engine/notify-kit-engine.ts +706 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +240 -236
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -154
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -101
  30. package/src/maintenance/toggle.ts +66 -64
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -64
  33. package/src/permission/index.ts +183 -183
  34. package/src/permission/state.ts +77 -77
  35. package/src/push/envelope.ts +165 -165
  36. package/src/push/fcm-provider.ts +143 -146
  37. package/src/push/gate.ts +190 -190
  38. package/src/push/index.ts +33 -29
  39. package/src/push/instructions.ts +114 -109
  40. package/src/push/provider-adapter.ts +54 -54
  41. package/src/push/signout.ts +65 -65
  42. package/src/reconcile/chunk.ts +53 -53
  43. package/src/reconcile/diff.ts +142 -145
  44. package/src/reconcile/duplicates.ts +73 -73
  45. package/src/reconcile/index.ts +180 -180
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -319
  49. package/src/recurrence/index.ts +270 -259
  50. package/src/repeat/expiry.ts +53 -55
  51. package/src/repeat/index.ts +54 -54
  52. package/src/repeat/marker.ts +72 -72
  53. package/src/repeat/place.ts +73 -73
  54. package/src/schedule/anchor.ts +278 -267
  55. package/src/schedule/index.ts +174 -181
  56. package/src/schedule/partition.ts +88 -85
  57. package/src/schedule/resolve-moment.ts +80 -80
  58. package/src/schedule/validate-content.ts +111 -111
  59. package/src/schedule/validate-identifier.ts +126 -126
  60. package/src/schedule/writer.ts +55 -55
  61. package/src/state/caps.ts +71 -71
  62. package/src/state/dedup-ledger.ts +35 -35
  63. package/src/state/interaction-buffer.ts +73 -73
  64. package/src/state/mmkv-store.ts +75 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +266 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +40 -40
  70. package/src/testing/conformance/index.ts +258 -248
  71. package/src/testing/fake-engine.ts +462 -468
  72. package/src/testing/fake-provider.ts +126 -129
  73. package/src/testing/faults.ts +72 -72
  74. package/src/testing/index.ts +23 -28
  75. package/src/testing/measure.ts +337 -337
@@ -1,41 +1,41 @@
1
- /**
2
- * Permission-request history.
3
- *
4
- * The platform cannot tell you whether you have already asked. On both
5
- * platforms a second request after a denial is silently a no-op, so an app that
6
- * does not remember will keep "asking" and keep appearing to be refused.
7
- *
8
- * Keyed by owner tag so a multi-account device can clear one account's history
9
- * on sign-out without forgetting what it asked the others.
10
- */
11
-
12
- import type { StateManager } from './record';
13
- import type { PermissionKind } from '../contract/permission';
14
-
15
- function key(kind: PermissionKind, ownerTag?: string): string {
16
- return `${ownerTag ?? '*'}:${kind}`;
17
- }
18
-
19
- export function hasRequested(
20
- state: StateManager,
21
- kind: PermissionKind,
22
- ownerTag?: string,
23
- ): boolean {
24
- const target = key(kind, ownerTag);
25
- return state.read().permissionHistory.some(entry => entry.key === target && entry.requested);
26
- }
27
-
28
- export function recordRequested(
29
- state: StateManager,
30
- kind: PermissionKind,
31
- now: number,
32
- ownerTag?: string,
33
- ): void {
34
- const target = key(kind, ownerTag);
35
- if (hasRequested(state, kind, ownerTag)) return;
36
-
37
- state.update('permissionHistory', current => [
38
- ...current.filter(entry => entry.key !== target),
39
- { at: now, key: target, requested: true },
40
- ]);
41
- }
1
+ /**
2
+ * Permission-request history.
3
+ *
4
+ * The platform cannot tell you whether you have already asked. On both
5
+ * platforms a second request after a denial is silently a no-op, so an app that
6
+ * does not remember will keep "asking" and keep appearing to be refused.
7
+ *
8
+ * Keyed by owner tag so a multi-account device can clear one account's history
9
+ * on sign-out without forgetting what it asked the others.
10
+ */
11
+
12
+ import type { StateManager } from './record';
13
+ import type { PermissionKind } from '../contract/permission';
14
+
15
+ function key(kind: PermissionKind, ownerTag?: string): string {
16
+ return `${ownerTag ?? '*'}:${kind}`;
17
+ }
18
+
19
+ export function hasRequested(
20
+ state: StateManager,
21
+ kind: PermissionKind,
22
+ ownerTag?: string,
23
+ ): boolean {
24
+ const target = key(kind, ownerTag);
25
+ return state.read().permissionHistory.some(entry => entry.key === target && entry.requested);
26
+ }
27
+
28
+ export function recordRequested(
29
+ state: StateManager,
30
+ kind: PermissionKind,
31
+ now: number,
32
+ ownerTag?: string,
33
+ ): void {
34
+ const target = key(kind, ownerTag);
35
+ if (hasRequested(state, kind, ownerTag)) return;
36
+
37
+ state.update('permissionHistory', current => [
38
+ ...current.filter(entry => entry.key !== target),
39
+ { at: now, key: target, requested: true },
40
+ ]);
41
+ }
@@ -1,263 +1,266 @@
1
- /**
2
- * The persisted state record.
3
- *
4
- * Six collections, holding only what the platform will not tell us. No
5
- * notification content, no push token, no desired set. The interaction buffer
6
- * is the sole content-bearing exception, and it evicts on delivery.
7
- *
8
- * Corrupt or incompatible state is discarded and rebuilt rather than blocking:
9
- * everything already scheduled lives in the OS and is unaffected, which is what
10
- * makes discarding safe rather than destructive.
11
- */
12
-
13
- import type { StateStore } from './store';
14
- import { evict, COLLECTION_CAPS, type CollectionName, type Timestamped } from './caps';
15
- import type { AnchoringMode, RoutingPayload } from '../contract/notification';
16
- import type { CollectionHealth, StateHealth } from '../contract/diagnostics';
17
- import { DIAGNOSTIC_CODES, type DiagnosticCode } from '../contract/errors';
18
-
19
- const STATE_KEY = 'chipmobilesdk.notification.state';
20
-
21
- /**
22
- * Bumped whenever the shape below changes. A mismatch discards and rebuilds —
23
- * never a migration, because none of this data is worth migrating and all of it
24
- * is reconstructible.
25
- */
26
- export const STATE_SCHEMA_VERSION = 1;
27
-
28
- export interface DedupEntry extends Timestamped {
29
- readonly dedupId: string;
30
- readonly ownerTag?: string;
31
- }
32
-
33
- export interface BufferedInteraction extends Timestamped {
34
- readonly id: string;
35
- readonly kind: 'tap' | 'action' | 'dismiss';
36
- readonly notificationId: string;
37
- readonly actionId?: string;
38
- readonly inputText?: string;
39
- /** The one content-bearing thing persisted. Evicted the moment it is delivered. */
40
- readonly routing?: RoutingPayload;
41
- readonly ownerTag?: string;
42
- }
43
-
44
- export interface MigrationEntry extends Timestamped {
45
- readonly toneId: string;
46
- readonly appliedVersion: number;
47
- readonly channelId: string;
48
- readonly retired: boolean;
49
- /**
50
- * Fingerprint of the labels last pushed to the platform.
51
- *
52
- * Optional because records written before labels were carried at all have
53
- * none — and their absence is exactly right: an unknown fingerprint refreshes
54
- * once, which is the one-time correction every existing device needs, since
55
- * every existing channel currently carries the tone id as its name.
56
- *
57
- * A fingerprint rather than the labels themselves. Persisted state holds no
58
- * consumer text, and a hash answers the only question asked of it.
59
- */
60
- readonly labelFingerprint?: string;
61
- }
62
-
63
- export interface SeriesEntry extends Timestamped {
64
- readonly seriesId: string;
65
- readonly anchoring: AnchoringMode;
66
- readonly expandedThrough: number;
67
- readonly emittedCount: number;
68
- readonly exhausted: boolean;
69
- /**
70
- * Calendar key of the latest occurrence counted into `emittedCount`. Absent in
71
- * entries written before 0.4.0; recovered from the device on next expansion.
72
- */
73
- readonly lastEmittedKey?: string;
74
- }
75
-
76
- export interface PermissionHistoryEntry extends Timestamped {
77
- readonly key: string;
78
- readonly requested: boolean;
79
- }
80
-
81
- export interface StateRecord {
82
- readonly schemaVersion: number;
83
- readonly dedupLedger: readonly DedupEntry[];
84
- readonly interactionBuffer: readonly BufferedInteraction[];
85
- readonly migrationLedger: readonly MigrationEntry[];
86
- readonly seriesLedger: readonly SeriesEntry[];
87
- readonly permissionHistory: readonly PermissionHistoryEntry[];
88
- readonly lastReconcileSummary?: Readonly<Record<string, unknown>>;
89
- readonly lastMaintenanceSummary?: Readonly<Record<string, unknown>>;
90
- }
91
-
92
- export function emptyRecord(): StateRecord {
93
- return {
94
- schemaVersion: STATE_SCHEMA_VERSION,
95
- dedupLedger: [],
96
- interactionBuffer: [],
97
- migrationLedger: [],
98
- seriesLedger: [],
99
- permissionHistory: [],
100
- };
101
- }
102
-
103
- export interface StateEvent {
104
- readonly code: DiagnosticCode;
105
- readonly context?: Readonly<Record<string, unknown>>;
106
- }
107
-
108
- export interface StateManagerOptions {
109
- readonly store: StateStore;
110
- readonly now: () => number;
111
- readonly emit?: (event: StateEvent) => void;
112
- }
113
-
114
- /**
115
- * Owns the record's lifecycle. Reads are synchronous and cheap; writes are
116
- * best-effort and never propagate a failure to the caller's operation.
117
- */
118
- export class StateManager {
119
- private record: StateRecord;
120
- private rebuilt = false;
121
- private readonly store: StateStore;
122
- private readonly now: () => number;
123
- private readonly emit: (event: StateEvent) => void;
124
-
125
- constructor(options: StateManagerOptions) {
126
- this.store = options.store;
127
- this.now = options.now;
128
- this.emit = options.emit ?? (() => {});
129
- this.record = this.load();
130
- }
131
-
132
- private load(): StateRecord {
133
- const raw = this.store.getString(STATE_KEY);
134
- if (raw === undefined) {
135
- return emptyRecord();
136
- }
137
-
138
- try {
139
- const parsed = JSON.parse(raw) as Partial<StateRecord>;
140
- if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
141
- return this.discardAndRebuild('schema-version-mismatch', {
142
- found: parsed.schemaVersion,
143
- expected: STATE_SCHEMA_VERSION,
144
- });
145
- }
146
- return {
147
- schemaVersion: STATE_SCHEMA_VERSION,
148
- dedupLedger: parsed.dedupLedger ?? [],
149
- interactionBuffer: parsed.interactionBuffer ?? [],
150
- migrationLedger: parsed.migrationLedger ?? [],
151
- seriesLedger: parsed.seriesLedger ?? [],
152
- permissionHistory: parsed.permissionHistory ?? [],
153
- lastReconcileSummary: parsed.lastReconcileSummary,
154
- lastMaintenanceSummary: parsed.lastMaintenanceSummary,
155
- };
156
- } catch {
157
- return this.discardAndRebuild('unparseable');
158
- }
159
- }
160
-
161
- private discardAndRebuild(
162
- reason: string,
163
- extra?: Readonly<Record<string, unknown>>,
164
- ): StateRecord {
165
- this.rebuilt = true;
166
- this.store.delete(STATE_KEY);
167
- this.emit({
168
- code: DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT,
169
- // Note what is actually lost, so the consequence is stated rather than implied.
170
- context: { reason, lost: ['dedupLedger', 'interactionBuffer'], ...extra },
171
- });
172
- return emptyRecord();
173
- }
174
-
175
- /** Never throws. A storage failure degrades the affected guarantee, nothing more. */
176
- private persist(): void {
177
- try {
178
- this.store.setString(STATE_KEY, JSON.stringify(this.record));
179
- } catch (error) {
180
- this.emit({
181
- code: DIAGNOSTIC_CODES.STATE_WRITE_FAILED,
182
- context: { reason: error instanceof Error ? error.name : 'unknown' },
183
- });
184
- }
185
- }
186
-
187
- read(): StateRecord {
188
- return this.record;
189
- }
190
-
191
- /** Applies a change, evicts the touched collection, and persists. */
192
- update<K extends CollectionName>(
193
- collection: K,
194
- mutate: (current: StateRecord[K]) => StateRecord[K],
195
- ): void {
196
- const next = mutate(this.record[collection]);
197
- const { kept, evicted, reason } = evict(next as readonly Timestamped[], collection, this.now());
198
-
199
- if (evicted.length > 0) {
200
- this.emit({
201
- code:
202
- collection === 'interactionBuffer'
203
- ? DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED
204
- : DIAGNOSTIC_CODES.STATE_CAP_EVICTED,
205
- context: { collection, evicted: evicted.length, reason },
206
- });
207
- }
208
-
209
- this.record = { ...this.record, [collection]: kept } as StateRecord;
210
- this.persist();
211
- }
212
-
213
- setSummary(key: 'lastReconcileSummary' | 'lastMaintenanceSummary', value: Readonly<Record<string, unknown>>): void {
214
- this.record = { ...this.record, [key]: value };
215
- this.persist();
216
- }
217
-
218
- /** Clears everything. Public through `resetState()`. */
219
- reset(): void {
220
- this.record = emptyRecord();
221
- this.rebuilt = false;
222
- this.store.delete(STATE_KEY);
223
- }
224
-
225
- /**
226
- * Clears one owner tag's entries, leaving every other tag untouched.
227
- *
228
- * Collections without an owner tag are left alone: a migration ledger and a
229
- * series ledger describe the device, not an account.
230
- */
231
- clearOwner(ownerTag: string): void {
232
- this.record = {
233
- ...this.record,
234
- dedupLedger: this.record.dedupLedger.filter(e => e.ownerTag !== ownerTag),
235
- interactionBuffer: this.record.interactionBuffer.filter(e => e.ownerTag !== ownerTag),
236
- permissionHistory: this.record.permissionHistory.filter(
237
- e => !e.key.startsWith(`${ownerTag}:`),
238
- ),
239
- };
240
- this.persist();
241
- }
242
-
243
- health(): StateHealth {
244
- const collections: CollectionHealth[] = (
245
- Object.keys(COLLECTION_CAPS) as CollectionName[]
246
- ).map(name => {
247
- const entries = this.record[name] as readonly Timestamped[];
248
- return {
249
- name,
250
- size: entries.length,
251
- cap: COLLECTION_CAPS[name].maxEntries,
252
- oldestEntryAt: entries.length > 0 ? Math.min(...entries.map(e => e.at)) : undefined,
253
- };
254
- });
255
-
256
- return {
257
- readable: true,
258
- rebuilt: this.rebuilt,
259
- collections,
260
- approximateBytes: this.store.approximateBytes(),
261
- };
262
- }
263
- }
1
+ /**
2
+ * The persisted state record.
3
+ *
4
+ * Six collections, holding only what the platform will not tell us. No
5
+ * notification content, no push token, no desired set. The interaction buffer
6
+ * is the sole content-bearing exception, and it evicts on delivery.
7
+ *
8
+ * Corrupt or incompatible state is discarded and rebuilt rather than blocking:
9
+ * everything already scheduled lives in the OS and is unaffected, which is what
10
+ * makes discarding safe rather than destructive.
11
+ */
12
+
13
+ import type { StateStore } from './store';
14
+ import { evict, COLLECTION_CAPS, type CollectionName, type Timestamped } from './caps';
15
+ import type { AnchoringMode, RoutingPayload } from '../contract/notification';
16
+ import type { CollectionHealth, StateHealth } from '../contract/diagnostics';
17
+ import { DIAGNOSTIC_CODES, type DiagnosticCode } from '../contract/errors';
18
+
19
+ const STATE_KEY = 'chipmobilesdk.notification.state';
20
+
21
+ /**
22
+ * Bumped whenever the shape below changes. A mismatch discards and rebuilds —
23
+ * never a migration, because none of this data is worth migrating and all of it
24
+ * is reconstructible.
25
+ */
26
+ export const STATE_SCHEMA_VERSION = 1;
27
+
28
+ export interface DedupEntry extends Timestamped {
29
+ readonly dedupId: string;
30
+ readonly ownerTag?: string;
31
+ }
32
+
33
+ export interface BufferedInteraction extends Timestamped {
34
+ readonly id: string;
35
+ readonly kind: 'tap' | 'action' | 'dismiss';
36
+ readonly notificationId: string;
37
+ readonly actionId?: string;
38
+ readonly inputText?: string;
39
+ /** The one content-bearing thing persisted. Evicted the moment it is delivered. */
40
+ readonly routing?: RoutingPayload;
41
+ readonly ownerTag?: string;
42
+ }
43
+
44
+ export interface MigrationEntry extends Timestamped {
45
+ readonly toneId: string;
46
+ readonly appliedVersion: number;
47
+ readonly channelId: string;
48
+ readonly retired: boolean;
49
+ /**
50
+ * Fingerprint of the labels last pushed to the platform.
51
+ *
52
+ * Optional because records written before labels were carried at all have
53
+ * none — and their absence is exactly right: an unknown fingerprint refreshes
54
+ * once, which is the one-time correction every existing device needs, since
55
+ * every existing channel currently carries the tone id as its name.
56
+ *
57
+ * A fingerprint rather than the labels themselves. Persisted state holds no
58
+ * consumer text, and a hash answers the only question asked of it.
59
+ */
60
+ readonly labelFingerprint?: string;
61
+ }
62
+
63
+ export interface SeriesEntry extends Timestamped {
64
+ readonly seriesId: string;
65
+ readonly anchoring: AnchoringMode;
66
+ readonly expandedThrough: number;
67
+ readonly emittedCount: number;
68
+ readonly exhausted: boolean;
69
+ /**
70
+ * Calendar key of the latest occurrence counted into `emittedCount`. Absent in
71
+ * entries written before 0.4.0; recovered from the device on next expansion.
72
+ */
73
+ readonly lastEmittedKey?: string;
74
+ }
75
+
76
+ export interface PermissionHistoryEntry extends Timestamped {
77
+ readonly key: string;
78
+ readonly requested: boolean;
79
+ }
80
+
81
+ export interface StateRecord {
82
+ readonly schemaVersion: number;
83
+ readonly dedupLedger: readonly DedupEntry[];
84
+ readonly interactionBuffer: readonly BufferedInteraction[];
85
+ readonly migrationLedger: readonly MigrationEntry[];
86
+ readonly seriesLedger: readonly SeriesEntry[];
87
+ readonly permissionHistory: readonly PermissionHistoryEntry[];
88
+ readonly lastReconcileSummary?: Readonly<Record<string, unknown>>;
89
+ readonly lastMaintenanceSummary?: Readonly<Record<string, unknown>>;
90
+ }
91
+
92
+ export function emptyRecord(): StateRecord {
93
+ return {
94
+ schemaVersion: STATE_SCHEMA_VERSION,
95
+ dedupLedger: [],
96
+ interactionBuffer: [],
97
+ migrationLedger: [],
98
+ seriesLedger: [],
99
+ permissionHistory: [],
100
+ };
101
+ }
102
+
103
+ export interface StateEvent {
104
+ readonly code: DiagnosticCode;
105
+ readonly context?: Readonly<Record<string, unknown>>;
106
+ }
107
+
108
+ export interface StateManagerOptions {
109
+ readonly store: StateStore;
110
+ readonly now: () => number;
111
+ readonly emit?: (event: StateEvent) => void;
112
+ }
113
+
114
+ /**
115
+ * Owns the record's lifecycle. Reads are synchronous and cheap; writes are
116
+ * best-effort and never propagate a failure to the caller's operation.
117
+ */
118
+ export class StateManager {
119
+ private record: StateRecord;
120
+ private rebuilt = false;
121
+ private readonly store: StateStore;
122
+ private readonly now: () => number;
123
+ private readonly emit: (event: StateEvent) => void;
124
+
125
+ constructor(options: StateManagerOptions) {
126
+ this.store = options.store;
127
+ this.now = options.now;
128
+ this.emit = options.emit ?? (() => {});
129
+ this.record = this.load();
130
+ }
131
+
132
+ private load(): StateRecord {
133
+ const raw = this.store.getString(STATE_KEY);
134
+ if (raw === undefined) {
135
+ return emptyRecord();
136
+ }
137
+
138
+ try {
139
+ const parsed = JSON.parse(raw) as Partial<StateRecord>;
140
+ if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) {
141
+ return this.discardAndRebuild('schema-version-mismatch', {
142
+ found: parsed.schemaVersion,
143
+ expected: STATE_SCHEMA_VERSION,
144
+ });
145
+ }
146
+ return {
147
+ schemaVersion: STATE_SCHEMA_VERSION,
148
+ dedupLedger: parsed.dedupLedger ?? [],
149
+ interactionBuffer: parsed.interactionBuffer ?? [],
150
+ migrationLedger: parsed.migrationLedger ?? [],
151
+ seriesLedger: parsed.seriesLedger ?? [],
152
+ permissionHistory: parsed.permissionHistory ?? [],
153
+ lastReconcileSummary: parsed.lastReconcileSummary,
154
+ lastMaintenanceSummary: parsed.lastMaintenanceSummary,
155
+ };
156
+ } catch {
157
+ return this.discardAndRebuild('unparseable');
158
+ }
159
+ }
160
+
161
+ private discardAndRebuild(
162
+ reason: string,
163
+ extra?: Readonly<Record<string, unknown>>,
164
+ ): StateRecord {
165
+ this.rebuilt = true;
166
+ this.store.delete(STATE_KEY);
167
+ this.emit({
168
+ code: DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT,
169
+ // Note what is actually lost, so the consequence is stated rather than implied.
170
+ context: { reason, lost: ['dedupLedger', 'interactionBuffer'], ...extra },
171
+ });
172
+ return emptyRecord();
173
+ }
174
+
175
+ /** Never throws. A storage failure degrades the affected guarantee, nothing more. */
176
+ private persist(): void {
177
+ try {
178
+ this.store.setString(STATE_KEY, JSON.stringify(this.record));
179
+ } catch (error) {
180
+ this.emit({
181
+ code: DIAGNOSTIC_CODES.STATE_WRITE_FAILED,
182
+ context: { reason: error instanceof Error ? error.name : 'unknown' },
183
+ });
184
+ }
185
+ }
186
+
187
+ read(): StateRecord {
188
+ return this.record;
189
+ }
190
+
191
+ /** Applies a change, evicts the touched collection, and persists. */
192
+ update<K extends CollectionName>(
193
+ collection: K,
194
+ mutate: (current: StateRecord[K]) => StateRecord[K],
195
+ ): void {
196
+ const next = mutate(this.record[collection]);
197
+ const { kept, evicted, reason } = evict(next as readonly Timestamped[], collection, this.now());
198
+
199
+ if (evicted.length > 0) {
200
+ this.emit({
201
+ code:
202
+ collection === 'interactionBuffer'
203
+ ? DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED
204
+ : DIAGNOSTIC_CODES.STATE_CAP_EVICTED,
205
+ context: { collection, evicted: evicted.length, reason },
206
+ });
207
+ }
208
+
209
+ this.record = { ...this.record, [collection]: kept } as StateRecord;
210
+ this.persist();
211
+ }
212
+
213
+ setSummary(
214
+ key: 'lastReconcileSummary' | 'lastMaintenanceSummary',
215
+ value: Readonly<Record<string, unknown>>,
216
+ ): void {
217
+ this.record = { ...this.record, [key]: value };
218
+ this.persist();
219
+ }
220
+
221
+ /** Clears everything. Public through `resetState()`. */
222
+ reset(): void {
223
+ this.record = emptyRecord();
224
+ this.rebuilt = false;
225
+ this.store.delete(STATE_KEY);
226
+ }
227
+
228
+ /**
229
+ * Clears one owner tag's entries, leaving every other tag untouched.
230
+ *
231
+ * Collections without an owner tag are left alone: a migration ledger and a
232
+ * series ledger describe the device, not an account.
233
+ */
234
+ clearOwner(ownerTag: string): void {
235
+ this.record = {
236
+ ...this.record,
237
+ dedupLedger: this.record.dedupLedger.filter(e => e.ownerTag !== ownerTag),
238
+ interactionBuffer: this.record.interactionBuffer.filter(e => e.ownerTag !== ownerTag),
239
+ permissionHistory: this.record.permissionHistory.filter(
240
+ e => !e.key.startsWith(`${ownerTag}:`),
241
+ ),
242
+ };
243
+ this.persist();
244
+ }
245
+
246
+ health(): StateHealth {
247
+ const collections: CollectionHealth[] = (Object.keys(COLLECTION_CAPS) as CollectionName[]).map(
248
+ name => {
249
+ const entries = this.record[name] as readonly Timestamped[];
250
+ return {
251
+ name,
252
+ size: entries.length,
253
+ cap: COLLECTION_CAPS[name].maxEntries,
254
+ oldestEntryAt: entries.length > 0 ? Math.min(...entries.map(e => e.at)) : undefined,
255
+ };
256
+ },
257
+ );
258
+
259
+ return {
260
+ readable: true,
261
+ rebuilt: this.rebuilt,
262
+ collections,
263
+ approximateBytes: this.store.approximateBytes(),
264
+ };
265
+ }
266
+ }