@qilin-agent/schedule 3.1.7 → 3.1.9

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.
@@ -1,36 +1,14 @@
1
1
  /**
2
- * Strict Schedule decoding, replay, time validation, and framing.
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 { SessionLogOffset } from '@qilin-agent/session';
7
- /** Durable Schedule protocol version implemented by this package. */
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
- /** Fixed v1 upper bound for a stored task title. */
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 session-local id without changing its runtime value.
65
- * @param value - Raw session-local id.
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
- /** Whether an unknown value is a non-array object. */
72
- function isRecord(value) {
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
- /** Require exactly the named durable object keys. */
76
- function hasExactKeys(value, expected) {
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
- * Decode the stored title of one historical `schedule/change` create record.
144
- *
145
- * A version-1 event written before names existed has no `title` member, so the
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 decodeHistoricalRecordTitle(value, expected, message) {
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
- /** Validate one canonical four-digit-year UTC instant. */
167
- function decodeInstant(value) {
168
- if (typeof value !== 'string' || !UTC_INSTANT.test(value)) {
169
- throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant');
170
- }
171
- const epoch = Date.parse(value);
172
- if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) {
173
- throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant');
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
- /** Read one required named regular-expression group as a number. */
178
- function groupNumber(groups, name) {
179
- const value = groups[name];
180
- /* v8 ignore next -- successful fixed regexes always provide every requested group. */
181
- if (value === undefined)
182
- throw new ScheduleInputError('invalid_rule', 'The at value has an invalid shape.');
183
- return Number(value);
184
- }
185
- /** Convert exact calendar fields to a UTC-shaped epoch while rejecting normalization. */
186
- function calendarEpoch(parts) {
187
- const value = new Date(0);
188
- value.setUTCHours(0, 0, 0, 0);
189
- value.setUTCFullYear(parts.year, parts.month - 1, parts.day);
190
- value.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
191
- const epoch = value.getTime();
192
- if (!Number.isFinite(epoch)
193
- || value.getUTCFullYear() !== parts.year
194
- || value.getUTCMonth() + 1 !== parts.month
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
- /** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */
225
- function parseOffsetInstant(value) {
226
- const match = OFFSET_INSTANT.exec(value);
227
- const groups = match?.groups;
228
- if (groups === undefined) {
229
- 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.');
230
- }
231
- const parts = {
232
- year: groupNumber(groups, 'year'),
233
- month: groupNumber(groups, 'month'),
234
- day: groupNumber(groups, 'day'),
235
- hour: groupNumber(groups, 'hour'),
236
- minute: groupNumber(groups, 'minute'),
237
- second: groupNumber(groups, 'second'),
238
- millisecond: milliseconds(groups['fraction']),
239
- };
240
- if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) {
241
- throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.');
242
- }
243
- const localEpoch = calendarEpoch(parts);
244
- if (groups['zone'] === 'Z')
245
- return localEpoch;
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
- if (value.length === 0 || value.trim() !== value || (value !== 'UTC' && !IANA_ZONE.test(value))) {
262
- throw new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.');
263
- }
264
- let canonical;
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 dateMatch = LOCAL_DATE.exec(value.date);
280
- const timeMatch = LOCAL_TIME.exec(value.time);
281
- const date = dateMatch?.groups;
282
- const time = timeMatch?.groups;
283
- if (date === undefined || time === undefined) {
284
- throw new ScheduleInputError('invalid_rule', 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.');
285
- }
286
- const parts = {
287
- year: groupNumber(date, 'year'),
288
- month: groupNumber(date, 'month'),
289
- day: groupNumber(date, 'day'),
290
- hour: groupNumber(time, 'hour'),
291
- minute: groupNumber(time, 'minute'),
292
- second: groupNumber(time, 'second'),
293
- millisecond: milliseconds(time['fraction']),
294
- };
295
- if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) {
296
- throw new ScheduleInputError('invalid_rule', 'The local at value must be a real ISO calendar date and time.');
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 resolveLocalInstant(parts, timeZone) {
308
- const target = localInstant(Temporal.PlainDateTime.from(parts, { overflow: 'reject' }), timeZone);
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
- /** Decode the exact v1 after record shape. */
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
- /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
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
- ...(title === undefined ? {} : { title }),
288
+ title,
332
289
  prompt,
333
290
  afterSeconds: afterSeconds,
334
291
  scheduledAt: decodeInstant(value['scheduledAt']),
335
292
  });
336
293
  }
337
- /** Decode the exact v1 absolute one-shot record shape. */
294
+ /** Decode an absolute one-shot Host record. */
338
295
  function decodeAtRecord(value) {
339
- /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
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
- ...(title === undefined ? {} : { title }),
304
+ title,
351
305
  prompt,
352
306
  scheduledAt: decodeInstant(value['scheduledAt']),
353
307
  });
354
308
  }
355
- /** Decode the exact v1 fixed-rate record shape. */
309
+ /** Decode a fixed-rate Host record. */
356
310
  function decodeEveryRecord(value) {
357
- /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */
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
- ...(title === undefined ? {} : { title }),
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 = parseCronExpression(expression).expression;
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 a current Host task record, preserving its committed target and stored zone spelling.
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; malformed fields throw ScheduleLogError.
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
- default: throw new ScheduleLogError('v1 schedule kind must be "after", "at", or "every"');
582
- }
583
- }
584
- /**
585
- * Decode one strict version-1 `schedule/change` payload.
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 (!Number.isSafeInteger(acceptedAt)
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 (!Number.isSafeInteger(next) || next > MAX_FOUR_DIGIT_YEAR_MS) {
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 = localInstant(date.toPlainDateTime(time), timeZone);
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 = localInstant(date.toPlainDateTime(time), timeZone);
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
- /** Resolve a wall-clock rule only when its decision is at a representable four-digit-year instant. */
719
- function acceptedDecision(selector, acceptedAt) {
720
- if (!Number.isSafeInteger(acceptedAt)
721
- || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {
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
- * Build the local-date predicate of one parsed cron rule under Vixie day-of-month/day-of-week semantics.
779
- *
780
- * The star flag selects the branch only; it never excuses a field's matched values.
781
- * When either field text starts with `*`, a local date must satisfy BOTH fields, so a
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
- const decision = acceptedDecision('cron', acceptedAt);
922
- const savedTarget = Date.parse(record.scheduledAt);
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
- const parsed = parseCronExpression(record.expression);
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 = parseOffsetInstant(at);
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 = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone));
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 (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
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 (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
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 (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
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 = nextCronTarget(parseCronExpression(expression), timeZone, now);
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
- const CRON_FIELDS = [
1288
- { name: 'minute', min: 0, max: 59, canonicalMax: 59 },
1289
- { name: 'hour', min: 0, max: 23, canonicalMax: 23 },
1290
- { name: 'day-of-month', min: 1, max: 31, canonicalMax: 31 },
1291
- { name: 'month', min: 1, max: 12, canonicalMax: 12 },
1292
- { name: 'day-of-week', min: 0, max: 7, canonicalMax: 6 },
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 strict five-field cron expression.
1518
- * @param expression - Candidate `minute hour day-of-month month day-of-week` expression.
1519
- * @returns The canonical expression; malformed or unsupported input throws ScheduleInputError.
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 parseCronExpression(expression).expression;
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: parseCronExpression(cron['expression']).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 a due reminder.
1558
- * @param record - Due active record.
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