@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/recurrence/index.ts
CHANGED
|
@@ -1,259 +1,270 @@
|
|
|
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
|
-
* Occurrences are written through the shared `ScheduleWriter`, like every other
|
|
14
|
-
* consumer notification, so a repeating tone gets its stop marker and the
|
|
15
|
-
* platform ceiling is respected here too.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type {
|
|
19
|
-
RecurrenceRule,
|
|
20
|
-
SeriesResult,
|
|
21
|
-
SeriesSummary,
|
|
22
|
-
LookaheadConfig,
|
|
23
|
-
} from '../contract/recurrence';
|
|
24
|
-
import type { NotificationRequest, PendingEntry } from '../contract/notification';
|
|
25
|
-
import type { ResolvedTone } from '../contract/channel';
|
|
26
|
-
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
27
|
-
import type { StateManager } from '../state/record';
|
|
28
|
-
import { partsInZone, type TimeZoneReader } from '../schedule/anchor';
|
|
29
|
-
import { validateIdentifier } from '../schedule/validate-identifier';
|
|
30
|
-
import { validateRequest } from '../schedule';
|
|
31
|
-
import { createScheduleWriter } from '../schedule/writer';
|
|
32
|
-
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
33
|
-
import { expand, occurrenceKey, type Occurrence } from './expand';
|
|
34
|
-
import { guardAgainstClockChange } from './clock-guard';
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
45
|
-
readonly
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
* Occurrences are written through the shared `ScheduleWriter`, like every other
|
|
14
|
+
* consumer notification, so a repeating tone gets its stop marker and the
|
|
15
|
+
* platform ceiling is respected here too.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
RecurrenceRule,
|
|
20
|
+
SeriesResult,
|
|
21
|
+
SeriesSummary,
|
|
22
|
+
LookaheadConfig,
|
|
23
|
+
} from '../contract/recurrence';
|
|
24
|
+
import type { NotificationRequest, PendingEntry } from '../contract/notification';
|
|
25
|
+
import type { ResolvedTone } from '../contract/channel';
|
|
26
|
+
import type { NotificationEngineAdapter } from '../engine/adapter';
|
|
27
|
+
import type { StateManager } from '../state/record';
|
|
28
|
+
import { partsInZone, type TimeZoneReader } from '../schedule/anchor';
|
|
29
|
+
import { validateIdentifier } from '../schedule/validate-identifier';
|
|
30
|
+
import { validateRequest } from '../schedule';
|
|
31
|
+
import { createScheduleWriter } from '../schedule/writer';
|
|
32
|
+
import { DIAGNOSTIC_CODES, NotificationError } from '../contract/errors';
|
|
33
|
+
import { expand, occurrenceKey, type Occurrence } from './expand';
|
|
34
|
+
import { guardAgainstClockChange } from './clock-guard';
|
|
35
|
+
import {
|
|
36
|
+
listSeries as readLedger,
|
|
37
|
+
readSeries,
|
|
38
|
+
removeSeries,
|
|
39
|
+
upsertSeries,
|
|
40
|
+
} from '../state/series-ledger';
|
|
41
|
+
|
|
42
|
+
export interface RecurrenceDependencies {
|
|
43
|
+
readonly engine: NotificationEngineAdapter;
|
|
44
|
+
readonly state: StateManager;
|
|
45
|
+
readonly resolveTone: (toneId: string) => ResolvedTone | undefined;
|
|
46
|
+
readonly timeZone: TimeZoneReader;
|
|
47
|
+
readonly now: () => number;
|
|
48
|
+
readonly lookahead: LookaheadConfig;
|
|
49
|
+
readonly canScheduleExactly: () => boolean;
|
|
50
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Builds the request for one occurrence from the series template. */
|
|
54
|
+
function toRequest(rule: RecurrenceRule, occurrence: Occurrence): NotificationRequest {
|
|
55
|
+
return {
|
|
56
|
+
id: occurrence.id,
|
|
57
|
+
content: rule.template.content,
|
|
58
|
+
tone: rule.template.tone,
|
|
59
|
+
moment: { at: occurrence.at, anchoring: occurrence.anchoring },
|
|
60
|
+
routing: rule.template.routing,
|
|
61
|
+
actions: rule.template.actions,
|
|
62
|
+
ownerTag: rule.template.ownerTag,
|
|
63
|
+
groupTag: rule.template.groupTag,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const belongsTo = (entry: PendingEntry, seriesId: string) =>
|
|
68
|
+
entry.seriesId === seriesId || entry.id.startsWith(`${seriesId}#`);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The latest occurrence a ledger entry written before 0.4.0 had emitted. Those
|
|
72
|
+
* entries kept no key, so it is read back off the device: the last occurrence
|
|
73
|
+
* still pending is the last one emitted.
|
|
74
|
+
*/
|
|
75
|
+
function recoverLastEmittedKey(
|
|
76
|
+
pending: readonly PendingEntry[],
|
|
77
|
+
anchoring: RecurrenceRule['anchoring'],
|
|
78
|
+
zone: string,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
if (pending.length === 0) return undefined;
|
|
81
|
+
const latest = Math.max(...pending.map(entry => entry.resolvedAt));
|
|
82
|
+
return occurrenceKey(partsInZone(latest, anchoring === 'absolute' ? 'UTC' : zone));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createRecurrence(deps: RecurrenceDependencies) {
|
|
86
|
+
const emit = deps.emit ?? (() => {});
|
|
87
|
+
const writer = createScheduleWriter(deps.engine);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validates the template as the request every occurrence will be, once, so
|
|
91
|
+
* a series is refused at registration for exactly what a direct schedule
|
|
92
|
+
* would be refused for — an undeclared tone, too many actions, an oversized
|
|
93
|
+
* routing payload — rather than failing later inside maintenance.
|
|
94
|
+
*/
|
|
95
|
+
const validateTemplate = (rule: RecurrenceRule): ResolvedTone =>
|
|
96
|
+
validateRequest(
|
|
97
|
+
{
|
|
98
|
+
id: rule.id,
|
|
99
|
+
content: rule.template.content,
|
|
100
|
+
tone: rule.template.tone,
|
|
101
|
+
moment: { at: deps.now(), anchoring: rule.anchoring ?? 'wallClock' },
|
|
102
|
+
routing: rule.template.routing,
|
|
103
|
+
actions: rule.template.actions,
|
|
104
|
+
ownerTag: rule.template.ownerTag,
|
|
105
|
+
groupTag: rule.template.groupTag,
|
|
106
|
+
},
|
|
107
|
+
deps.resolveTone,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const toneFor = (rule: RecurrenceRule): ResolvedTone => {
|
|
111
|
+
const tone = deps.resolveTone(rule.template.tone);
|
|
112
|
+
if (tone === undefined) {
|
|
113
|
+
throw new NotificationError(
|
|
114
|
+
DIAGNOSTIC_CODES.UNDECLARED_TONE,
|
|
115
|
+
`Tone "${rule.template.tone}" was never declared in the package configuration.`,
|
|
116
|
+
{ toneId: rule.template.tone, seriesId: rule.id },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return tone;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Expands and registers, scheduling only occurrences the device lacks. */
|
|
123
|
+
const materialize = async (rule: RecurrenceRule, tone: ResolvedTone): Promise<SeriesResult> => {
|
|
124
|
+
const now = deps.now();
|
|
125
|
+
const zone = deps.timeZone();
|
|
126
|
+
const previous = readSeries(deps.state, rule.id);
|
|
127
|
+
|
|
128
|
+
const pending = await deps.engine.getPending();
|
|
129
|
+
const ofSeries = pending.filter(entry => belongsTo(entry, rule.id));
|
|
130
|
+
|
|
131
|
+
const expansion = expand({
|
|
132
|
+
rule,
|
|
133
|
+
from: now,
|
|
134
|
+
lookahead: deps.lookahead,
|
|
135
|
+
timeZone: zone,
|
|
136
|
+
alreadyEmitted: previous?.emittedCount ?? 0,
|
|
137
|
+
lastEmittedKey:
|
|
138
|
+
previous?.lastEmittedKey ??
|
|
139
|
+
(previous !== undefined
|
|
140
|
+
? recoverLastEmittedKey(ofSeries, rule.anchoring, zone)
|
|
141
|
+
: undefined),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const guarded = guardAgainstClockChange({
|
|
145
|
+
occurrences: expansion.occurrences,
|
|
146
|
+
previousHorizon: previous?.expandedThrough,
|
|
147
|
+
now,
|
|
148
|
+
previousNow: previous?.at,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (guarded.discontinuity !== 'none') {
|
|
152
|
+
emit(
|
|
153
|
+
'CLOCK_DISCONTINUITY',
|
|
154
|
+
`Device clock moved ${guarded.discontinuity}; ${guarded.suppressed} already-handled occurrence(s) were suppressed rather than re-delivered.`,
|
|
155
|
+
{ direction: guarded.discontinuity, suppressed: guarded.suppressed, seriesId: rule.id },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const wanted = new Set(guarded.occurrences.map(occurrence => occurrence.id));
|
|
160
|
+
|
|
161
|
+
// Upcoming entries of this series the expansion no longer produces: ones
|
|
162
|
+
// written under the instant-based identity before 0.4.0, or ones whose slot
|
|
163
|
+
// the rule no longer has. Left in place, each would fire beside its
|
|
164
|
+
// replacement. Entries already due are left alone — they may be firing now.
|
|
165
|
+
const stale = ofSeries.filter(entry => !wanted.has(entry.id) && entry.resolvedAt > now);
|
|
166
|
+
for (const entry of stale) {
|
|
167
|
+
await writer.cancel(entry.id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const onDevice = new Set(
|
|
171
|
+
ofSeries.filter(entry => !stale.includes(entry)).map(entry => entry.id),
|
|
172
|
+
);
|
|
173
|
+
const missing = guarded.occurrences.filter(occurrence => !onDevice.has(occurrence.id));
|
|
174
|
+
|
|
175
|
+
// The same ceiling reconciliation respects: soonest first, the rest
|
|
176
|
+
// registered by a later refresh once they move into range.
|
|
177
|
+
const room = Math.max(0, (await writer.capacity()) - (pending.length - stale.length));
|
|
178
|
+
const toSchedule = missing.slice(0, room);
|
|
179
|
+
if (toSchedule.length < missing.length) {
|
|
180
|
+
emit(
|
|
181
|
+
DIAGNOSTIC_CODES.TRUNCATED_OVER_CEILING,
|
|
182
|
+
`Series "${rule.id}" has ${missing.length - toSchedule.length} occurrence(s) beyond the platform ceiling; they are registered by a later window refresh.`,
|
|
183
|
+
{ seriesId: rule.id, dropped: missing.length - toSchedule.length },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const approximate = !deps.canScheduleExactly();
|
|
188
|
+
for (const occurrence of toSchedule) {
|
|
189
|
+
await writer.write({
|
|
190
|
+
request: toRequest(rule, occurrence),
|
|
191
|
+
resolvedAt: occurrence.at,
|
|
192
|
+
tone,
|
|
193
|
+
// The zone the series was expanded against; absent for absolute series.
|
|
194
|
+
anchoringGeneration: occurrence.anchoring === 'absolute' ? undefined : zone,
|
|
195
|
+
approximate,
|
|
196
|
+
seriesId: rule.id,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Counted once, when first emitted — whether or not the ceiling let it on
|
|
201
|
+
// the device yet. Occurrences re-produced from an earlier expansion are not
|
|
202
|
+
// counted again.
|
|
203
|
+
const newlyEmitted = guarded.occurrences.filter(occurrence => !occurrence.previouslyEmitted);
|
|
204
|
+
const lastEmittedKey =
|
|
205
|
+
newlyEmitted.length > 0
|
|
206
|
+
? newlyEmitted[newlyEmitted.length - 1].key
|
|
207
|
+
: previous?.lastEmittedKey;
|
|
208
|
+
|
|
209
|
+
upsertSeries(
|
|
210
|
+
deps.state,
|
|
211
|
+
rule,
|
|
212
|
+
{
|
|
213
|
+
expandedThrough: expansion.expandedThrough,
|
|
214
|
+
emittedCount: (previous?.emittedCount ?? 0) + newlyEmitted.length,
|
|
215
|
+
exhausted: expansion.exhausted,
|
|
216
|
+
lastEmittedKey,
|
|
217
|
+
},
|
|
218
|
+
now,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
seriesId: rule.id,
|
|
223
|
+
scheduled: toSchedule.length,
|
|
224
|
+
expandedThrough: expansion.expandedThrough,
|
|
225
|
+
exhausted: expansion.exhausted,
|
|
226
|
+
cancelled: stale.map(entry => entry.id),
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
async registerSeries(rule: RecurrenceRule): Promise<SeriesResult> {
|
|
232
|
+
validateIdentifier(rule.id, 'seriesId');
|
|
233
|
+
return materialize(rule, validateTemplate(rule));
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** Re-expands a tracked series. Called by the window-refresh maintenance step. */
|
|
237
|
+
async refreshSeries(rule: RecurrenceRule): Promise<SeriesResult> {
|
|
238
|
+
return materialize(rule, toneFor(rule));
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async cancelSeries(seriesId: string): Promise<readonly string[]> {
|
|
242
|
+
validateIdentifier(seriesId, 'seriesId');
|
|
243
|
+
|
|
244
|
+
// Every occurrence already scheduled from this series goes, identified by
|
|
245
|
+
// its derived prefix rather than by a list the ledger would have to keep
|
|
246
|
+
// in sync with the device.
|
|
247
|
+
const pending = await deps.engine.getPending();
|
|
248
|
+
const ids = pending.filter(entry => belongsTo(entry, seriesId)).map(entry => entry.id);
|
|
249
|
+
|
|
250
|
+
await deps.engine.cancelMany(ids);
|
|
251
|
+
for (const id of ids) await writer.releaseStop(id);
|
|
252
|
+
removeSeries(deps.state, seriesId);
|
|
253
|
+
return ids;
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async listSeries(): Promise<readonly SeriesSummary[]> {
|
|
257
|
+
const pending = await deps.engine.getPending();
|
|
258
|
+
|
|
259
|
+
return readLedger(deps.state).map(entry => ({
|
|
260
|
+
seriesId: entry.seriesId,
|
|
261
|
+
anchoring: entry.anchoring,
|
|
262
|
+
expandedThrough: entry.expandedThrough,
|
|
263
|
+
pendingOccurrences: pending.filter(p => belongsTo(p, entry.seriesId)).length,
|
|
264
|
+
exhausted: entry.exhausted,
|
|
265
|
+
}));
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type Recurrence = ReturnType<typeof createRecurrence>;
|