@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
@@ -1,181 +1,174 @@
1
- /**
2
- * The scheduling operations.
3
- *
4
- * Validation runs in a fixed order and completes entirely before any platform
5
- * call: identifier, tags, content, moment shape, tone, actions, routing size.
6
- * A caller therefore never sees a native-level error surface through the
7
- * package, and a rejected request leaves the device untouched.
8
- *
9
- * Scheduling an identifier that already has a notification **replaces** it.
10
- * That is the defined semantic rather than a side effect, which is what lets a
11
- * caller stop tracking whether it has already scheduled something.
12
- */
13
-
14
- import type {
15
- NotificationRequest,
16
- PendingEntry,
17
- ScheduleResult,
18
- } from '../contract/notification';
19
- import type { ResolvedTone } from '../contract/channel';
20
- import type { NotificationEngineAdapter } from '../engine/adapter';
21
- import {
22
- DIAGNOSTIC_CODES,
23
- NotificationError,
24
- } from '../contract/errors';
25
- import { MAX_ACTIONS_PER_NOTIFICATION, MAX_ROUTING_PAYLOAD_BYTES } from '../contract/interaction';
26
- import { validateIdentifier, validateOptionalTag } from './validate-identifier';
27
- import { validateContent, byteLength } from './validate-content';
28
- import { resolveMoment, validateMoment } from './resolve-moment';
29
- import { anchorMoment, type TimeZoneReader } from './anchor';
30
- import { idsForTag } from './partition';
31
- import { createScheduleWriter } from './writer';
32
-
33
- export interface ScheduleDependencies {
34
- readonly engine: NotificationEngineAdapter;
35
- readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
36
- readonly timeZone: TimeZoneReader;
37
- readonly now: () => number;
38
- /** False when exact-alarm capability is unavailable, forcing approximate mode. */
39
- readonly canScheduleExactly: () => boolean;
40
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
41
- }
42
-
43
- /** Validates a request completely, before the engine is touched at all. */
44
- export function validateRequest(
45
- request: NotificationRequest,
46
- resolveTone: (toneId: string) => ResolvedTone | undefined,
47
- ): ResolvedTone {
48
- validateIdentifier(request.id, 'id');
49
- validateOptionalTag(request.ownerTag, 'ownerTag');
50
- validateOptionalTag(request.groupTag, 'groupTag');
51
- validateContent(request.content);
52
- validateMoment(request.moment);
53
-
54
- const tone = resolveTone(request.tone);
55
- if (tone === undefined) {
56
- throw new NotificationError(
57
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
58
- `Tone "${request.tone}" was never declared in the package configuration. Declare it before scheduling against it.`,
59
- { toneId: request.tone },
60
- );
61
- }
62
-
63
- if (request.actions !== undefined) {
64
- if (request.actions.length > MAX_ACTIONS_PER_NOTIFICATION) {
65
- throw new NotificationError(
66
- DIAGNOSTIC_CODES.TOO_MANY_ACTIONS,
67
- `A notification may carry at most ${MAX_ACTIONS_PER_NOTIFICATION} actions; ${request.actions.length} were supplied.`,
68
- { count: request.actions.length, limit: MAX_ACTIONS_PER_NOTIFICATION },
69
- );
70
- }
71
- for (const action of request.actions) {
72
- validateIdentifier(action.id, 'actionId');
73
- }
74
- }
75
-
76
- if (request.routing !== undefined) {
77
- // Sized at schedule time rather than discovered as truncated at tap time,
78
- // which is where an over-large payload would otherwise surface.
79
- const size = byteLength(JSON.stringify(request.routing));
80
- if (size > MAX_ROUTING_PAYLOAD_BYTES) {
81
- throw new NotificationError(
82
- DIAGNOSTIC_CODES.ROUTING_PAYLOAD_TOO_LARGE,
83
- `Routing payload is ${size} bytes, over the ${MAX_ROUTING_PAYLOAD_BYTES}-byte limit.`,
84
- { bytes: size, limit: MAX_ROUTING_PAYLOAD_BYTES },
85
- );
86
- }
87
- }
88
-
89
- return tone;
90
- }
91
-
92
- export function createScheduler(deps: ScheduleDependencies) {
93
- const emit = deps.emit ?? (() => {});
94
-
95
- const writer = createScheduleWriter(deps.engine);
96
-
97
- const scheduleOne = async (
98
- request: NotificationRequest,
99
- options: { readonly seriesId?: string; readonly pushCreated?: boolean } = {},
100
- ): Promise<ScheduleResult> => {
101
- const tone = validateRequest(request, deps.resolveTone);
102
-
103
- const zone = deps.timeZone();
104
- const anchored = anchorMoment(request.moment, zone);
105
- const resolution = resolveMoment(anchored.resolvedAt, deps.now());
106
-
107
- const existing = await deps.engine.getPending();
108
- const replaced = existing.some(entry => entry.id === request.id);
109
-
110
- // Approximate mode is a degradation, not a refusal: the notification is
111
- // still scheduled and the caller is told it may arrive late.
112
- const approximate = !deps.canScheduleExactly();
113
- if (approximate) {
114
- emit(
115
- DIAGNOSTIC_CODES.EXACT_ALARM_UNAVAILABLE,
116
- 'Scheduled in approximate mode; delivery may be late.',
117
- { id: request.id },
118
- );
119
- }
120
-
121
- await writer.write({
122
- request,
123
- resolvedAt: resolution.at,
124
- tone,
125
- // The zone this was anchored against, so `getPending` can report it and a
126
- // consumer can detect a stale anchor (FR-007c). Absent for absolute
127
- // moments, which are never anchored to a zone.
128
- anchoringGeneration: anchored.generation,
129
- approximate,
130
- seriesId: options.seriesId,
131
- pushCreated: options.pushCreated,
132
- });
133
-
134
- return {
135
- id: request.id,
136
- resolvedAt: resolution.at,
137
- anchoring: anchored.anchoring,
138
- replaced,
139
- deliveredImmediately: resolution.kind === 'immediate',
140
- mayBeDelayed: approximate,
141
- };
142
- };
143
-
144
- return {
145
- schedule: scheduleOne,
146
-
147
- async cancel(id: string): Promise<void> {
148
- validateIdentifier(id, 'id');
149
- // A marker guarding a notification that no longer exists would fire
150
- // against nothing. Harmless, but it holds platform capacity a real
151
- // notification could use.
152
- await writer.cancel(id);
153
- },
154
-
155
- async cancelByGroup(groupTag: string): Promise<readonly string[]> {
156
- validateIdentifier(groupTag, 'groupTag');
157
- const ids = idsForTag(await deps.engine.getPending(), 'group', groupTag);
158
- await deps.engine.cancelMany(ids);
159
- for (const id of ids) await writer.releaseStop(id);
160
- return ids;
161
- },
162
-
163
- async cancelByOwner(ownerTag: string): Promise<readonly string[]> {
164
- validateIdentifier(ownerTag, 'ownerTag');
165
- const ids = idsForTag(await deps.engine.getPending(), 'owner', ownerTag);
166
- await deps.engine.cancelMany(ids);
167
- for (const id of ids) await writer.releaseStop(id);
168
- return ids;
169
- },
170
-
171
- async cancelAll(): Promise<void> {
172
- await deps.engine.cancelAll();
173
- },
174
-
175
- async getPending(): Promise<readonly PendingEntry[]> {
176
- return deps.engine.getPending();
177
- },
178
- };
179
- }
180
-
181
- export type Scheduler = ReturnType<typeof createScheduler>;
1
+ /**
2
+ * The scheduling operations.
3
+ *
4
+ * Validation runs in a fixed order and completes entirely before any platform
5
+ * call: identifier, tags, content, moment shape, tone, actions, routing size.
6
+ * A caller therefore never sees a native-level error surface through the
7
+ * package, and a rejected request leaves the device untouched.
8
+ *
9
+ * Scheduling an identifier that already has a notification **replaces** it.
10
+ * That is the defined semantic rather than a side effect, which is what lets a
11
+ * caller stop tracking whether it has already scheduled something.
12
+ */
13
+
14
+ import type { NotificationRequest, PendingEntry, ScheduleResult } from '../contract/notification';
15
+ import type { ResolvedTone } from '../contract/channel';
16
+ import type { NotificationEngineAdapter } from '../engine/adapter';
17
+ import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
18
+ import { MAX_ACTIONS_PER_NOTIFICATION, MAX_ROUTING_PAYLOAD_BYTES } from '../contract/interaction';
19
+ import { validateIdentifier, validateOptionalTag } from './validate-identifier';
20
+ import { validateContent, byteLength } from './validate-content';
21
+ import { resolveMoment, validateMoment } from './resolve-moment';
22
+ import { anchorMoment, type TimeZoneReader } from './anchor';
23
+ import { idsForTag } from './partition';
24
+ import { createScheduleWriter } from './writer';
25
+
26
+ export interface ScheduleDependencies {
27
+ readonly engine: NotificationEngineAdapter;
28
+ readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
29
+ readonly timeZone: TimeZoneReader;
30
+ readonly now: () => number;
31
+ /** False when exact-alarm capability is unavailable, forcing approximate mode. */
32
+ readonly canScheduleExactly: () => boolean;
33
+ readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
34
+ }
35
+
36
+ /** Validates a request completely, before the engine is touched at all. */
37
+ export function validateRequest(
38
+ request: NotificationRequest,
39
+ resolveTone: (toneId: string) => ResolvedTone | undefined,
40
+ ): ResolvedTone {
41
+ validateIdentifier(request.id, 'id');
42
+ validateOptionalTag(request.ownerTag, 'ownerTag');
43
+ validateOptionalTag(request.groupTag, 'groupTag');
44
+ validateContent(request.content);
45
+ validateMoment(request.moment);
46
+
47
+ const tone = resolveTone(request.tone);
48
+ if (tone === undefined) {
49
+ throw new NotificationError(
50
+ DIAGNOSTIC_CODES.UNDECLARED_TONE,
51
+ `Tone "${request.tone}" was never declared in the package configuration. Declare it before scheduling against it.`,
52
+ { toneId: request.tone },
53
+ );
54
+ }
55
+
56
+ if (request.actions !== undefined) {
57
+ if (request.actions.length > MAX_ACTIONS_PER_NOTIFICATION) {
58
+ throw new NotificationError(
59
+ DIAGNOSTIC_CODES.TOO_MANY_ACTIONS,
60
+ `A notification may carry at most ${MAX_ACTIONS_PER_NOTIFICATION} actions; ${request.actions.length} were supplied.`,
61
+ { count: request.actions.length, limit: MAX_ACTIONS_PER_NOTIFICATION },
62
+ );
63
+ }
64
+ for (const action of request.actions) {
65
+ validateIdentifier(action.id, 'actionId');
66
+ }
67
+ }
68
+
69
+ if (request.routing !== undefined) {
70
+ // Sized at schedule time rather than discovered as truncated at tap time,
71
+ // which is where an over-large payload would otherwise surface.
72
+ const size = byteLength(JSON.stringify(request.routing));
73
+ if (size > MAX_ROUTING_PAYLOAD_BYTES) {
74
+ throw new NotificationError(
75
+ DIAGNOSTIC_CODES.ROUTING_PAYLOAD_TOO_LARGE,
76
+ `Routing payload is ${size} bytes, over the ${MAX_ROUTING_PAYLOAD_BYTES}-byte limit.`,
77
+ { bytes: size, limit: MAX_ROUTING_PAYLOAD_BYTES },
78
+ );
79
+ }
80
+ }
81
+
82
+ return tone;
83
+ }
84
+
85
+ export function createScheduler(deps: ScheduleDependencies) {
86
+ const emit = deps.emit ?? (() => {});
87
+
88
+ const writer = createScheduleWriter(deps.engine);
89
+
90
+ const scheduleOne = async (
91
+ request: NotificationRequest,
92
+ options: { readonly seriesId?: string; readonly pushCreated?: boolean } = {},
93
+ ): Promise<ScheduleResult> => {
94
+ const tone = validateRequest(request, deps.resolveTone);
95
+
96
+ const zone = deps.timeZone();
97
+ const anchored = anchorMoment(request.moment, zone);
98
+ const resolution = resolveMoment(anchored.resolvedAt, deps.now());
99
+
100
+ const existing = await deps.engine.getPending();
101
+ const replaced = existing.some(entry => entry.id === request.id);
102
+
103
+ // Approximate mode is a degradation, not a refusal: the notification is
104
+ // still scheduled and the caller is told it may arrive late.
105
+ const approximate = !deps.canScheduleExactly();
106
+ if (approximate) {
107
+ emit(
108
+ DIAGNOSTIC_CODES.EXACT_ALARM_UNAVAILABLE,
109
+ 'Scheduled in approximate mode; delivery may be late.',
110
+ { id: request.id },
111
+ );
112
+ }
113
+
114
+ await writer.write({
115
+ request,
116
+ resolvedAt: resolution.at,
117
+ tone,
118
+ // The zone this was anchored against, so `getPending` can report it and a
119
+ // consumer can detect a stale anchor (FR-007c). Absent for absolute
120
+ // moments, which are never anchored to a zone.
121
+ anchoringGeneration: anchored.generation,
122
+ approximate,
123
+ seriesId: options.seriesId,
124
+ pushCreated: options.pushCreated,
125
+ });
126
+
127
+ return {
128
+ id: request.id,
129
+ resolvedAt: resolution.at,
130
+ anchoring: anchored.anchoring,
131
+ replaced,
132
+ deliveredImmediately: resolution.kind === 'immediate',
133
+ mayBeDelayed: approximate,
134
+ };
135
+ };
136
+
137
+ return {
138
+ schedule: scheduleOne,
139
+
140
+ async cancel(id: string): Promise<void> {
141
+ validateIdentifier(id, 'id');
142
+ // A marker guarding a notification that no longer exists would fire
143
+ // against nothing. Harmless, but it holds platform capacity a real
144
+ // notification could use.
145
+ await writer.cancel(id);
146
+ },
147
+
148
+ async cancelByGroup(groupTag: string): Promise<readonly string[]> {
149
+ validateIdentifier(groupTag, 'groupTag');
150
+ const ids = idsForTag(await deps.engine.getPending(), 'group', groupTag);
151
+ await deps.engine.cancelMany(ids);
152
+ for (const id of ids) await writer.releaseStop(id);
153
+ return ids;
154
+ },
155
+
156
+ async cancelByOwner(ownerTag: string): Promise<readonly string[]> {
157
+ validateIdentifier(ownerTag, 'ownerTag');
158
+ const ids = idsForTag(await deps.engine.getPending(), 'owner', ownerTag);
159
+ await deps.engine.cancelMany(ids);
160
+ for (const id of ids) await writer.releaseStop(id);
161
+ return ids;
162
+ },
163
+
164
+ async cancelAll(): Promise<void> {
165
+ await deps.engine.cancelAll();
166
+ },
167
+
168
+ async getPending(): Promise<readonly PendingEntry[]> {
169
+ return deps.engine.getPending();
170
+ },
171
+ };
172
+ }
173
+
174
+ export type Scheduler = ReturnType<typeof createScheduler>;
@@ -1,85 +1,88 @@
1
- /**
2
- * Owner-tag and group-tag partitioning.
3
- *
4
- * A tag is an opaque label the caller supplies and the package never
5
- * interprets. That is deliberate: modelling identity would mean acquiring an
6
- * opinion about it, and this package is forbidden from knowing anything about
7
- * accounts, tenants, or users.
8
- *
9
- * What the tag buys is one call — "clear this account's notifications" — on a
10
- * shared device, without the package ever learning what an account is.
11
- */
12
-
13
- import type { PendingEntry } from '../contract/notification';
14
-
15
- export type PartitionKind = 'owner' | 'group';
16
-
17
- /** Selects entries carrying a tag. Comparison is exact; nothing is normalized. */
18
- export function selectByTag(
19
- entries: readonly PendingEntry[],
20
- kind: PartitionKind,
21
- tag: string,
22
- ): readonly PendingEntry[] {
23
- return entries.filter(entry =>
24
- kind === 'owner' ? entry.ownerTag === tag : entry.groupTag === tag,
25
- );
26
- }
27
-
28
- /** Identifiers to cancel for a tag. */
29
- export function idsForTag(
30
- entries: readonly PendingEntry[],
31
- kind: PartitionKind,
32
- tag: string,
33
- ): readonly string[] {
34
- return selectByTag(entries, kind, tag).map(entry => entry.id);
35
- }
36
-
37
- /**
38
- * Groups entries by a tag, keeping untagged entries under a distinct bucket.
39
- *
40
- * Untagged is not the same as any tag: an entry with no owner belongs to no
41
- * account, and folding it into one would make a sign-out delete notifications
42
- * that were never that account's.
43
- */
44
- export function partition(
45
- entries: readonly PendingEntry[],
46
- kind: PartitionKind,
47
- ): { readonly tagged: ReadonlyMap<string, readonly PendingEntry[]>; readonly untagged: readonly PendingEntry[] } {
48
- const tagged = new Map<string, PendingEntry[]>();
49
- const untagged: PendingEntry[] = [];
50
-
51
- for (const entry of entries) {
52
- const tag = kind === 'owner' ? entry.ownerTag : entry.groupTag;
53
- if (tag === undefined) {
54
- untagged.push(entry);
55
- continue;
56
- }
57
- const bucket = tagged.get(tag);
58
- if (bucket === undefined) {
59
- tagged.set(tag, [entry]);
60
- } else {
61
- bucket.push(entry);
62
- }
63
- }
64
-
65
- return { tagged, untagged };
66
- }
67
-
68
- /**
69
- * True when cancelling `tag` would affect any entry outside it.
70
- *
71
- * Exists so isolation can be asserted directly rather than inferred from a
72
- * count — the property that matters is that one account's sign-out changes
73
- * exactly zero of another account's notifications.
74
- */
75
- export function wouldAffectOtherTags(
76
- entries: readonly PendingEntry[],
77
- kind: PartitionKind,
78
- tag: string,
79
- ): boolean {
80
- const selected = new Set(idsForTag(entries, kind, tag));
81
- return entries.some(entry => {
82
- const entryTag = kind === 'owner' ? entry.ownerTag : entry.groupTag;
83
- return selected.has(entry.id) && entryTag !== tag;
84
- });
85
- }
1
+ /**
2
+ * Owner-tag and group-tag partitioning.
3
+ *
4
+ * A tag is an opaque label the caller supplies and the package never
5
+ * interprets. That is deliberate: modelling identity would mean acquiring an
6
+ * opinion about it, and this package is forbidden from knowing anything about
7
+ * accounts, tenants, or users.
8
+ *
9
+ * What the tag buys is one call — "clear this account's notifications" — on a
10
+ * shared device, without the package ever learning what an account is.
11
+ */
12
+
13
+ import type { PendingEntry } from '../contract/notification';
14
+
15
+ export type PartitionKind = 'owner' | 'group';
16
+
17
+ /** Selects entries carrying a tag. Comparison is exact; nothing is normalized. */
18
+ export function selectByTag(
19
+ entries: readonly PendingEntry[],
20
+ kind: PartitionKind,
21
+ tag: string,
22
+ ): readonly PendingEntry[] {
23
+ return entries.filter(entry =>
24
+ kind === 'owner' ? entry.ownerTag === tag : entry.groupTag === tag,
25
+ );
26
+ }
27
+
28
+ /** Identifiers to cancel for a tag. */
29
+ export function idsForTag(
30
+ entries: readonly PendingEntry[],
31
+ kind: PartitionKind,
32
+ tag: string,
33
+ ): readonly string[] {
34
+ return selectByTag(entries, kind, tag).map(entry => entry.id);
35
+ }
36
+
37
+ /**
38
+ * Groups entries by a tag, keeping untagged entries under a distinct bucket.
39
+ *
40
+ * Untagged is not the same as any tag: an entry with no owner belongs to no
41
+ * account, and folding it into one would make a sign-out delete notifications
42
+ * that were never that account's.
43
+ */
44
+ export function partition(
45
+ entries: readonly PendingEntry[],
46
+ kind: PartitionKind,
47
+ ): {
48
+ readonly tagged: ReadonlyMap<string, readonly PendingEntry[]>;
49
+ readonly untagged: readonly PendingEntry[];
50
+ } {
51
+ const tagged = new Map<string, PendingEntry[]>();
52
+ const untagged: PendingEntry[] = [];
53
+
54
+ for (const entry of entries) {
55
+ const tag = kind === 'owner' ? entry.ownerTag : entry.groupTag;
56
+ if (tag === undefined) {
57
+ untagged.push(entry);
58
+ continue;
59
+ }
60
+ const bucket = tagged.get(tag);
61
+ if (bucket === undefined) {
62
+ tagged.set(tag, [entry]);
63
+ } else {
64
+ bucket.push(entry);
65
+ }
66
+ }
67
+
68
+ return { tagged, untagged };
69
+ }
70
+
71
+ /**
72
+ * True when cancelling `tag` would affect any entry outside it.
73
+ *
74
+ * Exists so isolation can be asserted directly rather than inferred from a
75
+ * count — the property that matters is that one account's sign-out changes
76
+ * exactly zero of another account's notifications.
77
+ */
78
+ export function wouldAffectOtherTags(
79
+ entries: readonly PendingEntry[],
80
+ kind: PartitionKind,
81
+ tag: string,
82
+ ): boolean {
83
+ const selected = new Set(idsForTag(entries, kind, tag));
84
+ return entries.some(entry => {
85
+ const entryTag = kind === 'owner' ? entry.ownerTag : entry.groupTag;
86
+ return selected.has(entry.id) && entryTag !== tag;
87
+ });
88
+ }