@inixiative/archive 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/local.ts DELETED
@@ -1,355 +0,0 @@
1
- import { Database } from 'bun:sqlite';
2
- import { randomUUID } from 'node:crypto';
3
- import { chmodSync, mkdirSync } from 'node:fs';
4
- import { dirname } from 'node:path';
5
- import { z } from 'zod';
6
- import {
7
- type ArchiveChunk,
8
- type ArchiveSnapshot,
9
- archiveKey,
10
- archiveSnapshotSchema,
11
- chunkArchive,
12
- selectChunks,
13
- snapshotDigest,
14
- } from './index';
15
- import {
16
- archiveIntegrationSchema,
17
- archiveReferences,
18
- defaultIntegrations,
19
- suggestTags,
20
- } from './tags';
21
-
22
- export const archiveSettingsSchema = z.strictObject({
23
- /** Integrations sessions can reference; Kingdom and Foundry display these, Archive owns them. */
24
- integrations: z
25
- .array(archiveIntegrationSchema)
26
- .max(50)
27
- .refine(
28
- (list) => new Set(list.map((i) => i.key)).size === list.length,
29
- 'Duplicate integration keys',
30
- ),
31
- /** Archives whose latest capture is older than this are deleted here. Deletions never sync. */
32
- retentionDays: z.number().int().positive().max(36_500).nullable(),
33
- });
34
- export type ArchiveSettings = z.infer<typeof archiveSettingsSchema>;
35
- const defaultSettings: ArchiveSettings = { integrations: defaultIntegrations, retentionDays: null };
36
-
37
- const tagSchema = z.string().min(1).max(120);
38
- /** A conceptual tag this archive offers: archive-wide (no actor) or for one actor. */
39
- export const tagDefinitionSchema = z.strictObject({
40
- tag: tagSchema,
41
- actorId: z.string().min(1).max(256).optional(),
42
- description: z.string().max(500).optional(),
43
- });
44
- export type TagDefinition = z.infer<typeof tagDefinitionSchema>;
45
-
46
- export interface ArchiveFilter {
47
- projectId?: string;
48
- source?: ArchiveSnapshot['source'];
49
- tag?: string;
50
- actorId?: string;
51
- reference?: { integration: string; ref: string };
52
- model?: string;
53
- effort?: string;
54
- }
55
-
56
- export class LocalArchiveStore {
57
- private readonly db: Database;
58
- readonly sourceId: string;
59
- constructor(path: string) {
60
- if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
61
- this.db = new Database(path, { create: true });
62
- if (path !== ':memory:') chmodSync(path, 0o600);
63
- this.db.exec(`PRAGMA foreign_keys=ON; PRAGMA busy_timeout=1000;
64
- CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
65
- CREATE TABLE IF NOT EXISTS archives (id TEXT PRIMARY KEY, digest TEXT NOT NULL, revision INTEGER NOT NULL);
66
- CREATE TABLE IF NOT EXISTS revisions (archive_id TEXT NOT NULL REFERENCES archives(id), revision INTEGER NOT NULL,
67
- digest TEXT NOT NULL, snapshot TEXT NOT NULL, chunks TEXT NOT NULL, PRIMARY KEY(archive_id, revision));
68
- CREATE TABLE IF NOT EXISTS receipts (archive_id TEXT NOT NULL REFERENCES archives(id), destination TEXT NOT NULL,
69
- digest TEXT NOT NULL, PRIMARY KEY(archive_id, destination));
70
- CREATE TABLE IF NOT EXISTS outbox (archive_id TEXT NOT NULL REFERENCES archives(id), destination TEXT NOT NULL,
71
- pending TEXT NOT NULL, PRIMARY KEY(archive_id, destination));
72
- CREATE TABLE IF NOT EXISTS tag_edits (archive_id TEXT NOT NULL REFERENCES archives(id), tag TEXT NOT NULL,
73
- added INTEGER NOT NULL, PRIMARY KEY(archive_id, tag));
74
- CREATE TABLE IF NOT EXISTS tag_definitions (actor_id TEXT NOT NULL, tag TEXT NOT NULL, description TEXT,
75
- PRIMARY KEY(actor_id, tag));`);
76
- this.db.query("INSERT OR IGNORE INTO settings VALUES ('sourceId', ?)").run(randomUUID());
77
- this.sourceId = (
78
- this.db.query("SELECT value FROM settings WHERE key='sourceId'").get() as { value: string }
79
- ).value;
80
- }
81
- capture(input: unknown) {
82
- const snapshot = archiveSnapshotSchema.parse(input);
83
- const id = archiveKey(snapshot);
84
- const hash = snapshotDigest(snapshot);
85
- return this.db.transaction(() => {
86
- const old = this.read(id);
87
- if (old?.digest === hash) return { id, digest: hash, revision: old.revision, changed: false };
88
- if (old && old.snapshot.capturedAt > snapshot.capturedAt)
89
- throw new Error('Older capture cannot replace newer archive');
90
- const revision = (old?.revision ?? 0) + 1;
91
- const chunks = chunkArchive(snapshot);
92
- this.db
93
- .query(
94
- 'INSERT INTO archives VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET digest=excluded.digest, revision=excluded.revision',
95
- )
96
- .run(id, hash, revision);
97
- this.db
98
- .query('INSERT INTO revisions VALUES (?, ?, ?, ?, ?)')
99
- .run(id, revision, hash, JSON.stringify(snapshot), JSON.stringify(chunks));
100
- return { id, digest: hash, revision, changed: true };
101
- })();
102
- }
103
- read(
104
- id: string,
105
- revision?: number,
106
- ):
107
- | {
108
- id: string;
109
- revision: number;
110
- digest: string;
111
- snapshot: ArchiveSnapshot;
112
- chunks: ArchiveChunk[];
113
- }
114
- | undefined {
115
- const row = this.db
116
- .query(`SELECT r.* FROM revisions r JOIN archives a ON a.id=r.archive_id
117
- WHERE a.id=? AND r.revision=COALESCE(?, a.revision)`)
118
- .get(id, revision ?? null) as any;
119
- return row
120
- ? {
121
- id,
122
- revision: row.revision,
123
- digest: row.digest,
124
- snapshot: archiveSnapshotSchema.parse(JSON.parse(row.snapshot)),
125
- chunks: JSON.parse(row.chunks),
126
- }
127
- : undefined;
128
- }
129
- /** Computed per revision: references and suggestions scan the whole transcript. */
130
- private summaries = new Map<
131
- string,
132
- ReturnType<LocalArchiveStore['summarize']> & { digest: string }
133
- >();
134
- private summarize(id: string, revision: number) {
135
- const row = this.db
136
- .query('SELECT snapshot FROM revisions WHERE archive_id=? AND revision=?')
137
- .get(id, revision) as { snapshot: string };
138
- const full = archiveSnapshotSchema.parse(JSON.parse(row.snapshot));
139
- const { entries, tags, ...snapshot } = full;
140
- const models = new Map<string, { model: string; effort?: string; entries: number }>();
141
- for (const entry of entries) {
142
- if (!entry.model) continue;
143
- const key = JSON.stringify([entry.model, entry.effort]);
144
- const current = models.get(key) ?? {
145
- model: entry.model,
146
- ...(entry.effort ? { effort: entry.effort } : {}),
147
- entries: 0,
148
- };
149
- current.entries++;
150
- models.set(key, current);
151
- }
152
- return {
153
- snapshot,
154
- capturedTags: tags,
155
- entries: entries.length,
156
- models: [...models.values()],
157
- suggestedTags: suggestTags(full),
158
- references: archiveReferences(full, this.settings().integrations),
159
- };
160
- }
161
- /** Archive metadata, newest first. Tags are the captured tags with this archive's edits applied. */
162
- list(filter: ArchiveFilter = {}) {
163
- const rows = this.db
164
- .query('SELECT id, revision, digest FROM archives ORDER BY rowid DESC')
165
- .all() as { id: string; revision: number; digest: string }[];
166
- return rows.flatMap(({ id, revision, digest }) => {
167
- let summary = this.summaries.get(id);
168
- if (summary?.digest !== digest) {
169
- summary = { ...this.summarize(id, revision), digest };
170
- this.summaries.set(id, summary);
171
- }
172
- const { snapshot, references, models } = summary;
173
- const tags = this.tags(id, summary.capturedTags);
174
- const reference = filter.reference;
175
- if (
176
- (filter.projectId && snapshot.projectId !== filter.projectId) ||
177
- (filter.source && snapshot.source !== filter.source) ||
178
- (filter.tag && !tags.includes(filter.tag)) ||
179
- (filter.actorId && snapshot.actor?.id !== filter.actorId) ||
180
- (filter.model && !models.some((m) => m.model === filter.model)) ||
181
- (filter.effort &&
182
- !models.some(
183
- (m) => m.effort === filter.effort && (!filter.model || m.model === filter.model),
184
- )) ||
185
- (reference &&
186
- !references.some(
187
- (r) => r.integration === reference.integration && r.ref === reference.ref,
188
- ))
189
- )
190
- return [];
191
- return [
192
- {
193
- id,
194
- revision,
195
- digest,
196
- ...snapshot,
197
- tags,
198
- entries: summary.entries,
199
- models,
200
- suggestedTags: summary.suggestedTags,
201
- references,
202
- },
203
- ];
204
- });
205
- }
206
- private tags(id: string, captured: string[]) {
207
- const edits = this.db.query('SELECT tag, added FROM tag_edits WHERE archive_id=?').all(id) as {
208
- tag: string;
209
- added: number;
210
- }[];
211
- const removed = new Set(edits.filter((e) => !e.added).map((e) => e.tag));
212
- return [...new Set([...captured, ...edits.filter((e) => e.added).map((e) => e.tag)])].filter(
213
- (tag) => !removed.has(tag),
214
- );
215
- }
216
- /** Tag or untag without a new revision. Edits belong to this archive and do not sync. */
217
- tag(id: string, change: { add?: string[]; remove?: string[] }) {
218
- const add = z
219
- .array(tagSchema)
220
- .max(100)
221
- .parse(change.add ?? []);
222
- const remove = z
223
- .array(tagSchema)
224
- .max(100)
225
- .parse(change.remove ?? []);
226
- const archive = this.read(id);
227
- if (!archive) throw new Error('Archive unavailable');
228
- const captured = new Set(archive.snapshot.tags);
229
- const upsert = this.db.query(
230
- 'INSERT INTO tag_edits VALUES (?, ?, ?) ON CONFLICT(archive_id, tag) DO UPDATE SET added=excluded.added',
231
- );
232
- const clear = this.db.query('DELETE FROM tag_edits WHERE archive_id=? AND tag=?');
233
- return this.db.transaction(() => {
234
- for (const tag of add) captured.has(tag) ? clear.run(id, tag) : upsert.run(id, tag, 1);
235
- for (const tag of remove) captured.has(tag) ? upsert.run(id, tag, 0) : clear.run(id, tag);
236
- const tags = this.tags(id, archive.snapshot.tags);
237
- z.array(tagSchema).max(100, 'An archive holds at most 100 tags').parse(tags);
238
- return { id, tags };
239
- })();
240
- }
241
- delete(id: string) {
242
- return this.db.transaction(() => {
243
- for (const table of ['tag_edits', 'receipts', 'outbox', 'revisions'])
244
- this.db.query(`DELETE FROM ${table} WHERE archive_id=?`).run(id);
245
- this.summaries.delete(id);
246
- return this.db.query('DELETE FROM archives WHERE id=?').run(id).changes > 0;
247
- })();
248
- }
249
- /** Applies the retention setting; returns the deleted archive ids. */
250
- prune(now = Date.now()) {
251
- const { retentionDays } = this.settings();
252
- if (retentionDays === null) return [];
253
- const cutoff = now - retentionDays * 86_400_000;
254
- const expired = (this.db.query('SELECT id FROM archives').all() as { id: string }[]).filter(
255
- ({ id }) => this.read(id)!.snapshot.capturedAt < cutoff,
256
- );
257
- for (const { id } of expired) this.delete(id);
258
- return expired.map(({ id }) => id);
259
- }
260
- settings(): ArchiveSettings {
261
- const row = this.db.query("SELECT value FROM settings WHERE key='archive'").get() as {
262
- value: string;
263
- } | null;
264
- return row ? archiveSettingsSchema.parse(JSON.parse(row.value)) : defaultSettings;
265
- }
266
- updateSettings(change: Partial<ArchiveSettings>) {
267
- const next = archiveSettingsSchema.parse({ ...this.settings(), ...change });
268
- this.db
269
- .query(
270
- "INSERT INTO settings VALUES ('archive', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
271
- )
272
- .run(JSON.stringify(next));
273
- this.summaries.clear();
274
- return next;
275
- }
276
- /** Offered tags with how many archives carry each; an actor sees archive-wide and their own. */
277
- tagDefinitions(actorId?: string) {
278
- const rows = this.db
279
- .query(
280
- "SELECT actor_id, tag, description FROM tag_definitions WHERE actor_id IN ('', ?) ORDER BY tag",
281
- )
282
- .all(actorId ?? '') as { actor_id: string; tag: string; description: string | null }[];
283
- const archives = this.list(actorId ? { actorId } : {});
284
- return rows.map((row) => ({
285
- tag: row.tag,
286
- ...(row.actor_id ? { actorId: row.actor_id } : {}),
287
- ...(row.description ? { description: row.description } : {}),
288
- archives: archives.filter((archive) => archive.tags.includes(row.tag)).length,
289
- }));
290
- }
291
- defineTag(input: TagDefinition) {
292
- const definition = tagDefinitionSchema.parse(input);
293
- this.db
294
- .query(
295
- 'INSERT INTO tag_definitions VALUES (?, ?, ?) ON CONFLICT(actor_id, tag) DO UPDATE SET description=excluded.description',
296
- )
297
- .run(definition.actorId ?? '', definition.tag, definition.description ?? null);
298
- return definition;
299
- }
300
- undefineTag(input: { tag: string; actorId?: string }) {
301
- return (
302
- this.db
303
- .query('DELETE FROM tag_definitions WHERE actor_id=? AND tag=?')
304
- .run(input.actorId ?? '', input.tag).changes > 0
305
- );
306
- }
307
- search(ids: string[], query: string, budget = 2048) {
308
- return ids.map((id) => {
309
- const archive = this.read(id);
310
- if (!archive) throw new Error('Archive unavailable');
311
- return {
312
- id,
313
- revision: archive.revision,
314
- digest: archive.digest,
315
- ...selectChunks(archive.chunks, query, budget),
316
- };
317
- });
318
- }
319
- receipt(id: string, destination: string): string | null {
320
- return (
321
- (
322
- this.db
323
- .query('SELECT digest FROM receipts WHERE archive_id=? AND destination=?')
324
- .get(id, destination) as { digest: string } | null
325
- )?.digest ?? null
326
- );
327
- }
328
- acknowledge(id: string, destination: string, hash: string) {
329
- this.db
330
- .query(
331
- 'INSERT INTO receipts VALUES (?, ?, ?) ON CONFLICT(archive_id,destination) DO UPDATE SET digest=excluded.digest',
332
- )
333
- .run(id, destination, hash);
334
- }
335
- pending(id: string, destination: string): { revision: number } | null {
336
- const row = this.db
337
- .query('SELECT pending FROM outbox WHERE archive_id=? AND destination=?')
338
- .get(id, destination) as { pending: string } | null;
339
- return row ? JSON.parse(row.pending) : null;
340
- }
341
- enqueue(id: string, destination: string, pending: { revision: number }) {
342
- this.db
343
- .query('INSERT OR IGNORE INTO outbox VALUES (?, ?, ?)')
344
- .run(id, destination, JSON.stringify(pending));
345
- }
346
- delivered(id: string, destination: string, hash: string) {
347
- this.db.transaction(() => {
348
- this.acknowledge(id, destination, hash);
349
- this.db.query('DELETE FROM outbox WHERE archive_id=? AND destination=?').run(id, destination);
350
- })();
351
- }
352
- close() {
353
- this.db.close();
354
- }
355
- }