@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.
Files changed (104) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +18 -0
  3. package/dist/arid-derivation-1CJuU-kZ.cjs +150 -0
  4. package/dist/arid-derivation-1CJuU-kZ.cjs.map +1 -0
  5. package/dist/arid-derivation-CbqACjdg.mjs +126 -0
  6. package/dist/arid-derivation-CbqACjdg.mjs.map +1 -0
  7. package/dist/bin/hubert.cjs +384 -0
  8. package/dist/bin/hubert.cjs.map +1 -0
  9. package/dist/bin/hubert.d.cts +1 -0
  10. package/dist/bin/hubert.d.mts +1 -0
  11. package/dist/bin/hubert.mjs +383 -0
  12. package/dist/bin/hubert.mjs.map +1 -0
  13. package/dist/chunk-CbDLau6x.cjs +34 -0
  14. package/dist/hybrid/index.cjs +14 -0
  15. package/dist/hybrid/index.d.cts +3 -0
  16. package/dist/hybrid/index.d.mts +3 -0
  17. package/dist/hybrid/index.mjs +6 -0
  18. package/dist/hybrid-BZhumygj.mjs +356 -0
  19. package/dist/hybrid-BZhumygj.mjs.map +1 -0
  20. package/dist/hybrid-dX5JLumO.cjs +410 -0
  21. package/dist/hybrid-dX5JLumO.cjs.map +1 -0
  22. package/dist/index-BEzpUC7r.d.mts +380 -0
  23. package/dist/index-BEzpUC7r.d.mts.map +1 -0
  24. package/dist/index-C2F6ugLL.d.mts +210 -0
  25. package/dist/index-C2F6ugLL.d.mts.map +1 -0
  26. package/dist/index-CUnDouMb.d.mts +215 -0
  27. package/dist/index-CUnDouMb.d.mts.map +1 -0
  28. package/dist/index-CV6lZJqY.d.cts +380 -0
  29. package/dist/index-CV6lZJqY.d.cts.map +1 -0
  30. package/dist/index-CY3TCzIm.d.cts +217 -0
  31. package/dist/index-CY3TCzIm.d.cts.map +1 -0
  32. package/dist/index-DEr4SR1J.d.cts +215 -0
  33. package/dist/index-DEr4SR1J.d.cts.map +1 -0
  34. package/dist/index-T1LHanIb.d.mts +217 -0
  35. package/dist/index-T1LHanIb.d.mts.map +1 -0
  36. package/dist/index-jyzuOhFB.d.cts +210 -0
  37. package/dist/index-jyzuOhFB.d.cts.map +1 -0
  38. package/dist/index.cjs +60 -0
  39. package/dist/index.d.cts +161 -0
  40. package/dist/index.d.cts.map +1 -0
  41. package/dist/index.d.mts +161 -0
  42. package/dist/index.d.mts.map +1 -0
  43. package/dist/index.mjs +10 -0
  44. package/dist/ipfs/index.cjs +13 -0
  45. package/dist/ipfs/index.d.cts +3 -0
  46. package/dist/ipfs/index.d.mts +3 -0
  47. package/dist/ipfs/index.mjs +5 -0
  48. package/dist/ipfs-BRMMCBjv.mjs +1 -0
  49. package/dist/ipfs-CetOVQcO.cjs +0 -0
  50. package/dist/kv-BAmhmMOo.cjs +425 -0
  51. package/dist/kv-BAmhmMOo.cjs.map +1 -0
  52. package/dist/kv-C-emxv0w.mjs +375 -0
  53. package/dist/kv-C-emxv0w.mjs.map +1 -0
  54. package/dist/kv-DJiKvypY.mjs +403 -0
  55. package/dist/kv-DJiKvypY.mjs.map +1 -0
  56. package/dist/kv-store-DmngWWuw.d.mts +183 -0
  57. package/dist/kv-store-DmngWWuw.d.mts.map +1 -0
  58. package/dist/kv-store-ww-AUyLd.d.cts +183 -0
  59. package/dist/kv-store-ww-AUyLd.d.cts.map +1 -0
  60. package/dist/kv-yjvQa_LH.cjs +457 -0
  61. package/dist/kv-yjvQa_LH.cjs.map +1 -0
  62. package/dist/logging-hmzNzifq.mjs +158 -0
  63. package/dist/logging-hmzNzifq.mjs.map +1 -0
  64. package/dist/logging-qc9uMgil.cjs +212 -0
  65. package/dist/logging-qc9uMgil.cjs.map +1 -0
  66. package/dist/mainline/index.cjs +12 -0
  67. package/dist/mainline/index.d.cts +3 -0
  68. package/dist/mainline/index.d.mts +3 -0
  69. package/dist/mainline/index.mjs +5 -0
  70. package/dist/mainline-D_jfeFMh.cjs +0 -0
  71. package/dist/mainline-cFIuXbo-.mjs +1 -0
  72. package/dist/server/index.cjs +14 -0
  73. package/dist/server/index.d.cts +3 -0
  74. package/dist/server/index.d.mts +3 -0
  75. package/dist/server/index.mjs +3 -0
  76. package/dist/server-BBNRZ30D.cjs +912 -0
  77. package/dist/server-BBNRZ30D.cjs.map +1 -0
  78. package/dist/server-DVyk9gqU.mjs +836 -0
  79. package/dist/server-DVyk9gqU.mjs.map +1 -0
  80. package/package.json +125 -0
  81. package/src/arid-derivation.ts +155 -0
  82. package/src/bin/hubert.ts +667 -0
  83. package/src/error.ts +89 -0
  84. package/src/hybrid/error.ts +77 -0
  85. package/src/hybrid/index.ts +24 -0
  86. package/src/hybrid/kv.ts +236 -0
  87. package/src/hybrid/reference.ts +176 -0
  88. package/src/index.ts +145 -0
  89. package/src/ipfs/error.ts +83 -0
  90. package/src/ipfs/index.ts +24 -0
  91. package/src/ipfs/kv.ts +476 -0
  92. package/src/ipfs/value.ts +85 -0
  93. package/src/kv-store.ts +128 -0
  94. package/src/logging.ts +88 -0
  95. package/src/mainline/error.ts +108 -0
  96. package/src/mainline/index.ts +23 -0
  97. package/src/mainline/kv.ts +411 -0
  98. package/src/server/error.ts +83 -0
  99. package/src/server/index.ts +29 -0
  100. package/src/server/kv.ts +211 -0
  101. package/src/server/memory-kv.ts +191 -0
  102. package/src/server/server-kv.ts +92 -0
  103. package/src/server/server.ts +369 -0
  104. 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
+ }