bridge-index-last-indexed.mjs
134 lines 5.4 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * "Last indexed at" sidecar for `hub/bridge/server.mjs POST /api/v1/index`.
3 *
4 * Why a sidecar rather than reading from the vector store: the sqlite-vec
5 * backend is downloaded into `/tmp` per Netlify cold start (see
6 * `getVectorsDirForUser` in `hub/bridge/server.mjs`); fetching the whole DB
7 * just to read one timestamp would be 50–500× more expensive than reading a
8 * 1–2 KB JSON blob. The sidecar lets the Hub UI show a passive "Last indexed:
9 * N minutes ago" line on every page load with one cheap blob read.
10 *
11 * The same record is updated by both the synchronous and background index paths
12 * (`hub/bridge/server.mjs` + `netlify/functions/bridge-index-background.mjs`),
13 * so the UI gets a consistent signal regardless of which route ran.
14 *
15 * Storage shape (Netlify Blob): one record per `(canisterUid, vaultId)` pair at
16 * key `index-meta/${canisterUid}/${vaultId}.json`. Append-only fields so older
17 * bridge deploys can still parse the record during a rolling deploy.
18 */
19
20 /**
21 * Build the canonical Blob key for a vault's last-indexed sidecar. Centralized
22 * so callers can't accidentally collide with the job-lock key (which lives at
23 * `index-jobs/...`) or with each other.
24 *
25 * @param {string} canisterUid - Sanitized canister user id.
26 * @param {string} vaultId - Sanitized vault id.
27 * @returns {string}
28 */
29 export function lastIndexedKey(canisterUid, vaultId) {
30 if (typeof canisterUid !== 'string' || canisterUid === '') {
31 throw new TypeError('lastIndexedKey: canisterUid must be a non-empty string');
32 }
33 if (typeof vaultId !== 'string' || vaultId === '') {
34 throw new TypeError('lastIndexedKey: vaultId must be a non-empty string');
35 }
36 return `index-meta/${canisterUid}/${vaultId}.json`;
37 }
38
39 /**
40 * Persist a "last indexed at" record. Called from BOTH the synchronous index
41 * handler (after a successful inline embed+upsert+persist) AND the background
42 * function (after the same work runs out-of-band). The Hub UI reads this via
43 * `GET /api/v1/index/status` to render "Last indexed: 2 minutes ago".
44 *
45 * Always overwrites the prior record — the most recent successful index is
46 * always the source of truth, and a partial/failed run never reaches this code
47 * path (errors are logged separately and DO NOT update the timestamp, which is
48 * how the UI distinguishes "indexed 5 min ago" from "indexed never / failed").
49 *
50 * @param {{ get: Function, set: Function, delete: Function }} blobStore
51 * @param {{
52 * canisterUid: string,
53 * vaultId: string,
54 * actorUid?: string|null,
55 * notesProcessed?: number,
56 * chunksIndexed?: number,
57 * chunksEmbedded?: number,
58 * chunksSkippedCached?: number,
59 * vectorsDeleted?: number,
60 * embeddingInputTokens?: number,
61 * durationMs?: number,
62 * mode?: 'sync' | 'background',
63 * provider?: string|null,
64 * model?: string|null,
65 * now?: () => number,
66 * }} opts
67 * @returns {Promise<{ written: true, record: object }>}
68 */
69 export async function setLastIndexedAt(blobStore, opts) {
70 if (!blobStore || typeof blobStore.set !== 'function') {
71 throw new TypeError('setLastIndexedAt: blobStore with set is required');
72 }
73 if (opts == null || typeof opts !== 'object') {
74 throw new TypeError('setLastIndexedAt: opts is required');
75 }
76 const { canisterUid, vaultId } = opts;
77 const now = typeof opts.now === 'function' ? opts.now : Date.now;
78 const t = now();
79 const record = {
80 lastIndexedAt: new Date(t).toISOString(),
81 lastIndexedAtEpochMs: t,
82 actorUid: typeof opts.actorUid === 'string' ? opts.actorUid : null,
83 notesProcessed: numberOr(opts.notesProcessed, 0),
84 chunksIndexed: numberOr(opts.chunksIndexed, 0),
85 chunksEmbedded: numberOr(opts.chunksEmbedded, 0),
86 chunksSkippedCached: numberOr(opts.chunksSkippedCached, 0),
87 vectorsDeleted: numberOr(opts.vectorsDeleted, 0),
88 embeddingInputTokens: numberOr(opts.embeddingInputTokens, 0),
89 durationMs: numberOr(opts.durationMs, 0),
90 mode: opts.mode === 'background' ? 'background' : 'sync',
91 provider: typeof opts.provider === 'string' ? opts.provider : null,
92 model: typeof opts.model === 'string' ? opts.model : null,
93 };
94 await blobStore.set(lastIndexedKey(canisterUid, vaultId), JSON.stringify(record));
95 return { written: true, record };
96 }
97
98 /**
99 * Read the last-indexed sidecar. Returns `null` when the vault has never been
100 * successfully indexed (no blob yet) or the blob is malformed (e.g. partial
101 * write from an old deploy). The Hub UI treats `null` as "Last indexed: never".
102 *
103 * @param {{ get: Function }} blobStore
104 * @param {{ canisterUid: string, vaultId: string }} opts
105 * @returns {Promise<object|null>}
106 */
107 export async function getLastIndexedAt(blobStore, opts) {
108 if (!blobStore || typeof blobStore.get !== 'function') {
109 throw new TypeError('getLastIndexedAt: blobStore with get is required');
110 }
111 if (opts == null || typeof opts !== 'object') {
112 throw new TypeError('getLastIndexedAt: opts is required');
113 }
114 const { canisterUid, vaultId } = opts;
115 const key = lastIndexedKey(canisterUid, vaultId);
116 let raw;
117 try {
118 raw = await blobStore.get(key, { type: 'text' });
119 } catch (_) {
120 return null;
121 }
122 if (!raw || typeof raw !== 'string') return null;
123 try {
124 const parsed = JSON.parse(raw);
125 if (parsed && typeof parsed === 'object') return parsed;
126 } catch (_) {}
127 return null;
128 }
129
130 function numberOr(value, fallback) {
131 if (value == null) return fallback;
132 const n = typeof value === 'number' ? value : Number(value);
133 return Number.isFinite(n) ? n : fallback;
134 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago