@inixiative/archive 0.2.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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +57 -0
- package/src/cli.ts +238 -0
- package/src/client.ts +148 -0
- package/src/collector.ts +85 -0
- package/src/config.ts +80 -0
- package/src/import-file.ts +45 -0
- package/src/import.ts +173 -0
- package/src/index.ts +172 -0
- package/src/local.ts +150 -0
- package/src/preview.ts +57 -0
- package/src/server.ts +155 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { archiveKey, archiveSnapshotSchema, selectChunks, snapshotDigest } from './index';
|
|
4
|
+
import { LocalArchiveStore } from './local';
|
|
5
|
+
|
|
6
|
+
const ingestSchema = z.strictObject({
|
|
7
|
+
snapshot: archiveSnapshotSchema,
|
|
8
|
+
previousDigest: z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(/^[a-f0-9]{64}$/)
|
|
11
|
+
.nullable(),
|
|
12
|
+
});
|
|
13
|
+
const searchSchema = z.strictObject({
|
|
14
|
+
query: z.string().max(1000).default(''),
|
|
15
|
+
budget: z.number().int().min(16).max(32768).default(2048),
|
|
16
|
+
projectId: z.string().max(256).optional(),
|
|
17
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
18
|
+
beforeId: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
const readSchema = z.strictObject({
|
|
21
|
+
archiveId: z.string().regex(/^[a-f0-9]{64}$/),
|
|
22
|
+
revision: z.number().int().positive().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** One deployment, one ownership boundary. No shared cross-tenant database or admin API. */
|
|
26
|
+
export function createArchiveHandler(store: LocalArchiveStore, token: string) {
|
|
27
|
+
if (token.length < 32)
|
|
28
|
+
throw new Error('ARCHIVE_SERVER_TOKEN must contain at least 32 characters');
|
|
29
|
+
const expected = Buffer.from(`Bearer ${token}`);
|
|
30
|
+
const json = (body: unknown, status = 200) =>
|
|
31
|
+
Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } });
|
|
32
|
+
return async (request: Request) => {
|
|
33
|
+
const path = new URL(request.url).pathname;
|
|
34
|
+
if (path === '/health' && request.method === 'GET')
|
|
35
|
+
return json({ status: 'ok', service: 'archive', protocol: 1 });
|
|
36
|
+
const supplied = Buffer.from(request.headers.get('authorization') ?? '');
|
|
37
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
|
|
38
|
+
return json({ error: 'Unauthorized' }, 401);
|
|
39
|
+
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405);
|
|
40
|
+
try {
|
|
41
|
+
// Enforce a limit for both fixed-length and streamed/chunked requests.
|
|
42
|
+
const reader = request.body?.getReader();
|
|
43
|
+
if (!reader) return json({ error: 'Body required' }, 400);
|
|
44
|
+
const chunks: Uint8Array[] = [];
|
|
45
|
+
let size = 0;
|
|
46
|
+
while (true) {
|
|
47
|
+
const item = await reader.read();
|
|
48
|
+
if (item.done) break;
|
|
49
|
+
size += item.value.length;
|
|
50
|
+
if (size > 65_000_000) {
|
|
51
|
+
await reader.cancel();
|
|
52
|
+
return json({ error: 'Body too large' }, 413);
|
|
53
|
+
}
|
|
54
|
+
chunks.push(item.value);
|
|
55
|
+
}
|
|
56
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
57
|
+
if (path === '/api/v1/archive/ingest') {
|
|
58
|
+
const { snapshot, previousDigest } = ingestSchema.parse(body);
|
|
59
|
+
const id = archiveKey(snapshot),
|
|
60
|
+
old = store.read(id),
|
|
61
|
+
next = snapshotDigest(snapshot);
|
|
62
|
+
if (old?.digest !== next && (old?.digest ?? null) !== previousDigest)
|
|
63
|
+
return json({ error: 'Revision conflict' }, 409);
|
|
64
|
+
if (old && old.digest !== next && old.snapshot.capturedAt > snapshot.capturedAt)
|
|
65
|
+
return json({ error: 'Stale capture' }, 409);
|
|
66
|
+
return json({ data: store.capture(snapshot) });
|
|
67
|
+
}
|
|
68
|
+
if (path === '/api/v1/archive/read') {
|
|
69
|
+
const input = readSchema.parse(body),
|
|
70
|
+
archive = store.read(input.archiveId, input.revision);
|
|
71
|
+
return archive ? json({ data: archive }) : json({ error: 'Archive unavailable' }, 404);
|
|
72
|
+
}
|
|
73
|
+
if (path === '/api/v1/archive/search') {
|
|
74
|
+
const input = searchSchema.parse(body);
|
|
75
|
+
let remaining = input.budget;
|
|
76
|
+
const matching = store
|
|
77
|
+
.list()
|
|
78
|
+
.filter((a) => !input.projectId || a.projectId === input.projectId)
|
|
79
|
+
.map((a) => ({
|
|
80
|
+
a,
|
|
81
|
+
selected: selectChunks(store.read(a.id)!.chunks, input.query, input.budget),
|
|
82
|
+
}))
|
|
83
|
+
.filter(
|
|
84
|
+
({ a, selected }) =>
|
|
85
|
+
!input.query ||
|
|
86
|
+
selected.matchingChunks > 0 ||
|
|
87
|
+
[a.title, ...a.tags].some((text) =>
|
|
88
|
+
text.toLowerCase().includes(input.query.toLowerCase()),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const cursor = input.beforeId ? matching.findIndex(({ a }) => a.id === input.beforeId) : -1;
|
|
92
|
+
if (input.beforeId && cursor < 0) return json({ error: 'Invalid cursor' }, 400);
|
|
93
|
+
const page = matching.slice(cursor + 1, cursor + 1 + input.limit);
|
|
94
|
+
const archives = page.map(({ a }) => {
|
|
95
|
+
const selected =
|
|
96
|
+
remaining >= 16
|
|
97
|
+
? selectChunks(store.read(a.id)!.chunks, input.query, remaining)
|
|
98
|
+
: { chunks: [], tokenCount: 0 };
|
|
99
|
+
remaining -= selected.tokenCount;
|
|
100
|
+
return {
|
|
101
|
+
archiveId: a.id,
|
|
102
|
+
revision: a.revision,
|
|
103
|
+
digest: a.digest,
|
|
104
|
+
title: a.title,
|
|
105
|
+
projectId: a.projectId,
|
|
106
|
+
tags: a.tags,
|
|
107
|
+
suggestedTags: a.suggestedTags,
|
|
108
|
+
coverage: a.coverage,
|
|
109
|
+
...selected,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
return json({
|
|
113
|
+
data: {
|
|
114
|
+
archives,
|
|
115
|
+
tokenCount: input.budget - remaining,
|
|
116
|
+
nextCursor: matching.length > cursor + 1 + page.length ? page.at(-1)?.a.id : null,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return json({ error: 'Not found' }, 404);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof z.ZodError || error instanceof SyntaxError)
|
|
123
|
+
return json({ error: 'Invalid archive request' }, 400);
|
|
124
|
+
return json({ error: 'Archive operation failed' }, 500);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function startArchiveServer(options: {
|
|
130
|
+
store: string;
|
|
131
|
+
token: string;
|
|
132
|
+
port?: number;
|
|
133
|
+
hostname?: string;
|
|
134
|
+
}) {
|
|
135
|
+
const store = new LocalArchiveStore(options.store);
|
|
136
|
+
try {
|
|
137
|
+
const server = Bun.serve({
|
|
138
|
+
port: options.port ?? 4411,
|
|
139
|
+
hostname: options.hostname ?? '127.0.0.1',
|
|
140
|
+
maxRequestBodySize: 65_000_000,
|
|
141
|
+
fetch: createArchiveHandler(store, options.token),
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
server,
|
|
145
|
+
store,
|
|
146
|
+
async close() {
|
|
147
|
+
await server.stop(true);
|
|
148
|
+
store.close();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
} catch (error) {
|
|
152
|
+
store.close();
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|