@bcts/hubert 1.0.0-alpha.17
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 +48 -0
- package/README.md +18 -0
- package/dist/arid-derivation-1CJuU-kZ.cjs +150 -0
- package/dist/arid-derivation-1CJuU-kZ.cjs.map +1 -0
- package/dist/arid-derivation-CbqACjdg.mjs +126 -0
- package/dist/arid-derivation-CbqACjdg.mjs.map +1 -0
- package/dist/bin/hubert.cjs +384 -0
- package/dist/bin/hubert.cjs.map +1 -0
- package/dist/bin/hubert.d.cts +1 -0
- package/dist/bin/hubert.d.mts +1 -0
- package/dist/bin/hubert.mjs +383 -0
- package/dist/bin/hubert.mjs.map +1 -0
- package/dist/chunk-CbDLau6x.cjs +34 -0
- package/dist/hybrid/index.cjs +14 -0
- package/dist/hybrid/index.d.cts +3 -0
- package/dist/hybrid/index.d.mts +3 -0
- package/dist/hybrid/index.mjs +6 -0
- package/dist/hybrid-BZhumygj.mjs +356 -0
- package/dist/hybrid-BZhumygj.mjs.map +1 -0
- package/dist/hybrid-dX5JLumO.cjs +410 -0
- package/dist/hybrid-dX5JLumO.cjs.map +1 -0
- package/dist/index-BEzpUC7r.d.mts +380 -0
- package/dist/index-BEzpUC7r.d.mts.map +1 -0
- package/dist/index-C2F6ugLL.d.mts +210 -0
- package/dist/index-C2F6ugLL.d.mts.map +1 -0
- package/dist/index-CUnDouMb.d.mts +215 -0
- package/dist/index-CUnDouMb.d.mts.map +1 -0
- package/dist/index-CV6lZJqY.d.cts +380 -0
- package/dist/index-CV6lZJqY.d.cts.map +1 -0
- package/dist/index-CY3TCzIm.d.cts +217 -0
- package/dist/index-CY3TCzIm.d.cts.map +1 -0
- package/dist/index-DEr4SR1J.d.cts +215 -0
- package/dist/index-DEr4SR1J.d.cts.map +1 -0
- package/dist/index-T1LHanIb.d.mts +217 -0
- package/dist/index-T1LHanIb.d.mts.map +1 -0
- package/dist/index-jyzuOhFB.d.cts +210 -0
- package/dist/index-jyzuOhFB.d.cts.map +1 -0
- package/dist/index.cjs +60 -0
- package/dist/index.d.cts +161 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +161 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +10 -0
- package/dist/ipfs/index.cjs +13 -0
- package/dist/ipfs/index.d.cts +3 -0
- package/dist/ipfs/index.d.mts +3 -0
- package/dist/ipfs/index.mjs +5 -0
- package/dist/ipfs-BRMMCBjv.mjs +1 -0
- package/dist/ipfs-CetOVQcO.cjs +0 -0
- package/dist/kv-BAmhmMOo.cjs +425 -0
- package/dist/kv-BAmhmMOo.cjs.map +1 -0
- package/dist/kv-C-emxv0w.mjs +375 -0
- package/dist/kv-C-emxv0w.mjs.map +1 -0
- package/dist/kv-DJiKvypY.mjs +403 -0
- package/dist/kv-DJiKvypY.mjs.map +1 -0
- package/dist/kv-store-DmngWWuw.d.mts +183 -0
- package/dist/kv-store-DmngWWuw.d.mts.map +1 -0
- package/dist/kv-store-ww-AUyLd.d.cts +183 -0
- package/dist/kv-store-ww-AUyLd.d.cts.map +1 -0
- package/dist/kv-yjvQa_LH.cjs +457 -0
- package/dist/kv-yjvQa_LH.cjs.map +1 -0
- package/dist/logging-hmzNzifq.mjs +158 -0
- package/dist/logging-hmzNzifq.mjs.map +1 -0
- package/dist/logging-qc9uMgil.cjs +212 -0
- package/dist/logging-qc9uMgil.cjs.map +1 -0
- package/dist/mainline/index.cjs +12 -0
- package/dist/mainline/index.d.cts +3 -0
- package/dist/mainline/index.d.mts +3 -0
- package/dist/mainline/index.mjs +5 -0
- package/dist/mainline-D_jfeFMh.cjs +0 -0
- package/dist/mainline-cFIuXbo-.mjs +1 -0
- package/dist/server/index.cjs +14 -0
- package/dist/server/index.d.cts +3 -0
- package/dist/server/index.d.mts +3 -0
- package/dist/server/index.mjs +3 -0
- package/dist/server-BBNRZ30D.cjs +912 -0
- package/dist/server-BBNRZ30D.cjs.map +1 -0
- package/dist/server-DVyk9gqU.mjs +836 -0
- package/dist/server-DVyk9gqU.mjs.map +1 -0
- package/package.json +125 -0
- package/src/arid-derivation.ts +155 -0
- package/src/bin/hubert.ts +667 -0
- package/src/error.ts +89 -0
- package/src/hybrid/error.ts +77 -0
- package/src/hybrid/index.ts +24 -0
- package/src/hybrid/kv.ts +236 -0
- package/src/hybrid/reference.ts +176 -0
- package/src/index.ts +145 -0
- package/src/ipfs/error.ts +83 -0
- package/src/ipfs/index.ts +24 -0
- package/src/ipfs/kv.ts +476 -0
- package/src/ipfs/value.ts +85 -0
- package/src/kv-store.ts +128 -0
- package/src/logging.ts +88 -0
- package/src/mainline/error.ts +108 -0
- package/src/mainline/index.ts +23 -0
- package/src/mainline/kv.ts +411 -0
- package/src/server/error.ts +83 -0
- package/src/server/index.ts +29 -0
- package/src/server/kv.ts +211 -0
- package/src/server/memory-kv.ts +191 -0
- package/src/server/server-kv.ts +92 -0
- package/src/server/server.ts +369 -0
- package/src/server/sqlite-kv.ts +295 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hubert HTTP server implementation.
|
|
3
|
+
*
|
|
4
|
+
* Port of server/server.rs from hubert-rust.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyReply } from "fastify";
|
|
10
|
+
|
|
11
|
+
import { ARID } from "@bcts/components";
|
|
12
|
+
import { Envelope } from "@bcts/envelope";
|
|
13
|
+
|
|
14
|
+
import { verbosePrintln } from "../logging.js";
|
|
15
|
+
import { MemoryKv } from "./memory-kv.js";
|
|
16
|
+
import { type ServerKv, getSync, putSync } from "./server-kv.js";
|
|
17
|
+
import { type SqliteKv } from "./sqlite-kv.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Package version for health endpoint.
|
|
21
|
+
*/
|
|
22
|
+
const VERSION = "1.0.0-alpha.1";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Configuration for the Hubert server.
|
|
26
|
+
*
|
|
27
|
+
* Port of `struct ServerConfig` from server/server.rs lines 19-30.
|
|
28
|
+
*
|
|
29
|
+
* @category Server
|
|
30
|
+
*/
|
|
31
|
+
export interface ServerConfig {
|
|
32
|
+
/** Port to listen on */
|
|
33
|
+
port: number;
|
|
34
|
+
/**
|
|
35
|
+
* Maximum TTL in seconds allowed.
|
|
36
|
+
* If a put() specifies a TTL higher than this, it will be clamped.
|
|
37
|
+
* If put() specifies None, this value will be used.
|
|
38
|
+
* Hubert is intended for coordination, not long-term storage.
|
|
39
|
+
*/
|
|
40
|
+
maxTtl: number;
|
|
41
|
+
/** Enable verbose logging with timestamps */
|
|
42
|
+
verbose: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default server configuration.
|
|
47
|
+
*
|
|
48
|
+
* Port of `impl Default for ServerConfig` from server/server.rs lines 32-40.
|
|
49
|
+
*/
|
|
50
|
+
export const defaultServerConfig: ServerConfig = {
|
|
51
|
+
port: 45678,
|
|
52
|
+
maxTtl: 86400, // 24 hours max (and default)
|
|
53
|
+
verbose: false,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shared server state.
|
|
58
|
+
*
|
|
59
|
+
* Port of `struct ServerState` from server/server.rs lines 43-47.
|
|
60
|
+
*
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
class ServerState {
|
|
64
|
+
storage: ServerKv;
|
|
65
|
+
config: ServerConfig;
|
|
66
|
+
|
|
67
|
+
constructor(config: ServerConfig, storage: ServerKv) {
|
|
68
|
+
this.storage = storage;
|
|
69
|
+
this.config = config;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Put an envelope into storage.
|
|
74
|
+
*
|
|
75
|
+
* Port of `ServerState::put()` from server/server.rs lines 54-101.
|
|
76
|
+
*/
|
|
77
|
+
async put(
|
|
78
|
+
arid: ARID,
|
|
79
|
+
envelope: Envelope,
|
|
80
|
+
requestedTtl: number | undefined,
|
|
81
|
+
clientIp: string | undefined,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
// Determine effective TTL:
|
|
84
|
+
// - If requested, use it (clamped to maxTtl)
|
|
85
|
+
// - If undefined requested, use maxTtl
|
|
86
|
+
// All entries expire (hubert is for coordination, not long-term storage)
|
|
87
|
+
const maxTtl = this.config.maxTtl;
|
|
88
|
+
let ttl: number;
|
|
89
|
+
if (requestedTtl !== undefined) {
|
|
90
|
+
ttl = requestedTtl > maxTtl ? maxTtl : requestedTtl;
|
|
91
|
+
} else {
|
|
92
|
+
ttl = maxTtl;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await putSync(this.storage, arid, envelope, ttl);
|
|
97
|
+
|
|
98
|
+
if (this.config.verbose) {
|
|
99
|
+
const ipStr = clientIp ? `${clientIp}: ` : "";
|
|
100
|
+
verbosePrintln(`${ipStr}PUT ${arid.urString()} (TTL ${ttl}s) OK`);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (this.config.verbose) {
|
|
104
|
+
const ipStr = clientIp ? `${clientIp}: ` : "";
|
|
105
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
106
|
+
verbosePrintln(`${ipStr}PUT ${arid.urString()} (TTL ${ttl}s) ERROR: ${errorMsg}`);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get an envelope from storage.
|
|
114
|
+
*
|
|
115
|
+
* Port of `ServerState::get()` from server/server.rs lines 103-126.
|
|
116
|
+
*/
|
|
117
|
+
async get(arid: ARID, clientIp: string | undefined): Promise<Envelope | null> {
|
|
118
|
+
const result = await getSync(this.storage, arid);
|
|
119
|
+
|
|
120
|
+
if (this.config.verbose) {
|
|
121
|
+
const ipStr = clientIp ? `${clientIp}: ` : "";
|
|
122
|
+
const status = result ? "OK" : "NOT_FOUND";
|
|
123
|
+
verbosePrintln(`${ipStr}GET ${arid.urString()} ${status}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Hubert HTTP server.
|
|
132
|
+
*
|
|
133
|
+
* Port of `struct Server` from server/server.rs lines 128-133.
|
|
134
|
+
*
|
|
135
|
+
* @category Server
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```typescript
|
|
139
|
+
* const server = Server.newMemory({ port: 8080, maxTtl: 3600, verbose: true });
|
|
140
|
+
* await server.run();
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export class Server {
|
|
144
|
+
private readonly config: ServerConfig;
|
|
145
|
+
private readonly state: ServerState;
|
|
146
|
+
private readonly fastify: FastifyInstance;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Create a new server with the given configuration and storage backend.
|
|
150
|
+
*
|
|
151
|
+
* Port of `Server::new()` from server/server.rs lines 135-139.
|
|
152
|
+
*/
|
|
153
|
+
constructor(config: ServerConfig, storage: ServerKv) {
|
|
154
|
+
this.config = config;
|
|
155
|
+
this.state = new ServerState(config, storage);
|
|
156
|
+
this.fastify = Fastify({ logger: false });
|
|
157
|
+
this.setupRoutes();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a new server with in-memory storage.
|
|
162
|
+
*
|
|
163
|
+
* Port of `Server::new_memory()` from server/server.rs lines 142-144.
|
|
164
|
+
*/
|
|
165
|
+
static newMemory(config: Partial<ServerConfig> = {}): Server {
|
|
166
|
+
const fullConfig = { ...defaultServerConfig, ...config };
|
|
167
|
+
return new Server(fullConfig, new MemoryKv());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create a new server with SQLite storage.
|
|
172
|
+
*
|
|
173
|
+
* Port of `Server::new_sqlite()` from server/server.rs lines 147-149.
|
|
174
|
+
*/
|
|
175
|
+
static newSqlite(config: Partial<ServerConfig> = {}, storage: SqliteKv): Server {
|
|
176
|
+
const fullConfig = { ...defaultServerConfig, ...config };
|
|
177
|
+
return new Server(fullConfig, storage);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Setup HTTP routes.
|
|
182
|
+
* @internal
|
|
183
|
+
*/
|
|
184
|
+
private setupRoutes(): void {
|
|
185
|
+
// Health check endpoint
|
|
186
|
+
this.fastify.get("/health", this.handleHealth.bind(this));
|
|
187
|
+
|
|
188
|
+
// PUT endpoint
|
|
189
|
+
this.fastify.post("/put", this.handlePut.bind(this));
|
|
190
|
+
|
|
191
|
+
// GET endpoint
|
|
192
|
+
this.fastify.post("/get", this.handleGet.bind(this));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle health check requests.
|
|
197
|
+
*
|
|
198
|
+
* Port of `handle_health()` from server/server.rs lines 179-187.
|
|
199
|
+
*/
|
|
200
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
201
|
+
private async handleHealth(_request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
202
|
+
reply.send({
|
|
203
|
+
server: "hubert",
|
|
204
|
+
version: VERSION,
|
|
205
|
+
status: "ok",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Handle PUT requests.
|
|
211
|
+
*
|
|
212
|
+
* Port of `handle_put()` from server/server.rs lines 195-238.
|
|
213
|
+
*/
|
|
214
|
+
private async handlePut(
|
|
215
|
+
request: FastifyRequest<{ Body: string }>,
|
|
216
|
+
reply: FastifyReply,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
try {
|
|
219
|
+
const bodyStr = request.body;
|
|
220
|
+
if (typeof bodyStr !== "string") {
|
|
221
|
+
reply.status(400).send("Expected text body");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const lines = bodyStr.split("\n");
|
|
226
|
+
if (lines.length < 2) {
|
|
227
|
+
reply.status(400).send("Expected at least 2 lines: ur:arid and ur:envelope");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Parse ARID
|
|
232
|
+
let arid: ARID;
|
|
233
|
+
try {
|
|
234
|
+
arid = ARID.fromUrString(lines[0]);
|
|
235
|
+
} catch {
|
|
236
|
+
reply.status(400).send("Invalid ur:arid");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Parse Envelope
|
|
241
|
+
let envelope: Envelope;
|
|
242
|
+
try {
|
|
243
|
+
envelope = Envelope.fromUrString(lines[1]);
|
|
244
|
+
} catch {
|
|
245
|
+
reply.status(400).send("Invalid ur:envelope");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse optional TTL
|
|
250
|
+
let ttl: number | undefined;
|
|
251
|
+
if (lines.length > 2 && lines[2].trim() !== "") {
|
|
252
|
+
const parsed = parseInt(lines[2], 10);
|
|
253
|
+
if (isNaN(parsed)) {
|
|
254
|
+
reply.status(400).send("Invalid TTL");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
ttl = parsed;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Get client IP
|
|
261
|
+
const clientIp = request.ip;
|
|
262
|
+
|
|
263
|
+
// Store the envelope
|
|
264
|
+
await this.state.put(arid, envelope, ttl, clientIp);
|
|
265
|
+
|
|
266
|
+
reply.status(200).send("OK");
|
|
267
|
+
} catch (error) {
|
|
268
|
+
// Check if it's an AlreadyExists error
|
|
269
|
+
if (error instanceof Error && error.name === "AlreadyExistsError") {
|
|
270
|
+
reply.status(409).send(error.message);
|
|
271
|
+
} else {
|
|
272
|
+
reply.status(500).send(error instanceof Error ? error.message : "Internal server error");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Handle GET requests.
|
|
279
|
+
*
|
|
280
|
+
* Port of `handle_get()` from server/server.rs lines 244-269.
|
|
281
|
+
*/
|
|
282
|
+
private async handleGet(
|
|
283
|
+
request: FastifyRequest<{ Body: string }>,
|
|
284
|
+
reply: FastifyReply,
|
|
285
|
+
): Promise<void> {
|
|
286
|
+
try {
|
|
287
|
+
const bodyStr = request.body;
|
|
288
|
+
if (typeof bodyStr !== "string") {
|
|
289
|
+
reply.status(400).send("Expected text body");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const aridStr = bodyStr.trim();
|
|
294
|
+
if (aridStr === "") {
|
|
295
|
+
reply.status(400).send("Expected ur:arid");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Parse ARID
|
|
300
|
+
let arid: ARID;
|
|
301
|
+
try {
|
|
302
|
+
arid = ARID.fromUrString(aridStr);
|
|
303
|
+
} catch {
|
|
304
|
+
reply.status(400).send("Invalid ur:arid");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Get client IP
|
|
309
|
+
const clientIp = request.ip;
|
|
310
|
+
|
|
311
|
+
// Retrieve the envelope
|
|
312
|
+
const envelope = await this.state.get(arid, clientIp);
|
|
313
|
+
|
|
314
|
+
if (envelope) {
|
|
315
|
+
reply.status(200).send(envelope.urString());
|
|
316
|
+
} else {
|
|
317
|
+
reply.status(404).send("Not found");
|
|
318
|
+
}
|
|
319
|
+
} catch (error) {
|
|
320
|
+
reply.status(500).send(error instanceof Error ? error.message : "Internal server error");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Run the server.
|
|
326
|
+
*
|
|
327
|
+
* Port of `Server::run()` from server/server.rs lines 152-170.
|
|
328
|
+
*/
|
|
329
|
+
async run(): Promise<void> {
|
|
330
|
+
const addr = `127.0.0.1:${this.config.port}`;
|
|
331
|
+
|
|
332
|
+
// Configure Fastify to parse plain text bodies
|
|
333
|
+
this.fastify.addContentTypeParser(
|
|
334
|
+
"text/plain",
|
|
335
|
+
{ parseAs: "string" },
|
|
336
|
+
(_request, payload, done) => {
|
|
337
|
+
done(null, payload);
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Also handle application/octet-stream and no content-type
|
|
342
|
+
this.fastify.addContentTypeParser(
|
|
343
|
+
"application/octet-stream",
|
|
344
|
+
{ parseAs: "string" },
|
|
345
|
+
(_request, payload, done) => {
|
|
346
|
+
done(null, payload);
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
await this.fastify.listen({ port: this.config.port, host: "127.0.0.1" });
|
|
351
|
+
console.log(`✓ Hubert server listening on ${addr}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get the port the server is configured to listen on.
|
|
356
|
+
*
|
|
357
|
+
* Port of `Server::port()` from server/server.rs line 173.
|
|
358
|
+
*/
|
|
359
|
+
port(): number {
|
|
360
|
+
return this.config.port;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Stop the server.
|
|
365
|
+
*/
|
|
366
|
+
async close(): Promise<void> {
|
|
367
|
+
await this.fastify.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed key-value store for Gordian Envelopes.
|
|
3
|
+
*
|
|
4
|
+
* Port of server/sqlite_kv.rs from hubert-rust.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Database from "better-sqlite3";
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
|
|
13
|
+
import { type ARID } from "@bcts/components";
|
|
14
|
+
import { type Envelope } from "@bcts/envelope";
|
|
15
|
+
|
|
16
|
+
import { AlreadyExistsError } from "../error.js";
|
|
17
|
+
import { type KvStore } from "../kv-store.js";
|
|
18
|
+
import { verbosePrintln } from "../logging.js";
|
|
19
|
+
import { SqliteError } from "./error.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SQLite-backed key-value store for Gordian Envelopes.
|
|
23
|
+
*
|
|
24
|
+
* Provides persistent storage with TTL support and automatic cleanup of
|
|
25
|
+
* expired entries.
|
|
26
|
+
*
|
|
27
|
+
* Port of `struct SqliteKv` from server/sqlite_kv.rs lines 16-24.
|
|
28
|
+
*
|
|
29
|
+
* @category Server Backend
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const store = new SqliteKv("./hubert.db");
|
|
34
|
+
* const arid = ARID.new();
|
|
35
|
+
* const envelope = Envelope.new("Hello, SQLite!");
|
|
36
|
+
*
|
|
37
|
+
* await store.put(arid, envelope, 3600); // 1 hour TTL
|
|
38
|
+
* const result = await store.get(arid);
|
|
39
|
+
*
|
|
40
|
+
* // Cleanup when done
|
|
41
|
+
* store.close();
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class SqliteKv implements KvStore {
|
|
45
|
+
private readonly db: Database.Database;
|
|
46
|
+
private readonly dbPath: string;
|
|
47
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a new SQLite-backed key-value store.
|
|
51
|
+
*
|
|
52
|
+
* Port of `SqliteKv::new()` from server/sqlite_kv.rs lines 26-67.
|
|
53
|
+
*
|
|
54
|
+
* @param dbPath - Path to the SQLite database file. Will be created if it doesn't exist.
|
|
55
|
+
* @throws {SqliteError} If database initialization fails
|
|
56
|
+
*/
|
|
57
|
+
constructor(dbPath: string) {
|
|
58
|
+
this.dbPath = dbPath;
|
|
59
|
+
|
|
60
|
+
// Create parent directory if it doesn't exist
|
|
61
|
+
const parentDir = path.dirname(dbPath);
|
|
62
|
+
if (parentDir && parentDir !== "." && !fs.existsSync(parentDir)) {
|
|
63
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
this.db = new Database(dbPath);
|
|
68
|
+
|
|
69
|
+
// Create table if it doesn't exist
|
|
70
|
+
this.db.exec(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS hubert_store (
|
|
72
|
+
arid TEXT PRIMARY KEY,
|
|
73
|
+
envelope TEXT NOT NULL,
|
|
74
|
+
expires_at INTEGER
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_expires_at ON hubert_store(expires_at);
|
|
77
|
+
`);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new SqliteError(
|
|
80
|
+
`Failed to initialize database: ${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
error instanceof Error ? error : undefined,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Start background cleanup task
|
|
86
|
+
this.startCleanupTask();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start a background task that prunes expired entries every minute.
|
|
91
|
+
*
|
|
92
|
+
* Port of `start_cleanup_task()` from server/sqlite_kv.rs lines 70-126.
|
|
93
|
+
*
|
|
94
|
+
* @internal
|
|
95
|
+
*/
|
|
96
|
+
private startCleanupTask(): void {
|
|
97
|
+
this.cleanupInterval = setInterval(() => {
|
|
98
|
+
const now = Math.floor(Date.now() / 1000);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// First collect the ARIDs that will be deleted
|
|
102
|
+
const selectStmt = this.db.prepare(
|
|
103
|
+
"SELECT arid FROM hubert_store WHERE expires_at IS NOT NULL AND expires_at <= ?",
|
|
104
|
+
);
|
|
105
|
+
const rows = selectStmt.all(now) as { arid: string }[];
|
|
106
|
+
const arids = rows.map((row) => row.arid);
|
|
107
|
+
|
|
108
|
+
if (arids.length > 0) {
|
|
109
|
+
// Now delete them
|
|
110
|
+
const deleteStmt = this.db.prepare(
|
|
111
|
+
"DELETE FROM hubert_store WHERE expires_at IS NOT NULL AND expires_at <= ?",
|
|
112
|
+
);
|
|
113
|
+
deleteStmt.run(now);
|
|
114
|
+
|
|
115
|
+
const count = arids.length;
|
|
116
|
+
const aridList = arids.join(" ");
|
|
117
|
+
verbosePrintln(
|
|
118
|
+
`Pruned ${count} expired ${count === 1 ? "entry" : "entries"}: ${aridList}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Silently ignore cleanup errors
|
|
123
|
+
}
|
|
124
|
+
}, 60 * 1000); // Every 60 seconds
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if an ARID exists and is not expired.
|
|
129
|
+
*
|
|
130
|
+
* Port of `check_exists()` from server/sqlite_kv.rs lines 129-170.
|
|
131
|
+
*
|
|
132
|
+
* @internal
|
|
133
|
+
*/
|
|
134
|
+
private checkExists(arid: ARID): boolean {
|
|
135
|
+
const aridStr = arid.urString();
|
|
136
|
+
const now = Math.floor(Date.now() / 1000);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const stmt = this.db.prepare("SELECT expires_at FROM hubert_store WHERE arid = ?");
|
|
140
|
+
const row = stmt.get(aridStr) as { expires_at: number | null } | undefined;
|
|
141
|
+
|
|
142
|
+
if (row) {
|
|
143
|
+
// Check if expired
|
|
144
|
+
if (row.expires_at !== null && now >= row.expires_at) {
|
|
145
|
+
// Entry is expired, remove it
|
|
146
|
+
const deleteStmt = this.db.prepare("DELETE FROM hubert_store WHERE arid = ?");
|
|
147
|
+
deleteStmt.run(aridStr);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Store an envelope at the given ARID.
|
|
160
|
+
*
|
|
161
|
+
* Port of `KvStore::put()` implementation from server/sqlite_kv.rs lines 175-236.
|
|
162
|
+
*/
|
|
163
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
164
|
+
async put(
|
|
165
|
+
arid: ARID,
|
|
166
|
+
envelope: Envelope,
|
|
167
|
+
ttlSeconds?: number,
|
|
168
|
+
verbose?: boolean,
|
|
169
|
+
): Promise<string> {
|
|
170
|
+
// Check if already exists
|
|
171
|
+
if (this.checkExists(arid)) {
|
|
172
|
+
if (verbose) {
|
|
173
|
+
verbosePrintln(`PUT ${arid.urString()} ALREADY_EXISTS`);
|
|
174
|
+
}
|
|
175
|
+
throw new AlreadyExistsError(arid.urString());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const aridStr = arid.urString();
|
|
179
|
+
const envelopeStr = envelope.urString();
|
|
180
|
+
|
|
181
|
+
const expiresAt = ttlSeconds !== undefined ? Math.floor(Date.now() / 1000) + ttlSeconds : null;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const stmt = this.db.prepare(
|
|
185
|
+
"INSERT INTO hubert_store (arid, envelope, expires_at) VALUES (?, ?, ?)",
|
|
186
|
+
);
|
|
187
|
+
stmt.run(aridStr, envelopeStr, expiresAt);
|
|
188
|
+
|
|
189
|
+
if (verbose) {
|
|
190
|
+
const ttlMsg = ttlSeconds !== undefined ? ` (TTL ${ttlSeconds}s)` : "";
|
|
191
|
+
verbosePrintln(`PUT ${aridStr}${ttlMsg} OK (SQLite: ${this.dbPath})`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return `Stored in SQLite: ${this.dbPath}`;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
throw new SqliteError(
|
|
197
|
+
`Failed to insert: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
error instanceof Error ? error : undefined,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Retrieve an envelope for the given ARID.
|
|
205
|
+
*
|
|
206
|
+
* Port of `KvStore::get()` implementation from server/sqlite_kv.rs lines 238-354.
|
|
207
|
+
*/
|
|
208
|
+
async get(arid: ARID, timeoutSeconds?: number, verbose?: boolean): Promise<Envelope | null> {
|
|
209
|
+
const timeout = timeoutSeconds ?? 30;
|
|
210
|
+
const start = Date.now();
|
|
211
|
+
let firstAttempt = true;
|
|
212
|
+
const aridStr = arid.urString();
|
|
213
|
+
|
|
214
|
+
// Dynamic import to avoid circular dependencies
|
|
215
|
+
const { Envelope } = await import("@bcts/envelope");
|
|
216
|
+
|
|
217
|
+
while (true) {
|
|
218
|
+
const now = Math.floor(Date.now() / 1000);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const stmt = this.db.prepare(
|
|
222
|
+
"SELECT envelope, expires_at FROM hubert_store WHERE arid = ?",
|
|
223
|
+
);
|
|
224
|
+
const row = stmt.get(aridStr) as
|
|
225
|
+
| { envelope: string; expires_at: number | null }
|
|
226
|
+
| undefined;
|
|
227
|
+
|
|
228
|
+
if (row) {
|
|
229
|
+
// Check if expired
|
|
230
|
+
if (row.expires_at !== null && now >= row.expires_at) {
|
|
231
|
+
// Entry is expired, remove it
|
|
232
|
+
const deleteStmt = this.db.prepare("DELETE FROM hubert_store WHERE arid = ?");
|
|
233
|
+
deleteStmt.run(aridStr);
|
|
234
|
+
|
|
235
|
+
if (verbose) {
|
|
236
|
+
verbosePrintln(`GET ${aridStr} EXPIRED`);
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Entry found and not expired
|
|
242
|
+
const envelope = Envelope.fromUrString(row.envelope);
|
|
243
|
+
|
|
244
|
+
if (verbose) {
|
|
245
|
+
verbosePrintln(`GET ${aridStr} OK (SQLite: ${this.dbPath})`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return envelope;
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// Query failed, treat as not found
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Not found yet
|
|
255
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
256
|
+
if (elapsed >= timeout) {
|
|
257
|
+
if (verbose) {
|
|
258
|
+
verbosePrintln(`GET ${aridStr} NOT_FOUND (timeout after ${timeout}s)`);
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (firstAttempt && verbose) {
|
|
264
|
+
verbosePrintln(`Polling for ${aridStr} (timeout: ${timeout}s)`);
|
|
265
|
+
firstAttempt = false;
|
|
266
|
+
} else if (verbose) {
|
|
267
|
+
process.stdout.write(".");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Wait 500ms before polling again
|
|
271
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Check if an envelope exists at the given ARID.
|
|
277
|
+
*
|
|
278
|
+
* Port of `KvStore::exists()` implementation from server/sqlite_kv.rs lines 356-359.
|
|
279
|
+
*/
|
|
280
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
281
|
+
async exists(arid: ARID): Promise<boolean> {
|
|
282
|
+
return this.checkExists(arid);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Close the database connection and stop the cleanup task.
|
|
287
|
+
*/
|
|
288
|
+
close(): void {
|
|
289
|
+
if (this.cleanupInterval) {
|
|
290
|
+
clearInterval(this.cleanupInterval);
|
|
291
|
+
this.cleanupInterval = null;
|
|
292
|
+
}
|
|
293
|
+
this.db.close();
|
|
294
|
+
}
|
|
295
|
+
}
|