@chipmobilesdk/rn-notification 0.1.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 +42 -0
- package/README.md +306 -0
- package/package.json +73 -0
- package/src/channel/migrate.ts +211 -0
- package/src/channel/tone-map.ts +90 -0
- package/src/contract/channel.ts +89 -0
- package/src/contract/diagnostics.ts +102 -0
- package/src/contract/errors.ts +123 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/interaction.ts +62 -0
- package/src/contract/notification.ts +118 -0
- package/src/contract/permission.ts +60 -0
- package/src/contract/reconcile.ts +88 -0
- package/src/contract/recurrence.ts +123 -0
- package/src/diagnostics/burst.ts +101 -0
- package/src/diagnostics/events.ts +72 -0
- package/src/diagnostics/redact.ts +72 -0
- package/src/diagnostics/snapshot.ts +64 -0
- package/src/engine/adapter.ts +79 -0
- package/src/engine/notify-kit-engine.ts +535 -0
- package/src/index.ts +173 -0
- package/src/initialize.ts +174 -0
- package/src/interaction/index.ts +146 -0
- package/src/maintenance/cycle.ts +147 -0
- package/src/maintenance/permission-refresh.ts +41 -0
- package/src/maintenance/reanchor.ts +95 -0
- package/src/maintenance/toggle.ts +60 -0
- package/src/maintenance/verify.ts +83 -0
- package/src/maintenance/window-refresh.ts +61 -0
- package/src/permission/index.ts +183 -0
- package/src/permission/state.ts +77 -0
- package/src/push/envelope.ts +165 -0
- package/src/push/fcm-provider.ts +146 -0
- package/src/push/gate.ts +190 -0
- package/src/push/index.ts +29 -0
- package/src/push/instructions.ts +109 -0
- package/src/push/provider-adapter.ts +54 -0
- package/src/push/signout.ts +65 -0
- package/src/reconcile/chunk.ts +53 -0
- package/src/reconcile/diff.ts +145 -0
- package/src/reconcile/duplicates.ts +73 -0
- package/src/reconcile/index.ts +175 -0
- package/src/reconcile/truncate.ts +56 -0
- package/src/recurrence/clock-guard.ts +71 -0
- package/src/recurrence/expand.ts +292 -0
- package/src/recurrence/index.ts +177 -0
- package/src/schedule/anchor.ts +267 -0
- package/src/schedule/index.ts +173 -0
- package/src/schedule/partition.ts +85 -0
- package/src/schedule/resolve-moment.ts +80 -0
- package/src/schedule/validate-content.ts +111 -0
- package/src/schedule/validate-identifier.ts +114 -0
- package/src/state/caps.ts +71 -0
- package/src/state/dedup-ledger.ts +39 -0
- package/src/state/interaction-buffer.ts +73 -0
- package/src/state/mmkv-store.ts +71 -0
- package/src/state/permission-history.ts +41 -0
- package/src/state/record.ts +246 -0
- package/src/state/reset.ts +41 -0
- package/src/state/series-ledger.ts +84 -0
- package/src/state/store.ts +40 -0
- package/src/testing/conformance/index.ts +194 -0
- package/src/testing/fake-engine.ts +284 -0
- package/src/testing/fake-provider.ts +129 -0
- package/src/testing/faults.ts +72 -0
- package/src/testing/index.ts +26 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence expansion.
|
|
3
|
+
*
|
|
4
|
+
* Written first-party rather than delegated to an RFC 5545 library, because the
|
|
5
|
+
* daylight-saving rules here are specified behaviour (FR-008) rather than a
|
|
6
|
+
* dependency's opinion, and because expansion must be provable with no device.
|
|
7
|
+
*
|
|
8
|
+
* Determinism is the hard requirement: the same rule, anchoring mode, zone, and
|
|
9
|
+
* evaluation moment must produce exactly the same occurrences in the same order
|
|
10
|
+
* on every run and on both platforms. Without that, reconciliation could not be
|
|
11
|
+
* idempotent over a recurring series.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
RecurrenceRule,
|
|
16
|
+
RecurrencePattern,
|
|
17
|
+
DailyPattern,
|
|
18
|
+
WeeklyPattern,
|
|
19
|
+
MonthlyByDayPattern,
|
|
20
|
+
LookaheadConfig,
|
|
21
|
+
Weekday,
|
|
22
|
+
} from '../contract/recurrence';
|
|
23
|
+
import type { AnchoringMode } from '../contract/notification';
|
|
24
|
+
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
25
|
+
import { instantForParts, partsInZone, type WallClockParts } from '../schedule/anchor';
|
|
26
|
+
|
|
27
|
+
export interface Occurrence {
|
|
28
|
+
/** Derived from the series id and the occurrence instant — never random. */
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly seriesId: string;
|
|
31
|
+
readonly at: number;
|
|
32
|
+
readonly anchoring: AnchoringMode;
|
|
33
|
+
readonly index: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Occurrence identity.
|
|
38
|
+
*
|
|
39
|
+
* Derived from the series and the instant so re-expanding the same series
|
|
40
|
+
* produces the same identifiers — which is what lets reconciliation recognize
|
|
41
|
+
* an occurrence it already scheduled instead of creating a duplicate.
|
|
42
|
+
*/
|
|
43
|
+
export function occurrenceId(seriesId: string, at: number): string {
|
|
44
|
+
return `${seriesId}#${at}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
function validatePattern(pattern: RecurrencePattern): void {
|
|
50
|
+
const { hour, minute } = pattern;
|
|
51
|
+
|
|
52
|
+
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
|
53
|
+
throw new NotificationError(
|
|
54
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
55
|
+
'Recurrence hour must be an integer from 0 to 23.',
|
|
56
|
+
{ hour },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
60
|
+
throw new NotificationError(
|
|
61
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
62
|
+
'Recurrence minute must be an integer from 0 to 59.',
|
|
63
|
+
{ minute },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const interval = pattern.interval ?? 1;
|
|
68
|
+
if (!Number.isInteger(interval) || interval < 1) {
|
|
69
|
+
throw new NotificationError(
|
|
70
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
71
|
+
'Recurrence interval must be a positive integer.',
|
|
72
|
+
{ interval },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (pattern.kind === 'weekly') {
|
|
77
|
+
if (pattern.weekdays.length === 0) {
|
|
78
|
+
throw new NotificationError(
|
|
79
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
80
|
+
'A weekly recurrence must name at least one weekday.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
for (const day of pattern.weekdays) {
|
|
84
|
+
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
|
85
|
+
throw new NotificationError(
|
|
86
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
87
|
+
'Weekdays must be integers from 0 (Sunday) to 6 (Saturday).',
|
|
88
|
+
{ day },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (pattern.kind === 'monthlyByDay') {
|
|
95
|
+
if (!Number.isInteger(pattern.dayOfMonth) || pattern.dayOfMonth < 1 || pattern.dayOfMonth > 31) {
|
|
96
|
+
throw new NotificationError(
|
|
97
|
+
DIAGNOSTIC_CODES.INVALID_RECURRENCE,
|
|
98
|
+
'Day of month must be an integer from 1 to 31.',
|
|
99
|
+
{ dayOfMonth: pattern.dayOfMonth },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Calendar date in the anchoring zone, carrying the pattern's clock time. */
|
|
106
|
+
function partsFor(date: { year: number; month: number; day: number }, pattern: RecurrencePattern): WallClockParts {
|
|
107
|
+
return {
|
|
108
|
+
year: date.year,
|
|
109
|
+
month: date.month,
|
|
110
|
+
day: date.day,
|
|
111
|
+
hour: pattern.hour,
|
|
112
|
+
minute: pattern.minute,
|
|
113
|
+
second: 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function addDays(parts: WallClockParts, days: number): WallClockParts {
|
|
118
|
+
const shifted = new Date(Date.UTC(parts.year, parts.month - 1, parts.day) + days * MS_PER_DAY);
|
|
119
|
+
return {
|
|
120
|
+
year: shifted.getUTCFullYear(),
|
|
121
|
+
month: shifted.getUTCMonth() + 1,
|
|
122
|
+
day: shifted.getUTCDate(),
|
|
123
|
+
hour: parts.hour,
|
|
124
|
+
minute: parts.minute,
|
|
125
|
+
second: 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function weekdayOf(parts: WallClockParts): Weekday {
|
|
130
|
+
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay() as Weekday;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function daysInMonth(year: number, month: number): number {
|
|
134
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ExpandInput {
|
|
138
|
+
readonly rule: RecurrenceRule;
|
|
139
|
+
/** Occurrences strictly after this instant are produced. */
|
|
140
|
+
readonly from: number;
|
|
141
|
+
readonly lookahead: LookaheadConfig;
|
|
142
|
+
/** Zone the series is anchored against. Ignored for absolute anchoring. */
|
|
143
|
+
readonly timeZone: string;
|
|
144
|
+
/** How many occurrences the series has already emitted, for count-limited rules. */
|
|
145
|
+
readonly alreadyEmitted?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ExpandResult {
|
|
149
|
+
readonly occurrences: readonly Occurrence[];
|
|
150
|
+
/** The horizon expansion reached. */
|
|
151
|
+
readonly expandedThrough: number;
|
|
152
|
+
/** True when the series produced its final occurrence. */
|
|
153
|
+
readonly exhausted: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Expands a rule inside the lookahead window.
|
|
158
|
+
*
|
|
159
|
+
* Bounded by both a duration and a count, because either alone fails: a
|
|
160
|
+
* duration alone lets a frequent series exhaust the platform ceiling, and a
|
|
161
|
+
* count alone leaves an annual series scheduled a decade out.
|
|
162
|
+
*/
|
|
163
|
+
export function expand(input: ExpandInput): ExpandResult {
|
|
164
|
+
const { rule, from, lookahead, timeZone } = input;
|
|
165
|
+
validatePattern(rule.pattern);
|
|
166
|
+
|
|
167
|
+
const anchoring = rule.anchoring ?? 'wallClock';
|
|
168
|
+
const horizon = from + lookahead.days * MS_PER_DAY;
|
|
169
|
+
const start = Math.max(rule.startAt ?? from, from);
|
|
170
|
+
const alreadyEmitted = input.alreadyEmitted ?? 0;
|
|
171
|
+
|
|
172
|
+
const occurrences: Occurrence[] = [];
|
|
173
|
+
let cursor = partsFor(partsInZone(start, timeZone), rule.pattern);
|
|
174
|
+
let index = alreadyEmitted;
|
|
175
|
+
let exhausted = false;
|
|
176
|
+
|
|
177
|
+
// Bounded independently of the emit conditions so a pattern that matches
|
|
178
|
+
// nothing (e.g. day 31 in February) can never spin forever.
|
|
179
|
+
const maxIterations = lookahead.days * 2 + 400;
|
|
180
|
+
|
|
181
|
+
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
182
|
+
if (occurrences.length >= lookahead.occurrencesPerSeries) break;
|
|
183
|
+
|
|
184
|
+
const candidate = nextCandidate(cursor, rule.pattern, iteration === 0);
|
|
185
|
+
if (candidate === undefined) break;
|
|
186
|
+
cursor = candidate;
|
|
187
|
+
|
|
188
|
+
const at =
|
|
189
|
+
anchoring === 'absolute'
|
|
190
|
+
? Date.UTC(cursor.year, cursor.month - 1, cursor.day, cursor.hour, cursor.minute, 0)
|
|
191
|
+
: instantForParts(cursor, timeZone);
|
|
192
|
+
|
|
193
|
+
if (at > horizon) break;
|
|
194
|
+
|
|
195
|
+
if (at > start) {
|
|
196
|
+
if (rule.end.kind === 'untilDate' && at > rule.end.until) {
|
|
197
|
+
exhausted = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (rule.end.kind === 'count' && index >= rule.end.count) {
|
|
201
|
+
exhausted = true;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
occurrences.push({
|
|
206
|
+
id: occurrenceId(rule.id, at),
|
|
207
|
+
seriesId: rule.id,
|
|
208
|
+
at,
|
|
209
|
+
anchoring,
|
|
210
|
+
index,
|
|
211
|
+
});
|
|
212
|
+
index += 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
cursor = advance(cursor, rule.pattern);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
occurrences,
|
|
220
|
+
expandedThrough: horizon,
|
|
221
|
+
exhausted,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Moves the cursor onto the first date the pattern actually matches. */
|
|
226
|
+
function nextCandidate(
|
|
227
|
+
cursor: WallClockParts,
|
|
228
|
+
pattern: RecurrencePattern,
|
|
229
|
+
isFirst: boolean,
|
|
230
|
+
): WallClockParts | undefined {
|
|
231
|
+
if (pattern.kind === 'daily') return cursor;
|
|
232
|
+
|
|
233
|
+
if (pattern.kind === 'weekly') {
|
|
234
|
+
const wanted = new Set((pattern as WeeklyPattern).weekdays);
|
|
235
|
+
let candidate = cursor;
|
|
236
|
+
for (let step = 0; step < 14; step += 1) {
|
|
237
|
+
if (wanted.has(weekdayOf(candidate))) return candidate;
|
|
238
|
+
candidate = addDays(candidate, 1);
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const monthly = pattern as MonthlyByDayPattern;
|
|
244
|
+
let candidate = cursor;
|
|
245
|
+
// Months shorter than the requested day are skipped rather than clamped: a
|
|
246
|
+
// caller asking for the 31st means the 31st, and inventing the 28th would be
|
|
247
|
+
// the package deciding something it was not asked to decide.
|
|
248
|
+
for (let step = 0; step < 48; step += 1) {
|
|
249
|
+
if (daysInMonth(candidate.year, candidate.month) >= monthly.dayOfMonth) {
|
|
250
|
+
const onDay = { ...candidate, day: monthly.dayOfMonth };
|
|
251
|
+
if (isFirst || onDay.day >= candidate.day || step > 0) return onDay;
|
|
252
|
+
}
|
|
253
|
+
candidate = firstOfNextMonth(candidate);
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function firstOfNextMonth(parts: WallClockParts): WallClockParts {
|
|
259
|
+
const month = parts.month === 12 ? 1 : parts.month + 1;
|
|
260
|
+
const year = parts.month === 12 ? parts.year + 1 : parts.year;
|
|
261
|
+
return { ...parts, year, month, day: 1 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Steps the cursor past the occurrence just produced. */
|
|
265
|
+
function advance(cursor: WallClockParts, pattern: RecurrencePattern): WallClockParts {
|
|
266
|
+
const interval = pattern.interval ?? 1;
|
|
267
|
+
|
|
268
|
+
if (pattern.kind === 'daily') {
|
|
269
|
+
return addDays(cursor, (pattern as DailyPattern).interval ?? 1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (pattern.kind === 'weekly') {
|
|
273
|
+
const wanted = [...new Set((pattern as WeeklyPattern).weekdays)].sort((a, b) => a - b);
|
|
274
|
+
const current = weekdayOf(cursor);
|
|
275
|
+
const next = wanted.find(day => day > current);
|
|
276
|
+
|
|
277
|
+
// Wrapping into a new week is where the interval applies — stepping day by
|
|
278
|
+
// day within the week would multiply the interval by the weekday count.
|
|
279
|
+
return next === undefined
|
|
280
|
+
? addDays(cursor, 7 * interval - current + wanted[0])
|
|
281
|
+
: addDays(cursor, next - current);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const monthly = pattern as MonthlyByDayPattern;
|
|
285
|
+
let year = cursor.year;
|
|
286
|
+
let month = cursor.month + interval;
|
|
287
|
+
while (month > 12) {
|
|
288
|
+
month -= 12;
|
|
289
|
+
year += 1;
|
|
290
|
+
}
|
|
291
|
+
return { ...cursor, year, month, day: Math.min(monthly.dayOfMonth, daysInMonth(year, month)) };
|
|
292
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Series registration and the lookahead window.
|
|
3
|
+
*
|
|
4
|
+
* A caller declares a rule once. The package expands it inside a finite window
|
|
5
|
+
* and refreshes that window on its own, because only it knows when the horizon
|
|
6
|
+
* has moved — that is the one piece of recurrence knowledge the consumer does
|
|
7
|
+
* not have.
|
|
8
|
+
*
|
|
9
|
+
* Occurrences carry derived identifiers, so re-expanding a series recognises
|
|
10
|
+
* what it already scheduled instead of creating duplicates. That is what makes
|
|
11
|
+
* a window refresh with nothing to do cost zero platform operations.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
RecurrenceRule,
|
|
16
|
+
SeriesResult,
|
|
17
|
+
SeriesSummary,
|
|
18
|
+
LookaheadConfig,
|
|
19
|
+
} from '../contract/recurrence';
|
|
20
|
+
import type { NotificationRequest } from '../contract/notification';
|
|
21
|
+
import type { ResolvedTone } from '../contract/channel';
|
|
22
|
+
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
23
|
+
import type { StateManager } from '../state/record';
|
|
24
|
+
import type { TimeZoneReader } from '../schedule/anchor';
|
|
25
|
+
import { validateIdentifier } from '../schedule/validate-identifier';
|
|
26
|
+
import { validateContent } from '../schedule/validate-content';
|
|
27
|
+
import { expand, type Occurrence } from './expand';
|
|
28
|
+
import { guardAgainstClockChange } from './clock-guard';
|
|
29
|
+
import { listSeries as readLedger, readSeries, removeSeries, upsertSeries } from '../state/series-ledger';
|
|
30
|
+
|
|
31
|
+
export interface RecurrenceDependencies {
|
|
32
|
+
readonly engine: NotificationEngineAdapter;
|
|
33
|
+
readonly state: StateManager;
|
|
34
|
+
readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
|
|
35
|
+
readonly timeZone: TimeZoneReader;
|
|
36
|
+
readonly now: () => number;
|
|
37
|
+
readonly lookahead: LookaheadConfig;
|
|
38
|
+
readonly canScheduleExactly: () => boolean;
|
|
39
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Builds the request for one occurrence from the series template. */
|
|
43
|
+
function toRequest(rule: RecurrenceRule, occurrence: Occurrence): NotificationRequest {
|
|
44
|
+
return {
|
|
45
|
+
id: occurrence.id,
|
|
46
|
+
content: rule.template.content,
|
|
47
|
+
tone: rule.template.tone,
|
|
48
|
+
moment: { at: occurrence.at, anchoring: occurrence.anchoring },
|
|
49
|
+
routing: rule.template.routing,
|
|
50
|
+
actions: rule.template.actions,
|
|
51
|
+
ownerTag: rule.template.ownerTag,
|
|
52
|
+
groupTag: rule.template.groupTag,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createRecurrence(deps: RecurrenceDependencies) {
|
|
57
|
+
const emit = deps.emit ?? (() => {});
|
|
58
|
+
|
|
59
|
+
/** Expands and registers, scheduling only occurrences the device lacks. */
|
|
60
|
+
const materialize = async (rule: RecurrenceRule): Promise<SeriesResult> => {
|
|
61
|
+
const now = deps.now();
|
|
62
|
+
const zone = deps.timeZone();
|
|
63
|
+
const previous = readSeries(deps.state, rule.id);
|
|
64
|
+
|
|
65
|
+
const expansion = expand({
|
|
66
|
+
rule,
|
|
67
|
+
from: now,
|
|
68
|
+
lookahead: deps.lookahead,
|
|
69
|
+
timeZone: zone,
|
|
70
|
+
alreadyEmitted: previous?.emittedCount ?? 0,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const guarded = guardAgainstClockChange({
|
|
74
|
+
occurrences: expansion.occurrences,
|
|
75
|
+
previousHorizon: previous?.expandedThrough,
|
|
76
|
+
now,
|
|
77
|
+
previousNow: previous?.at,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (guarded.discontinuity !== 'none') {
|
|
81
|
+
emit(
|
|
82
|
+
'CLOCK_DISCONTINUITY',
|
|
83
|
+
`Device clock moved ${guarded.discontinuity}; ${guarded.suppressed} already-handled occurrence(s) were suppressed rather than re-delivered.`,
|
|
84
|
+
{ direction: guarded.discontinuity, suppressed: guarded.suppressed, seriesId: rule.id },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tone = deps.resolveTone(rule.template.tone);
|
|
89
|
+
if (tone === undefined) {
|
|
90
|
+
throw new Error(`Tone "${rule.template.tone}" was never declared.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Only what the device lacks — this is why a no-change refresh is free.
|
|
94
|
+
const existing = new Set((await deps.engine.getPending()).map(entry => entry.id));
|
|
95
|
+
const approximate = !deps.canScheduleExactly();
|
|
96
|
+
let scheduled = 0;
|
|
97
|
+
|
|
98
|
+
for (const occurrence of guarded.occurrences) {
|
|
99
|
+
if (existing.has(occurrence.id)) continue;
|
|
100
|
+
|
|
101
|
+
await deps.engine.schedule({
|
|
102
|
+
request: toRequest(rule, occurrence),
|
|
103
|
+
resolvedAt: occurrence.at,
|
|
104
|
+
tone,
|
|
105
|
+
// The zone the series was expanded against; absent for absolute series.
|
|
106
|
+
anchoringGeneration: occurrence.anchoring === 'absolute' ? undefined : zone,
|
|
107
|
+
approximate,
|
|
108
|
+
seriesId: rule.id,
|
|
109
|
+
});
|
|
110
|
+
scheduled += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const emittedCount = (previous?.emittedCount ?? 0) + guarded.occurrences.length;
|
|
114
|
+
upsertSeries(
|
|
115
|
+
deps.state,
|
|
116
|
+
rule,
|
|
117
|
+
{
|
|
118
|
+
expandedThrough: expansion.expandedThrough,
|
|
119
|
+
emittedCount,
|
|
120
|
+
exhausted: expansion.exhausted,
|
|
121
|
+
},
|
|
122
|
+
now,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
seriesId: rule.id,
|
|
127
|
+
scheduled,
|
|
128
|
+
expandedThrough: expansion.expandedThrough,
|
|
129
|
+
exhausted: expansion.exhausted,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
async registerSeries(rule: RecurrenceRule): Promise<SeriesResult> {
|
|
135
|
+
validateIdentifier(rule.id, 'seriesId');
|
|
136
|
+
validateContent(rule.template.content);
|
|
137
|
+
return materialize(rule);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
/** Re-expands a tracked series. Called by the window-refresh maintenance step. */
|
|
141
|
+
async refreshSeries(rule: RecurrenceRule): Promise<SeriesResult> {
|
|
142
|
+
return materialize(rule);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async cancelSeries(seriesId: string): Promise<readonly string[]> {
|
|
146
|
+
validateIdentifier(seriesId, 'seriesId');
|
|
147
|
+
|
|
148
|
+
// Every occurrence already scheduled from this series goes, identified by
|
|
149
|
+
// its derived prefix rather than by a list the ledger would have to keep
|
|
150
|
+
// in sync with the device.
|
|
151
|
+
const pending = await deps.engine.getPending();
|
|
152
|
+
const ids = pending
|
|
153
|
+
.filter(entry => entry.seriesId === seriesId || entry.id.startsWith(`${seriesId}#`))
|
|
154
|
+
.map(entry => entry.id);
|
|
155
|
+
|
|
156
|
+
await deps.engine.cancelMany(ids);
|
|
157
|
+
removeSeries(deps.state, seriesId);
|
|
158
|
+
return ids;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async listSeries(): Promise<readonly SeriesSummary[]> {
|
|
162
|
+
const pending = await deps.engine.getPending();
|
|
163
|
+
|
|
164
|
+
return readLedger(deps.state).map(entry => ({
|
|
165
|
+
seriesId: entry.seriesId,
|
|
166
|
+
anchoring: entry.anchoring,
|
|
167
|
+
expandedThrough: entry.expandedThrough,
|
|
168
|
+
pendingOccurrences: pending.filter(
|
|
169
|
+
p => p.seriesId === entry.seriesId || p.id.startsWith(`${entry.seriesId}#`),
|
|
170
|
+
).length,
|
|
171
|
+
exhausted: entry.exhausted,
|
|
172
|
+
}));
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type Recurrence = ReturnType<typeof createRecurrence>;
|