@qilin-agent/schedule 3.1.6 → 3.1.8
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/README.i18n.yaml +72 -39
- package/README.md +180 -56
- package/README.zh.md +185 -61
- package/lib/index.js +5565 -1191
- package/lib/typert.host.js +1886 -267
- package/lib/typert.remote-client.d.ts +22 -10
- package/lib/typert.remote-client.js +1727 -190
- package/lib/types/delivery-history.d.ts +4 -6
- package/lib/types/delivery-history.js +7 -20
- package/lib/types/domain.d.ts +117 -80
- package/lib/types/domain.js +311 -797
- package/lib/types/index.d.ts +168 -80
- package/lib/types/index.js +531 -222
- package/lib/types/runtime.d.ts +92 -4
- package/lib/types/runtime.js +392 -72
- package/lib/types/storage.d.ts +126 -24
- package/lib/types/storage.js +85 -16
- package/lib/types/types.d.ts +287 -148
- package/lib/types/types.js +1 -1
- package/lib/types/update.d.ts +16 -21
- package/lib/types/update.js +72 -144
- package/package.json +38 -35
package/lib/types/domain.js
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Host task decoding, timing validation, occurrence selection, and reminder framing.
|
|
3
3
|
* @module @qilin-agent/schedule
|
|
4
4
|
*/
|
|
5
5
|
import { Temporal } from '@js-temporal/polyfill';
|
|
6
|
-
import {
|
|
7
|
-
/**
|
|
8
|
-
export const SCHEDULE_CHANGE_VERSION = 1;
|
|
9
|
-
/** Fixed v1 lower bound for a fixed-rate reminder. */
|
|
6
|
+
import { Cron, RRule, MAX_FOUR_DIGIT_YEAR_MS, MIN_FOUR_DIGIT_YEAR_MS, canonicalizeTimeZone as canonicalizeSharedTimeZone, formatFourDigitYearInstant, fourDigitYearEpoch, isFourDigitYearInstant, parseLocalCalendar, parseOffsetInstant, resolveLocalInstant, } from '@qilin-agent/util-time';
|
|
7
|
+
/** Minimum supported fixed-rate interval. */
|
|
10
8
|
export const MIN_EVERY_INTERVAL_SECONDS = 60;
|
|
11
|
-
/**
|
|
9
|
+
/** Maximum stored task title length. */
|
|
12
10
|
export const MAX_TITLE_LENGTH = 120;
|
|
13
|
-
/**
|
|
14
|
-
* Longest forward or backward walk of the cron date search, in years.
|
|
15
|
-
*
|
|
16
|
-
* The proleptic Gregorian leap-year and weekday alignment repeats every 400
|
|
17
|
-
* years, so a rule that matches any local date has a match within this window;
|
|
18
|
-
* walking further can only reach a rule that never matches. Bounding the walk
|
|
19
|
-
* keeps a valid but unsatisfiable rule's creation and decision cost fixed
|
|
20
|
-
* instead of enumerating candidate dates toward the four-digit-year ceiling.
|
|
21
|
-
*/
|
|
22
|
-
export const CRON_SEARCH_HORIZON_YEARS = 400;
|
|
23
|
-
const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z');
|
|
24
|
-
const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z');
|
|
25
|
-
const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/;
|
|
26
|
-
const OFFSET_INSTANT = new RegExp(String.raw `^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})`
|
|
27
|
-
+ String.raw `T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})`
|
|
28
|
-
+ String.raw `(?:\.(?<fraction>\d{1,3}))?(?<zone>Z|(?<sign>[+-])`
|
|
29
|
-
+ String.raw `(?<offsetHour>\d{2}):(?<offsetMinute>\d{2}))$`);
|
|
30
|
-
const LOCAL_DATE = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
|
|
31
|
-
const LOCAL_TIME = /^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.(?<fraction>\d{1,3}))?$/;
|
|
32
11
|
const LOCAL_CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?$/;
|
|
33
|
-
const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/;
|
|
34
12
|
/** Error from malformed or transition-invalid durable Schedule data. */
|
|
35
13
|
export class ScheduleLogError extends Error {
|
|
36
14
|
/** Stable machine-readable error code. */
|
|
@@ -61,27 +39,31 @@ export class ScheduleInputError extends Error {
|
|
|
61
39
|
}
|
|
62
40
|
}
|
|
63
41
|
/**
|
|
64
|
-
* Brand a raw
|
|
65
|
-
* @param value - Raw
|
|
42
|
+
* Brand a raw task id without changing its runtime value.
|
|
43
|
+
* @param value - Raw task id.
|
|
66
44
|
* @returns The same string with the Schedule brand.
|
|
67
45
|
*/
|
|
68
46
|
export function ScheduleId(value) {
|
|
69
47
|
return value;
|
|
70
48
|
}
|
|
71
|
-
/**
|
|
72
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Narrow an unknown value to a non-array object.
|
|
51
|
+
* @param value - Value to inspect.
|
|
52
|
+
* @returns Whether the value is a non-null, non-array object.
|
|
53
|
+
*/
|
|
54
|
+
export function isRecord(value) {
|
|
73
55
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
74
56
|
}
|
|
75
|
-
/**
|
|
76
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Require exactly the named durable object keys.
|
|
59
|
+
* @param value - Object to inspect.
|
|
60
|
+
* @param expected - Complete allowed key set.
|
|
61
|
+
* @returns Whether every expected key is present and no other key is present.
|
|
62
|
+
*/
|
|
63
|
+
export function hasExactKeys(value, expected) {
|
|
77
64
|
const allowed = new Set(expected);
|
|
78
65
|
return expected.every(key => key in value) && Object.keys(value).every(key => allowed.has(key));
|
|
79
66
|
}
|
|
80
|
-
/** Require the named durable keys while admitting further optional members. */
|
|
81
|
-
function hasExactKeysWithOptional(value, required, optional) {
|
|
82
|
-
const allowed = new Set([...required, ...optional]);
|
|
83
|
-
return required.every(key => key in value) && Object.keys(value).every(key => allowed.has(key));
|
|
84
|
-
}
|
|
85
67
|
/** Stable diagnostic for a title that is missing or empty after trimming. */
|
|
86
68
|
export const REQUIRED_TITLE_MESSAGE = 'title is required and must be non-empty after trimming.';
|
|
87
69
|
/**
|
|
@@ -140,117 +122,75 @@ function decodeRecordTitle(value, expected, message) {
|
|
|
140
122
|
return title;
|
|
141
123
|
}
|
|
142
124
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* key set is required without it and the absent member decodes as undefined. A
|
|
147
|
-
* present title stays subject to the canonical stored form.
|
|
148
|
-
* @param value - Untrusted durable record already known to be an object.
|
|
149
|
-
* @param expected - Exact keys the record may carry; `title` is optional within them.
|
|
150
|
-
* @param message - Diagnostic naming the record's keys.
|
|
151
|
-
* @returns The stored title, or undefined when the historical record predates it.
|
|
125
|
+
* Validate one stable task id at the durable boundary.
|
|
126
|
+
* @param value - Untrusted durable id field.
|
|
127
|
+
* @returns The branded id; an invalid id throws ScheduleLogError.
|
|
152
128
|
*/
|
|
153
|
-
function
|
|
154
|
-
const keys = expected.filter(key => key !== 'title');
|
|
155
|
-
if (!hasExactKeysWithOptional(value, keys, ['title']))
|
|
156
|
-
throw new ScheduleLogError(message);
|
|
157
|
-
return value['title'] === undefined ? undefined : decodeStoredTitle(value['title']);
|
|
158
|
-
}
|
|
159
|
-
/** Validate one stable session-local id at the durable boundary. */
|
|
160
|
-
function decodeId(value) {
|
|
129
|
+
export function decodeId(value) {
|
|
161
130
|
if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) {
|
|
162
131
|
throw new ScheduleLogError('schedule id must be a non-empty string without surrounding whitespace');
|
|
163
132
|
}
|
|
164
133
|
return ScheduleId(value);
|
|
165
134
|
}
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
135
|
+
/** Stable Schedule diagnostic for a `scheduledAt` outside the canonical profile. */
|
|
136
|
+
const NON_CANONICAL_INSTANT_MESSAGE = 'scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant';
|
|
137
|
+
/**
|
|
138
|
+
* Validate one canonical four-digit-year UTC instant at the durable boundary.
|
|
139
|
+
* @param value - Untrusted durable instant field.
|
|
140
|
+
* @returns The canonical instant; an invalid instant throws ScheduleLogError.
|
|
141
|
+
*/
|
|
142
|
+
export function decodeInstant(value) {
|
|
143
|
+
if (typeof value !== 'string') {
|
|
144
|
+
throw new ScheduleLogError(NON_CANONICAL_INSTANT_MESSAGE);
|
|
174
145
|
}
|
|
146
|
+
const parsed = fourDigitYearEpoch(value);
|
|
147
|
+
if (parsed.kind === 'rejected')
|
|
148
|
+
throw new ScheduleLogError(instantDiagnostic(parsed.reason));
|
|
175
149
|
return value;
|
|
176
150
|
}
|
|
177
|
-
/**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|| value.getUTCDate() !== parts.day
|
|
196
|
-
|| value.getUTCHours() !== parts.hour
|
|
197
|
-
|| value.getUTCMinutes() !== parts.minute
|
|
198
|
-
|| value.getUTCSeconds() !== parts.second
|
|
199
|
-
|| value.getUTCMilliseconds() !== parts.millisecond) {
|
|
200
|
-
throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.');
|
|
201
|
-
}
|
|
202
|
-
return epoch;
|
|
203
|
-
}
|
|
204
|
-
/** Normalize an optional one-to-three digit fractional second to milliseconds. */
|
|
205
|
-
function milliseconds(value) {
|
|
206
|
-
return value === undefined ? 0 : Number(value.padEnd(3, '0'));
|
|
207
|
-
}
|
|
208
|
-
/** Require a safe, representable, strictly future UTC target. */
|
|
209
|
-
function futureInstant(epoch, now) {
|
|
210
|
-
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(epoch)
|
|
211
|
-
|| epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS) {
|
|
212
|
-
throw new ScheduleInputError('time_out_of_range', 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.');
|
|
213
|
-
}
|
|
214
|
-
if (epoch <= now) {
|
|
215
|
-
throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.');
|
|
216
|
-
}
|
|
217
|
-
const instant = new Date(epoch).toISOString();
|
|
218
|
-
/* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */
|
|
219
|
-
if (!UTC_INSTANT.test(instant)) {
|
|
220
|
-
throw new ScheduleInputError('time_out_of_range', 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.');
|
|
151
|
+
/**
|
|
152
|
+
* Map one shared instant rejection onto the stable stored-data diagnostic.
|
|
153
|
+
* @param reason - Reason the shared vocabulary refused the instant.
|
|
154
|
+
* @returns The Schedule log diagnostic for that refusal.
|
|
155
|
+
*/
|
|
156
|
+
function instantDiagnostic(reason) {
|
|
157
|
+
switch (reason) {
|
|
158
|
+
case 'not_a_calendar_date':
|
|
159
|
+
return 'scheduledAt is not a real UTC calendar instant';
|
|
160
|
+
case 'not_rfc3339':
|
|
161
|
+
case 'out_of_range':
|
|
162
|
+
case 'invalid_numeric_offset':
|
|
163
|
+
return NON_CANONICAL_INSTANT_MESSAGE;
|
|
164
|
+
/* v8 ignore next 3 -- the shared parser returns a closed rejection union. */
|
|
165
|
+
default: {
|
|
166
|
+
const unreachable = reason;
|
|
167
|
+
throw new ScheduleLogError(`unknown instant rejection ${String(unreachable)}`);
|
|
168
|
+
}
|
|
221
169
|
}
|
|
222
|
-
return instant;
|
|
223
170
|
}
|
|
224
|
-
/**
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const offsetHour = groupNumber(groups, 'offsetHour');
|
|
247
|
-
const offsetMinute = groupNumber(groups, 'offsetMinute');
|
|
248
|
-
if (offsetHour > 23 || offsetMinute > 59
|
|
249
|
-
|| (groups['sign'] === '-' && offsetHour === 0 && offsetMinute === 0)) {
|
|
250
|
-
throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.');
|
|
171
|
+
/**
|
|
172
|
+
* Map one shared zone rejection onto the stable Schedule input code.
|
|
173
|
+
* @param reason - Reason the shared vocabulary refused the name.
|
|
174
|
+
* @param cause - Platform refusal the shared vocabulary caught, when it carried one.
|
|
175
|
+
* @returns The public Schedule diagnostic for that refusal.
|
|
176
|
+
*/
|
|
177
|
+
function timeZoneError(reason, cause) {
|
|
178
|
+
const options = cause === undefined ? undefined : { cause };
|
|
179
|
+
switch (reason) {
|
|
180
|
+
case 'blank':
|
|
181
|
+
case 'padded':
|
|
182
|
+
case 'not_area_location':
|
|
183
|
+
case 'unsupported':
|
|
184
|
+
return new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.', options);
|
|
185
|
+
/* v8 ignore next 2 -- the shared parser does not answer not_canonical_shape for a canonical zone. */
|
|
186
|
+
case 'not_canonical_shape':
|
|
187
|
+
return new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.', options);
|
|
188
|
+
/* v8 ignore next 3 -- TimeZoneRejection is closed; this arm only makes adding a reason a compile error. */
|
|
189
|
+
default: {
|
|
190
|
+
const unreachable = reason;
|
|
191
|
+
return unreachable;
|
|
192
|
+
}
|
|
251
193
|
}
|
|
252
|
-
const direction = groups['sign'] === '+' ? 1 : -1;
|
|
253
|
-
return localEpoch - direction * (offsetHour * 60 + offsetMinute) * 60_000;
|
|
254
194
|
}
|
|
255
195
|
/**
|
|
256
196
|
* Validate and canonicalize one raw IANA time-zone selector.
|
|
@@ -258,65 +198,82 @@ function parseOffsetInstant(value) {
|
|
|
258
198
|
* @returns The runtime's canonical IANA name.
|
|
259
199
|
*/
|
|
260
200
|
export function canonicalizeTimeZone(value) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone;
|
|
267
|
-
}
|
|
268
|
-
catch (error) {
|
|
269
|
-
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.', { cause: error });
|
|
270
|
-
}
|
|
271
|
-
/* v8 ignore next -- Intl returns the requested canonical zone or an IANA canonical alias. */
|
|
272
|
-
if (canonical !== 'UTC' && !IANA_ZONE.test(canonical)) {
|
|
273
|
-
throw new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.');
|
|
274
|
-
}
|
|
275
|
-
return canonical;
|
|
201
|
+
const resolved = canonicalizeSharedTimeZone(value);
|
|
202
|
+
if (resolved.kind === 'rejected')
|
|
203
|
+
throw timeZoneError(resolved.reason, resolved.cause);
|
|
204
|
+
return resolved.timeZone;
|
|
276
205
|
}
|
|
277
206
|
/** Parse strict local calendar fields without consulting a process time zone. */
|
|
278
207
|
function parseLocalAt(value) {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
208
|
+
const parsed = parseLocalCalendar(value.date, value.time);
|
|
209
|
+
if (parsed.kind === 'rejected') {
|
|
210
|
+
throw new ScheduleInputError('invalid_rule', localCalendarDiagnostic(parsed.reason));
|
|
211
|
+
}
|
|
212
|
+
return parsed.time;
|
|
213
|
+
}
|
|
214
|
+
/** Map one shared local-calendar rejection onto the stable Schedule diagnostic. */
|
|
215
|
+
function localCalendarDiagnostic(reason) {
|
|
216
|
+
switch (reason) {
|
|
217
|
+
case 'malformed':
|
|
218
|
+
return 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.';
|
|
219
|
+
case 'not_a_calendar_date':
|
|
220
|
+
return 'The at value must be a real ISO calendar date and time.';
|
|
221
|
+
case 'out_of_range':
|
|
222
|
+
return 'The local at value must be a real ISO calendar date and time.';
|
|
223
|
+
/* v8 ignore next 3 -- the shared parser returns a closed rejection union. */
|
|
224
|
+
default: {
|
|
225
|
+
const unreachable = reason;
|
|
226
|
+
throw new Error(`unknown local calendar rejection ${String(unreachable)}`);
|
|
227
|
+
}
|
|
297
228
|
}
|
|
298
|
-
calendarEpoch(parts);
|
|
299
|
-
return parts;
|
|
300
|
-
}
|
|
301
|
-
/** Resolve only the earlier overlap instant; a gap fails the local-field round trip. */
|
|
302
|
-
function localInstant(local, timeZone) {
|
|
303
|
-
const zoned = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' });
|
|
304
|
-
return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : undefined;
|
|
305
229
|
}
|
|
306
230
|
/** Resolve a local one-shot, rejecting nonexistent wall-clock times. */
|
|
307
|
-
function
|
|
308
|
-
const target =
|
|
231
|
+
function resolveLocalAt(parts, timeZone) {
|
|
232
|
+
const target = resolveLocalInstant(parts, timeZone);
|
|
309
233
|
if (target === undefined) {
|
|
310
234
|
throw new ScheduleInputError('invalid_rule', 'The local at time does not exist in the selected time zone.');
|
|
311
235
|
}
|
|
312
236
|
return target;
|
|
313
237
|
}
|
|
314
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Require a safe, representable, strictly future UTC target.
|
|
240
|
+
* @param epoch - Candidate target in epoch milliseconds.
|
|
241
|
+
* @param now - Exclusive lower bound in epoch milliseconds.
|
|
242
|
+
* @returns The canonical UTC instant; an out-of-range or past target throws ScheduleInputError.
|
|
243
|
+
*/
|
|
244
|
+
export function futureInstant(epoch, now) {
|
|
245
|
+
const instant = formatFourDigitYearInstant(epoch);
|
|
246
|
+
if (!Number.isSafeInteger(now) || instant === undefined) {
|
|
247
|
+
throw new ScheduleInputError('time_out_of_range', 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.');
|
|
248
|
+
}
|
|
249
|
+
if (epoch <= now) {
|
|
250
|
+
throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.');
|
|
251
|
+
}
|
|
252
|
+
return instant;
|
|
253
|
+
}
|
|
254
|
+
/** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */
|
|
255
|
+
function parseAtOffsetInstant(value) {
|
|
256
|
+
const parsed = parseOffsetInstant(value);
|
|
257
|
+
if (parsed.kind === 'accepted')
|
|
258
|
+
return parsed.epoch;
|
|
259
|
+
switch (parsed.reason) {
|
|
260
|
+
case 'not_rfc3339':
|
|
261
|
+
throw new ScheduleInputError('invalid_rule', 'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.');
|
|
262
|
+
case 'invalid_numeric_offset':
|
|
263
|
+
throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.');
|
|
264
|
+
case 'not_a_calendar_date':
|
|
265
|
+
case 'out_of_range':
|
|
266
|
+
throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.');
|
|
267
|
+
/* v8 ignore next 3 -- the shared parser returns a closed rejection union. */
|
|
268
|
+
default: {
|
|
269
|
+
const unreachable = parsed.reason;
|
|
270
|
+
throw new Error(`unknown instant rejection ${String(unreachable)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Decode a delayed one-shot Host record. */
|
|
315
275
|
function decodeAfterRecord(value) {
|
|
316
|
-
|
|
317
|
-
if (!isRecord(value))
|
|
318
|
-
throw new ScheduleLogError('after schedule must be an object');
|
|
319
|
-
const title = decodeHistoricalRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'afterSeconds', 'scheduledAt'], 'after schedule must contain exactly id, kind, title, prompt, afterSeconds, and scheduledAt');
|
|
276
|
+
const title = decodeRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'afterSeconds', 'scheduledAt'], 'after schedule must contain exactly id, kind, title, prompt, afterSeconds, and scheduledAt');
|
|
320
277
|
const prompt = value['prompt'];
|
|
321
278
|
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
|
|
322
279
|
throw new ScheduleLogError('after prompt must be non-empty and already trimmed');
|
|
@@ -328,18 +285,15 @@ function decodeAfterRecord(value) {
|
|
|
328
285
|
return Object.freeze({
|
|
329
286
|
id: decodeId(value['id']),
|
|
330
287
|
kind: 'after',
|
|
331
|
-
|
|
288
|
+
title,
|
|
332
289
|
prompt,
|
|
333
290
|
afterSeconds: afterSeconds,
|
|
334
291
|
scheduledAt: decodeInstant(value['scheduledAt']),
|
|
335
292
|
});
|
|
336
293
|
}
|
|
337
|
-
/** Decode
|
|
294
|
+
/** Decode an absolute one-shot Host record. */
|
|
338
295
|
function decodeAtRecord(value) {
|
|
339
|
-
|
|
340
|
-
if (!isRecord(value))
|
|
341
|
-
throw new ScheduleLogError('at schedule must be an object');
|
|
342
|
-
const title = decodeHistoricalRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'scheduledAt'], 'at schedule must contain exactly id, kind, title, prompt, and scheduledAt');
|
|
296
|
+
const title = decodeRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'scheduledAt'], 'at schedule must contain exactly id, kind, title, prompt, and scheduledAt');
|
|
343
297
|
const prompt = value['prompt'];
|
|
344
298
|
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
|
|
345
299
|
throw new ScheduleLogError('at prompt must be non-empty and already trimmed');
|
|
@@ -347,17 +301,14 @@ function decodeAtRecord(value) {
|
|
|
347
301
|
return Object.freeze({
|
|
348
302
|
id: decodeId(value['id']),
|
|
349
303
|
kind: 'at',
|
|
350
|
-
|
|
304
|
+
title,
|
|
351
305
|
prompt,
|
|
352
306
|
scheduledAt: decodeInstant(value['scheduledAt']),
|
|
353
307
|
});
|
|
354
308
|
}
|
|
355
|
-
/** Decode
|
|
309
|
+
/** Decode a fixed-rate Host record. */
|
|
356
310
|
function decodeEveryRecord(value) {
|
|
357
|
-
|
|
358
|
-
if (!isRecord(value))
|
|
359
|
-
throw new ScheduleLogError('every schedule must be an object');
|
|
360
|
-
const title = decodeHistoricalRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'everySeconds', 'scheduledAt'], 'every schedule must contain exactly id, kind, title, prompt, everySeconds, and scheduledAt');
|
|
311
|
+
const title = decodeRecordTitle(value, ['id', 'kind', 'title', 'prompt', 'everySeconds', 'scheduledAt'], 'every schedule must contain exactly id, kind, title, prompt, everySeconds, and scheduledAt');
|
|
361
312
|
const prompt = value['prompt'];
|
|
362
313
|
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
|
|
363
314
|
throw new ScheduleLogError('every prompt must be non-empty and already trimmed');
|
|
@@ -372,7 +323,7 @@ function decodeEveryRecord(value) {
|
|
|
372
323
|
return Object.freeze({
|
|
373
324
|
id: decodeId(value['id']),
|
|
374
325
|
kind: 'every',
|
|
375
|
-
|
|
326
|
+
title,
|
|
376
327
|
prompt,
|
|
377
328
|
everySeconds: everySeconds,
|
|
378
329
|
scheduledAt: decodeInstant(value['scheduledAt']),
|
|
@@ -404,9 +355,6 @@ export function weeklyTime(value) {
|
|
|
404
355
|
}
|
|
405
356
|
/** Fail already-typed durable values without wrapping the stored-data diagnostic. */
|
|
406
357
|
function rethrowLogError(error) {
|
|
407
|
-
/* v8 ignore next -- every wrapped call raises ScheduleInputError, so this pass-through never fires. */
|
|
408
|
-
if (error instanceof ScheduleLogError)
|
|
409
|
-
throw error;
|
|
410
358
|
throw new ScheduleLogError(String(error));
|
|
411
359
|
}
|
|
412
360
|
/** Decode a Host daily rule without re-resolving its committed UTC target. */
|
|
@@ -531,7 +479,7 @@ function decodeCronRecord(value) {
|
|
|
531
479
|
}
|
|
532
480
|
let canonical;
|
|
533
481
|
try {
|
|
534
|
-
canonical =
|
|
482
|
+
canonical = canonicalizeCronExpression(expression);
|
|
535
483
|
}
|
|
536
484
|
catch (error) {
|
|
537
485
|
rethrowLogError(error);
|
|
@@ -544,94 +492,22 @@ function decodeCronRecord(value) {
|
|
|
544
492
|
});
|
|
545
493
|
}
|
|
546
494
|
/**
|
|
547
|
-
* Decode
|
|
548
|
-
*
|
|
549
|
-
* Every variant of a stored Host task carries its title, so the historical
|
|
550
|
-
* one-shot variants are re-checked for that member after their shape decodes.
|
|
495
|
+
* Decode one of the seven current Host timing records without recomputing its committed occurrence.
|
|
551
496
|
* @param value - Untrusted durable JSON record.
|
|
552
|
-
* @returns Detached frozen record
|
|
497
|
+
* @returns Detached frozen record with its required title and authoritative timing fields.
|
|
553
498
|
*/
|
|
554
499
|
export function decodeScheduleRecord(value) {
|
|
555
|
-
if (isRecord(value) && value['kind'] === 'daily')
|
|
556
|
-
return decodeDailyRecord(value);
|
|
557
|
-
if (isRecord(value) && value['kind'] === 'weekly')
|
|
558
|
-
return decodeWeeklyRecord(value);
|
|
559
|
-
if (isRecord(value) && value['kind'] === 'cron')
|
|
560
|
-
return decodeCronRecord(value);
|
|
561
|
-
const record = decodeLegacyScheduleRecord(value);
|
|
562
|
-
if (record.title === undefined)
|
|
563
|
-
throw new ScheduleLogError(REQUIRED_TITLE_MESSAGE);
|
|
564
|
-
return record;
|
|
565
|
-
}
|
|
566
|
-
/**
|
|
567
|
-
* Decode only the variants admitted by historical version-1 Session events.
|
|
568
|
-
*
|
|
569
|
-
* A record written before titles existed decodes without that member, so the
|
|
570
|
-
* fold keeps reading a log that the current creation path could not write.
|
|
571
|
-
* @param value - Untrusted durable JSON record from a Session event.
|
|
572
|
-
* @returns Detached frozen record, without a title when the event omitted it.
|
|
573
|
-
*/
|
|
574
|
-
function decodeLegacyScheduleRecord(value) {
|
|
575
500
|
if (!isRecord(value))
|
|
576
501
|
throw new ScheduleLogError('schedule record must be an object');
|
|
577
502
|
switch (value['kind']) {
|
|
578
503
|
case 'after': return decodeAfterRecord(value);
|
|
579
504
|
case 'at': return decodeAtRecord(value);
|
|
580
505
|
case 'every': return decodeEveryRecord(value);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
* @param value - Untrusted durable JSON value.
|
|
587
|
-
* @returns Detached, frozen Schedule change.
|
|
588
|
-
*/
|
|
589
|
-
export function decodeScheduleChange(value) {
|
|
590
|
-
if (!isRecord(value))
|
|
591
|
-
throw new ScheduleLogError('schedule/change payload must be an object');
|
|
592
|
-
if (value['version'] !== SCHEDULE_CHANGE_VERSION) {
|
|
593
|
-
throw new ScheduleLogError('schedule/change version must be 1');
|
|
594
|
-
}
|
|
595
|
-
switch (value['operation']) {
|
|
596
|
-
case 'create':
|
|
597
|
-
if (!hasExactKeys(value, ['version', 'operation', 'schedule'])) {
|
|
598
|
-
throw new ScheduleLogError('schedule create must contain exactly version, operation, and schedule');
|
|
599
|
-
}
|
|
600
|
-
return Object.freeze({
|
|
601
|
-
version: SCHEDULE_CHANGE_VERSION,
|
|
602
|
-
operation: 'create',
|
|
603
|
-
schedule: decodeLegacyScheduleRecord(value['schedule']),
|
|
604
|
-
});
|
|
605
|
-
case 'delete': {
|
|
606
|
-
if (!hasExactKeys(value, ['version', 'operation', 'id'])) {
|
|
607
|
-
throw new ScheduleLogError('schedule delete must contain exactly version, operation, and id');
|
|
608
|
-
}
|
|
609
|
-
return Object.freeze({
|
|
610
|
-
version: SCHEDULE_CHANGE_VERSION,
|
|
611
|
-
operation: 'delete',
|
|
612
|
-
id: decodeId(value['id']),
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
case 'dispatch': {
|
|
616
|
-
if (hasExactKeys(value, ['version', 'operation', 'id'])) {
|
|
617
|
-
return Object.freeze({
|
|
618
|
-
version: SCHEDULE_CHANGE_VERSION,
|
|
619
|
-
operation: 'dispatch',
|
|
620
|
-
id: decodeId(value['id']),
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
if (hasExactKeys(value, ['version', 'operation', 'id', 'acceptedAt'])) {
|
|
624
|
-
return Object.freeze({
|
|
625
|
-
version: SCHEDULE_CHANGE_VERSION,
|
|
626
|
-
operation: 'dispatch',
|
|
627
|
-
id: decodeId(value['id']),
|
|
628
|
-
acceptedAt: decodeInstant(value['acceptedAt']),
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
throw new ScheduleLogError('schedule dispatch must contain id and optional acceptedAt only');
|
|
632
|
-
}
|
|
633
|
-
default:
|
|
634
|
-
throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch');
|
|
506
|
+
case 'daily': return decodeDailyRecord(value);
|
|
507
|
+
case 'weekly': return decodeWeeklyRecord(value);
|
|
508
|
+
case 'cron': return decodeCronRecord(value);
|
|
509
|
+
case 'rrule': return decodeRRuleScheduleRecord(value);
|
|
510
|
+
default: throw new ScheduleLogError('schedule kind must be "after", "at", "every", "daily", "weekly", "cron", or "rrule"');
|
|
635
511
|
}
|
|
636
512
|
}
|
|
637
513
|
/**
|
|
@@ -643,9 +519,7 @@ export function decodeScheduleChange(value) {
|
|
|
643
519
|
export function resolveEveryOccurrence(record, acceptedAt) {
|
|
644
520
|
const target = Date.parse(record.scheduledAt);
|
|
645
521
|
const interval = record.everySeconds * 1_000;
|
|
646
|
-
if (!
|
|
647
|
-
|| acceptedAt < MIN_FOUR_DIGIT_YEAR_MS
|
|
648
|
-
|| acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {
|
|
522
|
+
if (!isFourDigitYearInstant(acceptedAt)) {
|
|
649
523
|
throw new ScheduleLogError('every acceptedAt must be a representable four-digit-year instant');
|
|
650
524
|
}
|
|
651
525
|
if (!Number.isSafeInteger(interval) || interval <= 0) {
|
|
@@ -662,7 +536,7 @@ export function resolveEveryOccurrence(record, acceptedAt) {
|
|
|
662
536
|
}
|
|
663
537
|
const occurrenceAt = new Date(occurrence).toISOString();
|
|
664
538
|
const next = occurrence + interval;
|
|
665
|
-
if (!
|
|
539
|
+
if (!isFourDigitYearInstant(next)) {
|
|
666
540
|
return Object.freeze({ occurrenceAt });
|
|
667
541
|
}
|
|
668
542
|
return Object.freeze({
|
|
@@ -691,7 +565,7 @@ function nextMatchingTarget(time, timeZone, now, matches, afterDate) {
|
|
|
691
565
|
for (; Temporal.PlainDate.compare(date, lastDate) <= 0; date = date.add({ days: 1 })) {
|
|
692
566
|
if (!matches(date))
|
|
693
567
|
continue;
|
|
694
|
-
const target =
|
|
568
|
+
const target = resolveLocalInstant(date.toPlainDateTime(time), timeZone);
|
|
695
569
|
if (target === undefined)
|
|
696
570
|
continue;
|
|
697
571
|
if (target > MAX_FOUR_DIGIT_YEAR_MS)
|
|
@@ -708,17 +582,21 @@ function latestDueOccurrence(timeZone, matches, time, acceptedAt, savedTarget) {
|
|
|
708
582
|
for (;; date = date.subtract({ days: 1 })) {
|
|
709
583
|
if (!matches(date))
|
|
710
584
|
continue;
|
|
711
|
-
const candidate =
|
|
585
|
+
const candidate = resolveLocalInstant(date.toPlainDateTime(time), timeZone);
|
|
712
586
|
if (candidate === undefined || candidate > acceptedAt)
|
|
713
587
|
continue;
|
|
714
588
|
// A committed target remains due even if current tzdata places the rule before it.
|
|
715
589
|
return Math.max(candidate, savedTarget);
|
|
716
590
|
}
|
|
717
591
|
}
|
|
718
|
-
/**
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Resolve a wall-clock rule only when its decision is at a representable four-digit-year instant.
|
|
594
|
+
* @param selector - Rule kind named in the failure message.
|
|
595
|
+
* @param acceptedAt - Explicit wall-clock decision time in epoch milliseconds.
|
|
596
|
+
* @returns The validated decision time.
|
|
597
|
+
*/
|
|
598
|
+
export function acceptedDecision(selector, acceptedAt) {
|
|
599
|
+
if (!isFourDigitYearInstant(acceptedAt)) {
|
|
722
600
|
throw new ScheduleLogError(`${selector} acceptedAt must be a representable four-digit-year instant`);
|
|
723
601
|
}
|
|
724
602
|
return acceptedAt;
|
|
@@ -775,158 +653,16 @@ export function resolveWeeklyOccurrence(record, acceptedAt) {
|
|
|
775
653
|
return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt });
|
|
776
654
|
}
|
|
777
655
|
/**
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
* stepped star such as `*` followed by `/2` still restricts the dates it matches. A bare
|
|
783
|
-
* `*` matches every value, which makes that field's match always true and degrades the
|
|
784
|
-
* AND to the other field, exactly like Vixie's `DOM_STAR`/`DOW_STAR` test. Only when
|
|
785
|
-
* neither field text starts with `*` does either field matching suffice.
|
|
786
|
-
* @param parsed - Parsed cron rule with canonical field text and matched values.
|
|
787
|
-
* @returns Whether one local date matches the rule.
|
|
788
|
-
*/
|
|
789
|
-
function cronDateMatch(parsed) {
|
|
790
|
-
const months = new Set(parsed.months);
|
|
791
|
-
const daysOfMonth = new Set(parsed.daysOfMonth);
|
|
792
|
-
const daysOfWeek = new Set(parsed.daysOfWeek);
|
|
793
|
-
const dayOfMonthRestricted = !parsed.dayOfMonthStar;
|
|
794
|
-
const dayOfWeekRestricted = !parsed.dayOfWeekStar;
|
|
795
|
-
return (date) => {
|
|
796
|
-
/* v8 ignore next -- both callers pre-filter by month before matching a date. */
|
|
797
|
-
if (!months.has(date.month))
|
|
798
|
-
return false;
|
|
799
|
-
const dayOfMonth = daysOfMonth.has(date.day);
|
|
800
|
-
const dayOfWeek = daysOfWeek.has(date.dayOfWeek % 7);
|
|
801
|
-
if (dayOfMonthRestricted && dayOfWeekRestricted)
|
|
802
|
-
return dayOfMonth || dayOfWeek;
|
|
803
|
-
return dayOfMonth && dayOfWeek;
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
/** Enumerate one parsed cron rule's local times of day in ascending wall-clock order. */
|
|
807
|
-
function cronTimes(parsed) {
|
|
808
|
-
const times = [];
|
|
809
|
-
for (const hour of parsed.hours) {
|
|
810
|
-
for (const minute of parsed.minutes)
|
|
811
|
-
times.push(Temporal.PlainTime.from({ hour, minute }));
|
|
812
|
-
}
|
|
813
|
-
return times;
|
|
814
|
-
}
|
|
815
|
-
/**
|
|
816
|
-
* Find the first strictly future cron occurrence without leaving the four-digit UTC year range.
|
|
817
|
-
*
|
|
818
|
-
* The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} past the floor date when that
|
|
819
|
-
* horizon precedes the four-digit ceiling: a rule with no match inside the horizon
|
|
820
|
-
* never matches, so its search resolves to exhaustion instead of scanning to the ceiling.
|
|
821
|
-
* @param parsed - Parsed cron rule.
|
|
822
|
-
* @param timeZone - Explicit zone interpreting its local date and time.
|
|
823
|
-
* @param floor - Instant every returned target must exceed.
|
|
824
|
-
* @returns The first matching target, or exhaustion as undefined.
|
|
825
|
-
*/
|
|
826
|
-
function nextCronTarget(parsed, timeZone, floor) {
|
|
827
|
-
const times = cronTimes(parsed);
|
|
828
|
-
const matches = cronDateMatch(parsed);
|
|
829
|
-
const months = new Set(parsed.months);
|
|
830
|
-
const firstDate = localDate(floor, timeZone);
|
|
831
|
-
let date = firstDate;
|
|
832
|
-
// The floor's own local wall clock, so the first date converts only candidates
|
|
833
|
-
// that can exceed it instead of its whole day of times.
|
|
834
|
-
const floorTime = Temporal.Instant.fromEpochMilliseconds(floor).toZonedDateTimeISO(timeZone).toPlainTime();
|
|
835
|
-
const ceiling = localDate(MAX_FOUR_DIGIT_YEAR_MS, 'UTC').add({ days: 1 });
|
|
836
|
-
const horizon = date.add({ years: CRON_SEARCH_HORIZON_YEARS });
|
|
837
|
-
const lastDate = Temporal.PlainDate.compare(horizon, ceiling) < 0 ? horizon : ceiling;
|
|
838
|
-
while (Temporal.PlainDate.compare(date, lastDate) <= 0) {
|
|
839
|
-
if (!months.has(date.month)) {
|
|
840
|
-
date = date.add({ months: 1 }).with({ day: 1 });
|
|
841
|
-
continue;
|
|
842
|
-
}
|
|
843
|
-
if (matches(date)) {
|
|
844
|
-
for (const time of times) {
|
|
845
|
-
if (date.equals(firstDate) && Temporal.PlainTime.compare(time, floorTime) < 0)
|
|
846
|
-
continue;
|
|
847
|
-
const target = localInstant(date.toPlainDateTime(time), timeZone);
|
|
848
|
-
if (target === undefined)
|
|
849
|
-
continue;
|
|
850
|
-
if (target > MAX_FOUR_DIGIT_YEAR_MS)
|
|
851
|
-
return undefined;
|
|
852
|
-
if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > floor)
|
|
853
|
-
return new Date(target).toISOString();
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
date = date.add({ days: 1 });
|
|
857
|
-
}
|
|
858
|
-
return undefined;
|
|
859
|
-
}
|
|
860
|
-
/**
|
|
861
|
-
* Find the latest matching cron occurrence at or before a decision without leaving the committed floor.
|
|
862
|
-
*
|
|
863
|
-
* The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} before the decision date when that
|
|
864
|
-
* horizon follows the four-digit floor, which is the local date holding that floor instant
|
|
865
|
-
* in the rule's own zone: a rule with no match inside the horizon keeps the committed target
|
|
866
|
-
* instead of scanning back to the floor, and a candidate before the floor instant is skipped.
|
|
867
|
-
* @param parsed - Parsed cron rule.
|
|
868
|
-
* @param timeZone - Explicit zone interpreting its local date and time.
|
|
869
|
-
* @param decision - Wall-clock decision time in epoch milliseconds.
|
|
870
|
-
* @param savedTarget - Committed target that stays due even when current zone data resolves past it.
|
|
871
|
-
* @returns The latest due occurrence, or the committed target when no local occurrence is representable.
|
|
872
|
-
*/
|
|
873
|
-
function latestCronOccurrence(parsed, timeZone, decision, savedTarget) {
|
|
874
|
-
const times = cronTimes(parsed);
|
|
875
|
-
const matches = cronDateMatch(parsed);
|
|
876
|
-
const months = new Set(parsed.months);
|
|
877
|
-
// Offsets are less than 24 hours; a date-line rollback can make a due local date
|
|
878
|
-
// later than the UTC decision date, which is why the walk starts one day after it.
|
|
879
|
-
let date = localDate(decision, 'UTC').add({ days: 1 });
|
|
880
|
-
// The floor is an instant: the earliest local date the walk may reach is the one
|
|
881
|
-
// holding it in the rule's own zone, and candidates below it are skipped, so a
|
|
882
|
-
// negative offset can still resolve a due local minute dated before the UTC floor.
|
|
883
|
-
const floorDate = localDate(MIN_FOUR_DIGIT_YEAR_MS, timeZone);
|
|
884
|
-
const horizon = date.subtract({ years: CRON_SEARCH_HORIZON_YEARS });
|
|
885
|
-
const firstDate = Temporal.PlainDate.compare(horizon, floorDate) > 0 ? horizon : floorDate;
|
|
886
|
-
while (Temporal.PlainDate.compare(date, firstDate) >= 0) {
|
|
887
|
-
if (!months.has(date.month)) {
|
|
888
|
-
date = date.with({ day: 1 }).subtract({ days: 1 });
|
|
889
|
-
continue;
|
|
890
|
-
}
|
|
891
|
-
if (matches(date)) {
|
|
892
|
-
// One conversion of the date's earliest wall clock decides whether the whole
|
|
893
|
-
// date lies beyond the decision: every later time on it is later too, so the
|
|
894
|
-
// walk steps back instead of converting the rest of a day that cannot match.
|
|
895
|
-
// An earliest time that a DST gap removes leaves the bound to the first time
|
|
896
|
-
// that does exist, and a date with no existing time keeps the full walk.
|
|
897
|
-
const earliest = times.reduce((found, time) => found ?? localInstant(date.toPlainDateTime(time), timeZone), undefined);
|
|
898
|
-
if (earliest !== undefined && earliest > decision) {
|
|
899
|
-
date = date.subtract({ days: 1 });
|
|
900
|
-
continue;
|
|
901
|
-
}
|
|
902
|
-
for (const time of [...times].reverse()) {
|
|
903
|
-
const candidate = localInstant(date.toPlainDateTime(time), timeZone);
|
|
904
|
-
if (candidate === undefined || candidate > decision || candidate < MIN_FOUR_DIGIT_YEAR_MS)
|
|
905
|
-
continue;
|
|
906
|
-
// A committed target remains due even if current tzdata places the rule before it.
|
|
907
|
-
return Math.max(candidate, savedTarget);
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
date = date.subtract({ days: 1 });
|
|
911
|
-
}
|
|
912
|
-
return savedTarget;
|
|
913
|
-
}
|
|
914
|
-
/**
|
|
915
|
-
* Resolve a cron decision near the decision's local date, not across its missed history.
|
|
916
|
-
* @param record - Cron rule with a committed earliest unaccepted UTC target.
|
|
917
|
-
* @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.
|
|
918
|
-
* @returns Latest actual due occurrence and the first future occurrence.
|
|
656
|
+
* Resolve a Cron rule through the shared bounded recurrence engine.
|
|
657
|
+
* @param record - Authoritative Cron expression, zone, and committed occurrence.
|
|
658
|
+
* @param acceptedAt - Decision time at or after the committed occurrence.
|
|
659
|
+
* @returns The latest due occurrence and optional future successor.
|
|
919
660
|
*/
|
|
920
661
|
export function resolveCronOccurrence(record, acceptedAt) {
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
if (decision < savedTarget)
|
|
662
|
+
acceptedDecision('cron', acceptedAt);
|
|
663
|
+
if (acceptedAt < Date.parse(record.scheduledAt))
|
|
924
664
|
throw new ScheduleLogError('cron dispatch cannot precede the active scheduledAt');
|
|
925
|
-
|
|
926
|
-
const occurrence = latestCronOccurrence(parsed, record.timeZone, decision, savedTarget);
|
|
927
|
-
const occurrenceAt = new Date(occurrence).toISOString();
|
|
928
|
-
const nextScheduledAt = nextCronTarget(parsed, record.timeZone, decision);
|
|
929
|
-
return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt });
|
|
665
|
+
return readRecurrence(Cron.parse(record.expression, record.timeZone)).resolve(record.scheduledAt, acceptedAt);
|
|
930
666
|
}
|
|
931
667
|
/**
|
|
932
668
|
* Identify recurring Host records explicitly, excluding both one-shot variants.
|
|
@@ -934,7 +670,7 @@ export function resolveCronOccurrence(record, acceptedAt) {
|
|
|
934
670
|
* @returns Whether the record uses a recurring rule.
|
|
935
671
|
*/
|
|
936
672
|
export function isRecurringScheduleRecord(record) {
|
|
937
|
-
return record.kind === 'every' || record.kind === 'daily' || record.kind === 'weekly' || record.kind === 'cron';
|
|
673
|
+
return record.kind === 'every' || record.kind === 'daily' || record.kind === 'weekly' || record.kind === 'cron' || record.kind === 'rrule';
|
|
938
674
|
}
|
|
939
675
|
/**
|
|
940
676
|
* Resolve one due recurring Host record with its rule-specific calendar or interval arithmetic.
|
|
@@ -948,112 +684,9 @@ export function resolveRecurringOccurrence(record, acceptedAt) {
|
|
|
948
684
|
case 'daily': return resolveDailyOccurrence(record, acceptedAt);
|
|
949
685
|
case 'weekly': return resolveWeeklyOccurrence(record, acceptedAt);
|
|
950
686
|
case 'cron': return resolveCronOccurrence(record, acceptedAt);
|
|
687
|
+
case 'rrule': return resolveRRuleOccurrence(record, acceptedAt);
|
|
951
688
|
}
|
|
952
689
|
}
|
|
953
|
-
/** Apply one decoded dispatch to its exact active record. */
|
|
954
|
-
function dispatchedRecord(record, change) {
|
|
955
|
-
const hasAcceptedAt = 'acceptedAt' in change;
|
|
956
|
-
if (record.kind !== 'every') {
|
|
957
|
-
if (hasAcceptedAt)
|
|
958
|
-
throw new ScheduleLogError('one-shot dispatch must not contain acceptedAt');
|
|
959
|
-
return undefined;
|
|
960
|
-
}
|
|
961
|
-
if (!hasAcceptedAt)
|
|
962
|
-
throw new ScheduleLogError('every dispatch must contain acceptedAt');
|
|
963
|
-
const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt));
|
|
964
|
-
return occurrence.nextScheduledAt === undefined
|
|
965
|
-
? undefined
|
|
966
|
-
: Object.freeze({ ...record, scheduledAt: occurrence.nextScheduledAt });
|
|
967
|
-
}
|
|
968
|
-
/**
|
|
969
|
-
* Apply already-decoded Schedule changes to one complete fold value.
|
|
970
|
-
*
|
|
971
|
-
* The transition authority for full-log replay. One mutable Map/Set pair spans
|
|
972
|
-
* the whole batch; the returned arrays are materialized and frozen once.
|
|
973
|
-
* @param folded - complete active records and used-id history before the changes.
|
|
974
|
-
* @param changes - strictly decoded durable mutations in log order.
|
|
975
|
-
* @returns the complete fold value after every mutation.
|
|
976
|
-
*/
|
|
977
|
-
export function applyScheduleChanges(folded, changes) {
|
|
978
|
-
const active = new Map(folded.active.map(record => [record.id, record]));
|
|
979
|
-
const seen = new Set(folded.seenIds);
|
|
980
|
-
for (const change of changes) {
|
|
981
|
-
switch (change.operation) {
|
|
982
|
-
case 'create':
|
|
983
|
-
if (seen.has(change.schedule.id)) {
|
|
984
|
-
throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`);
|
|
985
|
-
}
|
|
986
|
-
seen.add(change.schedule.id);
|
|
987
|
-
active.set(change.schedule.id, change.schedule);
|
|
988
|
-
break;
|
|
989
|
-
case 'delete':
|
|
990
|
-
if (!active.delete(change.id)) {
|
|
991
|
-
throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`);
|
|
992
|
-
}
|
|
993
|
-
break;
|
|
994
|
-
case 'dispatch': {
|
|
995
|
-
const record = active.get(change.id);
|
|
996
|
-
if (record === undefined) {
|
|
997
|
-
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`);
|
|
998
|
-
}
|
|
999
|
-
const next = dispatchedRecord(record, change);
|
|
1000
|
-
if (next === undefined)
|
|
1001
|
-
active.delete(change.id);
|
|
1002
|
-
else
|
|
1003
|
-
active.set(change.id, next);
|
|
1004
|
-
break;
|
|
1005
|
-
}
|
|
1006
|
-
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
|
|
1007
|
-
default: {
|
|
1008
|
-
const unreachable = change;
|
|
1009
|
-
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`);
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
return Object.freeze({
|
|
1014
|
-
active: Object.freeze([...active.values()]),
|
|
1015
|
-
seenIds: Object.freeze([...seen]),
|
|
1016
|
-
});
|
|
1017
|
-
}
|
|
1018
|
-
/**
|
|
1019
|
-
* Fold the package-owned stream after the durable fork seed boundary.
|
|
1020
|
-
* @param events - Complete ordered session log or candidate-extended log.
|
|
1021
|
-
* @param inheritedEventCount - Inherited prefix length excluded from child ownership.
|
|
1022
|
-
* @returns Active records and all previously used ids.
|
|
1023
|
-
*/
|
|
1024
|
-
export function foldScheduleEvents(events, inheritedEventCount = SessionLogOffset(0)) {
|
|
1025
|
-
if (!Number.isSafeInteger(inheritedEventCount)
|
|
1026
|
-
|| inheritedEventCount < 0
|
|
1027
|
-
|| inheritedEventCount > events.length) {
|
|
1028
|
-
throw new ScheduleLogError('schedule inheritedEventCount must be within the supplied event log');
|
|
1029
|
-
}
|
|
1030
|
-
const initial = Object.freeze({
|
|
1031
|
-
active: Object.freeze([]),
|
|
1032
|
-
seenIds: Object.freeze([]),
|
|
1033
|
-
});
|
|
1034
|
-
const changes = function* () {
|
|
1035
|
-
for (const event of events.slice(inheritedEventCount)) {
|
|
1036
|
-
if (event.type === 'schedule/change')
|
|
1037
|
-
yield decodeScheduleChange(event.data);
|
|
1038
|
-
}
|
|
1039
|
-
};
|
|
1040
|
-
return applyScheduleChanges(initial, changes());
|
|
1041
|
-
}
|
|
1042
|
-
/**
|
|
1043
|
-
* Allocate the next readable id without reusing any prior session-local id.
|
|
1044
|
-
* @param folded - Fold containing every previously created id.
|
|
1045
|
-
* @returns A fresh `schedule-N` identity.
|
|
1046
|
-
*/
|
|
1047
|
-
export function allocateScheduleId(folded) {
|
|
1048
|
-
const seen = new Set(folded.seenIds);
|
|
1049
|
-
let sequence = seen.size + 1;
|
|
1050
|
-
let candidate = ScheduleId(`schedule-${sequence}`);
|
|
1051
|
-
while (seen.has(candidate)) {
|
|
1052
|
-
sequence += 1;
|
|
1053
|
-
candidate = ScheduleId(`schedule-${sequence}`);
|
|
1054
|
-
}
|
|
1055
|
-
return candidate;
|
|
1056
|
-
}
|
|
1057
690
|
/**
|
|
1058
691
|
* Validate a model after rule and compute its durable target.
|
|
1059
692
|
* @param id - Already allocated task id.
|
|
@@ -1112,7 +745,7 @@ export function createAtScheduleRecord(id, prompt, at, now, title) {
|
|
|
1112
745
|
export function parseAtInput(at) {
|
|
1113
746
|
let target;
|
|
1114
747
|
if (typeof at === 'string') {
|
|
1115
|
-
target =
|
|
748
|
+
target = parseAtOffsetInstant(at);
|
|
1116
749
|
}
|
|
1117
750
|
else if (isRecord(at)) {
|
|
1118
751
|
if (!hasExactKeys(at, ['date', 'time', 'time_zone'])) {
|
|
@@ -1130,7 +763,7 @@ export function parseAtInput(at) {
|
|
|
1130
763
|
time: at['time'],
|
|
1131
764
|
time_zone: rawTimeZone,
|
|
1132
765
|
};
|
|
1133
|
-
target =
|
|
766
|
+
target = resolveLocalAt(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone));
|
|
1134
767
|
}
|
|
1135
768
|
else {
|
|
1136
769
|
throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.');
|
|
@@ -1183,7 +816,7 @@ export function createDailyScheduleRecord(id, prompt, daily, now, title) {
|
|
|
1183
816
|
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
|
|
1184
817
|
}
|
|
1185
818
|
const { time, timeZone } = parseDailyInput(daily);
|
|
1186
|
-
if (!
|
|
819
|
+
if (!isFourDigitYearInstant(now)) {
|
|
1187
820
|
throw new ScheduleInputError('time_out_of_range', 'Daily creation time must be a representable four-digit-year UTC instant.');
|
|
1188
821
|
}
|
|
1189
822
|
const scheduledAt = nextDailyTarget(dailyTime(time), timeZone, now);
|
|
@@ -1226,7 +859,7 @@ export function createWeeklyScheduleRecord(id, prompt, weekly, now, title) {
|
|
|
1226
859
|
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
|
|
1227
860
|
}
|
|
1228
861
|
const { time, timeZone, weekdays } = parseWeeklyInput(weekly);
|
|
1229
|
-
if (!
|
|
862
|
+
if (!isFourDigitYearInstant(now)) {
|
|
1230
863
|
throw new ScheduleInputError('time_out_of_range', 'Weekly creation time must be a representable four-digit-year UTC instant.');
|
|
1231
864
|
}
|
|
1232
865
|
const scheduledAt = nextWeeklyTarget(weeklyTime(time), timeZone, weekdays, now);
|
|
@@ -1253,10 +886,10 @@ export function createCronScheduleRecord(id, prompt, cron, now, title) {
|
|
|
1253
886
|
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
|
|
1254
887
|
}
|
|
1255
888
|
const { expression, timeZone } = parseCronInput(cron);
|
|
1256
|
-
if (!
|
|
889
|
+
if (!isFourDigitYearInstant(now)) {
|
|
1257
890
|
throw new ScheduleInputError('time_out_of_range', 'Cron creation time must be a representable four-digit-year UTC instant.');
|
|
1258
891
|
}
|
|
1259
|
-
const scheduledAt =
|
|
892
|
+
const scheduledAt = readRecurrence(Cron.parse(expression, timeZone)).next(now);
|
|
1260
893
|
if (scheduledAt === undefined) {
|
|
1261
894
|
throw new ScheduleInputError('time_out_of_range', 'No future cron occurrence is representable as a four-digit-year UTC instant.');
|
|
1262
895
|
}
|
|
@@ -1284,242 +917,20 @@ export function parseWeeklyInput(weekly) {
|
|
|
1284
917
|
weekdays: normalizeWeekdays(weekly['weekdays']),
|
|
1285
918
|
};
|
|
1286
919
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
{
|
|
1292
|
-
|
|
1293
|
-
];
|
|
1294
|
-
/** Build the stable diagnostic for one malformed cron field element. */
|
|
1295
|
-
function invalidCronField(spec, element) {
|
|
1296
|
-
return new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field element ${JSON.stringify(element)} must be *, a value, a-b, */n, a-b/n, `
|
|
1297
|
-
+ 'or a comma-separated list of those.');
|
|
1298
|
-
}
|
|
1299
|
-
/** Read one in-range cron field value. */
|
|
1300
|
-
function cronFieldValue(text, spec) {
|
|
1301
|
-
const value = Number(text);
|
|
1302
|
-
if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) {
|
|
1303
|
-
throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field value ${text} is outside ${spec.min}-${spec.max}.`);
|
|
1304
|
-
}
|
|
1305
|
-
return value;
|
|
1306
|
-
}
|
|
1307
|
-
/** Read one positive cron field step. */
|
|
1308
|
-
function cronFieldStep(text, spec) {
|
|
1309
|
-
const step = Number(text);
|
|
1310
|
-
if (!Number.isSafeInteger(step) || step < 1) {
|
|
1311
|
-
throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field step must be a positive integer.`);
|
|
1312
|
-
}
|
|
1313
|
-
return step;
|
|
1314
|
-
}
|
|
1315
|
-
/** Expand one comma-separated cron field into its matched value set. */
|
|
1316
|
-
function cronFieldValues(raw, spec) {
|
|
1317
|
-
const matched = new Set();
|
|
1318
|
-
for (const element of raw.split(',')) {
|
|
1319
|
-
if (element.length === 0) {
|
|
1320
|
-
throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field must not contain an empty list element.`);
|
|
1321
|
-
}
|
|
1322
|
-
if (element === '*') {
|
|
1323
|
-
for (let value = spec.min; value <= spec.max; value += 1)
|
|
1324
|
-
matched.add(value);
|
|
1325
|
-
continue;
|
|
1326
|
-
}
|
|
1327
|
-
if (element.startsWith('*')) {
|
|
1328
|
-
const stepped = /^\*\/(?<step>\d+)$/.exec(element);
|
|
1329
|
-
const groups = stepped?.groups;
|
|
1330
|
-
if (groups === undefined)
|
|
1331
|
-
throw invalidCronField(spec, element);
|
|
1332
|
-
const stepText = groups['step'];
|
|
1333
|
-
/* v8 ignore next -- a successful fixed regex always provides the step group. */
|
|
1334
|
-
if (stepText === undefined)
|
|
1335
|
-
throw invalidCronField(spec, element);
|
|
1336
|
-
const step = cronFieldStep(stepText, spec);
|
|
1337
|
-
for (let value = spec.min; value <= spec.max; value += step)
|
|
1338
|
-
matched.add(value);
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
const parsed = /^(?<start>\d+)(?:-(?<end>\d+))?(?:\/(?<step>\d+))?$/.exec(element);
|
|
1342
|
-
const groups = parsed?.groups;
|
|
1343
|
-
if (groups === undefined)
|
|
1344
|
-
throw invalidCronField(spec, element);
|
|
1345
|
-
const startText = groups['start'];
|
|
1346
|
-
/* v8 ignore next -- a successful fixed regex always provides the start group. */
|
|
1347
|
-
if (startText === undefined)
|
|
1348
|
-
throw invalidCronField(spec, element);
|
|
1349
|
-
const start = cronFieldValue(startText, spec);
|
|
1350
|
-
const end = groups['end'];
|
|
1351
|
-
const stepText = groups['step'];
|
|
1352
|
-
if (end === undefined) {
|
|
1353
|
-
if (stepText !== undefined)
|
|
1354
|
-
throw invalidCronField(spec, element);
|
|
1355
|
-
matched.add(start);
|
|
1356
|
-
continue;
|
|
1357
|
-
}
|
|
1358
|
-
const last = cronFieldValue(end, spec);
|
|
1359
|
-
if (start > last) {
|
|
1360
|
-
throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field range ${start}-${last} is inverted.`);
|
|
1361
|
-
}
|
|
1362
|
-
const step = stepText === undefined ? 1 : cronFieldStep(stepText, spec);
|
|
1363
|
-
for (let value = start; value <= last; value += step)
|
|
1364
|
-
matched.add(value);
|
|
1365
|
-
}
|
|
1366
|
-
const values = new Set();
|
|
1367
|
-
for (const value of matched) {
|
|
1368
|
-
values.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value);
|
|
1369
|
-
}
|
|
1370
|
-
return [...values].sort((left, right) => left - right);
|
|
1371
|
-
}
|
|
1372
|
-
/**
|
|
1373
|
-
* Read one value the encoder already proved present.
|
|
1374
|
-
* @param values - matched value set the parser always fills.
|
|
1375
|
-
* @param index - index the caller derived from that set.
|
|
1376
|
-
* @returns the value at that index.
|
|
1377
|
-
*/
|
|
1378
|
-
function cronFieldValueAt(values, index) {
|
|
1379
|
-
const value = values[index];
|
|
1380
|
-
/* v8 ignore next -- a parsed cron field always matches at least one value. */
|
|
1381
|
-
if (value === undefined)
|
|
1382
|
-
throw new ScheduleLogError('cron field encoding requires a non-empty value set');
|
|
1383
|
-
return value;
|
|
1384
|
-
}
|
|
1385
|
-
/** Encode one matched value set as the shortest equivalent comma-separated cron field.
|
|
1386
|
-
* @param values - Matched values in ascending order.
|
|
1387
|
-
* @returns Field text that re-parses to exactly `values`, never spelled with a leading `*`.
|
|
1388
|
-
*/
|
|
1389
|
-
function encodeCronField(values) {
|
|
1390
|
-
const first = cronFieldValueAt(values, 0);
|
|
1391
|
-
if (values.length === 1)
|
|
1392
|
-
return String(first);
|
|
1393
|
-
const last = cronFieldValueAt(values, values.length - 1);
|
|
1394
|
-
const step = cronFieldValueAt(values, 1) - first;
|
|
1395
|
-
let uniform = true;
|
|
1396
|
-
for (let index = 2; index < values.length; index += 1) {
|
|
1397
|
-
if (cronFieldValueAt(values, index) - cronFieldValueAt(values, index - 1) !== step) {
|
|
1398
|
-
uniform = false;
|
|
1399
|
-
break;
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
if (uniform) {
|
|
1403
|
-
if (step === 1)
|
|
1404
|
-
return `${first}-${last}`;
|
|
1405
|
-
return `${first}-${last}/${step}`;
|
|
1406
|
-
}
|
|
1407
|
-
const parts = [];
|
|
1408
|
-
let runStart = first;
|
|
1409
|
-
for (let index = 1; index < values.length; index += 1) {
|
|
1410
|
-
const current = cronFieldValueAt(values, index);
|
|
1411
|
-
const previous = cronFieldValueAt(values, index - 1);
|
|
1412
|
-
if (current === previous + 1)
|
|
1413
|
-
continue;
|
|
1414
|
-
parts.push(runStart === previous ? String(runStart) : `${runStart}-${previous}`);
|
|
1415
|
-
runStart = current;
|
|
1416
|
-
}
|
|
1417
|
-
const final = cronFieldValueAt(values, values.length - 1);
|
|
1418
|
-
parts.push(runStart === final ? String(runStart) : `${runStart}-${final}`);
|
|
1419
|
-
return parts.join(',');
|
|
1420
|
-
}
|
|
1421
|
-
/**
|
|
1422
|
-
* One uniform walk from a field's minimum, folded exactly as a parsed field folds.
|
|
1423
|
-
* @param spec - Field range and canonical maximum.
|
|
1424
|
-
* @param step - Positive step of the walk.
|
|
1425
|
-
* @returns Ascending values `step` produces from the minimum.
|
|
1426
|
-
*/
|
|
1427
|
-
function starWalk(spec, step) {
|
|
1428
|
-
const walked = new Set();
|
|
1429
|
-
for (let value = spec.min; value <= spec.max; value += step) {
|
|
1430
|
-
walked.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value);
|
|
1431
|
-
}
|
|
1432
|
-
return [...walked].sort((left, right) => left - right);
|
|
1433
|
-
}
|
|
1434
|
-
/**
|
|
1435
|
-
* Canonical spelling of a field whose text started with `*`.
|
|
1436
|
-
*
|
|
1437
|
-
* Only a `*`-prefixed spelling keeps the star flag, and only the bare star or a star-step
|
|
1438
|
-
* (a `*` followed by `/n`) admits a leading `*`, so the encoding is the bare star for every
|
|
1439
|
-
* value, otherwise the widest star-step walk plus any remaining values as a list.
|
|
1440
|
-
* Re-parsing therefore reproduces both the star flag and the matched set, which is what
|
|
1441
|
-
* keeps day-of-month/day-of-week AND/OR selection stable across a stored canonical
|
|
1442
|
-
* expression.
|
|
1443
|
-
* @param values - Matched values in ascending order.
|
|
1444
|
-
* @param spec - Field range and canonical maximum.
|
|
1445
|
-
* @returns Canonical field text that starts with `*`.
|
|
1446
|
-
*/
|
|
1447
|
-
function encodeStarCronField(values, spec) {
|
|
1448
|
-
if (values.length === spec.canonicalMax - spec.min + 1)
|
|
1449
|
-
return '*';
|
|
1450
|
-
const present = new Set(values);
|
|
1451
|
-
let bestStep;
|
|
1452
|
-
let bestWalk = [];
|
|
1453
|
-
for (let step = 1; step <= spec.max - spec.min + 1; step += 1) {
|
|
1454
|
-
const walk = starWalk(spec, step);
|
|
1455
|
-
if (walk.length > bestWalk.length && walk.every(value => present.has(value))) {
|
|
1456
|
-
bestStep = step;
|
|
1457
|
-
bestWalk = walk;
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
/* v8 ignore next -- the widest step yields the single minimum, which every `*`-led field matches. */
|
|
1461
|
-
if (bestStep === undefined)
|
|
1462
|
-
throw new ScheduleInputError('invalid_rule', `${spec.name} cannot keep a leading \`*\`.`);
|
|
1463
|
-
const walked = new Set(bestWalk);
|
|
1464
|
-
const remaining = values.filter(value => !walked.has(value));
|
|
1465
|
-
const walkText = `*/${bestStep}`;
|
|
1466
|
-
return remaining.length === 0
|
|
1467
|
-
? walkText
|
|
1468
|
-
: `${walkText},${encodeCronField(remaining)}`;
|
|
1469
|
-
}
|
|
1470
|
-
/**
|
|
1471
|
-
* Parse one cron field into its matched values, canonical spelling, and star flag.
|
|
1472
|
-
*
|
|
1473
|
-
* The star flag tests the field text's first character, which is how Vixie sets
|
|
1474
|
-
* `DOM_STAR`/`DOW_STAR`: a stepped star (`*` followed by `/2`) is a star although
|
|
1475
|
-
* it matches half the range, while an explicit full range such as `1-31` is
|
|
1476
|
-
* restricted. Canonicalization preserves the flag: a `*`-led field keeps a `*`-led
|
|
1477
|
-
* spelling, and a field that did not start with `*` is never spelled as a star-step.
|
|
1478
|
-
*/
|
|
1479
|
-
function parseCronField(raw, spec) {
|
|
1480
|
-
const values = cronFieldValues(raw, spec);
|
|
1481
|
-
const star = raw.startsWith('*');
|
|
1482
|
-
return Object.freeze({
|
|
1483
|
-
values: Object.freeze(values),
|
|
1484
|
-
canonical: star ? encodeStarCronField(values, spec) : encodeCronField(values),
|
|
1485
|
-
star,
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
/** Parse and canonicalize one strict five-field cron expression. */
|
|
1489
|
-
function parseCronExpression(expression) {
|
|
1490
|
-
if (typeof expression !== 'string' || expression.length === 0 || expression.trim() !== expression) {
|
|
1491
|
-
throw new ScheduleInputError('invalid_rule', 'cron.expression must be a non-empty trimmed string.');
|
|
1492
|
-
}
|
|
1493
|
-
const fields = expression.split(/\s+/);
|
|
1494
|
-
if (fields.length !== 5) {
|
|
1495
|
-
throw new ScheduleInputError('invalid_rule', 'cron.expression must contain exactly five whitespace-separated fields: '
|
|
1496
|
-
+ 'minute hour day-of-month month day-of-week.');
|
|
1497
|
-
}
|
|
1498
|
-
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields.map((field, index) => {
|
|
1499
|
-
const fieldSpec = CRON_FIELDS[index];
|
|
1500
|
-
/* v8 ignore next -- the five-field split bounds this index. */
|
|
1501
|
-
if (fieldSpec === undefined)
|
|
1502
|
-
throw new ScheduleLogError('cron field specification is missing');
|
|
1503
|
-
return parseCronField(field, fieldSpec);
|
|
1504
|
-
});
|
|
1505
|
-
return Object.freeze({
|
|
1506
|
-
expression: [minute, hour, dayOfMonth, month, dayOfWeek].map(field => field.canonical).join(' '),
|
|
1507
|
-
minutes: minute.values,
|
|
1508
|
-
hours: hour.values,
|
|
1509
|
-
daysOfMonth: dayOfMonth.values,
|
|
1510
|
-
months: month.values,
|
|
1511
|
-
daysOfWeek: dayOfWeek.values,
|
|
1512
|
-
dayOfMonthStar: dayOfMonth.star,
|
|
1513
|
-
dayOfWeekStar: dayOfWeek.star,
|
|
1514
|
-
});
|
|
920
|
+
/** Map a shared time-parser reason into Schedule's public error vocabulary. */
|
|
921
|
+
function readRecurrence(result) {
|
|
922
|
+
if (result.kind === 'accepted')
|
|
923
|
+
return result.rule;
|
|
924
|
+
const codes = { syntax: 'invalid_rule', time_zone: 'invalid_time_zone', density: 'frequency_too_high' };
|
|
925
|
+
throw new ScheduleInputError(codes[result.reason], result.detail, { cause: result.cause });
|
|
1515
926
|
}
|
|
1516
927
|
/**
|
|
1517
|
-
* Canonicalize one
|
|
1518
|
-
* @param expression -
|
|
1519
|
-
* @returns
|
|
928
|
+
* Canonicalize one supported five-field Cron expression.
|
|
929
|
+
* @param expression - Strict five-field input text.
|
|
930
|
+
* @returns Canonical text preserving the Vixie day-matching semantics.
|
|
1520
931
|
*/
|
|
1521
932
|
export function canonicalizeCronExpression(expression) {
|
|
1522
|
-
return
|
|
933
|
+
return readRecurrence(Cron.parse(expression, 'UTC')).expression;
|
|
1523
934
|
}
|
|
1524
935
|
/**
|
|
1525
936
|
* Normalize a cron selector without calculating a new committed target.
|
|
@@ -1534,28 +945,15 @@ export function parseCronInput(cron) {
|
|
|
1534
945
|
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.');
|
|
1535
946
|
}
|
|
1536
947
|
return {
|
|
1537
|
-
expression:
|
|
948
|
+
expression: canonicalizeCronExpression(cron['expression']),
|
|
1538
949
|
timeZone: canonicalizeTimeZone(cron['time_zone']),
|
|
1539
950
|
};
|
|
1540
951
|
}
|
|
1541
|
-
/**
|
|
1542
|
-
* Derive one execution-local management view.
|
|
1543
|
-
* @param record - Active durable record.
|
|
1544
|
-
* @param now - Wall-clock sample used for its timing state.
|
|
1545
|
-
* @returns Complete Host delivery view.
|
|
1546
|
-
*/
|
|
1547
|
-
export function scheduleView(record, now) {
|
|
1548
|
-
return Object.freeze({
|
|
1549
|
-
...record,
|
|
1550
|
-
state: now >= Date.parse(record.scheduledAt) ? 'overdue' : 'scheduled',
|
|
1551
|
-
deliveryMode: 'host',
|
|
1552
|
-
});
|
|
1553
|
-
}
|
|
1554
952
|
/** Fixed model-facing origin line shared by one-shot and recurring reminder delivery. */
|
|
1555
953
|
const SCHEDULED_MESSAGE_FRAMING = 'This is a scheduled message from the user';
|
|
1556
954
|
/**
|
|
1557
|
-
* Render the fixed model framing for
|
|
1558
|
-
* @param record - Due
|
|
955
|
+
* Render the fixed model framing for one due reminder.
|
|
956
|
+
* @param record - Due task identity, instruction, and delivered occurrence.
|
|
1559
957
|
* @returns Stable model-visible text with JSON-escaped dynamic fields.
|
|
1560
958
|
*/
|
|
1561
959
|
export function renderReminderFraming(record) {
|
|
@@ -1584,4 +982,120 @@ export function renderRecurringReminderBatchFraming(reminders) {
|
|
|
1584
982
|
`reminders_json: ${JSON.stringify(payload)}`,
|
|
1585
983
|
].join('\n');
|
|
1586
984
|
}
|
|
985
|
+
/** Freeze a record whose scheduledAt is the first occurrence of a prepared rule strictly after a time. */
|
|
986
|
+
function firstRecord(id, title, prompt, prepared, now) {
|
|
987
|
+
if (!isFourDigitYearInstant(now))
|
|
988
|
+
throw new ScheduleInputError('time_out_of_range', 'The recurrence reference time is outside the supported range.');
|
|
989
|
+
const scheduledAt = prepared.next(now);
|
|
990
|
+
if (scheduledAt === undefined) {
|
|
991
|
+
throw new ScheduleInputError('not_future', 'rrule must contain an occurrence strictly in the future.');
|
|
992
|
+
}
|
|
993
|
+
return Object.freeze({ id, kind: 'rrule', title, prompt, rrule: prepared.expression, scheduledAt });
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Validate and normalize the supported RFC 5545 recurrence content.
|
|
997
|
+
* @param value - Two-line DTSTART and RRULE input.
|
|
998
|
+
* @returns The trimmed newline-separated recurrence; an unsupported rule throws ScheduleInputError.
|
|
999
|
+
*/
|
|
1000
|
+
export function normalizeRRule(value) {
|
|
1001
|
+
return readRecurrence(RRule.parse(value)).expression;
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Create one Host task record with its first committed occurrence.
|
|
1005
|
+
* @param id - Host-allocated canonical schedule id.
|
|
1006
|
+
* @param title - User-facing schedule title.
|
|
1007
|
+
* @param prompt - Instruction delivered when the schedule fires.
|
|
1008
|
+
* @param rrule - DTSTART and RRULE content.
|
|
1009
|
+
* @param now - Exclusive creation-time lower bound in epoch milliseconds.
|
|
1010
|
+
* @returns Frozen schedule record.
|
|
1011
|
+
*/
|
|
1012
|
+
export function createRRuleScheduleRecord(id, title, prompt, rrule, now) {
|
|
1013
|
+
const normalizedTitle = scheduleTitle(title);
|
|
1014
|
+
const normalizedPrompt = prompt.trim();
|
|
1015
|
+
if (normalizedPrompt.length === 0) {
|
|
1016
|
+
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
|
|
1017
|
+
}
|
|
1018
|
+
const prepared = readRecurrence(RRule.parse(rrule, { rejectIneffectiveSetPositions: true }));
|
|
1019
|
+
return firstRecord(id, normalizedTitle, normalizedPrompt, prepared, now);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Update descriptive fields and optionally recompute the next occurrence.
|
|
1023
|
+
* @param current - Existing schedule record.
|
|
1024
|
+
* @param change - Supplied editable record fields.
|
|
1025
|
+
* @param now - Exclusive edit-time lower bound in epoch milliseconds.
|
|
1026
|
+
* @param recompute - Whether to derive a fresh next occurrence.
|
|
1027
|
+
* @returns Frozen updated schedule record.
|
|
1028
|
+
*/
|
|
1029
|
+
export function updateRRuleScheduleRecord(current, change, now, recompute) {
|
|
1030
|
+
const title = scheduleTitle(change.title ?? current.title);
|
|
1031
|
+
const prompt = (change.prompt ?? current.prompt).trim();
|
|
1032
|
+
if (prompt.length === 0)
|
|
1033
|
+
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.');
|
|
1034
|
+
const prepared = change.rrule === undefined
|
|
1035
|
+
? readRecurrence(RRule.parse(current.rrule))
|
|
1036
|
+
: readRecurrence(RRule.parse(change.rrule, { rejectIneffectiveSetPositions: true }));
|
|
1037
|
+
if (recompute)
|
|
1038
|
+
return firstRecord(current.id, title, prompt, prepared, now);
|
|
1039
|
+
return Object.freeze({ ...current, title, prompt, rrule: prepared.expression });
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Resolve the latest due occurrence and the first later occurrence without replaying a backlog.
|
|
1043
|
+
* @param record - Due schedule record.
|
|
1044
|
+
* @param acceptedAt - Dispatch admission time in epoch milliseconds.
|
|
1045
|
+
* @returns Latest due occurrence and optional next occurrence.
|
|
1046
|
+
*/
|
|
1047
|
+
export function resolveRRuleOccurrence(record, acceptedAt) {
|
|
1048
|
+
const decision = acceptedDecision('rrule', acceptedAt);
|
|
1049
|
+
const savedTarget = Date.parse(record.scheduledAt);
|
|
1050
|
+
if (decision < savedTarget)
|
|
1051
|
+
throw new ScheduleLogError('rrule dispatch cannot precede the active scheduledAt');
|
|
1052
|
+
return readRecurrence(RRule.parse(record.rrule)).resolve(record.scheduledAt, decision);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Decode a current Host task record.
|
|
1056
|
+
* @param value - Untrusted durable JSON record.
|
|
1057
|
+
* @returns Detached frozen record; malformed fields throw ScheduleLogError.
|
|
1058
|
+
*/
|
|
1059
|
+
export function decodeRRuleScheduleRecord(value) {
|
|
1060
|
+
if (!isRecord(value) || value['kind'] !== 'rrule' || !hasExactKeys(value, ['id', 'kind', 'title', 'prompt', 'rrule', 'scheduledAt'])) {
|
|
1061
|
+
throw new ScheduleLogError('rrule schedule must contain exactly id, kind, title, prompt, rrule, and scheduledAt');
|
|
1062
|
+
}
|
|
1063
|
+
const title = decodeStoredTitle(value['title']);
|
|
1064
|
+
const prompt = value['prompt'];
|
|
1065
|
+
const rrule = value['rrule'];
|
|
1066
|
+
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
|
|
1067
|
+
throw new ScheduleLogError('schedule prompt must be non-empty and already trimmed');
|
|
1068
|
+
}
|
|
1069
|
+
if (typeof rrule !== 'string')
|
|
1070
|
+
throw new ScheduleLogError('schedule rrule must be a string');
|
|
1071
|
+
let normalized;
|
|
1072
|
+
try {
|
|
1073
|
+
normalized = normalizeRRule(rrule);
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
throw new ScheduleLogError(`invalid schedule rrule: ${String(error)}`);
|
|
1077
|
+
}
|
|
1078
|
+
if (normalized !== rrule)
|
|
1079
|
+
throw new ScheduleLogError('schedule rrule must be normalized');
|
|
1080
|
+
return Object.freeze({
|
|
1081
|
+
id: decodeId(value['id']),
|
|
1082
|
+
kind: 'rrule',
|
|
1083
|
+
title,
|
|
1084
|
+
prompt,
|
|
1085
|
+
rrule,
|
|
1086
|
+
scheduledAt: decodeInstant(value['scheduledAt']),
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Render an instant in the authoritative zone of any timing kind; elapsed and absolute rules use UTC.
|
|
1091
|
+
* @param record - Timing record that owns the display zone.
|
|
1092
|
+
* @param instant - UTC instant to render with second precision.
|
|
1093
|
+
* @returns Local ISO date-time with its numeric offset.
|
|
1094
|
+
*/
|
|
1095
|
+
export function formatScheduleLocalTime(record, instant) {
|
|
1096
|
+
const timeZone = record.kind === 'rrule' ? readRecurrence(RRule.parse(record.rrule)).timeZone
|
|
1097
|
+
: 'timeZone' in record ? record.timeZone : 'UTC';
|
|
1098
|
+
return Temporal.Instant.from(instant).toZonedDateTimeISO(timeZone)
|
|
1099
|
+
.toString({ timeZoneName: 'never', smallestUnit: 'second' });
|
|
1100
|
+
}
|
|
1587
1101
|
//# sourceMappingURL=domain.js.map
|