@chipmobilesdk/rn-notification 0.4.0 → 0.5.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +257 -166
  2. package/README.md +420 -410
  3. package/package.json +61 -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 +142 -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 +81 -72
  19. package/src/diagnostics/redact.ts +55 -72
  20. package/src/diagnostics/snapshot.ts +100 -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 +715 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +247 -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 +150 -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 +294 -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 +101 -73
  64. package/src/state/mmkv-store.ts +92 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +270 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +43 -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
package/src/initialize.ts CHANGED
@@ -1,236 +1,247 @@
1
- /**
2
- * Package initialization.
3
- *
4
- * Composes the parts that must be in place before the first schedule call:
5
- * validate configuration, load persisted state, apply tone declarations, run
6
- * any pending channel migration, and start the maintenance registry.
7
- *
8
- * Budgeted at 100 ms of app startup, which is why state loading is synchronous
9
- * and tone declaration is the only platform round-trip.
10
- *
11
- * Automatic maintenance is started here too: whatever steps are registered on
12
- * `runtime.registry` run when the app returns to the foreground, with the
13
- * trigger reported as `timeZoneChange` when the device zone moved meanwhile.
14
- * Until 0.4.0 nothing ever called `runAutomatic`, so "automatic" maintenance
15
- * only ran when the app invoked it by hand.
16
- */
17
-
18
- import { AppState } from 'react-native';
19
- import type { InitializeResult, NotificationConfig } from './contract/diagnostics';
20
- import { DEFAULT_LOOKAHEAD, LOOKAHEAD_BOUNDS, type LookaheadConfig } from './contract/recurrence';
21
- import type { MaintenanceResult } from './contract/reconcile';
22
- import { DIAGNOSTIC_CODES, NotificationError, type DiagnosticCode } from './contract/errors';
23
- import { DiagnosticEmitter } from './diagnostics/events';
24
- import { MaintenanceRegistry } from './maintenance/cycle';
25
- import { MaintenanceController } from './maintenance/toggle';
26
- import { StateManager } from './state/record';
27
- import { ownedIdentifiers } from './state/series-ledger';
28
- import type { StateStore } from './state/store';
29
- import type { NotificationEngineAdapter } from './engine/adapter';
30
- import { createChannels, type Channels } from './channel/migrate';
31
- import { createPlatformTimeZoneReader, type TimeZoneReader } from './schedule/anchor';
32
-
33
- /** The slice of React Native's `AppState` the lifecycle observer uses. */
34
- export interface AppStateSource {
35
- addEventListener(type: 'change', listener: (state: string) => void): { remove(): void };
36
- }
37
-
38
- export interface RuntimeDependencies {
39
- readonly engine: NotificationEngineAdapter;
40
- readonly store: StateStore;
41
- readonly now?: () => number;
42
- /** Injected in tests; defaults to React Native's `AppState`. */
43
- readonly appState?: AppStateSource;
44
- /** Injected in tests; defaults to the platform zone reader. */
45
- readonly timeZone?: TimeZoneReader;
46
- }
47
-
48
- /** Everything assembled at initialization, shared by the operation modules. */
49
- export interface NotificationRuntime {
50
- readonly engine: NotificationEngineAdapter;
51
- readonly state: StateManager;
52
- readonly emitter: DiagnosticEmitter;
53
- readonly registry: MaintenanceRegistry;
54
- readonly maintenance: MaintenanceController;
55
- /** The channel module that applied `config.tones`. Added in 0.4.0. */
56
- readonly channels: Channels;
57
- readonly lookahead: LookaheadConfig;
58
- readonly config: NotificationConfig;
59
- readonly now: () => number;
60
- /** The result of the latest automatic cycle, if one has run. Added in 0.4.0. */
61
- readonly lastAutomaticMaintenance: () => MaintenanceResult | undefined;
62
- /** Stops the lifecycle observer. Added in 0.4.0. */
63
- dispose(): void;
64
- }
65
-
66
- /**
67
- * Clamps the lookahead into its documented bounds.
68
- *
69
- * Bounded on adjustment because the platform ceiling is not negotiable: a
70
- * consumer asking for a five-year window would silently exhaust the 64-entry
71
- * iOS ceiling on the first frequent series.
72
- */
73
- export function resolveLookahead(partial: Partial<LookaheadConfig> | undefined): LookaheadConfig {
74
- const days = partial?.days ?? DEFAULT_LOOKAHEAD.days;
75
- const occurrencesPerSeries = partial?.occurrencesPerSeries ?? DEFAULT_LOOKAHEAD.occurrencesPerSeries;
76
-
77
- return {
78
- days: Math.min(Math.max(days, LOOKAHEAD_BOUNDS.minDays), LOOKAHEAD_BOUNDS.maxDays),
79
- occurrencesPerSeries: Math.min(
80
- Math.max(occurrencesPerSeries, LOOKAHEAD_BOUNDS.minOccurrences),
81
- LOOKAHEAD_BOUNDS.maxOccurrences,
82
- ),
83
- };
84
- }
85
-
86
- /** Fails before any platform call, so a bad configuration never half-applies. */
87
- export function validateConfig(config: NotificationConfig): void {
88
- if (config.tones.length === 0) {
89
- throw new NotificationError(
90
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
91
- 'At least one tone must be declared before notifications can be scheduled.',
92
- );
93
- }
94
-
95
- const seen = new Set<string>();
96
- for (const tone of config.tones) {
97
- if (seen.has(tone.id)) {
98
- throw new NotificationError(
99
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
100
- `Tone "${tone.id}" is declared more than once.`,
101
- { toneId: tone.id },
102
- );
103
- }
104
- seen.add(tone.id);
105
-
106
- if (!Number.isInteger(tone.version) || tone.version < 1) {
107
- throw new NotificationError(
108
- DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
109
- `Tone "${tone.id}" must carry an integer version of 1 or greater, because a channel configuration change is a migration rather than an assignment.`,
110
- { toneId: tone.id, version: tone.version },
111
- );
112
- }
113
- }
114
-
115
- const groupIds = new Set((config.channelGroups ?? []).map(group => group.id));
116
- for (const tone of config.tones) {
117
- if (tone.groupId !== undefined && !groupIds.has(tone.groupId)) {
118
- throw new NotificationError(
119
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
120
- `Tone "${tone.id}" references undeclared channel group "${tone.groupId}".`,
121
- { toneId: tone.id, groupId: tone.groupId },
122
- );
123
- }
124
- }
125
- }
126
-
127
- export async function initialize(
128
- config: NotificationConfig,
129
- deps: RuntimeDependencies,
130
- ): Promise<{ runtime: NotificationRuntime; result: InitializeResult }> {
131
- const now = deps.now ?? (() => Date.now());
132
- const startedAt = now();
133
-
134
- validateConfig(config);
135
-
136
- const emitter = new DiagnosticEmitter({
137
- sink: config.eventSink,
138
- verbose: config.verboseDiagnostics,
139
- now,
140
- });
141
-
142
- const state = new StateManager({
143
- store: deps.store,
144
- now,
145
- emit: event => emitter.emit(event.code, describeStateEvent(event.code), event.context),
146
- });
147
-
148
- const channels = createChannels({
149
- engine: deps.engine,
150
- state,
151
- now,
152
- // The channel module emits only DIAGNOSTIC_CODES values; its port types the
153
- // code as a string so it stays usable without the emitter.
154
- emit: (code, message, context) => emitter.emit(code as DiagnosticCode, message, context),
155
- });
156
- // Declares groups and tones, and runs any version migration. An unchanged
157
- // configuration costs no platform work, so doing this on every launch is free.
158
- const applied = await channels.apply(config.tones, config.channelGroups ?? []);
159
-
160
- const registry = new MaintenanceRegistry({ clock: now });
161
- const maintenance = new MaintenanceController({
162
- registry,
163
- now,
164
- // Occurrence identifiers, not series identifiers. Maintenance cancels
165
- // occurrences, so a set of series ids made every legitimate cancel look
166
- // like a scope violation.
167
- ownedIds: async () =>
168
- ownedIdentifiers(state, (await deps.engine.getPending()).map(entry => entry.id)),
169
- enabled: config.automaticMaintenance ?? true,
170
- });
171
-
172
- const timeZone = deps.timeZone ?? createPlatformTimeZoneReader();
173
- let lastZone = timeZone();
174
- let lastAutomatic: MaintenanceResult | undefined;
175
- const onAppState = (next: string) => {
176
- if (next !== 'active') return;
177
- const zone = timeZone();
178
- const trigger = zone !== lastZone ? 'timeZoneChange' : 'foreground';
179
- lastZone = zone;
180
- maintenance.runAutomatic(trigger).then(
181
- result => {
182
- if (result !== undefined) lastAutomatic = result;
183
- },
184
- error => {
185
- // Nothing awaits an automatic cycle, so a failure is reported here or
186
- // not at all.
187
- emitter.emit(
188
- DIAGNOSTIC_CODES.MAINTENANCE_FAILED,
189
- `Automatic maintenance (${trigger}) failed: ${error instanceof Error ? error.name : 'unknown error'}.`,
190
- { trigger },
191
- );
192
- },
193
- );
194
- };
195
- const subscription = (deps.appState ?? AppState).addEventListener('change', onAppState);
196
-
197
- const runtime: NotificationRuntime = {
198
- engine: deps.engine,
199
- state,
200
- emitter,
201
- registry,
202
- maintenance,
203
- channels,
204
- lookahead: resolveLookahead(config.lookahead),
205
- config,
206
- now,
207
- lastAutomaticMaintenance: () => lastAutomatic,
208
- dispose: () => subscription.remove(),
209
- };
210
-
211
- return {
212
- runtime,
213
- result: {
214
- tonesApplied: config.tones.length,
215
- migrationsRun: applied.migrated.length,
216
- stateRebuilt: state.health().rebuilt,
217
- automaticMaintenance: maintenance.isEnabled,
218
- durationMs: now() - startedAt,
219
- },
220
- };
221
- }
222
-
223
- function describeStateEvent(code: string): string {
224
- switch (code) {
225
- case DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT:
226
- return 'Persisted state was unreadable and has been rebuilt. Deduplication history and buffered interactions from before the loss are gone; scheduled notifications are unaffected.';
227
- case DIAGNOSTIC_CODES.STATE_WRITE_FAILED:
228
- return 'Persisted state could not be written. The affected guarantee degrades to this session only.';
229
- case DIAGNOSTIC_CODES.STATE_CAP_EVICTED:
230
- return 'A persisted collection reached its cap and the oldest entries were evicted.';
231
- case DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED:
232
- return 'Buffered interactions were evicted at the cap or retention limit.';
233
- default:
234
- return 'Persisted state event.';
235
- }
236
- }
1
+ /**
2
+ * Package initialization.
3
+ *
4
+ * Composes the parts that must be in place before the first schedule call:
5
+ * validate configuration, load persisted state, apply tone declarations, run
6
+ * any pending channel migration, and start the maintenance registry.
7
+ *
8
+ * Budgeted at 100 ms of app startup, which is why state loading is synchronous
9
+ * and tone declaration is the only platform round-trip.
10
+ *
11
+ * Automatic maintenance is started here too: whatever steps are registered on
12
+ * `runtime.registry` run when the app returns to the foreground, with the
13
+ * trigger reported as `timeZoneChange` when the device zone moved meanwhile.
14
+ * Until 0.4.0 nothing ever called `runAutomatic`, so "automatic" maintenance
15
+ * only ran when the app invoked it by hand.
16
+ */
17
+
18
+ import { appLifecycle } from '@chipmobilesdk/rn-core';
19
+ import type { InitializeResult, NotificationConfig } from './contract/diagnostics';
20
+ import { DEFAULT_LOOKAHEAD, LOOKAHEAD_BOUNDS, type LookaheadConfig } from './contract/recurrence';
21
+ import type { MaintenanceResult } from './contract/reconcile';
22
+ import { DIAGNOSTIC_CODES, NotificationError, type DiagnosticCode } from './contract/errors';
23
+ import { DiagnosticEmitter } from './diagnostics/events';
24
+ import { MaintenanceRegistry } from './maintenance/cycle';
25
+ import { MaintenanceController } from './maintenance/toggle';
26
+ import { StateManager } from './state/record';
27
+ import { ownedIdentifiers } from './state/series-ledger';
28
+ import type { StateStore } from './state/store';
29
+ import type { NotificationEngineAdapter } from './engine/adapter';
30
+ import { createChannels, type Channels } from './channel/migrate';
31
+ import { createPlatformTimeZoneReader, type TimeZoneReader } from './schedule/anchor';
32
+
33
+ /** The slice of React Native's `AppState` the lifecycle observer uses. */
34
+ export interface AppStateSource {
35
+ addEventListener(type: 'change', listener: (state: string) => void): { remove(): void };
36
+ }
37
+
38
+ /** rn-core's shared lifecycle port in the observer's shape: one native subscription for every package (PERF-07). */
39
+ const sharedLifecycle: AppStateSource = {
40
+ addEventListener: (_type, listener) => ({
41
+ remove: appLifecycle().subscribe(state => listener(state)),
42
+ }),
43
+ };
44
+
45
+ export interface RuntimeDependencies {
46
+ readonly engine: NotificationEngineAdapter;
47
+ readonly store: StateStore;
48
+ readonly now?: () => number;
49
+ /** Injected in tests; defaults to rn-core's shared `appLifecycle()`. */
50
+ readonly appState?: AppStateSource;
51
+ /** Injected in tests; defaults to the platform zone reader. */
52
+ readonly timeZone?: TimeZoneReader;
53
+ }
54
+
55
+ /** Everything assembled at initialization, shared by the operation modules. */
56
+ export interface NotificationRuntime {
57
+ readonly engine: NotificationEngineAdapter;
58
+ readonly state: StateManager;
59
+ readonly emitter: DiagnosticEmitter;
60
+ readonly registry: MaintenanceRegistry;
61
+ readonly maintenance: MaintenanceController;
62
+ /** The channel module that applied `config.tones`. Added in 0.4.0. */
63
+ readonly channels: Channels;
64
+ readonly lookahead: LookaheadConfig;
65
+ readonly config: NotificationConfig;
66
+ readonly now: () => number;
67
+ /** The result of the latest automatic cycle, if one has run. Added in 0.4.0. */
68
+ readonly lastAutomaticMaintenance: () => MaintenanceResult | undefined;
69
+ /** Stops the lifecycle observer. Added in 0.4.0. */
70
+ dispose(): void;
71
+ }
72
+
73
+ /**
74
+ * Clamps the lookahead into its documented bounds.
75
+ *
76
+ * Bounded on adjustment because the platform ceiling is not negotiable: a
77
+ * consumer asking for a five-year window would silently exhaust the 64-entry
78
+ * iOS ceiling on the first frequent series.
79
+ */
80
+ export function resolveLookahead(partial: Partial<LookaheadConfig> | undefined): LookaheadConfig {
81
+ const days = partial?.days ?? DEFAULT_LOOKAHEAD.days;
82
+ const occurrencesPerSeries =
83
+ partial?.occurrencesPerSeries ?? DEFAULT_LOOKAHEAD.occurrencesPerSeries;
84
+
85
+ return {
86
+ days: Math.min(Math.max(days, LOOKAHEAD_BOUNDS.minDays), LOOKAHEAD_BOUNDS.maxDays),
87
+ occurrencesPerSeries: Math.min(
88
+ Math.max(occurrencesPerSeries, LOOKAHEAD_BOUNDS.minOccurrences),
89
+ LOOKAHEAD_BOUNDS.maxOccurrences,
90
+ ),
91
+ };
92
+ }
93
+
94
+ /** Fails before any platform call, so a bad configuration never half-applies. */
95
+ export function validateConfig(config: NotificationConfig): void {
96
+ if (config.tones.length === 0) {
97
+ throw new NotificationError(
98
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
99
+ 'At least one tone must be declared before notifications can be scheduled.',
100
+ );
101
+ }
102
+
103
+ const seen = new Set<string>();
104
+ for (const tone of config.tones) {
105
+ if (seen.has(tone.id)) {
106
+ throw new NotificationError(
107
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
108
+ `Tone "${tone.id}" is declared more than once.`,
109
+ { toneId: tone.id },
110
+ );
111
+ }
112
+ seen.add(tone.id);
113
+
114
+ if (!Number.isInteger(tone.version) || tone.version < 1) {
115
+ throw new NotificationError(
116
+ DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
117
+ `Tone "${tone.id}" must carry an integer version of 1 or greater, because a channel configuration change is a migration rather than an assignment.`,
118
+ { toneId: tone.id, version: tone.version },
119
+ );
120
+ }
121
+ }
122
+
123
+ const groupIds = new Set((config.channelGroups ?? []).map(group => group.id));
124
+ for (const tone of config.tones) {
125
+ if (tone.groupId !== undefined && !groupIds.has(tone.groupId)) {
126
+ throw new NotificationError(
127
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
128
+ `Tone "${tone.id}" references undeclared channel group "${tone.groupId}".`,
129
+ { toneId: tone.id, groupId: tone.groupId },
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ export async function initialize(
136
+ config: NotificationConfig,
137
+ deps: RuntimeDependencies,
138
+ ): Promise<{ runtime: NotificationRuntime; result: InitializeResult }> {
139
+ const now = deps.now ?? (() => Date.now());
140
+ const startedAt = now();
141
+
142
+ validateConfig(config);
143
+
144
+ const emitter = new DiagnosticEmitter({
145
+ sink: config.eventSink,
146
+ verbose: config.verboseDiagnostics,
147
+ now,
148
+ });
149
+
150
+ const state = new StateManager({
151
+ store: deps.store,
152
+ now,
153
+ emit: event => emitter.emit(event.code, describeStateEvent(event.code), event.context),
154
+ });
155
+
156
+ const channels = createChannels({
157
+ engine: deps.engine,
158
+ state,
159
+ now,
160
+ // The channel module emits only DIAGNOSTIC_CODES values; its port types the
161
+ // code as a string so it stays usable without the emitter.
162
+ emit: (code, message, context) => emitter.emit(code as DiagnosticCode, message, context),
163
+ });
164
+ // Declares groups and tones, and runs any version migration. An unchanged
165
+ // configuration costs no platform work, so doing this on every launch is free.
166
+ const applied = await channels.apply(config.tones, config.channelGroups ?? []);
167
+
168
+ const registry = new MaintenanceRegistry({ clock: now });
169
+ const maintenance = new MaintenanceController({
170
+ registry,
171
+ now,
172
+ // Occurrence identifiers, not series identifiers. Maintenance cancels
173
+ // occurrences, so a set of series ids made every legitimate cancel look
174
+ // like a scope violation.
175
+ ownedIds: async () =>
176
+ ownedIdentifiers(
177
+ state,
178
+ (await deps.engine.getPending()).map(entry => entry.id),
179
+ ),
180
+ enabled: config.automaticMaintenance ?? true,
181
+ });
182
+
183
+ const timeZone = deps.timeZone ?? createPlatformTimeZoneReader();
184
+ let lastZone = timeZone();
185
+ let lastAutomatic: MaintenanceResult | undefined;
186
+ const onAppState = (next: string) => {
187
+ if (next !== 'active') return;
188
+ const zone = timeZone();
189
+ const trigger = zone !== lastZone ? 'timeZoneChange' : 'foreground';
190
+ lastZone = zone;
191
+ maintenance.runAutomatic(trigger).then(
192
+ result => {
193
+ if (result !== undefined) lastAutomatic = result;
194
+ },
195
+ error => {
196
+ // Nothing awaits an automatic cycle, so a failure is reported here or
197
+ // not at all.
198
+ emitter.emit(
199
+ DIAGNOSTIC_CODES.MAINTENANCE_FAILED,
200
+ `Automatic maintenance (${trigger}) failed: ${error instanceof Error ? error.name : 'unknown error'}.`,
201
+ { trigger },
202
+ );
203
+ },
204
+ );
205
+ };
206
+ const subscription = (deps.appState ?? sharedLifecycle).addEventListener('change', onAppState);
207
+
208
+ const runtime: NotificationRuntime = {
209
+ engine: deps.engine,
210
+ state,
211
+ emitter,
212
+ registry,
213
+ maintenance,
214
+ channels,
215
+ lookahead: resolveLookahead(config.lookahead),
216
+ config,
217
+ now,
218
+ lastAutomaticMaintenance: () => lastAutomatic,
219
+ dispose: () => subscription.remove(),
220
+ };
221
+
222
+ return {
223
+ runtime,
224
+ result: {
225
+ tonesApplied: config.tones.length,
226
+ migrationsRun: applied.migrated.length,
227
+ stateRebuilt: state.health().rebuilt,
228
+ automaticMaintenance: maintenance.isEnabled,
229
+ durationMs: now() - startedAt,
230
+ },
231
+ };
232
+ }
233
+
234
+ function describeStateEvent(code: string): string {
235
+ switch (code) {
236
+ case DIAGNOSTIC_CODES.STATE_CORRUPT_REBUILT:
237
+ return 'Persisted state was unreadable and has been rebuilt. Deduplication history and buffered interactions from before the loss are gone; scheduled notifications are unaffected.';
238
+ case DIAGNOSTIC_CODES.STATE_WRITE_FAILED:
239
+ return 'Persisted state could not be written. The affected guarantee degrades to this session only.';
240
+ case DIAGNOSTIC_CODES.STATE_CAP_EVICTED:
241
+ return 'A persisted collection reached its cap and the oldest entries were evicted.';
242
+ case DIAGNOSTIC_CODES.INTERACTION_BUFFER_EVICTED:
243
+ return 'Buffered interactions were evicted at the cap or retention limit.';
244
+ default:
245
+ return 'Persisted state event.';
246
+ }
247
+ }