event-store.mjs
313 lines 9.2 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day 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 const MAX_ICS_IMPORT_BYTES = 5 * 1024 * 1024;
19
20 /** @typedef {import('./source-calendar-defaults.mjs').AgentContextTier} AgentContextTier */
21
22 /**
23 * @typedef {Object} StoredSourceCalendar
24 * @property {string} source_calendar_id
25 * @property {string} connector_id
26 * @property {string} display_name
27 * @property {string|null} [color]
28 * @property {'personal'|'work'|'school'|'other'|null} [user_group]
29 * @property {boolean} enabled_for_sync
30 * @property {boolean} enabled_for_display
31 * @property {boolean} enabled_for_agents
32 * @property {AgentContextTier} agent_context_tier_max
33 * @property {string} [provider]
34 */
35
36 /**
37 * @typedef {Object} StoredCalendarEvent
38 * @property {string} event_id
39 * @property {string} source_calendar_id
40 * @property {string} external_uid
41 * @property {string} start
42 * @property {string} end
43 * @property {string} timezone
44 * @property {string|null} summary
45 * @property {boolean} busy
46 * @property {'confirmed'|'cancelled'|'tentative'} status
47 * @property {string|null} recurrence_rule
48 * @property {string[]|null} linked_note_paths
49 * @property {string|null} deleted_at
50 */
51
52 /**
53 * @typedef {Object} VaultCalendarStore
54 * @property {StoredSourceCalendar[]} source_calendars
55 * @property {StoredCalendarEvent[]} events
56 */
57
58 /**
59 * @typedef {Object} CalendarStoreFile
60 * @property {Record<string, VaultCalendarStore>} vaults
61 */
62
63 /**
64 * @param {string} dataDir
65 * @returns {string}
66 */
67 export function getCalendarStorePath(dataDir) {
68 return path.join(dataDir, STORE_FILENAME);
69 }
70
71 /**
72 * @param {string} dataDir
73 * @returns {CalendarStoreFile}
74 */
75 export function loadCalendarStore(dataDir) {
76 const filePath = getCalendarStorePath(dataDir);
77 if (!fs.existsSync(filePath)) {
78 return { vaults: {} };
79 }
80 try {
81 const raw = fs.readFileSync(filePath, 'utf8');
82 const parsed = JSON.parse(raw);
83 if (!parsed || typeof parsed !== 'object' || !parsed.vaults || typeof parsed.vaults !== 'object') {
84 return { vaults: {} };
85 }
86 return /** @type {CalendarStoreFile} */ (parsed);
87 } catch {
88 return { vaults: {} };
89 }
90 }
91
92 /**
93 * @param {string} dataDir
94 * @param {CalendarStoreFile} store
95 */
96 export function saveCalendarStore(dataDir, store) {
97 const filePath = getCalendarStorePath(dataDir);
98 const dir = path.dirname(filePath);
99 if (!fs.existsSync(dir)) {
100 fs.mkdirSync(dir, { recursive: true });
101 }
102 const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
103 fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8');
104 fs.renameSync(tmp, filePath);
105 }
106
107 /**
108 * @param {string} dataDir
109 * @param {string} vaultId
110 * @returns {VaultCalendarStore}
111 */
112 export function getVaultCalendarStore(dataDir, vaultId) {
113 const store = loadCalendarStore(dataDir);
114 if (!store.vaults[vaultId]) {
115 store.vaults[vaultId] = { source_calendars: [], events: [] };
116 }
117 return store.vaults[vaultId];
118 }
119
120 /**
121 * @param {string} sourceCalendarId
122 * @param {string} externalUid
123 * @returns {string}
124 */
125 export function buildEventId(sourceCalendarId, externalUid) {
126 const digest = crypto.createHash('sha256')
127 .update(`${sourceCalendarId}:${externalUid}`, 'utf8')
128 .digest('hex')
129 .slice(0, 24);
130 return `evt_${digest}`;
131 }
132
133 /**
134 * @param {string} dataDir
135 * @param {string} vaultId
136 * @returns {StoredSourceCalendar[]}
137 */
138 export function listSourceCalendars(dataDir, vaultId) {
139 return getVaultCalendarStore(dataDir, vaultId).source_calendars.slice();
140 }
141
142 /**
143 * @param {string} dataDir
144 * @param {string} vaultId
145 * @param {string} sourceCalendarId
146 * @returns {StoredSourceCalendar|undefined}
147 */
148 export function getSourceCalendar(dataDir, vaultId, sourceCalendarId) {
149 return getVaultCalendarStore(dataDir, vaultId)
150 .source_calendars
151 .find((c) => c.source_calendar_id === sourceCalendarId);
152 }
153
154 /**
155 * @param {StoredSourceCalendar} calendar
156 * @returns {object}
157 */
158 export function sourceCalendarForClient(calendar) {
159 return {
160 source_calendar_id: calendar.source_calendar_id,
161 connector_id: calendar.connector_id,
162 display_name: calendar.display_name,
163 color: calendar.color ?? null,
164 user_group: calendar.user_group ?? null,
165 enabled_for_sync: calendar.enabled_for_sync,
166 enabled_for_display: calendar.enabled_for_display,
167 enabled_for_agents: calendar.enabled_for_agents,
168 agent_context_tier_max: calendar.agent_context_tier_max,
169 provider: calendar.provider ?? 'ics_file',
170 };
171 }
172
173 /**
174 * @param {string} dataDir
175 * @param {string} vaultId
176 * @param {{
177 * icsText: string,
178 * displayName?: string,
179 * sourceCalendarId?: string,
180 * connectorId?: string,
181 * defaultTimezone?: string,
182 * }} input
183 * @returns {{ source_calendar_id: string, connector_id: string, imported: number, updated: number }}
184 */
185 export function importIcsIntoVault(dataDir, vaultId, input) {
186 const icsText = input.icsText;
187 if (typeof icsText !== 'string' || !icsText.trim()) {
188 throw new TypeError('icsText is required');
189 }
190 if (icsText.length > MAX_ICS_IMPORT_BYTES) {
191 throw new RangeError(`ICS import exceeds ${MAX_ICS_IMPORT_BYTES} bytes`);
192 }
193
194 const store = loadCalendarStore(dataDir);
195 if (!store.vaults[vaultId]) {
196 store.vaults[vaultId] = { source_calendars: [], events: [] };
197 }
198 const vaultStore = store.vaults[vaultId];
199
200 let sourceCalendarId = typeof input.sourceCalendarId === 'string' ? input.sourceCalendarId.trim() : '';
201 let connectorId = typeof input.connectorId === 'string' ? input.connectorId.trim() : '';
202 let sourceCalendar = sourceCalendarId
203 ? vaultStore.source_calendars.find((c) => c.source_calendar_id === sourceCalendarId)
204 : undefined;
205
206 if (sourceCalendarId && !sourceCalendar) {
207 throw new Error(`Source calendar not found: ${sourceCalendarId}`);
208 }
209
210 if (!sourceCalendar) {
211 sourceCalendarId = `cal_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
212 connectorId = connectorId || `conn_ics_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
213 sourceCalendar = {
214 source_calendar_id: sourceCalendarId,
215 connector_id: connectorId,
216 display_name: (input.displayName ?? 'Imported calendar').trim().slice(0, 120) || 'Imported calendar',
217 color: null,
218 user_group: null,
219 provider: 'ics_file',
220 ...buildSourceCalendarDefaults(),
221 };
222 vaultStore.source_calendars.push(sourceCalendar);
223 } else {
224 connectorId = sourceCalendar.connector_id;
225 }
226
227 const normalized = parseIcsToEvents(icsText, {
228 defaultTimezone: input.defaultTimezone ?? 'UTC',
229 });
230
231 let imported = 0;
232 let updated = 0;
233 const byId = new Map(vaultStore.events.map((e) => [e.event_id, e]));
234
235 for (const row of normalized) {
236 const eventId = buildEventId(sourceCalendarId, row.external_uid);
237 const existing = byId.get(eventId);
238 const stored = {
239 event_id: eventId,
240 source_calendar_id: sourceCalendarId,
241 external_uid: row.external_uid,
242 start: row.start,
243 end: row.end,
244 timezone: row.timezone,
245 summary: row.summary,
246 busy: row.busy,
247 status: row.status,
248 recurrence_rule: row.recurrence_rule,
249 linked_note_paths: existing?.linked_note_paths ?? null,
250 deleted_at: existing?.deleted_at ?? null,
251 };
252
253 if (existing) {
254 Object.assign(existing, stored);
255 updated += 1;
256 } else {
257 vaultStore.events.push(stored);
258 byId.set(eventId, stored);
259 imported += 1;
260 }
261 }
262
263 saveCalendarStore(dataDir, store);
264 return {
265 source_calendar_id: sourceCalendarId,
266 connector_id: connectorId,
267 imported,
268 updated,
269 };
270 }
271
272 /**
273 * Query stored events overlapping a UTC range.
274 *
275 * @param {string} dataDir
276 * @param {string} vaultId
277 * @param {{
278 * fromIso: string,
279 * toIso: string,
280 * sourceCalendarIds?: string[],
281 * displayOnly?: boolean,
282 * }} query
283 * @returns {StoredCalendarEvent[]}
284 */
285 export function queryStoredEvents(dataDir, vaultId, query) {
286 const vaultStore = getVaultCalendarStore(dataDir, vaultId);
287 const fromMs = Date.parse(query.fromIso);
288 const toMs = Date.parse(query.toIso);
289 if (Number.isNaN(fromMs) || Number.isNaN(toMs) || toMs <= fromMs) {
290 throw new RangeError('Invalid timeline range');
291 }
292
293 const allowedCalendars = new Set(
294 vaultStore.source_calendars
295 .filter((c) => {
296 if (query.displayOnly && !c.enabled_for_display) return false;
297 return true;
298 })
299 .map((c) => c.source_calendar_id),
300 );
301
302 const filterIds = query.sourceCalendarIds?.length
303 ? new Set(query.sourceCalendarIds.filter((id) => allowedCalendars.has(id)))
304 : allowedCalendars;
305
306 return vaultStore.events.filter((event) => {
307 if (!filterIds.has(event.source_calendar_id)) return false;
308 if (event.deleted_at) return false;
309 const startMs = Date.parse(event.start);
310 const endMs = Date.parse(event.end);
311 return startMs < toMs && endMs > fromMs;
312 });
313 }
File History 1 commit
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago