@chipmobilesdk/rn-notification 0.1.1 → 0.2.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 (71) hide show
  1. package/CHANGELOG.md +80 -42
  2. package/README.md +399 -306
  3. package/package.json +2 -1
  4. package/skills/sdk-rn-notification/SKILL.md +68 -3
  5. package/src/channel/migrate.ts +86 -12
  6. package/src/channel/tone-map.ts +130 -90
  7. package/src/channel/validate-tone.ts +61 -0
  8. package/src/contract/channel.ts +188 -89
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +130 -123
  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 +60 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +123 -123
  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 +64 -64
  21. package/src/engine/adapter.ts +172 -79
  22. package/src/engine/notify-kit-engine.ts +761 -535
  23. package/src/index.ts +35 -0
  24. package/src/initialize.ts +174 -174
  25. package/src/interaction/index.ts +146 -146
  26. package/src/maintenance/cycle.ts +147 -147
  27. package/src/maintenance/permission-refresh.ts +41 -41
  28. package/src/maintenance/reanchor.ts +95 -95
  29. package/src/maintenance/toggle.ts +60 -60
  30. package/src/maintenance/verify.ts +83 -83
  31. package/src/maintenance/window-refresh.ts +61 -61
  32. package/src/permission/index.ts +183 -183
  33. package/src/permission/state.ts +77 -77
  34. package/src/push/envelope.ts +165 -165
  35. package/src/push/fcm-provider.ts +146 -146
  36. package/src/push/gate.ts +190 -190
  37. package/src/push/instructions.ts +109 -109
  38. package/src/push/provider-adapter.ts +54 -54
  39. package/src/push/signout.ts +65 -65
  40. package/src/reconcile/chunk.ts +53 -53
  41. package/src/reconcile/diff.ts +145 -145
  42. package/src/reconcile/duplicates.ts +73 -73
  43. package/src/reconcile/index.ts +14 -1
  44. package/src/reconcile/truncate.ts +56 -56
  45. package/src/recurrence/clock-guard.ts +71 -71
  46. package/src/recurrence/expand.ts +292 -292
  47. package/src/repeat/expiry.ts +55 -0
  48. package/src/repeat/index.ts +54 -0
  49. package/src/repeat/marker.ts +72 -0
  50. package/src/repeat/place.ts +73 -0
  51. package/src/schedule/anchor.ts +267 -267
  52. package/src/schedule/index.ts +184 -173
  53. package/src/schedule/partition.ts +85 -85
  54. package/src/schedule/resolve-moment.ts +80 -80
  55. package/src/schedule/validate-content.ts +111 -111
  56. package/src/schedule/validate-identifier.ts +126 -114
  57. package/src/state/caps.ts +71 -71
  58. package/src/state/dedup-ledger.ts +35 -39
  59. package/src/state/interaction-buffer.ts +73 -73
  60. package/src/state/mmkv-store.ts +71 -71
  61. package/src/state/permission-history.ts +41 -41
  62. package/src/state/record.ts +258 -246
  63. package/src/state/reset.ts +41 -41
  64. package/src/state/series-ledger.ts +84 -84
  65. package/src/state/store.ts +40 -40
  66. package/src/testing/conformance/index.ts +248 -194
  67. package/src/testing/fake-engine.ts +469 -284
  68. package/src/testing/fake-provider.ts +129 -129
  69. package/src/testing/faults.ts +72 -72
  70. package/src/testing/index.ts +2 -0
  71. package/src/testing/measure.ts +337 -0
@@ -1,173 +1,184 @@
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
-
32
- export interface ScheduleDependencies {
33
- readonly engine: NotificationEngineAdapter;
34
- readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
35
- readonly timeZone: TimeZoneReader;
36
- readonly now: () => number;
37
- /** False when exact-alarm capability is unavailable, forcing approximate mode. */
38
- readonly canScheduleExactly: () => boolean;
39
- readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
40
- }
41
-
42
- /** Validates a request completely, before the engine is touched at all. */
43
- export function validateRequest(
44
- request: NotificationRequest,
45
- resolveTone: (toneId: string) => ResolvedTone | undefined,
46
- ): ResolvedTone {
47
- validateIdentifier(request.id, 'id');
48
- validateOptionalTag(request.ownerTag, 'ownerTag');
49
- validateOptionalTag(request.groupTag, 'groupTag');
50
- validateContent(request.content);
51
- validateMoment(request.moment);
52
-
53
- const tone = resolveTone(request.tone);
54
- if (tone === undefined) {
55
- throw new NotificationError(
56
- DIAGNOSTIC_CODES.UNDECLARED_TONE,
57
- `Tone "${request.tone}" was never declared in the package configuration. Declare it before scheduling against it.`,
58
- { toneId: request.tone },
59
- );
60
- }
61
-
62
- if (request.actions !== undefined) {
63
- if (request.actions.length > MAX_ACTIONS_PER_NOTIFICATION) {
64
- throw new NotificationError(
65
- DIAGNOSTIC_CODES.TOO_MANY_ACTIONS,
66
- `A notification may carry at most ${MAX_ACTIONS_PER_NOTIFICATION} actions; ${request.actions.length} were supplied.`,
67
- { count: request.actions.length, limit: MAX_ACTIONS_PER_NOTIFICATION },
68
- );
69
- }
70
- for (const action of request.actions) {
71
- validateIdentifier(action.id, 'actionId');
72
- }
73
- }
74
-
75
- if (request.routing !== undefined) {
76
- // Sized at schedule time rather than discovered as truncated at tap time,
77
- // which is where an over-large payload would otherwise surface.
78
- const size = byteLength(JSON.stringify(request.routing));
79
- if (size > MAX_ROUTING_PAYLOAD_BYTES) {
80
- throw new NotificationError(
81
- DIAGNOSTIC_CODES.ROUTING_PAYLOAD_TOO_LARGE,
82
- `Routing payload is ${size} bytes, over the ${MAX_ROUTING_PAYLOAD_BYTES}-byte limit.`,
83
- { bytes: size, limit: MAX_ROUTING_PAYLOAD_BYTES },
84
- );
85
- }
86
- }
87
-
88
- return tone;
89
- }
90
-
91
- export function createScheduler(deps: ScheduleDependencies) {
92
- const emit = deps.emit ?? (() => {});
93
-
94
- const scheduleOne = async (
95
- request: NotificationRequest,
96
- options: { readonly seriesId?: string; readonly pushCreated?: boolean } = {},
97
- ): Promise<ScheduleResult> => {
98
- const tone = validateRequest(request, deps.resolveTone);
99
-
100
- const zone = deps.timeZone();
101
- const anchored = anchorMoment(request.moment, zone);
102
- const resolution = resolveMoment(anchored.resolvedAt, deps.now());
103
-
104
- const existing = await deps.engine.getPending();
105
- const replaced = existing.some(entry => entry.id === request.id);
106
-
107
- // Approximate mode is a degradation, not a refusal: the notification is
108
- // still scheduled and the caller is told it may arrive late.
109
- const approximate = !deps.canScheduleExactly();
110
- if (approximate) {
111
- emit(
112
- DIAGNOSTIC_CODES.EXACT_ALARM_UNAVAILABLE,
113
- 'Scheduled in approximate mode; delivery may be late.',
114
- { id: request.id },
115
- );
116
- }
117
-
118
- await deps.engine.schedule({
119
- request,
120
- resolvedAt: resolution.at,
121
- tone,
122
- // The zone this was anchored against, so `getPending` can report it and a
123
- // consumer can detect a stale anchor (FR-007c). Absent for absolute
124
- // moments, which are never anchored to a zone.
125
- anchoringGeneration: anchored.generation,
126
- approximate,
127
- seriesId: options.seriesId,
128
- pushCreated: options.pushCreated,
129
- });
130
-
131
- return {
132
- id: request.id,
133
- resolvedAt: resolution.at,
134
- anchoring: anchored.anchoring,
135
- replaced,
136
- deliveredImmediately: resolution.kind === 'immediate',
137
- mayBeDelayed: approximate,
138
- };
139
- };
140
-
141
- return {
142
- schedule: scheduleOne,
143
-
144
- async cancel(id: string): Promise<void> {
145
- validateIdentifier(id, 'id');
146
- await deps.engine.cancel(id);
147
- },
148
-
149
- async cancelByGroup(groupTag: string): Promise<readonly string[]> {
150
- validateIdentifier(groupTag, 'groupTag');
151
- const ids = idsForTag(await deps.engine.getPending(), 'group', groupTag);
152
- await deps.engine.cancelMany(ids);
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
- return ids;
161
- },
162
-
163
- async cancelAll(): Promise<void> {
164
- await deps.engine.cancelAll();
165
- },
166
-
167
- async getPending(): Promise<readonly PendingEntry[]> {
168
- return deps.engine.getPending();
169
- },
170
- };
171
- }
172
-
173
- 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 {
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 { createStopPlacement } from '../repeat/place';
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 stops = createStopPlacement(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 deps.engine.schedule({
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
+ await stops.sync(request.id, tone, resolution.at);
135
+
136
+ return {
137
+ id: request.id,
138
+ resolvedAt: resolution.at,
139
+ anchoring: anchored.anchoring,
140
+ replaced,
141
+ deliveredImmediately: resolution.kind === 'immediate',
142
+ mayBeDelayed: approximate,
143
+ };
144
+ };
145
+
146
+ return {
147
+ schedule: scheduleOne,
148
+
149
+ async cancel(id: string): Promise<void> {
150
+ validateIdentifier(id, 'id');
151
+ await deps.engine.cancel(id);
152
+ // A marker guarding a notification that no longer exists would fire
153
+ // against nothing. Harmless, but it holds platform capacity a real
154
+ // notification could use.
155
+ await stops.release(id);
156
+ },
157
+
158
+ async cancelByGroup(groupTag: string): Promise<readonly string[]> {
159
+ validateIdentifier(groupTag, 'groupTag');
160
+ const ids = idsForTag(await deps.engine.getPending(), 'group', groupTag);
161
+ await deps.engine.cancelMany(ids);
162
+ for (const id of ids) await stops.release(id);
163
+ return ids;
164
+ },
165
+
166
+ async cancelByOwner(ownerTag: string): Promise<readonly string[]> {
167
+ validateIdentifier(ownerTag, 'ownerTag');
168
+ const ids = idsForTag(await deps.engine.getPending(), 'owner', ownerTag);
169
+ await deps.engine.cancelMany(ids);
170
+ for (const id of ids) await stops.release(id);
171
+ return ids;
172
+ },
173
+
174
+ async cancelAll(): Promise<void> {
175
+ await deps.engine.cancelAll();
176
+ },
177
+
178
+ async getPending(): Promise<readonly PendingEntry[]> {
179
+ return deps.engine.getPending();
180
+ },
181
+ };
182
+ }
183
+
184
+ export type Scheduler = ReturnType<typeof createScheduler>;
@@ -1,85 +1,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
- ): { 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
+ ): { 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
+ }