task-loop-rrule.mjs
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