@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.
- package/CHANGELOG.md +174 -166
- package/README.md +414 -410
- package/package.json +58 -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 +134 -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 +72 -72
- package/src/diagnostics/redact.ts +72 -72
- package/src/diagnostics/snapshot.ts +67 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -89
- package/src/engine/notify-kit-engine.ts +706 -707
- package/src/index.ts +208 -208
- package/src/initialize.ts +240 -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 +143 -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 +278 -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 +73 -73
- package/src/state/mmkv-store.ts +75 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +266 -263
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -91
- package/src/state/store.ts +40 -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/reconcile/diff.ts
CHANGED
|
@@ -1,145 +1,142 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Desired-versus-device diffing.
|
|
3
|
-
*
|
|
4
|
-
* The package's central promise lives here: reconciling twice with unchanged
|
|
5
|
-
* input must produce **zero** platform operations on the second run. That is
|
|
6
|
-
* only true if "unchanged" is decided by comparing everything that would make
|
|
7
|
-
* the delivered notification different — miss a field and the second run
|
|
8
|
-
* rewrites entries needlessly; include a volatile one and it never settles.
|
|
9
|
-
*
|
|
10
|
-
* Anchoring mode is part of the comparison key deliberately. A mode change on
|
|
11
|
-
* the same identifier must force a replacement rather than read as unchanged,
|
|
12
|
-
* because the two modes resolve to different instants the moment a zone
|
|
13
|
-
* changes.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { NotificationRequest, PendingEntry } from '../contract/notification';
|
|
17
|
-
|
|
18
|
-
export type OperationKind = 'create' | 'replace' | 'cancel';
|
|
19
|
-
|
|
20
|
-
export interface DiffOperation {
|
|
21
|
-
readonly kind: OperationKind;
|
|
22
|
-
readonly id: string;
|
|
23
|
-
/** Present for create and replace. */
|
|
24
|
-
readonly request?: NotificationRequest;
|
|
25
|
-
/** The instant the entry should fire, after anchoring resolution. */
|
|
26
|
-
readonly resolvedAt?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface DiffResult {
|
|
30
|
-
readonly operations: readonly DiffOperation[];
|
|
31
|
-
readonly unchanged: readonly string[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Stable serialization of the fields that make a delivered notification different. */
|
|
35
|
-
function fingerprint(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
request.
|
|
42
|
-
request.
|
|
43
|
-
request.
|
|
44
|
-
request.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
entry.
|
|
61
|
-
entry.
|
|
62
|
-
entry.
|
|
63
|
-
entry.
|
|
64
|
-
entry.
|
|
65
|
-
entry.
|
|
66
|
-
entry.
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
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
|
-
return { operations: [...cancels, ...writes], unchanged };
|
|
145
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Desired-versus-device diffing.
|
|
3
|
+
*
|
|
4
|
+
* The package's central promise lives here: reconciling twice with unchanged
|
|
5
|
+
* input must produce **zero** platform operations on the second run. That is
|
|
6
|
+
* only true if "unchanged" is decided by comparing everything that would make
|
|
7
|
+
* the delivered notification different — miss a field and the second run
|
|
8
|
+
* rewrites entries needlessly; include a volatile one and it never settles.
|
|
9
|
+
*
|
|
10
|
+
* Anchoring mode is part of the comparison key deliberately. A mode change on
|
|
11
|
+
* the same identifier must force a replacement rather than read as unchanged,
|
|
12
|
+
* because the two modes resolve to different instants the moment a zone
|
|
13
|
+
* changes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { NotificationRequest, PendingEntry } from '../contract/notification';
|
|
17
|
+
|
|
18
|
+
export type OperationKind = 'create' | 'replace' | 'cancel';
|
|
19
|
+
|
|
20
|
+
export interface DiffOperation {
|
|
21
|
+
readonly kind: OperationKind;
|
|
22
|
+
readonly id: string;
|
|
23
|
+
/** Present for create and replace. */
|
|
24
|
+
readonly request?: NotificationRequest;
|
|
25
|
+
/** The instant the entry should fire, after anchoring resolution. */
|
|
26
|
+
readonly resolvedAt?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DiffResult {
|
|
30
|
+
readonly operations: readonly DiffOperation[];
|
|
31
|
+
readonly unchanged: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Stable serialization of the fields that make a delivered notification different. */
|
|
35
|
+
function fingerprint(request: NotificationRequest, resolvedAt: number): string {
|
|
36
|
+
return JSON.stringify([
|
|
37
|
+
resolvedAt,
|
|
38
|
+
request.moment.anchoring ?? 'wallClock',
|
|
39
|
+
request.content.title,
|
|
40
|
+
request.content.body,
|
|
41
|
+
request.content.subtitle ?? null,
|
|
42
|
+
request.tone,
|
|
43
|
+
request.ownerTag ?? null,
|
|
44
|
+
request.groupTag ?? null,
|
|
45
|
+
// Key order is not stable across constructions, so sort before comparing —
|
|
46
|
+
// otherwise two structurally identical payloads could compare unequal and
|
|
47
|
+
// the second pass would rewrite every entry carrying routing data.
|
|
48
|
+
request.routing === undefined ? null : stableStringify(request.routing),
|
|
49
|
+
request.actions === undefined
|
|
50
|
+
? null
|
|
51
|
+
: request.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function entryFingerprint(entry: PendingEntry): string {
|
|
56
|
+
return JSON.stringify([
|
|
57
|
+
entry.resolvedAt,
|
|
58
|
+
entry.anchoring,
|
|
59
|
+
entry.content.title,
|
|
60
|
+
entry.content.body,
|
|
61
|
+
entry.content.subtitle ?? null,
|
|
62
|
+
entry.tone,
|
|
63
|
+
entry.ownerTag ?? null,
|
|
64
|
+
entry.groupTag ?? null,
|
|
65
|
+
entry.routing === undefined ? null : stableStringify(entry.routing),
|
|
66
|
+
entry.actions === undefined
|
|
67
|
+
? null
|
|
68
|
+
: entry.actions.map(a => [a.id, a.title, a.mode, a.input?.placeholder ?? null]),
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Key-order-independent JSON, so structurally equal payloads compare equal. */
|
|
73
|
+
function stableStringify(value: unknown): string {
|
|
74
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
75
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
76
|
+
|
|
77
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
78
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
79
|
+
);
|
|
80
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DiffInput {
|
|
84
|
+
readonly desired: readonly NotificationRequest[];
|
|
85
|
+
/** Resolved firing instant per desired identifier, after anchoring. */
|
|
86
|
+
readonly resolvedAt: ReadonlyMap<string, number>;
|
|
87
|
+
/** What the device currently holds, already narrowed to the caller's domain. */
|
|
88
|
+
readonly device: readonly PendingEntry[];
|
|
89
|
+
/**
|
|
90
|
+
* Identifiers that were rejected before diffing and must be left alone.
|
|
91
|
+
*
|
|
92
|
+
* Without this they would look like strays — present on the device, absent
|
|
93
|
+
* from the desired set — and be cancelled. But a rejection is not a
|
|
94
|
+
* cancellation: the package could not decide what the caller meant, so
|
|
95
|
+
* destroying the existing notification would be the worst available answer.
|
|
96
|
+
*/
|
|
97
|
+
readonly protectedIds?: ReadonlySet<string>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Computes the minimal operation set.
|
|
102
|
+
*
|
|
103
|
+
* Cancels are emitted before creates so an interrupted pass leaves a
|
|
104
|
+
* recoverable gap rather than a duplicate: a missing notification is fixed by
|
|
105
|
+
* the next reconcile, whereas two notifications for one subject is a defect the
|
|
106
|
+
* user sees.
|
|
107
|
+
*/
|
|
108
|
+
export function diff(input: DiffInput): DiffResult {
|
|
109
|
+
const deviceById = new Map(input.device.map(entry => [entry.id, entry]));
|
|
110
|
+
const desiredIds = new Set(input.desired.map(request => request.id));
|
|
111
|
+
|
|
112
|
+
const cancels: DiffOperation[] = [];
|
|
113
|
+
const writes: DiffOperation[] = [];
|
|
114
|
+
const unchanged: string[] = [];
|
|
115
|
+
|
|
116
|
+
const protectedIds = input.protectedIds ?? new Set<string>();
|
|
117
|
+
|
|
118
|
+
for (const entry of input.device) {
|
|
119
|
+
if (!desiredIds.has(entry.id) && !protectedIds.has(entry.id)) {
|
|
120
|
+
cancels.push({ kind: 'cancel', id: entry.id });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const request of input.desired) {
|
|
125
|
+
const resolvedAt = input.resolvedAt.get(request.id) ?? request.moment.at;
|
|
126
|
+
const existing = deviceById.get(request.id);
|
|
127
|
+
|
|
128
|
+
if (existing === undefined) {
|
|
129
|
+
writes.push({ kind: 'create', id: request.id, request, resolvedAt });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (fingerprint(request, resolvedAt) === entryFingerprint(existing)) {
|
|
134
|
+
unchanged.push(request.id);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
writes.push({ kind: 'replace', id: request.id, request, resolvedAt });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { operations: [...cancels, ...writes], unchanged };
|
|
142
|
+
}
|
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Duplicate identifier resolution.
|
|
3
|
-
*
|
|
4
|
-
* An identifier appearing twice in one desired set means the caller's key
|
|
5
|
-
* derivation produced two notifications for one identity. That is a bug, not a
|
|
6
|
-
* preference, so the package refuses rather than silently picking a winner.
|
|
7
|
-
*
|
|
8
|
-
* **Every** copy is rejected — no entry wins. Order-independence then follows
|
|
9
|
-
* for free rather than having to be engineered: there is no "first" or "last"
|
|
10
|
-
* to depend on.
|
|
11
|
-
*
|
|
12
|
-
* The rule applies even to byte-identical duplicates. Treating those as
|
|
13
|
-
* mergeable would require a deep-equality definition that becomes a contract of
|
|
14
|
-
* its own, and a repeated identifier is a key-derivation bug either way.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { NotificationRequest } from '../contract/notification';
|
|
18
|
-
import type { RejectedEntry } from '../contract/reconcile';
|
|
19
|
-
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
20
|
-
|
|
21
|
-
export interface DuplicateSplit {
|
|
22
|
-
/** Entries whose identifier appeared exactly once. */
|
|
23
|
-
readonly unique: readonly NotificationRequest[];
|
|
24
|
-
/** One rejection record per repeated identifier, not per copy. */
|
|
25
|
-
readonly rejected: readonly RejectedEntry[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Splits a desired set into applicable entries and rejections.
|
|
30
|
-
*
|
|
31
|
-
* `existingIds` lets the rejection state whether a notification already
|
|
32
|
-
* scheduled under that identifier was left in place — without it a caller could
|
|
33
|
-
* reasonably read "rejected" as "cancelled".
|
|
34
|
-
*/
|
|
35
|
-
export function splitDuplicates(
|
|
36
|
-
desired: readonly NotificationRequest[],
|
|
37
|
-
existingIds: ReadonlySet<string> = new Set(),
|
|
38
|
-
): DuplicateSplit {
|
|
39
|
-
const counts = new Map<string, number>();
|
|
40
|
-
for (const request of desired) {
|
|
41
|
-
counts.set(request.id, (counts.get(request.id) ?? 0) + 1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const unique: NotificationRequest[] = [];
|
|
45
|
-
const rejected: RejectedEntry[] = [];
|
|
46
|
-
|
|
47
|
-
for (const request of desired) {
|
|
48
|
-
if ((counts.get(request.id) ?? 0) === 1) {
|
|
49
|
-
unique.push(request);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Sorted so the report itself is order-independent, not merely its effect.
|
|
54
|
-
const repeated = [...counts.entries()]
|
|
55
|
-
.filter(([, count]) => count > 1)
|
|
56
|
-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
57
|
-
|
|
58
|
-
for (const [id, occurrences] of repeated) {
|
|
59
|
-
rejected.push({
|
|
60
|
-
id,
|
|
61
|
-
code: DIAGNOSTIC_CODES.DUPLICATE_IDENTIFIER,
|
|
62
|
-
reason:
|
|
63
|
-
`Identifier appeared ${occurrences} times in one desired set. Every copy was rejected, because the package cannot know which was intended.` +
|
|
64
|
-
(existingIds.has(id)
|
|
65
|
-
? ' Any notification already scheduled under it was left in place — this is a rejection, not a cancellation.'
|
|
66
|
-
: ''),
|
|
67
|
-
occurrences,
|
|
68
|
-
existingLeftInPlace: existingIds.has(id),
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return { unique, rejected };
|
|
73
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Duplicate identifier resolution.
|
|
3
|
+
*
|
|
4
|
+
* An identifier appearing twice in one desired set means the caller's key
|
|
5
|
+
* derivation produced two notifications for one identity. That is a bug, not a
|
|
6
|
+
* preference, so the package refuses rather than silently picking a winner.
|
|
7
|
+
*
|
|
8
|
+
* **Every** copy is rejected — no entry wins. Order-independence then follows
|
|
9
|
+
* for free rather than having to be engineered: there is no "first" or "last"
|
|
10
|
+
* to depend on.
|
|
11
|
+
*
|
|
12
|
+
* The rule applies even to byte-identical duplicates. Treating those as
|
|
13
|
+
* mergeable would require a deep-equality definition that becomes a contract of
|
|
14
|
+
* its own, and a repeated identifier is a key-derivation bug either way.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { NotificationRequest } from '../contract/notification';
|
|
18
|
+
import type { RejectedEntry } from '../contract/reconcile';
|
|
19
|
+
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
20
|
+
|
|
21
|
+
export interface DuplicateSplit {
|
|
22
|
+
/** Entries whose identifier appeared exactly once. */
|
|
23
|
+
readonly unique: readonly NotificationRequest[];
|
|
24
|
+
/** One rejection record per repeated identifier, not per copy. */
|
|
25
|
+
readonly rejected: readonly RejectedEntry[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Splits a desired set into applicable entries and rejections.
|
|
30
|
+
*
|
|
31
|
+
* `existingIds` lets the rejection state whether a notification already
|
|
32
|
+
* scheduled under that identifier was left in place — without it a caller could
|
|
33
|
+
* reasonably read "rejected" as "cancelled".
|
|
34
|
+
*/
|
|
35
|
+
export function splitDuplicates(
|
|
36
|
+
desired: readonly NotificationRequest[],
|
|
37
|
+
existingIds: ReadonlySet<string> = new Set(),
|
|
38
|
+
): DuplicateSplit {
|
|
39
|
+
const counts = new Map<string, number>();
|
|
40
|
+
for (const request of desired) {
|
|
41
|
+
counts.set(request.id, (counts.get(request.id) ?? 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const unique: NotificationRequest[] = [];
|
|
45
|
+
const rejected: RejectedEntry[] = [];
|
|
46
|
+
|
|
47
|
+
for (const request of desired) {
|
|
48
|
+
if ((counts.get(request.id) ?? 0) === 1) {
|
|
49
|
+
unique.push(request);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Sorted so the report itself is order-independent, not merely its effect.
|
|
54
|
+
const repeated = [...counts.entries()]
|
|
55
|
+
.filter(([, count]) => count > 1)
|
|
56
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
57
|
+
|
|
58
|
+
for (const [id, occurrences] of repeated) {
|
|
59
|
+
rejected.push({
|
|
60
|
+
id,
|
|
61
|
+
code: DIAGNOSTIC_CODES.DUPLICATE_IDENTIFIER,
|
|
62
|
+
reason:
|
|
63
|
+
`Identifier appeared ${occurrences} times in one desired set. Every copy was rejected, because the package cannot know which was intended.` +
|
|
64
|
+
(existingIds.has(id)
|
|
65
|
+
? ' Any notification already scheduled under it was left in place — this is a rejection, not a cancellation.'
|
|
66
|
+
: ''),
|
|
67
|
+
occurrences,
|
|
68
|
+
existingLeftInPlace: existingIds.has(id),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { unique, rejected };
|
|
73
|
+
}
|