@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.
- package/CHANGELOG.md +257 -166
- package/README.md +420 -410
- package/package.json +61 -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 +142 -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 +81 -72
- package/src/diagnostics/redact.ts +55 -72
- package/src/diagnostics/snapshot.ts +100 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -89
- package/src/engine/notify-kit-engine.ts +715 -707
- package/src/index.ts +208 -208
- package/src/initialize.ts +247 -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 +150 -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 +294 -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 +101 -73
- package/src/state/mmkv-store.ts +92 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +270 -263
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -91
- package/src/state/store.ts +43 -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
package/src/channel/migrate.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from '
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
deps
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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>;
|