UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

257 lines • 10.7 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { FileChangeKind } from "../../types.js"; import { eventAt, field, textField } from "../../events/payload.js"; import { underPath } from "../../utils/key_prefix.js"; import { NotionAPIError } from "./client.js"; import { dataSourceSegmentName, databaseSegmentName, pageSegmentName } from "./normalize.js"; import { getDataSource, getDatabase, getPage } from "./pages.js"; const GONE = new Set([ 'page.deleted', 'database.deleted', 'data_source.deleted', ]); const MADE = new Set([ 'page.created', 'page.undeleted', 'database.created', 'database.undeleted', 'data_source.created', 'data_source.undeleted', ]); const PLACED = new Set([ 'page.properties_updated', 'page.moved', 'database.moved', 'database.schema_updated', 'data_source.moved', 'data_source.schema_updated', ]); const OWN_FILES = { page: ['page.json'], database: ['database.json'], data_source: ['data_source.json', 'rows.jsonl'], }; /** * The `rows.jsonl` a page's directory is a row of, or null. * * A row is the only page directly below a data source, which is the only * directory two levels below `databases`. * * Mirrors Python `_rows_of` (`core/notion/events.py`). */ function rowsOf(place) { if (place === null) return null; const parts = place.split('/'); if (parts.length !== 4 || parts[0] !== 'databases') return null; return `${parts.slice(0, 3).join('/')}/rows.jsonl`; } /** * The directory holding `place`. * * Mirrors Python `_parent` (`core/notion/events.py`). */ function parent(place) { return place.slice(0, place.lastIndexOf('/')); } /** * Map one Notion webhook event onto mount paths. * * The consumer owns the subscription's endpoint and passes the event's `type` * with its body. Nothing here runs a loop. An event names its entity by id * only, so each place is read from Notion: a page by its parent chain up to * the workspace or a data source, a data source through its database. Each * place is remembered, so a later event on the same page or one below it costs * nothing, and a move, rename or delete forgets what was below the old place. * * A page is a directory holding `page.json`, below `pages` when it is a * workspace page, below another page, or below a data source as a row. A page * inside some other block is not listed, so it maps to nothing, as does one * the integration cannot read up to the top. `page.json` renders the page's * properties and blocks, so a content or property change updates it, and a * row's line in its data source's `rows.jsonl` carries the same properties * and edit time. * * A page made or restored creates its `page.json`, and one deleted deletes its * directory. A page's directory carries its title, so a move or a retitle * moves everything below it, which the event does not list: the old place, * when the hook saw it, and the new one are UNKNOWN. A move of one it never * saw has an old place nothing names, so the whole mount is UNKNOWN, and a * retitle of a page it never saw is UNKNOWN on the page's parent. Databases and data sources * follow the same rules with `database.json` and `data_source.json`; a data * source's rows changing updates `rows.jsonl`, and a database lists its data * sources, so a data source made, moved, retitled or deleted also updates * `database.json`. Comments are rendered nowhere. * * Mirrors Python `NotionEventHook` (`core/notion/events.py`). */ export class NotionEventHook { accessor; places = new Map(); constructor(accessor) { this.accessor = accessor; } async read(get, objectId) { try { return (await get(this.accessor.transport, objectId)); } catch (err) { if (!(err instanceof NotionAPIError) || err.status !== 404) throw err; console.debug(`notion events: ${objectId} is not readable: ${err.message}`); return null; } } async place(kind, objectId, fresh = false) { if (!fresh && this.places.has(objectId)) return this.places.get(objectId) ?? null; let place = null; if (kind === 'database') { const database = await this.read(getDatabase, objectId); if (database !== null && typeof database === 'object' && !Array.isArray(database)) { place = `databases/${databaseSegmentName(database)}`; } } else if (kind === 'data_source') { const source = await this.read(getDataSource, objectId); const owner = textField(field(source, 'parent'), 'database_id'); const above = owner !== null && owner !== '' ? await this.place('database', owner) : null; if (source !== null && typeof source === 'object' && !Array.isArray(source) && above !== null) { place = `${above}/${dataSourceSegmentName(source)}`; } } else { const page = await this.read(getPage, objectId); const above = await this.pageParent(page); if (page !== null && typeof page === 'object' && !Array.isArray(page) && above !== null) { place = `${above}/${pageSegmentName(page)}`; } } this.places.set(objectId, place); return place; } async pageParent(page) { const from = field(page, 'parent'); const kind = textField(from, 'type'); if (kind === 'workspace') return 'pages'; if (kind === 'page_id') { const above = textField(from, 'page_id'); return above !== null && above !== '' ? this.place('page', above) : null; } if (kind === 'data_source_id') { const above = textField(from, 'data_source_id'); return above !== null && above !== '' ? this.place('data_source', above) : null; } return null; } forget(place) { for (const [objectId, known] of [...this.places]) { if (known !== null && underPath(known, place)) { this.places.delete(objectId); } } } companions(root, kind, places) { const found = []; for (const place of places) { if (place === null) continue; const beside = kind === 'page' ? rowsOf(place) : kind === 'data_source' ? `${parent(place)}/database.json` : null; if (beside !== null && !found.includes(beside)) found.push(beside); } return found.map((path) => eventAt(root, path, FileChangeKind.UPDATE)); } async toEvents(root, eventType, payload) { const dot = eventType.indexOf('.'); const kind = dot < 0 ? eventType : eventType.slice(0, dot); const change = dot < 0 ? '' : eventType.slice(dot + 1); const objectId = textField(field(payload, 'entity'), 'id'); if (objectId === null || !(kind === 'page' || kind === 'database' || kind === 'data_source')) { return []; } const own = OWN_FILES[kind] ?? []; if (GONE.has(eventType)) { const place = await this.place(kind, objectId); if (place === null) return []; this.forget(place); return [eventAt(root, place, FileChangeKind.DELETE), ...this.companions(root, kind, [place])]; } if (MADE.has(eventType)) { const place = await this.place(kind, objectId, true); if (place === null) return []; return [ ...own.map((name) => eventAt(root, `${place}/${name}`, FileChangeKind.CREATE)), ...this.companions(root, kind, [place]), ]; } if (PLACED.has(eventType)) { const was = this.places.get(objectId) ?? null; const now = await this.place(kind, objectId, true); const events = []; if (was === null && change === 'moved' && kind !== 'database') { events.push(eventAt(root, '', FileChangeKind.UNKNOWN)); } if (was !== null && was !== now) { this.forget(was); events.push(eventAt(root, was, FileChangeKind.UNKNOWN)); if (now !== null) this.places.set(objectId, now); } if (now === null) return [...events, ...this.companions(root, kind, [was])]; const properties = field(field(payload, 'data'), 'updated_properties'); const retitled = Array.isArray(properties) && properties.includes('title'); if ((was !== null && was !== now) || change === 'schema_updated' || (change === 'moved' && kind !== 'database')) { events.push(eventAt(root, now, FileChangeKind.UNKNOWN)); } else if (was === null && retitled) { events.push(eventAt(root, parent(now), FileChangeKind.UNKNOWN)); } else { events.push(eventAt(root, `${now}/${own[0] ?? ''}`, FileChangeKind.UPDATE)); } return [...events, ...this.companions(root, kind, [was, now])]; } if (change !== 'content_updated') return []; const place = await this.place(kind, objectId); if (place === null) return []; if (kind === 'page') { return [ eventAt(root, `${place}/page.json`, FileChangeKind.UPDATE), ...this.companions(root, kind, [place]), ]; } if (kind === 'data_source') return [eventAt(root, `${place}/rows.jsonl`, FileChangeKind.UPDATE)]; return [eventAt(root, place, FileChangeKind.UNKNOWN)]; } } //# sourceMappingURL=events.js.map