task-loop-rrule.mjs
251 lines 7.8 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Task loop RRULE recurrence validator + occurrence projection (Phase 2G-f-b — inert stub).
3 *
4 * Knowtation-canonical RRULE subset per TASK-LOOP-RRULE-RECURRENCE-CONTRACT-2G-f.md.
5 * Live store write rejection when {@link TASK_LOOP_RRULE_ENABLED} is false.
6 *
7 * @see docs/TASK-LOOP-RRULE-RECURRENCE-CONTRACT-2G-f.md
8 */
9
10 /** Compile-time posture — Tier 3 BUNDLED_2G_LIVE 2026-06-25. */
11 export const TASK_LOOP_RRULE_ENABLED = true;
12
13 const ALLOWED_FREQ = /** @type {const} */ (['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']);
14 const REJECTED_TOKENS = ['BYSETPOS', 'BYHOUR', 'BYMINUTE', 'BYYEARDAY', 'WKST', 'EXRULE'];
15 const ALLOWED_RRULE_KEYS = new Set(['FREQ', 'INTERVAL', 'BYDAY', 'BYMONTHDAY', 'COUNT', 'UNTIL']);
16 const MAX_RRULE_LENGTH = 512;
17
18 const DAY_MAP = /** @type {Record<string, number>} */ ({
19 SU: 0,
20 MO: 1,
21 TU: 2,
22 WE: 3,
23 TH: 4,
24 FR: 5,
25 SA: 6,
26 });
27
28 /**
29 * @param {unknown} value
30 * @returns {value is string}
31 */
32 function isIso8601(value) {
33 return typeof value === 'string' && value.trim().length > 0 && !Number.isNaN(Date.parse(value));
34 }
35
36 /**
37 * @param {string} rrule
38 * @returns {Record<string, string>}
39 */
40 function parseRruleTokens(rrule) {
41 /** @type {Record<string, string>} */
42 const tokens = {};
43 for (const part of rrule.split(';')) {
44 const trimmed = part.trim();
45 if (!trimmed) continue;
46 const eq = trimmed.indexOf('=');
47 if (eq <= 0) {
48 throw new Error('malformed_rrule');
49 }
50 const key = trimmed.slice(0, eq).toUpperCase();
51 const value = trimmed.slice(eq + 1).trim();
52 tokens[key] = value;
53 }
54 return tokens;
55 }
56
57 /**
58 * @param {object} recurrence
59 * @param {string} seriesTimezone
60 * @returns {{ ok: true, recurrence: object } | { ok: false, code: string, detail: string }}
61 */
62 export function validateTaskLoopRrule(recurrence, seriesTimezone) {
63 if (!recurrence || typeof recurrence !== 'object') {
64 return { ok: false, code: 'invalid_rrule', detail: 'recurrence' };
65 }
66 const row = /** @type {Record<string, unknown>} */ (recurrence);
67 if (row.kind !== 'rrule') {
68 return { ok: false, code: 'invalid_rrule', detail: 'kind' };
69 }
70 if (typeof row.rrule !== 'string' || !row.rrule.trim()) {
71 return { ok: false, code: 'invalid_rrule', detail: 'rrule' };
72 }
73 if (row.rrule.length > MAX_RRULE_LENGTH) {
74 return { ok: false, code: 'invalid_rrule', detail: 'oversized' };
75 }
76 if (!isIso8601(row.dtstart)) {
77 return { ok: false, code: 'invalid_rrule', detail: 'dtstart' };
78 }
79 if (typeof row.anchor_tz !== 'string' || !row.anchor_tz.trim()) {
80 return { ok: false, code: 'invalid_rrule', detail: 'anchor_tz' };
81 }
82 if (row.anchor_tz !== seriesTimezone) {
83 return { ok: false, code: 'invalid_rrule', detail: 'timezone_mismatch' };
84 }
85
86 /** @type {string[]} */
87 const exdates = [];
88 if (row.exdates != null) {
89 if (!Array.isArray(row.exdates)) {
90 return { ok: false, code: 'invalid_rrule', detail: 'exdates' };
91 }
92 const seen = new Set();
93 for (const ex of row.exdates) {
94 if (!isIso8601(ex)) {
95 return { ok: false, code: 'invalid_rrule', detail: 'exdate' };
96 }
97 if (seen.has(ex)) {
98 return { ok: false, code: 'invalid_rrule', detail: 'duplicate_exdate' };
99 }
100 seen.add(ex);
101 exdates.push(String(ex));
102 }
103 }
104
105 let tokens;
106 try {
107 tokens = parseRruleTokens(String(row.rrule));
108 } catch {
109 return { ok: false, code: 'invalid_rrule', detail: 'parse' };
110 }
111
112 for (const rejected of REJECTED_TOKENS) {
113 if (tokens[rejected] != null) {
114 return { ok: false, code: 'invalid_rrule', detail: rejected };
115 }
116 }
117
118 for (const key of Object.keys(tokens)) {
119 if (!ALLOWED_RRULE_KEYS.has(key)) {
120 return { ok: false, code: 'invalid_rrule', detail: key };
121 }
122 }
123
124 const freq = tokens.FREQ;
125 if (!freq || !ALLOWED_FREQ.includes(/** @type {typeof ALLOWED_FREQ[number]} */ (freq))) {
126 return { ok: false, code: 'invalid_rrule', detail: 'FREQ' };
127 }
128
129 if (tokens.INTERVAL != null) {
130 const interval = Number.parseInt(tokens.INTERVAL, 10);
131 if (!Number.isInteger(interval) || interval < 1) {
132 return { ok: false, code: 'invalid_rrule', detail: 'INTERVAL' };
133 }
134 }
135
136 if (tokens.BYDAY != null && freq !== 'WEEKLY' && freq !== 'MONTHLY') {
137 return { ok: false, code: 'invalid_rrule', detail: 'BYDAY' };
138 }
139
140 if (tokens.BYMONTHDAY != null && freq !== 'MONTHLY' && freq !== 'YEARLY') {
141 return { ok: false, code: 'invalid_rrule', detail: 'BYMONTHDAY' };
142 }
143
144 if (tokens.COUNT != null) {
145 const count = Number.parseInt(tokens.COUNT, 10);
146 if (!Number.isInteger(count) || count < 1) {
147 return { ok: false, code: 'invalid_rrule', detail: 'COUNT' };
148 }
149 }
150
151 /** @type {object} */
152 const canonical = {
153 kind: 'rrule',
154 rrule: String(row.rrule),
155 dtstart: String(row.dtstart),
156 anchor_tz: String(row.anchor_tz),
157 };
158 if (exdates.length > 0) {
159 canonical.exdates = exdates;
160 }
161 if (typeof row.source_ical === 'string' && row.source_ical.trim()) {
162 canonical.source_ical = row.source_ical;
163 }
164
165 return { ok: true, recurrence: canonical };
166 }
167
168 /**
169 * @param {Date} date
170 * @returns {string}
171 */
172 function isoWeekKey(date) {
173 const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
174 const day = target.getUTCDay() || 7;
175 target.setUTCDate(target.getUTCDate() + 4 - day);
176 const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
177 const week = Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
178 return `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
179 }
180
181 /**
182 * @param {object} recurrence
183 * @param {{ until_at?: string|null }} series
184 * @param {string} afterInstant
185 * @returns {{ occurrenceAt: string, occurrenceKey: string, skippedExdates: number } | null}
186 */
187 export function projectNextOccurrence(recurrence, series, afterInstant) {
188 const validation = validateTaskLoopRrule(recurrence, recurrence.anchor_tz);
189 if (!validation.ok) {
190 return null;
191 }
192
193 const row = validation.recurrence;
194 const tokens = parseRruleTokens(row.rrule);
195 const freq = tokens.FREQ;
196 const interval = tokens.INTERVAL != null ? Number.parseInt(tokens.INTERVAL, 10) : 1;
197 const after = new Date(afterInstant);
198 const anchor = new Date(row.dtstart);
199 /** @type {Set<string>} */
200 const exdateSet = new Set((row.exdates ?? []).map(String));
201
202 if (series.until_at != null && new Date(series.until_at).getTime() < after.getTime()) {
203 return null;
204 }
205
206 if (freq === 'WEEKLY') {
207 const byday = tokens.BYDAY ? tokens.BYDAY.split(',') : ['MO'];
208 const targetDays = byday
209 .map((d) => DAY_MAP[d.trim().slice(0, 2)])
210 .filter((d) => d != null);
211
212 let cursor = new Date(Math.max(anchor.getTime(), after.getTime() + 1));
213 cursor.setUTCHours(anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds(), 0);
214
215 for (let guard = 0; guard < 400; guard += 1) {
216 const day = cursor.getUTCDay();
217 if (targetDays.includes(day) && cursor.getTime() > after.getTime()) {
218 const iso = cursor.toISOString();
219 if (!exdateSet.has(iso)) {
220 return {
221 occurrenceAt: iso,
222 occurrenceKey: isoWeekKey(cursor),
223 skippedExdates: exdateSet.size,
224 };
225 }
226 }
227 cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
228 }
229 return null;
230 }
231
232 if (freq === 'DAILY') {
233 let cursor = new Date(Math.max(anchor.getTime(), after.getTime() + 1));
234 for (let guard = 0; guard < 400; guard += 1) {
235 const iso = cursor.toISOString();
236 if (!exdateSet.has(iso)) {
237 const y = cursor.getUTCFullYear();
238 const m = String(cursor.getUTCMonth() + 1).padStart(2, '0');
239 const d = String(cursor.getUTCDate()).padStart(2, '0');
240 return {
241 occurrenceAt: iso,
242 occurrenceKey: `${y}-${m}-${d}`,
243 skippedExdates: exdateSet.size,
244 };
245 }
246 cursor = new Date(cursor.getTime() + interval * 24 * 60 * 60 * 1000);
247 }
248 }
249
250 return null;
251 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago