@chipmobilesdk/rn-notification 0.1.2 → 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.
- package/CHANGELOG.md +80 -42
- package/README.md +399 -306
- package/package.json +2 -1
- package/skills/sdk-rn-notification/SKILL.md +68 -3
- package/src/channel/migrate.ts +86 -12
- package/src/channel/tone-map.ts +130 -90
- package/src/channel/validate-tone.ts +61 -0
- package/src/contract/channel.ts +188 -89
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +130 -123
- 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 +60 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +123 -123
- 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 +64 -64
- package/src/engine/adapter.ts +172 -79
- package/src/engine/notify-kit-engine.ts +761 -535
- package/src/index.ts +24 -0
- package/src/initialize.ts +174 -174
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +147 -147
- package/src/maintenance/permission-refresh.ts +41 -41
- package/src/maintenance/reanchor.ts +95 -95
- package/src/maintenance/toggle.ts +60 -60
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +61 -61
- 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 +146 -146
- package/src/push/gate.ts +190 -190
- package/src/push/instructions.ts +109 -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 +145 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +14 -1
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +71 -71
- package/src/recurrence/expand.ts +292 -292
- package/src/repeat/expiry.ts +55 -0
- package/src/repeat/index.ts +54 -0
- package/src/repeat/marker.ts +72 -0
- package/src/repeat/place.ts +73 -0
- package/src/schedule/anchor.ts +267 -267
- package/src/schedule/index.ts +184 -173
- package/src/schedule/partition.ts +85 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -114
- package/src/state/caps.ts +71 -71
- package/src/state/interaction-buffer.ts +73 -73
- package/src/state/mmkv-store.ts +71 -71
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +258 -246
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +84 -84
- package/src/state/store.ts +40 -40
- package/src/testing/conformance/index.ts +248 -194
- package/src/testing/fake-engine.ts +469 -284
- package/src/testing/fake-provider.ts +129 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/measure.ts +336 -333
|
@@ -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
|
+
}
|
package/src/reconcile/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { isNotificationError } from '../contract/errors';
|
|
|
23
23
|
import type { RejectedEntry } from '../contract/reconcile';
|
|
24
24
|
import { splitDuplicates } from './duplicates';
|
|
25
25
|
import { truncateToCeiling } from './truncate';
|
|
26
|
+
import { createStopPlacement } from '../repeat/place';
|
|
26
27
|
import { diff } from './diff';
|
|
27
28
|
import { applyInChunks } from './chunk';
|
|
28
29
|
|
|
@@ -51,6 +52,7 @@ function inDomain(entry: PendingEntry, input: ReconcileInput): boolean {
|
|
|
51
52
|
|
|
52
53
|
export function createReconciler(deps: ReconcileDependencies) {
|
|
53
54
|
const emit = deps.emit ?? (() => {});
|
|
55
|
+
const stops = createStopPlacement(deps.engine);
|
|
54
56
|
let inFlight: Promise<ReconcileResult> = Promise.resolve() as unknown as Promise<ReconcileResult>;
|
|
55
57
|
|
|
56
58
|
const run = async (input: ReconcileInput): Promise<ReconcileResult> => {
|
|
@@ -89,10 +91,16 @@ export function createReconciler(deps: ReconcileDependencies) {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
const capabilities = await deps.engine.getCapabilities();
|
|
94
|
+
|
|
95
|
+
// Stop markers occupy real platform capacity, so the ceiling a consumer's
|
|
96
|
+
// notifications can actually use is smaller than the platform's own.
|
|
97
|
+
// Hiding that would let the package quietly reduce how many notifications
|
|
98
|
+
// fit and report a full set as registered when it was not.
|
|
99
|
+
const markerCost = (await deps.engine.listRepeatStops()).length;
|
|
92
100
|
const { kept, report: truncation } = truncateToCeiling(
|
|
93
101
|
valid,
|
|
94
102
|
resolvedAt,
|
|
95
|
-
capabilities.pendingCeiling,
|
|
103
|
+
Math.max(0, capabilities.pendingCeiling - markerCost),
|
|
96
104
|
);
|
|
97
105
|
|
|
98
106
|
if (truncation !== undefined) {
|
|
@@ -124,6 +132,7 @@ export function createReconciler(deps: ReconcileDependencies) {
|
|
|
124
132
|
async operation => {
|
|
125
133
|
if (operation.kind === 'cancel') {
|
|
126
134
|
await deps.engine.cancel(operation.id);
|
|
135
|
+
await stops.release(operation.id);
|
|
127
136
|
cancelled.push(operation.id);
|
|
128
137
|
return;
|
|
129
138
|
}
|
|
@@ -138,6 +147,10 @@ export function createReconciler(deps: ReconcileDependencies) {
|
|
|
138
147
|
(request.moment.anchoring ?? 'wallClock') === 'absolute' ? undefined : zone,
|
|
139
148
|
approximate,
|
|
140
149
|
});
|
|
150
|
+
// Reconciliation is how most consumers schedule, so a bound placed
|
|
151
|
+
// only by the direct scheduler would be a bound that mostly did not
|
|
152
|
+
// exist.
|
|
153
|
+
await stops.sync(request.id, tone, operation.resolvedAt!);
|
|
141
154
|
(operation.kind === 'create' ? created : replaced).push(operation.id);
|
|
142
155
|
},
|
|
143
156
|
{ size: deps.chunkSize },
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Platform-ceiling truncation.
|
|
3
|
-
*
|
|
4
|
-
* iOS keeps only the 64 soonest pending notifications and silently discards the
|
|
5
|
-
* rest. The package computes the same cut deliberately, so the outcome is
|
|
6
|
-
* reported rather than discovered as missing notifications.
|
|
7
|
-
*
|
|
8
|
-
* Soonest-first is the only rule that degrades gracefully: dropped entries are
|
|
9
|
-
* not lost, they are simply not yet registered, and each window refresh
|
|
10
|
-
* registers those that have moved into range.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { NotificationRequest } from '../contract/notification';
|
|
14
|
-
import type { TruncationReport } from '../contract/reconcile';
|
|
15
|
-
|
|
16
|
-
export interface TruncationOutcome {
|
|
17
|
-
readonly kept: readonly NotificationRequest[];
|
|
18
|
-
readonly report?: TruncationReport;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Keeps the `ceiling` soonest entries.
|
|
23
|
-
*
|
|
24
|
-
* Ties on the delivery instant are broken by identifier so the cut is
|
|
25
|
-
* deterministic — otherwise two runs over the same data could drop different
|
|
26
|
-
* entries and the idempotence guarantee would not hold.
|
|
27
|
-
*/
|
|
28
|
-
export function truncateToCeiling(
|
|
29
|
-
desired: readonly NotificationRequest[],
|
|
30
|
-
resolvedAt: ReadonlyMap<string, number>,
|
|
31
|
-
ceiling: number,
|
|
32
|
-
): TruncationOutcome {
|
|
33
|
-
if (desired.length <= ceiling) {
|
|
34
|
-
return { kept: desired };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const ordered = [...desired].sort((a, b) => {
|
|
38
|
-
const aAt = resolvedAt.get(a.id) ?? a.moment.at;
|
|
39
|
-
const bAt = resolvedAt.get(b.id) ?? b.moment.at;
|
|
40
|
-
if (aAt !== bAt) return aAt - bAt;
|
|
41
|
-
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const kept = ordered.slice(0, ceiling);
|
|
45
|
-
const dropped = ordered.slice(ceiling);
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
kept,
|
|
49
|
-
report: {
|
|
50
|
-
ceiling,
|
|
51
|
-
droppedCount: dropped.length,
|
|
52
|
-
droppedIds: dropped.map(entry => entry.id),
|
|
53
|
-
reEvaluatedOnNextRefresh: true,
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Platform-ceiling truncation.
|
|
3
|
+
*
|
|
4
|
+
* iOS keeps only the 64 soonest pending notifications and silently discards the
|
|
5
|
+
* rest. The package computes the same cut deliberately, so the outcome is
|
|
6
|
+
* reported rather than discovered as missing notifications.
|
|
7
|
+
*
|
|
8
|
+
* Soonest-first is the only rule that degrades gracefully: dropped entries are
|
|
9
|
+
* not lost, they are simply not yet registered, and each window refresh
|
|
10
|
+
* registers those that have moved into range.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { NotificationRequest } from '../contract/notification';
|
|
14
|
+
import type { TruncationReport } from '../contract/reconcile';
|
|
15
|
+
|
|
16
|
+
export interface TruncationOutcome {
|
|
17
|
+
readonly kept: readonly NotificationRequest[];
|
|
18
|
+
readonly report?: TruncationReport;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Keeps the `ceiling` soonest entries.
|
|
23
|
+
*
|
|
24
|
+
* Ties on the delivery instant are broken by identifier so the cut is
|
|
25
|
+
* deterministic — otherwise two runs over the same data could drop different
|
|
26
|
+
* entries and the idempotence guarantee would not hold.
|
|
27
|
+
*/
|
|
28
|
+
export function truncateToCeiling(
|
|
29
|
+
desired: readonly NotificationRequest[],
|
|
30
|
+
resolvedAt: ReadonlyMap<string, number>,
|
|
31
|
+
ceiling: number,
|
|
32
|
+
): TruncationOutcome {
|
|
33
|
+
if (desired.length <= ceiling) {
|
|
34
|
+
return { kept: desired };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ordered = [...desired].sort((a, b) => {
|
|
38
|
+
const aAt = resolvedAt.get(a.id) ?? a.moment.at;
|
|
39
|
+
const bAt = resolvedAt.get(b.id) ?? b.moment.at;
|
|
40
|
+
if (aAt !== bAt) return aAt - bAt;
|
|
41
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const kept = ordered.slice(0, ceiling);
|
|
45
|
+
const dropped = ordered.slice(ceiling);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
kept,
|
|
49
|
+
report: {
|
|
50
|
+
ceiling,
|
|
51
|
+
droppedCount: dropped.length,
|
|
52
|
+
droppedIds: dropped.map(entry => entry.id),
|
|
53
|
+
reEvaluatedOnNextRefresh: true,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Clock-discontinuity bounding.
|
|
3
|
-
*
|
|
4
|
-
* A device clock moved backwards makes past occurrences look future again. Left
|
|
5
|
-
* alone, the next expansion would re-register every occurrence the user already
|
|
6
|
-
* received, and moving the clock forward again would fire them all at once.
|
|
7
|
-
*
|
|
8
|
-
* The guard is deliberately blunt: occurrences at or before the last horizon the
|
|
9
|
-
* series already expanded through are never re-emitted. The package would
|
|
10
|
-
* rather lose an occurrence after a manual clock change than deliver a burst of
|
|
11
|
-
* notifications for events that already happened.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { Occurrence } from './expand';
|
|
15
|
-
|
|
16
|
-
/** A jump larger than this is treated as a clock change rather than ordinary drift. */
|
|
17
|
-
export const CLOCK_DISCONTINUITY_THRESHOLD_MS = 60 * 60 * 1000;
|
|
18
|
-
|
|
19
|
-
export interface ClockGuardInput {
|
|
20
|
-
readonly occurrences: readonly Occurrence[];
|
|
21
|
-
/** Horizon this series had already expanded through before now. */
|
|
22
|
-
readonly previousHorizon: number | undefined;
|
|
23
|
-
readonly now: number;
|
|
24
|
-
/** The clock reading when the series was last expanded, if known. */
|
|
25
|
-
readonly previousNow: number | undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface ClockGuardResult {
|
|
29
|
-
readonly occurrences: readonly Occurrence[];
|
|
30
|
-
readonly suppressed: number;
|
|
31
|
-
readonly discontinuity: 'none' | 'backwards' | 'forwards';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Drops occurrences that a clock discontinuity would otherwise resurrect.
|
|
36
|
-
*
|
|
37
|
-
* Forward jumps are detected and reported but not filtered: an occurrence that
|
|
38
|
-
* became due because time genuinely passed is legitimate, and the platform's
|
|
39
|
-
* own delivery is the right place for it to be resolved.
|
|
40
|
-
*/
|
|
41
|
-
export function guardAgainstClockChange(input: ClockGuardInput): ClockGuardResult {
|
|
42
|
-
const { occurrences, previousHorizon, now, previousNow } = input;
|
|
43
|
-
|
|
44
|
-
let discontinuity: ClockGuardResult['discontinuity'] = 'none';
|
|
45
|
-
if (previousNow !== undefined) {
|
|
46
|
-
const delta = now - previousNow;
|
|
47
|
-
if (delta < -CLOCK_DISCONTINUITY_THRESHOLD_MS) {
|
|
48
|
-
discontinuity = 'backwards';
|
|
49
|
-
} else if (delta > CLOCK_DISCONTINUITY_THRESHOLD_MS * 24) {
|
|
50
|
-
discontinuity = 'forwards';
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (previousHorizon === undefined) {
|
|
55
|
-
return { occurrences, suppressed: 0, discontinuity };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Anything at or before the previously-expanded horizon was already handled
|
|
59
|
-
// once. Re-emitting it would mean delivering a notification for an event the
|
|
60
|
-
// user has already seen.
|
|
61
|
-
const kept = occurrences.filter(occurrence => occurrence.at > previousHorizon || occurrence.at > now);
|
|
62
|
-
const alreadyHandled = occurrences.filter(
|
|
63
|
-
occurrence => occurrence.at <= previousHorizon && occurrence.at <= now,
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
occurrences: kept,
|
|
68
|
-
suppressed: alreadyHandled.length,
|
|
69
|
-
discontinuity,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Clock-discontinuity bounding.
|
|
3
|
+
*
|
|
4
|
+
* A device clock moved backwards makes past occurrences look future again. Left
|
|
5
|
+
* alone, the next expansion would re-register every occurrence the user already
|
|
6
|
+
* received, and moving the clock forward again would fire them all at once.
|
|
7
|
+
*
|
|
8
|
+
* The guard is deliberately blunt: occurrences at or before the last horizon the
|
|
9
|
+
* series already expanded through are never re-emitted. The package would
|
|
10
|
+
* rather lose an occurrence after a manual clock change than deliver a burst of
|
|
11
|
+
* notifications for events that already happened.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Occurrence } from './expand';
|
|
15
|
+
|
|
16
|
+
/** A jump larger than this is treated as a clock change rather than ordinary drift. */
|
|
17
|
+
export const CLOCK_DISCONTINUITY_THRESHOLD_MS = 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export interface ClockGuardInput {
|
|
20
|
+
readonly occurrences: readonly Occurrence[];
|
|
21
|
+
/** Horizon this series had already expanded through before now. */
|
|
22
|
+
readonly previousHorizon: number | undefined;
|
|
23
|
+
readonly now: number;
|
|
24
|
+
/** The clock reading when the series was last expanded, if known. */
|
|
25
|
+
readonly previousNow: number | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ClockGuardResult {
|
|
29
|
+
readonly occurrences: readonly Occurrence[];
|
|
30
|
+
readonly suppressed: number;
|
|
31
|
+
readonly discontinuity: 'none' | 'backwards' | 'forwards';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Drops occurrences that a clock discontinuity would otherwise resurrect.
|
|
36
|
+
*
|
|
37
|
+
* Forward jumps are detected and reported but not filtered: an occurrence that
|
|
38
|
+
* became due because time genuinely passed is legitimate, and the platform's
|
|
39
|
+
* own delivery is the right place for it to be resolved.
|
|
40
|
+
*/
|
|
41
|
+
export function guardAgainstClockChange(input: ClockGuardInput): ClockGuardResult {
|
|
42
|
+
const { occurrences, previousHorizon, now, previousNow } = input;
|
|
43
|
+
|
|
44
|
+
let discontinuity: ClockGuardResult['discontinuity'] = 'none';
|
|
45
|
+
if (previousNow !== undefined) {
|
|
46
|
+
const delta = now - previousNow;
|
|
47
|
+
if (delta < -CLOCK_DISCONTINUITY_THRESHOLD_MS) {
|
|
48
|
+
discontinuity = 'backwards';
|
|
49
|
+
} else if (delta > CLOCK_DISCONTINUITY_THRESHOLD_MS * 24) {
|
|
50
|
+
discontinuity = 'forwards';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (previousHorizon === undefined) {
|
|
55
|
+
return { occurrences, suppressed: 0, discontinuity };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Anything at or before the previously-expanded horizon was already handled
|
|
59
|
+
// once. Re-emitting it would mean delivering a notification for an event the
|
|
60
|
+
// user has already seen.
|
|
61
|
+
const kept = occurrences.filter(occurrence => occurrence.at > previousHorizon || occurrence.at > now);
|
|
62
|
+
const alreadyHandled = occurrences.filter(
|
|
63
|
+
occurrence => occurrence.at <= previousHorizon && occurrence.at <= now,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
occurrences: kept,
|
|
68
|
+
suppressed: alreadyHandled.length,
|
|
69
|
+
discontinuity,
|
|
70
|
+
};
|
|
71
|
+
}
|