@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,289 +1,285 @@
1
- /**
2
- * Channel declaration and migration.
3
- *
4
- * Android channels are immutable once created: declaring a channel whose id
5
- * already exists silently keeps the original configuration. That single fact is
6
- * why changing a tone is a **migration** rather than an assignment, and why
7
- * this package keeps its own ledger — the platform will not tell you which
8
- * version of a channel a device already holds.
9
- *
10
- * Migration creates the new channel, re-homes notifications still pointing at
11
- * the old one, and only then retires it. That order matters: retiring first
12
- * would leave live notifications attached to a channel that no longer exists.
13
- */
14
-
15
- import type {
16
- ChannelApplyResult,
17
- ChannelGroup,
18
- ToneDeclaration,
19
- } from '../contract/channel';
20
- import type { NotificationEngineAdapter } from '../engine/adapter';
21
- import type { StateManager, MigrationEntry } from '../state/record';
22
- import { DIAGNOSTIC_CODES } from '../contract/errors';
23
- import { channelIdFor, resolveTone, toneLabelFingerprint } from './tone-map';
24
- import { validateToneDeclarations } from './validate-tone';
25
- import { createScheduleWriter } from '../schedule/writer';
26
-
27
- export interface ChannelDependencies {
28
- readonly engine: NotificationEngineAdapter;
29
- readonly state: StateManager;
30
- readonly now: () => number;
31
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
32
- }
33
-
34
- function ledgerFor(state: StateManager, toneId: string): MigrationEntry | undefined {
35
- return state.read().migrationLedger.find(entry => entry.toneId === toneId && !entry.retired);
36
- }
37
-
38
- function recordApplied(
39
- state: StateManager,
40
- tone: ToneDeclaration,
41
- now: number,
42
- retiredChannelId: string | undefined,
43
- labelFingerprint: string,
44
- ): void {
45
- state.update('migrationLedger', current => {
46
- const withoutTone = current.filter(entry => entry.toneId !== tone.id);
47
- const retired =
48
- retiredChannelId === undefined
49
- ? []
50
- : [
51
- {
52
- at: now,
53
- toneId: tone.id,
54
- appliedVersion: 0,
55
- channelId: retiredChannelId,
56
- retired: true,
57
- },
58
- ];
59
-
60
- return [
61
- ...withoutTone,
62
- ...retired,
63
- {
64
- at: now,
65
- toneId: tone.id,
66
- appliedVersion: tone.version,
67
- channelId: channelIdFor(tone.id, tone.version),
68
- retired: false,
69
- labelFingerprint,
70
- },
71
- ];
72
- });
73
- }
74
-
75
- export function createChannels(deps: ChannelDependencies) {
76
- const emit = deps.emit ?? (() => {});
77
- // Re-homing re-writes each notification on the new channel; its stop marker
78
- // has to follow, or it would retire with the old channel.
79
- const writer = createScheduleWriter(deps.engine);
80
-
81
- return {
82
- /**
83
- * Applies a tone configuration, migrating any tone whose version moved.
84
- *
85
- * Applying an unchanged configuration performs no work at all: no channel
86
- * is recreated and no migration is triggered.
87
- */
88
- async apply(
89
- tones: readonly ToneDeclaration[],
90
- groups: readonly ChannelGroup[] = [],
91
- ): Promise<ChannelApplyResult> {
92
- // Validation completes before the first engine call, so a rejected
93
- // declaration leaves the device exactly as it was — the same order the
94
- // scheduler has always used.
95
- validateToneDeclarations(tones);
96
-
97
- const now = deps.now();
98
-
99
- const created: string[] = [];
100
- const migrated: { toneId: string; from: number; to: number }[] = [];
101
- const labelsRefreshed: string[] = [];
102
- const retired: string[] = [];
103
- const reHomed: string[] = [];
104
- const cancelled: string[] = [];
105
- const bypassUnavailable: string[] = [];
106
- const repeatUnavailable: string[] = [];
107
- const unchanged: string[] = [];
108
-
109
- if (groups.length > 0) {
110
- await deps.engine.declareGroups(groups);
111
- }
112
-
113
- const capabilities = await deps.engine.getCapabilities();
114
-
115
- for (const tone of tones) {
116
- const previous = ledgerFor(deps.state, tone.id);
117
- const resolved = resolveTone(tone);
118
- const fingerprint = toneLabelFingerprint(tone);
119
-
120
- // Refusals are reported for every tone that asked, whether or not
121
- // anything else about it moved. A platform declining an attribute
122
- // says nothing about whether the channel is new.
123
- if (tone.bypassDoNotDisturb === true && !capabilities.supportsDoNotDisturbBypass) {
124
- // Reported honestly rather than silently dropped — a consumer that
125
- // asked for a bypass deserves to know the platform refused.
126
- bypassUnavailable.push(tone.id);
127
- emit(
128
- DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
129
- `Tone "${tone.id}" requested a Do Not Disturb bypass, which this platform does not permit.`,
130
- { toneId: tone.id },
131
- );
132
- }
133
-
134
- if (tone.repeatAlert !== undefined) {
135
- if (!capabilities.supportsRepeatingAlert) {
136
- repeatUnavailable.push(tone.id);
137
- emit(
138
- DIAGNOSTIC_CODES.REPEAT_ALERT_UNAVAILABLE,
139
- `Tone "${tone.id}" requested a repeating alert, which this platform does not offer. The notification still delivers and still sounds once.`,
140
- { toneId: tone.id },
141
- );
142
- } else if (!deps.engine.hasBackgroundDeliveryHandler()) {
143
- // Said out loud at declaration time, because the alternative is a
144
- // user meeting it as a phone that will not stop ringing.
145
- emit(
146
- DIAGNOSTIC_CODES.REPEAT_STOP_HANDLER_MISSING,
147
- `Tone "${tone.id}" declares a repeating alert, but no background notification handler is registered. Its alert will not stop on its own while the app is not running. Call registerBackgroundNotificationHandler at module scope in index.js.`,
148
- { toneId: tone.id },
149
- );
150
- }
151
- }
152
-
153
- const versionMoved = previous !== undefined && previous.appliedVersion !== tone.version;
154
-
155
- if (previous !== undefined && !versionMoved) {
156
- // The version is the same, so nothing the platform freezes has
157
- // changed. Only the labels can still be stale — and correcting
158
- // those must cost the user nothing, so the platform is touched
159
- // only when the fingerprint actually moved.
160
- if (previous.labelFingerprint === fingerprint) {
161
- unchanged.push(tone.id);
162
- continue;
163
- }
164
-
165
- const outcome = await deps.engine.declareTone(resolved);
166
- if (outcome === 'created') {
167
- // The channel was gone — deleted by the user, or lost with the
168
- // app's data — so this is a first declaration rather than a
169
- // refresh, whatever our ledger believed.
170
- created.push(resolved.android.channelId);
171
- } else if (outcome === 'labelsRefreshed') {
172
- labelsRefreshed.push(tone.id);
173
- emit(
174
- DIAGNOSTIC_CODES.TONE_LABELS_REFRESHED,
175
- `Tone "${tone.id}" had its name and description updated in place. No channel was recreated, so every setting the user adjusted on it is untouched.`,
176
- { toneId: tone.id },
177
- );
178
- } else {
179
- unchanged.push(tone.id);
180
- }
181
-
182
- recordApplied(deps.state, tone, now, undefined, fingerprint);
183
- continue;
184
- }
185
-
186
- const outcome = await deps.engine.declareTone(resolved);
187
- if (outcome === 'created') {
188
- created.push(resolved.android.channelId);
189
- }
190
-
191
- if (previous === undefined) {
192
- recordApplied(deps.state, tone, now, undefined, fingerprint);
193
- continue;
194
- }
195
-
196
- // A version moved, so this is a migration rather than a first
197
- // declaration. Re-home before retiring: the reverse order would leave
198
- // live notifications attached to a channel that no longer exists.
199
- emit(
200
- DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
201
- `Tone "${tone.id}" moved from version ${previous.appliedVersion} to ${tone.version}. Android channels are immutable, so a new channel was created and the old one is being retired.`,
202
- { toneId: tone.id, from: previous.appliedVersion, to: tone.version },
203
- );
204
-
205
- const pending = await deps.engine.getPending();
206
- const affected = pending.filter(entry => entry.tone === tone.id);
207
-
208
- for (const entry of affected) {
209
- await writer.write({
210
- request: {
211
- id: entry.id,
212
- content: entry.content,
213
- tone: entry.tone,
214
- moment: { at: entry.resolvedAt, anchoring: entry.anchoring },
215
- routing: entry.routing,
216
- actions: entry.actions,
217
- ownerTag: entry.ownerTag,
218
- groupTag: entry.groupTag,
219
- },
220
- resolvedAt: entry.resolvedAt,
221
- tone: resolved,
222
- // Re-homing changes the channel, not the anchoring — preserve it.
223
- anchoringGeneration: entry.anchoringGeneration,
224
- approximate: entry.mayBeDelayed,
225
- seriesId: entry.seriesId,
226
- pushCreated: entry.pushCreated,
227
- });
228
- reHomed.push(entry.id);
229
- }
230
-
231
- const didRetire = await deps.engine.retireTone(previous.channelId);
232
- if (didRetire) {
233
- retired.push(previous.channelId);
234
- }
235
-
236
- // A version move outranks a label change: the new channel simply
237
- // carries the new labels, and reporting it as a refresh would hide a
238
- // migration the consumer needs to know about.
239
- migrated.push({ toneId: tone.id, from: previous.appliedVersion, to: tone.version });
240
- recordApplied(deps.state, tone, now, previous.channelId, fingerprint);
241
- }
242
-
243
- // Anything left on a retired channel would be an orphan — a notification
244
- // the user can no longer control from system settings.
245
- const orphans = await findOrphans(deps, tones);
246
- if (orphans.length > 0) {
247
- await deps.engine.cancelMany(orphans);
248
- cancelled.push(...orphans);
249
- emit(
250
- DIAGNOSTIC_CODES.CHANNEL_ORPHANS_FOUND,
251
- `${orphans.length} notification(s) remained on a retired channel and were cancelled, rather than left uncontrollable from system settings.`,
252
- { count: orphans.length },
253
- );
254
- }
255
-
256
- return {
257
- created,
258
- migrated,
259
- labelsRefreshed,
260
- retired,
261
- reHomed,
262
- cancelled,
263
- bypassUnavailable,
264
- repeatUnavailable,
265
- unchanged,
266
- };
267
- },
268
-
269
- /** Which version of each tone this device actually holds. */
270
- appliedVersions(): readonly { readonly toneId: string; readonly appliedVersion: number }[] {
271
- return deps.state
272
- .read()
273
- .migrationLedger.filter(entry => !entry.retired)
274
- .map(entry => ({ toneId: entry.toneId, appliedVersion: entry.appliedVersion }));
275
- },
276
- };
277
- }
278
-
279
- /** Notifications whose tone is no longer declared at all. */
280
- async function findOrphans(
281
- deps: ChannelDependencies,
282
- tones: readonly ToneDeclaration[],
283
- ): Promise<readonly string[]> {
284
- const declared = new Set(tones.map(tone => tone.id));
285
- const pending = await deps.engine.getPending();
286
- return pending.filter(entry => !declared.has(entry.tone)).map(entry => entry.id);
287
- }
288
-
289
- export type Channels = ReturnType<typeof createChannels>;
1
+ /**
2
+ * Channel declaration and migration.
3
+ *
4
+ * Android channels are immutable once created: declaring a channel whose id
5
+ * already exists silently keeps the original configuration. That single fact is
6
+ * why changing a tone is a **migration** rather than an assignment, and why
7
+ * this package keeps its own ledger — the platform will not tell you which
8
+ * version of a channel a device already holds.
9
+ *
10
+ * Migration creates the new channel, re-homes notifications still pointing at
11
+ * the old one, and only then retires it. That order matters: retiring first
12
+ * would leave live notifications attached to a channel that no longer exists.
13
+ */
14
+
15
+ import type { ChannelApplyResult, ChannelGroup, ToneDeclaration } from '../contract/channel';
16
+ import type { NotificationEngineAdapter } from '../engine/adapter';
17
+ import type { StateManager, MigrationEntry } from '../state/record';
18
+ import { DIAGNOSTIC_CODES } from '../contract/errors';
19
+ import { channelIdFor, resolveTone, toneLabelFingerprint } from './tone-map';
20
+ import { validateToneDeclarations } from './validate-tone';
21
+ import { createScheduleWriter } from '../schedule/writer';
22
+
23
+ export interface ChannelDependencies {
24
+ readonly engine: NotificationEngineAdapter;
25
+ readonly state: StateManager;
26
+ readonly now: () => number;
27
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
28
+ }
29
+
30
+ function ledgerFor(state: StateManager, toneId: string): MigrationEntry | undefined {
31
+ return state.read().migrationLedger.find(entry => entry.toneId === toneId && !entry.retired);
32
+ }
33
+
34
+ function recordApplied(
35
+ state: StateManager,
36
+ tone: ToneDeclaration,
37
+ now: number,
38
+ retiredChannelId: string | undefined,
39
+ labelFingerprint: string,
40
+ ): void {
41
+ state.update('migrationLedger', current => {
42
+ const withoutTone = current.filter(entry => entry.toneId !== tone.id);
43
+ const retired =
44
+ retiredChannelId === undefined
45
+ ? []
46
+ : [
47
+ {
48
+ at: now,
49
+ toneId: tone.id,
50
+ appliedVersion: 0,
51
+ channelId: retiredChannelId,
52
+ retired: true,
53
+ },
54
+ ];
55
+
56
+ return [
57
+ ...withoutTone,
58
+ ...retired,
59
+ {
60
+ at: now,
61
+ toneId: tone.id,
62
+ appliedVersion: tone.version,
63
+ channelId: channelIdFor(tone.id, tone.version),
64
+ retired: false,
65
+ labelFingerprint,
66
+ },
67
+ ];
68
+ });
69
+ }
70
+
71
+ export function createChannels(deps: ChannelDependencies) {
72
+ const emit = deps.emit ?? (() => {});
73
+ // Re-homing re-writes each notification on the new channel; its stop marker
74
+ // has to follow, or it would retire with the old channel.
75
+ const writer = createScheduleWriter(deps.engine);
76
+
77
+ return {
78
+ /**
79
+ * Applies a tone configuration, migrating any tone whose version moved.
80
+ *
81
+ * Applying an unchanged configuration performs no work at all: no channel
82
+ * is recreated and no migration is triggered.
83
+ */
84
+ async apply(
85
+ tones: readonly ToneDeclaration[],
86
+ groups: readonly ChannelGroup[] = [],
87
+ ): Promise<ChannelApplyResult> {
88
+ // Validation completes before the first engine call, so a rejected
89
+ // declaration leaves the device exactly as it was — the same order the
90
+ // scheduler has always used.
91
+ validateToneDeclarations(tones);
92
+
93
+ const now = deps.now();
94
+
95
+ const created: string[] = [];
96
+ const migrated: { toneId: string; from: number; to: number }[] = [];
97
+ const labelsRefreshed: string[] = [];
98
+ const retired: string[] = [];
99
+ const reHomed: string[] = [];
100
+ const cancelled: string[] = [];
101
+ const bypassUnavailable: string[] = [];
102
+ const repeatUnavailable: string[] = [];
103
+ const unchanged: string[] = [];
104
+
105
+ if (groups.length > 0) {
106
+ await deps.engine.declareGroups(groups);
107
+ }
108
+
109
+ const capabilities = await deps.engine.getCapabilities();
110
+
111
+ for (const tone of tones) {
112
+ const previous = ledgerFor(deps.state, tone.id);
113
+ const resolved = resolveTone(tone);
114
+ const fingerprint = toneLabelFingerprint(tone);
115
+
116
+ // Refusals are reported for every tone that asked, whether or not
117
+ // anything else about it moved. A platform declining an attribute
118
+ // says nothing about whether the channel is new.
119
+ if (tone.bypassDoNotDisturb === true && !capabilities.supportsDoNotDisturbBypass) {
120
+ // Reported honestly rather than silently dropped — a consumer that
121
+ // asked for a bypass deserves to know the platform refused.
122
+ bypassUnavailable.push(tone.id);
123
+ emit(
124
+ DIAGNOSTIC_CODES.DND_BYPASS_UNAVAILABLE,
125
+ `Tone "${tone.id}" requested a Do Not Disturb bypass, which this platform does not permit.`,
126
+ { toneId: tone.id },
127
+ );
128
+ }
129
+
130
+ if (tone.repeatAlert !== undefined) {
131
+ if (!capabilities.supportsRepeatingAlert) {
132
+ repeatUnavailable.push(tone.id);
133
+ emit(
134
+ DIAGNOSTIC_CODES.REPEAT_ALERT_UNAVAILABLE,
135
+ `Tone "${tone.id}" requested a repeating alert, which this platform does not offer. The notification still delivers and still sounds once.`,
136
+ { toneId: tone.id },
137
+ );
138
+ } else if (!deps.engine.hasBackgroundDeliveryHandler()) {
139
+ // Said out loud at declaration time, because the alternative is a
140
+ // user meeting it as a phone that will not stop ringing.
141
+ emit(
142
+ DIAGNOSTIC_CODES.REPEAT_STOP_HANDLER_MISSING,
143
+ `Tone "${tone.id}" declares a repeating alert, but no background notification handler is registered. Its alert will not stop on its own while the app is not running. Call registerBackgroundNotificationHandler at module scope in index.js.`,
144
+ { toneId: tone.id },
145
+ );
146
+ }
147
+ }
148
+
149
+ const versionMoved = previous !== undefined && previous.appliedVersion !== tone.version;
150
+
151
+ if (previous !== undefined && !versionMoved) {
152
+ // The version is the same, so nothing the platform freezes has
153
+ // changed. Only the labels can still be stale — and correcting
154
+ // those must cost the user nothing, so the platform is touched
155
+ // only when the fingerprint actually moved.
156
+ if (previous.labelFingerprint === fingerprint) {
157
+ unchanged.push(tone.id);
158
+ continue;
159
+ }
160
+
161
+ const outcome = await deps.engine.declareTone(resolved);
162
+ if (outcome === 'created') {
163
+ // The channel was gone — deleted by the user, or lost with the
164
+ // app's data — so this is a first declaration rather than a
165
+ // refresh, whatever our ledger believed.
166
+ created.push(resolved.android.channelId);
167
+ } else if (outcome === 'labelsRefreshed') {
168
+ labelsRefreshed.push(tone.id);
169
+ emit(
170
+ DIAGNOSTIC_CODES.TONE_LABELS_REFRESHED,
171
+ `Tone "${tone.id}" had its name and description updated in place. No channel was recreated, so every setting the user adjusted on it is untouched.`,
172
+ { toneId: tone.id },
173
+ );
174
+ } else {
175
+ unchanged.push(tone.id);
176
+ }
177
+
178
+ recordApplied(deps.state, tone, now, undefined, fingerprint);
179
+ continue;
180
+ }
181
+
182
+ const outcome = await deps.engine.declareTone(resolved);
183
+ if (outcome === 'created') {
184
+ created.push(resolved.android.channelId);
185
+ }
186
+
187
+ if (previous === undefined) {
188
+ recordApplied(deps.state, tone, now, undefined, fingerprint);
189
+ continue;
190
+ }
191
+
192
+ // A version moved, so this is a migration rather than a first
193
+ // declaration. Re-home before retiring: the reverse order would leave
194
+ // live notifications attached to a channel that no longer exists.
195
+ emit(
196
+ DIAGNOSTIC_CODES.CHANNEL_MIGRATION_REQUIRED,
197
+ `Tone "${tone.id}" moved from version ${previous.appliedVersion} to ${tone.version}. Android channels are immutable, so a new channel was created and the old one is being retired.`,
198
+ { toneId: tone.id, from: previous.appliedVersion, to: tone.version },
199
+ );
200
+
201
+ const pending = await deps.engine.getPending();
202
+ const affected = pending.filter(entry => entry.tone === tone.id);
203
+
204
+ for (const entry of affected) {
205
+ await writer.write({
206
+ request: {
207
+ id: entry.id,
208
+ content: entry.content,
209
+ tone: entry.tone,
210
+ moment: { at: entry.resolvedAt, anchoring: entry.anchoring },
211
+ routing: entry.routing,
212
+ actions: entry.actions,
213
+ ownerTag: entry.ownerTag,
214
+ groupTag: entry.groupTag,
215
+ },
216
+ resolvedAt: entry.resolvedAt,
217
+ tone: resolved,
218
+ // Re-homing changes the channel, not the anchoring — preserve it.
219
+ anchoringGeneration: entry.anchoringGeneration,
220
+ approximate: entry.mayBeDelayed,
221
+ seriesId: entry.seriesId,
222
+ pushCreated: entry.pushCreated,
223
+ });
224
+ reHomed.push(entry.id);
225
+ }
226
+
227
+ const didRetire = await deps.engine.retireTone(previous.channelId);
228
+ if (didRetire) {
229
+ retired.push(previous.channelId);
230
+ }
231
+
232
+ // A version move outranks a label change: the new channel simply
233
+ // carries the new labels, and reporting it as a refresh would hide a
234
+ // migration the consumer needs to know about.
235
+ migrated.push({ toneId: tone.id, from: previous.appliedVersion, to: tone.version });
236
+ recordApplied(deps.state, tone, now, previous.channelId, fingerprint);
237
+ }
238
+
239
+ // Anything left on a retired channel would be an orphan — a notification
240
+ // the user can no longer control from system settings.
241
+ const orphans = await findOrphans(deps, tones);
242
+ if (orphans.length > 0) {
243
+ await deps.engine.cancelMany(orphans);
244
+ cancelled.push(...orphans);
245
+ emit(
246
+ DIAGNOSTIC_CODES.CHANNEL_ORPHANS_FOUND,
247
+ `${orphans.length} notification(s) remained on a retired channel and were cancelled, rather than left uncontrollable from system settings.`,
248
+ { count: orphans.length },
249
+ );
250
+ }
251
+
252
+ return {
253
+ created,
254
+ migrated,
255
+ labelsRefreshed,
256
+ retired,
257
+ reHomed,
258
+ cancelled,
259
+ bypassUnavailable,
260
+ repeatUnavailable,
261
+ unchanged,
262
+ };
263
+ },
264
+
265
+ /** Which version of each tone this device actually holds. */
266
+ appliedVersions(): readonly { readonly toneId: string; readonly appliedVersion: number }[] {
267
+ return deps.state
268
+ .read()
269
+ .migrationLedger.filter(entry => !entry.retired)
270
+ .map(entry => ({ toneId: entry.toneId, appliedVersion: entry.appliedVersion }));
271
+ },
272
+ };
273
+ }
274
+
275
+ /** Notifications whose tone is no longer declared at all. */
276
+ async function findOrphans(
277
+ deps: ChannelDependencies,
278
+ tones: readonly ToneDeclaration[],
279
+ ): Promise<readonly string[]> {
280
+ const declared = new Set(tones.map(tone => tone.id));
281
+ const pending = await deps.engine.getPending();
282
+ return pending.filter(entry => !declared.has(entry.tone)).map(entry => entry.id);
283
+ }
284
+
285
+ export type Channels = ReturnType<typeof createChannels>;