@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.
- package/CHANGELOG.md +174 -166
- package/README.md +414 -410
- package/package.json +58 -75
- package/skills/sdk-rn-notification/SKILL.md +172 -173
- package/src/channel/migrate.ts +285 -289
- package/src/channel/tone-map.ts +130 -130
- package/src/channel/validate-tone.ts +61 -61
- package/src/contract/channel.ts +192 -188
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +134 -134
- package/src/contract/index.ts +10 -10
- package/src/contract/interaction.ts +62 -62
- package/src/contract/notification.ts +118 -118
- package/src/contract/permission.ts +55 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +129 -129
- package/src/diagnostics/burst.ts +101 -101
- package/src/diagnostics/events.ts +72 -72
- package/src/diagnostics/redact.ts +72 -72
- package/src/diagnostics/snapshot.ts +67 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -89
- package/src/engine/notify-kit-engine.ts +706 -707
- package/src/index.ts +208 -208
- package/src/initialize.ts +240 -236
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +150 -154
- package/src/maintenance/permission-refresh.ts +39 -41
- package/src/maintenance/reanchor.ts +101 -101
- package/src/maintenance/toggle.ts +66 -64
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +64 -64
- package/src/permission/index.ts +183 -183
- package/src/permission/state.ts +77 -77
- package/src/push/envelope.ts +165 -165
- package/src/push/fcm-provider.ts +143 -146
- package/src/push/gate.ts +190 -190
- package/src/push/index.ts +33 -29
- package/src/push/instructions.ts +114 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +142 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +180 -180
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +73 -71
- package/src/recurrence/expand.ts +326 -319
- package/src/recurrence/index.ts +270 -259
- package/src/repeat/expiry.ts +53 -55
- package/src/repeat/index.ts +54 -54
- package/src/repeat/marker.ts +72 -72
- package/src/repeat/place.ts +73 -73
- package/src/schedule/anchor.ts +278 -267
- package/src/schedule/index.ts +174 -181
- package/src/schedule/partition.ts +88 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -126
- package/src/schedule/writer.ts +55 -55
- package/src/state/caps.ts +71 -71
- package/src/state/dedup-ledger.ts +35 -35
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +75 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +266 -263
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -91
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +258 -248
- package/src/testing/fake-engine.ts +462 -468
- package/src/testing/fake-provider.ts +126 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/index.ts +23 -28
- 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
|
+
}
|
package/src/state/record.ts
CHANGED
|
@@ -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(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
*
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
),
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
name
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
}
|