@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/index.ts
CHANGED
|
@@ -1,180 +1,180 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The reconciliation operation.
|
|
3
|
-
*
|
|
4
|
-
* A caller hands over the complete set its data implies; the package works out
|
|
5
|
-
* what to add, replace, and remove. This is **never** run automatically — only
|
|
6
|
-
* the caller knows what its data implies, and a package that guessed a desired
|
|
7
|
-
* set would cancel notifications it does not understand.
|
|
8
|
-
*
|
|
9
|
-
* Overlapping calls are serialized rather than run concurrently, because two
|
|
10
|
-
* passes interleaving their cancels and creates is exactly how duplicates
|
|
11
|
-
* appear.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { NotificationRequest, PendingEntry } from '../contract/notification';
|
|
15
|
-
import type { ReconcileInput, ReconcileResult } from '../contract/reconcile';
|
|
16
|
-
import type { ResolvedTone } from '../contract/channel';
|
|
17
|
-
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
18
|
-
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
19
|
-
import { anchorMoment, type TimeZoneReader } from '../schedule/anchor';
|
|
20
|
-
import { resolveMoment } from '../schedule/resolve-moment';
|
|
21
|
-
import { validateRequest } from '../schedule';
|
|
22
|
-
import { isNotificationError } from '../contract/errors';
|
|
23
|
-
import type { RejectedEntry } from '../contract/reconcile';
|
|
24
|
-
import { splitDuplicates } from './duplicates';
|
|
25
|
-
import { truncateToCeiling } from './truncate';
|
|
26
|
-
import { createScheduleWriter } from '../schedule/writer';
|
|
27
|
-
import { diff } from './diff';
|
|
28
|
-
import { applyInChunks } from './chunk';
|
|
29
|
-
|
|
30
|
-
export interface ReconcileDependencies {
|
|
31
|
-
readonly engine: NotificationEngineAdapter;
|
|
32
|
-
readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
|
|
33
|
-
readonly timeZone: TimeZoneReader;
|
|
34
|
-
readonly now: () => number;
|
|
35
|
-
readonly canScheduleExactly: () => boolean;
|
|
36
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
37
|
-
/** Overridable for tests that need to observe chunk boundaries. */
|
|
38
|
-
readonly chunkSize?: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Narrows the device set to the caller's declared domain.
|
|
43
|
-
*
|
|
44
|
-
* Everything outside it is invisible to this pass, which is what makes it safe
|
|
45
|
-
* to cancel strays: a notification from another domain or another library is
|
|
46
|
-
* never a stray, it simply is not ours.
|
|
47
|
-
*/
|
|
48
|
-
function inDomain(entry: PendingEntry, input: ReconcileInput): boolean {
|
|
49
|
-
if (entry.pushCreated && input.includePushCreated !== true) return false;
|
|
50
|
-
return entry.groupTag === input.domain;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function createReconciler(deps: ReconcileDependencies) {
|
|
54
|
-
const emit = deps.emit ?? (() => {});
|
|
55
|
-
const writer = createScheduleWriter(deps.engine);
|
|
56
|
-
let inFlight: Promise<ReconcileResult> = Promise.resolve() as unknown as Promise<ReconcileResult>;
|
|
57
|
-
|
|
58
|
-
const run = async (input: ReconcileInput): Promise<ReconcileResult> => {
|
|
59
|
-
const startedAt = deps.now();
|
|
60
|
-
const zone = deps.timeZone();
|
|
61
|
-
|
|
62
|
-
const device = (await deps.engine.getPending()).filter(entry => inDomain(entry, input));
|
|
63
|
-
const existingIds = new Set(device.map(entry => entry.id));
|
|
64
|
-
|
|
65
|
-
// Duplicates first: a repeated identifier is refused before anything else
|
|
66
|
-
// looks at it, so no later stage has to decide which copy it meant.
|
|
67
|
-
const { unique, rejected: duplicateRejections } = splitDuplicates(input.desired, existingIds);
|
|
68
|
-
const rejected: RejectedEntry[] = [...duplicateRejections];
|
|
69
|
-
|
|
70
|
-
// Validate and anchor. A rejection here removes one entry; it never fails
|
|
71
|
-
// the pass, because 499 valid entries should not be lost to one bad one.
|
|
72
|
-
const valid: NotificationRequest[] = [];
|
|
73
|
-
const resolvedAt = new Map<string, number>();
|
|
74
|
-
|
|
75
|
-
for (const request of unique) {
|
|
76
|
-
try {
|
|
77
|
-
validateRequest(request, deps.resolveTone);
|
|
78
|
-
const anchored = anchorMoment(request.moment, zone);
|
|
79
|
-
const resolution = resolveMoment(anchored.resolvedAt, deps.now());
|
|
80
|
-
resolvedAt.set(request.id, resolution.at);
|
|
81
|
-
valid.push(request);
|
|
82
|
-
} catch (error) {
|
|
83
|
-
rejected.push({
|
|
84
|
-
id: request.id,
|
|
85
|
-
code: isNotificationError(error) ? error.code : 'UNKNOWN',
|
|
86
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
87
|
-
occurrences: 1,
|
|
88
|
-
existingLeftInPlace: existingIds.has(request.id),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Stop markers occupy real platform capacity, so the ceiling a consumer's
|
|
94
|
-
// notifications can actually use is smaller than the platform's own.
|
|
95
|
-
// Hiding that would let the package quietly reduce how many notifications
|
|
96
|
-
// fit and report a full set as registered when it was not.
|
|
97
|
-
const { kept, report: truncation } = truncateToCeiling(
|
|
98
|
-
valid,
|
|
99
|
-
resolvedAt,
|
|
100
|
-
await writer.capacity(),
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
if (truncation !== undefined) {
|
|
104
|
-
emit(
|
|
105
|
-
DIAGNOSTIC_CODES.TRUNCATED_OVER_CEILING,
|
|
106
|
-
`Desired set exceeded the platform ceiling of ${truncation.ceiling}; ${truncation.droppedCount} furthest-out entries were not registered. They are re-evaluated at the next window refresh.`,
|
|
107
|
-
{ ceiling: truncation.ceiling, dropped: truncation.droppedCount },
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Every rejected identifier is protected from cancellation. A rejection
|
|
112
|
-
// means the package could not tell what the caller intended, and
|
|
113
|
-
// destroying the existing notification on that basis would be the worst
|
|
114
|
-
// available answer (FR-028c).
|
|
115
|
-
const { operations, unchanged } = diff({
|
|
116
|
-
desired: kept,
|
|
117
|
-
resolvedAt,
|
|
118
|
-
device,
|
|
119
|
-
protectedIds: new Set(rejected.map(entry => entry.id)),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const created: string[] = [];
|
|
123
|
-
const replaced: string[] = [];
|
|
124
|
-
const cancelled: string[] = [];
|
|
125
|
-
const approximate = !deps.canScheduleExactly();
|
|
126
|
-
|
|
127
|
-
await applyInChunks(
|
|
128
|
-
operations,
|
|
129
|
-
async operation => {
|
|
130
|
-
if (operation.kind === 'cancel') {
|
|
131
|
-
await writer.cancel(operation.id);
|
|
132
|
-
cancelled.push(operation.id);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const request = operation.request!;
|
|
137
|
-
const tone = deps.resolveTone(request.tone)!;
|
|
138
|
-
await writer.write({
|
|
139
|
-
request,
|
|
140
|
-
resolvedAt: operation.resolvedAt!,
|
|
141
|
-
tone,
|
|
142
|
-
anchoringGeneration:
|
|
143
|
-
(request.moment.anchoring ?? 'wallClock') === 'absolute' ? undefined : zone,
|
|
144
|
-
approximate,
|
|
145
|
-
});
|
|
146
|
-
(operation.kind === 'create' ? created : replaced).push(operation.id);
|
|
147
|
-
},
|
|
148
|
-
{ size: deps.chunkSize },
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
domain: input.domain,
|
|
153
|
-
created,
|
|
154
|
-
replaced,
|
|
155
|
-
cancelled,
|
|
156
|
-
unchanged,
|
|
157
|
-
rejected,
|
|
158
|
-
truncated: truncation,
|
|
159
|
-
operationCount: operations.length,
|
|
160
|
-
durationMs: deps.now() - startedAt,
|
|
161
|
-
};
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
return {
|
|
165
|
-
/**
|
|
166
|
-
* Serialized against itself: a second call waits for the first rather than
|
|
167
|
-
* interleaving with it.
|
|
168
|
-
*/
|
|
169
|
-
async reconcile(input: ReconcileInput): Promise<ReconcileResult> {
|
|
170
|
-
const next = inFlight.then(
|
|
171
|
-
() => run(input),
|
|
172
|
-
() => run(input),
|
|
173
|
-
);
|
|
174
|
-
inFlight = next.catch(() => undefined) as Promise<ReconcileResult>;
|
|
175
|
-
return next;
|
|
176
|
-
},
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export type Reconciler = ReturnType<typeof createReconciler>;
|
|
1
|
+
/**
|
|
2
|
+
* The reconciliation operation.
|
|
3
|
+
*
|
|
4
|
+
* A caller hands over the complete set its data implies; the package works out
|
|
5
|
+
* what to add, replace, and remove. This is **never** run automatically — only
|
|
6
|
+
* the caller knows what its data implies, and a package that guessed a desired
|
|
7
|
+
* set would cancel notifications it does not understand.
|
|
8
|
+
*
|
|
9
|
+
* Overlapping calls are serialized rather than run concurrently, because two
|
|
10
|
+
* passes interleaving their cancels and creates is exactly how duplicates
|
|
11
|
+
* appear.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { NotificationRequest, PendingEntry } from '../contract/notification';
|
|
15
|
+
import type { ReconcileInput, ReconcileResult } from '../contract/reconcile';
|
|
16
|
+
import type { ResolvedTone } from '../contract/channel';
|
|
17
|
+
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
18
|
+
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
19
|
+
import { anchorMoment, type TimeZoneReader } from '../schedule/anchor';
|
|
20
|
+
import { resolveMoment } from '../schedule/resolve-moment';
|
|
21
|
+
import { validateRequest } from '../schedule';
|
|
22
|
+
import { isNotificationError } from '../contract/errors';
|
|
23
|
+
import type { RejectedEntry } from '../contract/reconcile';
|
|
24
|
+
import { splitDuplicates } from './duplicates';
|
|
25
|
+
import { truncateToCeiling } from './truncate';
|
|
26
|
+
import { createScheduleWriter } from '../schedule/writer';
|
|
27
|
+
import { diff } from './diff';
|
|
28
|
+
import { applyInChunks } from './chunk';
|
|
29
|
+
|
|
30
|
+
export interface ReconcileDependencies {
|
|
31
|
+
readonly engine: NotificationEngineAdapter;
|
|
32
|
+
readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
|
|
33
|
+
readonly timeZone: TimeZoneReader;
|
|
34
|
+
readonly now: () => number;
|
|
35
|
+
readonly canScheduleExactly: () => boolean;
|
|
36
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
37
|
+
/** Overridable for tests that need to observe chunk boundaries. */
|
|
38
|
+
readonly chunkSize?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Narrows the device set to the caller's declared domain.
|
|
43
|
+
*
|
|
44
|
+
* Everything outside it is invisible to this pass, which is what makes it safe
|
|
45
|
+
* to cancel strays: a notification from another domain or another library is
|
|
46
|
+
* never a stray, it simply is not ours.
|
|
47
|
+
*/
|
|
48
|
+
function inDomain(entry: PendingEntry, input: ReconcileInput): boolean {
|
|
49
|
+
if (entry.pushCreated && input.includePushCreated !== true) return false;
|
|
50
|
+
return entry.groupTag === input.domain;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createReconciler(deps: ReconcileDependencies) {
|
|
54
|
+
const emit = deps.emit ?? (() => {});
|
|
55
|
+
const writer = createScheduleWriter(deps.engine);
|
|
56
|
+
let inFlight: Promise<ReconcileResult> = Promise.resolve() as unknown as Promise<ReconcileResult>;
|
|
57
|
+
|
|
58
|
+
const run = async (input: ReconcileInput): Promise<ReconcileResult> => {
|
|
59
|
+
const startedAt = deps.now();
|
|
60
|
+
const zone = deps.timeZone();
|
|
61
|
+
|
|
62
|
+
const device = (await deps.engine.getPending()).filter(entry => inDomain(entry, input));
|
|
63
|
+
const existingIds = new Set(device.map(entry => entry.id));
|
|
64
|
+
|
|
65
|
+
// Duplicates first: a repeated identifier is refused before anything else
|
|
66
|
+
// looks at it, so no later stage has to decide which copy it meant.
|
|
67
|
+
const { unique, rejected: duplicateRejections } = splitDuplicates(input.desired, existingIds);
|
|
68
|
+
const rejected: RejectedEntry[] = [...duplicateRejections];
|
|
69
|
+
|
|
70
|
+
// Validate and anchor. A rejection here removes one entry; it never fails
|
|
71
|
+
// the pass, because 499 valid entries should not be lost to one bad one.
|
|
72
|
+
const valid: NotificationRequest[] = [];
|
|
73
|
+
const resolvedAt = new Map<string, number>();
|
|
74
|
+
|
|
75
|
+
for (const request of unique) {
|
|
76
|
+
try {
|
|
77
|
+
validateRequest(request, deps.resolveTone);
|
|
78
|
+
const anchored = anchorMoment(request.moment, zone);
|
|
79
|
+
const resolution = resolveMoment(anchored.resolvedAt, deps.now());
|
|
80
|
+
resolvedAt.set(request.id, resolution.at);
|
|
81
|
+
valid.push(request);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
rejected.push({
|
|
84
|
+
id: request.id,
|
|
85
|
+
code: isNotificationError(error) ? error.code : 'UNKNOWN',
|
|
86
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
87
|
+
occurrences: 1,
|
|
88
|
+
existingLeftInPlace: existingIds.has(request.id),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Stop markers occupy real platform capacity, so the ceiling a consumer's
|
|
94
|
+
// notifications can actually use is smaller than the platform's own.
|
|
95
|
+
// Hiding that would let the package quietly reduce how many notifications
|
|
96
|
+
// fit and report a full set as registered when it was not.
|
|
97
|
+
const { kept, report: truncation } = truncateToCeiling(
|
|
98
|
+
valid,
|
|
99
|
+
resolvedAt,
|
|
100
|
+
await writer.capacity(),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (truncation !== undefined) {
|
|
104
|
+
emit(
|
|
105
|
+
DIAGNOSTIC_CODES.TRUNCATED_OVER_CEILING,
|
|
106
|
+
`Desired set exceeded the platform ceiling of ${truncation.ceiling}; ${truncation.droppedCount} furthest-out entries were not registered. They are re-evaluated at the next window refresh.`,
|
|
107
|
+
{ ceiling: truncation.ceiling, dropped: truncation.droppedCount },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Every rejected identifier is protected from cancellation. A rejection
|
|
112
|
+
// means the package could not tell what the caller intended, and
|
|
113
|
+
// destroying the existing notification on that basis would be the worst
|
|
114
|
+
// available answer (FR-028c).
|
|
115
|
+
const { operations, unchanged } = diff({
|
|
116
|
+
desired: kept,
|
|
117
|
+
resolvedAt,
|
|
118
|
+
device,
|
|
119
|
+
protectedIds: new Set(rejected.map(entry => entry.id)),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const created: string[] = [];
|
|
123
|
+
const replaced: string[] = [];
|
|
124
|
+
const cancelled: string[] = [];
|
|
125
|
+
const approximate = !deps.canScheduleExactly();
|
|
126
|
+
|
|
127
|
+
await applyInChunks(
|
|
128
|
+
operations,
|
|
129
|
+
async operation => {
|
|
130
|
+
if (operation.kind === 'cancel') {
|
|
131
|
+
await writer.cancel(operation.id);
|
|
132
|
+
cancelled.push(operation.id);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const request = operation.request!;
|
|
137
|
+
const tone = deps.resolveTone(request.tone)!;
|
|
138
|
+
await writer.write({
|
|
139
|
+
request,
|
|
140
|
+
resolvedAt: operation.resolvedAt!,
|
|
141
|
+
tone,
|
|
142
|
+
anchoringGeneration:
|
|
143
|
+
(request.moment.anchoring ?? 'wallClock') === 'absolute' ? undefined : zone,
|
|
144
|
+
approximate,
|
|
145
|
+
});
|
|
146
|
+
(operation.kind === 'create' ? created : replaced).push(operation.id);
|
|
147
|
+
},
|
|
148
|
+
{ size: deps.chunkSize },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
domain: input.domain,
|
|
153
|
+
created,
|
|
154
|
+
replaced,
|
|
155
|
+
cancelled,
|
|
156
|
+
unchanged,
|
|
157
|
+
rejected,
|
|
158
|
+
truncated: truncation,
|
|
159
|
+
operationCount: operations.length,
|
|
160
|
+
durationMs: deps.now() - startedAt,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
/**
|
|
166
|
+
* Serialized against itself: a second call waits for the first rather than
|
|
167
|
+
* interleaving with it.
|
|
168
|
+
*/
|
|
169
|
+
async reconcile(input: ReconcileInput): Promise<ReconcileResult> {
|
|
170
|
+
const next = inFlight.then(
|
|
171
|
+
() => run(input),
|
|
172
|
+
() => run(input),
|
|
173
|
+
);
|
|
174
|
+
inFlight = next.catch(() => undefined) as Promise<ReconcileResult>;
|
|
175
|
+
return next;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type Reconciler = ReturnType<typeof createReconciler>;
|
|
@@ -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,73 @@
|
|
|
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(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
62
|
+
occurrence => occurrence.at > previousHorizon || occurrence.at > now,
|
|
63
|
+
);
|
|
64
|
+
const alreadyHandled = occurrences.filter(
|
|
65
|
+
occurrence => occurrence.at <= previousHorizon && occurrence.at <= now,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
occurrences: kept,
|
|
70
|
+
suppressed: alreadyHandled.length,
|
|
71
|
+
discontinuity,
|
|
72
|
+
};
|
|
73
|
+
}
|