event-store.mjs
520 lines 15.5 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Local file-backed calendar event store (Calendar Events v0 — Phase 1B).
3 *
4 * Persists source calendars and normalized events per vault under data_dir.
5 * Self-hosted Hub only in v0; hosted canister parity follows later.
6 *
7 * @see docs/CALENDAR-EVENTS-V0-SPEC.md
8 */
9
10 import fs from 'fs';
11 import path from 'path';
12 import crypto from 'crypto';
13 import { randomUUID } from 'crypto';
14 import { parseIcsToEvents } from './ics-normalizer.mjs';
15 import { buildSourceCalendarDefaults } from './source-calendar-defaults.mjs';
16
17 const STORE_FILENAME = 'hub_calendar_store.json';
18 export const CALENDAR_STORE_FILENAME = STORE_FILENAME;
19 export const CALENDAR_OAUTH_DIR = 'calendar_oauth';
20 const MAX_ICS_IMPORT_BYTES = 5 * 1024 * 1024;
21
22 /** @typedef {import('./source-calendar-defaults.mjs').AgentContextTier} AgentContextTier */
23
24 /**
25 * @typedef {Object} StoredSourceCalendar
26 * @property {string} source_calendar_id
27 * @property {string} connector_id
28 * @property {string} display_name
29 * @property {string|null} [color]
30 * @property {'personal'|'work'|'school'|'other'|null} [user_group]
31 * @property {boolean} enabled_for_sync
32 * @property {boolean} enabled_for_display
33 * @property {boolean} enabled_for_agents
34 * @property {AgentContextTier} agent_context_tier_max
35 * @property {string} [provider]
36 */
37
38 /**
39 * @typedef {Object} StoredCalendarEvent
40 * @property {string} event_id
41 * @property {string} source_calendar_id
42 * @property {string} external_uid
43 * @property {string} start
44 * @property {string} end
45 * @property {string} timezone
46 * @property {string|null} summary
47 * @property {boolean} busy
48 * @property {'confirmed'|'cancelled'|'tentative'} status
49 * @property {string|null} recurrence_rule
50 * @property {string[]|null} linked_note_paths
51 * @property {string|null} deleted_at
52 */
53
54 /**
55 * @typedef {'pending'|'connected'|'needs_reauth'|'revoked'} ConnectorStatus
56 */
57
58 /**
59 * @typedef {'auth_expired'|'rate_limited'|'provider_error'|'network_error'|'none'} ConnectorSyncError
60 */
61
62 /**
63 * @typedef {Object} StoredCalendarConnector
64 * @property {string} connector_id
65 * @property {string} provider
66 * @property {string} display_name
67 * @property {ConnectorStatus} status
68 * @property {string|null} [oauth_ref]
69 * @property {string|null} [account_sub]
70 * @property {Record<string, string>} [sync_cursors]
71 * @property {string|null} [last_sync_at]
72 * @property {ConnectorSyncError|null} [last_sync_error]
73 * @property {string|null} [revoked_at]
74 * @property {Object|null} [oauth_pending]
75 */
76
77 /**
78 * @typedef {Object} VaultCalendarStore
79 * @property {StoredCalendarConnector[]} [connectors]
80 * @property {StoredSourceCalendar[]} source_calendars
81 * @property {StoredCalendarEvent[]} events
82 */
83
84 /**
85 * @typedef {Object} CalendarStoreFile
86 * @property {Record<string, VaultCalendarStore>} vaults
87 */
88
89 /**
90 * @param {string} dataDir
91 * @returns {string}
92 */
93 export function getCalendarStorePath(dataDir) {
94 return path.join(dataDir, STORE_FILENAME);
95 }
96
97 /**
98 * @param {string} dataDir
99 * @returns {CalendarStoreFile}
100 */
101 export function loadCalendarStore(dataDir) {
102 const filePath = getCalendarStorePath(dataDir);
103 if (!fs.existsSync(filePath)) {
104 return { vaults: {} };
105 }
106 try {
107 const raw = fs.readFileSync(filePath, 'utf8');
108 const parsed = JSON.parse(raw);
109 if (!parsed || typeof parsed !== 'object' || !parsed.vaults || typeof parsed.vaults !== 'object') {
110 return { vaults: {} };
111 }
112 return /** @type {CalendarStoreFile} */ (parsed);
113 } catch {
114 return { vaults: {} };
115 }
116 }
117
118 /**
119 * @param {string} dataDir
120 * @param {CalendarStoreFile} store
121 */
122 export function saveCalendarStore(dataDir, store) {
123 const filePath = getCalendarStorePath(dataDir);
124 const dir = path.dirname(filePath);
125 if (!fs.existsSync(dir)) {
126 fs.mkdirSync(dir, { recursive: true });
127 }
128 const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
129 fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8');
130 fs.renameSync(tmp, filePath);
131 }
132
133 /**
134 * @param {string} dataDir
135 * @param {string} vaultId
136 * @returns {VaultCalendarStore}
137 */
138 export function getVaultCalendarStore(dataDir, vaultId, store = loadCalendarStore(dataDir)) {
139 if (!store.vaults[vaultId]) {
140 store.vaults[vaultId] = { connectors: [], source_calendars: [], events: [] };
141 }
142 const vault = store.vaults[vaultId];
143 if (!Array.isArray(vault.connectors)) {
144 vault.connectors = [];
145 }
146 return vault;
147 }
148
149 /**
150 * @param {string} sourceCalendarId
151 * @param {string} externalUid
152 * @returns {string}
153 */
154 export function buildEventId(sourceCalendarId, externalUid) {
155 const digest = crypto.createHash('sha256')
156 .update(`${sourceCalendarId}:${externalUid}`, 'utf8')
157 .digest('hex')
158 .slice(0, 24);
159 return `evt_${digest}`;
160 }
161
162 /**
163 * @param {string} dataDir
164 * @param {string} vaultId
165 * @returns {StoredSourceCalendar[]}
166 */
167 export function listSourceCalendars(dataDir, vaultId) {
168 return getVaultCalendarStore(dataDir, vaultId).source_calendars.slice();
169 }
170
171 /**
172 * @param {string} dataDir
173 * @param {string} vaultId
174 * @param {string} sourceCalendarId
175 * @returns {StoredSourceCalendar|undefined}
176 */
177 export function getSourceCalendar(dataDir, vaultId, sourceCalendarId) {
178 return getVaultCalendarStore(dataDir, vaultId)
179 .source_calendars
180 .find((c) => c.source_calendar_id === sourceCalendarId);
181 }
182
183 /**
184 * @param {StoredSourceCalendar} calendar
185 * @returns {object}
186 */
187 export function sourceCalendarForClient(calendar) {
188 return {
189 source_calendar_id: calendar.source_calendar_id,
190 connector_id: calendar.connector_id,
191 display_name: calendar.display_name,
192 color: calendar.color ?? null,
193 user_group: calendar.user_group ?? null,
194 enabled_for_sync: calendar.enabled_for_sync,
195 enabled_for_display: calendar.enabled_for_display,
196 enabled_for_agents: calendar.enabled_for_agents,
197 agent_context_tier_max: calendar.agent_context_tier_max,
198 provider: calendar.provider ?? 'ics_file',
199 };
200 }
201
202 /**
203 * @param {string} dataDir
204 * @param {string} vaultId
205 * @param {{
206 * icsText: string,
207 * displayName?: string,
208 * sourceCalendarId?: string,
209 * connectorId?: string,
210 * defaultTimezone?: string,
211 * }} input
212 * @returns {{ source_calendar_id: string, connector_id: string, imported: number, updated: number }}
213 */
214 export function importIcsIntoVault(dataDir, vaultId, input) {
215 const icsText = input.icsText;
216 if (typeof icsText !== 'string' || !icsText.trim()) {
217 throw new TypeError('icsText is required');
218 }
219 if (icsText.length > MAX_ICS_IMPORT_BYTES) {
220 throw new RangeError(`ICS import exceeds ${MAX_ICS_IMPORT_BYTES} bytes`);
221 }
222
223 const store = loadCalendarStore(dataDir);
224 if (!store.vaults[vaultId]) {
225 store.vaults[vaultId] = { source_calendars: [], events: [] };
226 }
227 const vaultStore = store.vaults[vaultId];
228
229 let sourceCalendarId = typeof input.sourceCalendarId === 'string' ? input.sourceCalendarId.trim() : '';
230 let connectorId = typeof input.connectorId === 'string' ? input.connectorId.trim() : '';
231 let sourceCalendar = sourceCalendarId
232 ? vaultStore.source_calendars.find((c) => c.source_calendar_id === sourceCalendarId)
233 : undefined;
234
235 if (sourceCalendarId && !sourceCalendar) {
236 throw new Error(`Source calendar not found: ${sourceCalendarId}`);
237 }
238
239 if (!sourceCalendar) {
240 sourceCalendarId = `cal_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
241 connectorId = connectorId || `conn_ics_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
242 sourceCalendar = {
243 source_calendar_id: sourceCalendarId,
244 connector_id: connectorId,
245 display_name: (input.displayName ?? 'Imported calendar').trim().slice(0, 120) || 'Imported calendar',
246 color: null,
247 user_group: null,
248 provider: 'ics_file',
249 ...buildSourceCalendarDefaults(),
250 };
251 vaultStore.source_calendars.push(sourceCalendar);
252 } else {
253 connectorId = sourceCalendar.connector_id;
254 }
255
256 const normalized = parseIcsToEvents(icsText, {
257 defaultTimezone: input.defaultTimezone ?? 'UTC',
258 });
259
260 let imported = 0;
261 let updated = 0;
262 const byId = new Map(vaultStore.events.map((e) => [e.event_id, e]));
263
264 for (const row of normalized) {
265 const eventId = buildEventId(sourceCalendarId, row.external_uid);
266 const existing = byId.get(eventId);
267 const stored = {
268 event_id: eventId,
269 source_calendar_id: sourceCalendarId,
270 external_uid: row.external_uid,
271 start: row.start,
272 end: row.end,
273 timezone: row.timezone,
274 summary: row.summary,
275 busy: row.busy,
276 status: row.status,
277 recurrence_rule: row.recurrence_rule,
278 linked_note_paths: existing?.linked_note_paths ?? null,
279 deleted_at: existing?.deleted_at ?? null,
280 };
281
282 if (existing) {
283 Object.assign(existing, stored);
284 updated += 1;
285 } else {
286 vaultStore.events.push(stored);
287 byId.set(eventId, stored);
288 imported += 1;
289 }
290 }
291
292 saveCalendarStore(dataDir, store);
293 return {
294 source_calendar_id: sourceCalendarId,
295 connector_id: connectorId,
296 imported,
297 updated,
298 };
299 }
300
301 /**
302 * Query stored events overlapping a UTC range.
303 *
304 * @param {string} dataDir
305 * @param {string} vaultId
306 * @param {{
307 * fromIso: string,
308 * toIso: string,
309 * sourceCalendarIds?: string[],
310 * displayOnly?: boolean,
311 * }} query
312 * @returns {StoredCalendarEvent[]}
313 */
314 export function queryStoredEvents(dataDir, vaultId, query) {
315 const vaultStore = getVaultCalendarStore(dataDir, vaultId);
316 const fromMs = Date.parse(query.fromIso);
317 const toMs = Date.parse(query.toIso);
318 if (Number.isNaN(fromMs) || Number.isNaN(toMs) || toMs <= fromMs) {
319 throw new RangeError('Invalid timeline range');
320 }
321
322 const allowedCalendars = new Set(
323 vaultStore.source_calendars
324 .filter((c) => {
325 if (query.displayOnly && !c.enabled_for_display) return false;
326 return true;
327 })
328 .map((c) => c.source_calendar_id),
329 );
330
331 const filterIds = query.sourceCalendarIds?.length
332 ? new Set(query.sourceCalendarIds.filter((id) => allowedCalendars.has(id)))
333 : allowedCalendars;
334
335 return vaultStore.events.filter((event) => {
336 if (!filterIds.has(event.source_calendar_id)) return false;
337 if (event.deleted_at) return false;
338 const startMs = Date.parse(event.start);
339 const endMs = Date.parse(event.end);
340 return startMs < toMs && endMs > fromMs;
341 });
342 }
343
344 /**
345 * @param {string} dataDir
346 * @param {string} vaultId
347 * @returns {StoredCalendarConnector[]}
348 */
349 export function listConnectors(dataDir, vaultId) {
350 return getVaultCalendarStore(dataDir, vaultId).connectors.slice();
351 }
352
353 /**
354 * @param {string} dataDir
355 * @param {string} vaultId
356 * @param {string} connectorId
357 * @returns {StoredCalendarConnector|undefined}
358 */
359 export function getConnector(dataDir, vaultId, connectorId) {
360 return getVaultCalendarStore(dataDir, vaultId)
361 .connectors
362 .find((c) => c.connector_id === connectorId);
363 }
364
365 /**
366 * @param {StoredCalendarConnector} connector
367 * @returns {object}
368 */
369 export function connectorForClient(connector, sourceCalendarCount) {
370 return {
371 connector_id: connector.connector_id,
372 provider: connector.provider,
373 display_name: connector.display_name,
374 status: connector.status,
375 last_sync_at: connector.last_sync_at ?? null,
376 last_sync_error: connector.last_sync_error ?? null,
377 source_calendar_count: sourceCalendarCount,
378 };
379 }
380
381 /**
382 * @param {string} dataDir
383 * @param {string} vaultId
384 * @param {StoredCalendarConnector} connector
385 */
386 export function saveConnector(dataDir, vaultId, connector) {
387 const store = loadCalendarStore(dataDir);
388 const vault = getVaultCalendarStore(dataDir, vaultId, store);
389 const idx = vault.connectors.findIndex((c) => c.connector_id === connector.connector_id);
390 if (idx === -1) {
391 vault.connectors.push(connector);
392 } else {
393 vault.connectors[idx] = connector;
394 }
395 saveCalendarStore(dataDir, store);
396 }
397
398 /**
399 * @param {string} dataDir
400 * @param {string} vaultId
401 * @param {string} connectorId
402 * @returns {number}
403 */
404 export function countSourceCalendarsForConnector(dataDir, vaultId, connectorId) {
405 return getVaultCalendarStore(dataDir, vaultId)
406 .source_calendars
407 .filter((c) => c.connector_id === connectorId)
408 .length;
409 }
410
411 /**
412 * @param {string} dataDir
413 * @param {string} vaultId
414 * @param {string} connectorId
415 * @param {string} externalGoogleId
416 * @param {string} displayName
417 * @returns {StoredSourceCalendar}
418 */
419 export function upsertGoogleSourceCalendar(dataDir, vaultId, connectorId, externalGoogleId, displayName) {
420 const store = loadCalendarStore(dataDir);
421 const vault = getVaultCalendarStore(dataDir, vaultId, store);
422 const sourceCalendarId = `cal_g_${externalGoogleId.replace(/[^A-Za-z0-9._:-]/g, '_').slice(0, 96)}`;
423 let existing = vault.source_calendars.find((c) => c.source_calendar_id === sourceCalendarId);
424 if (!existing) {
425 existing = {
426 source_calendar_id: sourceCalendarId,
427 connector_id: connectorId,
428 display_name: displayName.slice(0, 120) || 'Google calendar',
429 color: null,
430 user_group: null,
431 provider: 'google',
432 ...buildSourceCalendarDefaults(),
433 };
434 vault.source_calendars.push(existing);
435 } else {
436 existing.display_name = displayName.slice(0, 120) || existing.display_name;
437 }
438 saveCalendarStore(dataDir, store);
439 return existing;
440 }
441
442 /**
443 * Upsert normalized Google events into the vault store.
444 *
445 * @param {string} dataDir
446 * @param {string} vaultId
447 * @param {string} sourceCalendarId
448 * @param {import('./google-event-normalizer.mjs').NormalizedCalendarEvent[]} normalized
449 * @returns {{ imported: number, updated: number, tombstoned: number }}
450 */
451 export function upsertNormalizedEvents(dataDir, vaultId, sourceCalendarId, normalized) {
452 const store = loadCalendarStore(dataDir);
453 const vault = getVaultCalendarStore(dataDir, vaultId, store);
454 let imported = 0;
455 let updated = 0;
456 let tombstoned = 0;
457 const byId = new Map(vault.events.map((e) => [e.event_id, e]));
458 const now = new Date().toISOString();
459
460 for (const row of normalized) {
461 const eventId = buildEventId(sourceCalendarId, row.external_uid);
462 const existing = byId.get(eventId);
463 if (row.status === 'cancelled') {
464 if (existing) {
465 existing.deleted_at = now;
466 existing.status = 'cancelled';
467 tombstoned += 1;
468 }
469 continue;
470 }
471 const stored = {
472 event_id: eventId,
473 source_calendar_id: sourceCalendarId,
474 external_uid: row.external_uid,
475 start: row.start,
476 end: row.end,
477 timezone: row.timezone,
478 summary: row.summary,
479 busy: row.busy,
480 status: row.status,
481 recurrence_rule: row.recurrence_rule,
482 linked_note_paths: existing?.linked_note_paths ?? null,
483 deleted_at: null,
484 };
485 if (existing) {
486 Object.assign(existing, stored);
487 updated += 1;
488 } else {
489 vault.events.push(stored);
490 byId.set(eventId, stored);
491 imported += 1;
492 }
493 }
494
495 saveCalendarStore(dataDir, store);
496 return { imported, updated, tombstoned };
497 }
498
499 /**
500 * Delete all events and source calendars belonging to one connector.
501 *
502 * @param {string} dataDir
503 * @param {string} vaultId
504 * @param {string} connectorId
505 * @returns {number} events deleted
506 */
507 export function purgeConnectorData(dataDir, vaultId, connectorId) {
508 const store = loadCalendarStore(dataDir);
509 const vault = getVaultCalendarStore(dataDir, vaultId, store);
510 const calendarIds = new Set(
511 vault.source_calendars
512 .filter((c) => c.connector_id === connectorId)
513 .map((c) => c.source_calendar_id),
514 );
515 const before = vault.events.length;
516 vault.events = vault.events.filter((e) => !calendarIds.has(e.source_calendar_id));
517 vault.source_calendars = vault.source_calendars.filter((c) => c.connector_id !== connectorId);
518 saveCalendarStore(dataDir, store);
519 return before - vault.events.length;
520 }
File History 2 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 31 days ago