@prisma/streams-server 0.0.1 → 0.1.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.
Files changed (85) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +68 -0
  3. package/LICENSE +201 -0
  4. package/README.md +39 -2
  5. package/SECURITY.md +33 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +29 -34
  8. package/src/app.ts +74 -0
  9. package/src/app_core.ts +1983 -0
  10. package/src/app_local.ts +46 -0
  11. package/src/backpressure.ts +66 -0
  12. package/src/bootstrap.ts +239 -0
  13. package/src/config.ts +251 -0
  14. package/src/db/db.ts +1440 -0
  15. package/src/db/schema.ts +619 -0
  16. package/src/expiry_sweeper.ts +44 -0
  17. package/src/hist.ts +169 -0
  18. package/src/index/binary_fuse.ts +379 -0
  19. package/src/index/indexer.ts +745 -0
  20. package/src/index/run_cache.ts +84 -0
  21. package/src/index/run_format.ts +213 -0
  22. package/src/ingest.ts +655 -0
  23. package/src/lens/lens.ts +501 -0
  24. package/src/manifest.ts +114 -0
  25. package/src/memory.ts +155 -0
  26. package/src/metrics.ts +161 -0
  27. package/src/metrics_emitter.ts +50 -0
  28. package/src/notifier.ts +64 -0
  29. package/src/objectstore/interface.ts +13 -0
  30. package/src/objectstore/mock_r2.ts +269 -0
  31. package/src/objectstore/null.ts +32 -0
  32. package/src/objectstore/r2.ts +128 -0
  33. package/src/offset.ts +70 -0
  34. package/src/reader.ts +454 -0
  35. package/src/runtime/hash.ts +156 -0
  36. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  37. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  38. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  39. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  40. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  41. package/src/schema/lens_schema.ts +290 -0
  42. package/src/schema/proof.ts +547 -0
  43. package/src/schema/registry.ts +405 -0
  44. package/src/segment/cache.ts +179 -0
  45. package/src/segment/format.ts +331 -0
  46. package/src/segment/segmenter.ts +326 -0
  47. package/src/segment/segmenter_worker.ts +43 -0
  48. package/src/segment/segmenter_workers.ts +94 -0
  49. package/src/server.ts +326 -0
  50. package/src/sqlite/adapter.ts +164 -0
  51. package/src/stats.ts +205 -0
  52. package/src/touch/engine.ts +41 -0
  53. package/src/touch/interpreter_worker.ts +459 -0
  54. package/src/touch/live_keys.ts +118 -0
  55. package/src/touch/live_metrics.ts +858 -0
  56. package/src/touch/live_templates.ts +619 -0
  57. package/src/touch/manager.ts +1341 -0
  58. package/src/touch/naming.ts +13 -0
  59. package/src/touch/routing_key_notifier.ts +275 -0
  60. package/src/touch/spec.ts +526 -0
  61. package/src/touch/touch_journal.ts +671 -0
  62. package/src/touch/touch_key_id.ts +20 -0
  63. package/src/touch/worker_pool.ts +189 -0
  64. package/src/touch/worker_protocol.ts +58 -0
  65. package/src/types/proper-lockfile.d.ts +1 -0
  66. package/src/uploader.ts +317 -0
  67. package/src/util/base32_crockford.ts +81 -0
  68. package/src/util/bloom256.ts +67 -0
  69. package/src/util/cleanup.ts +22 -0
  70. package/src/util/crc32c.ts +29 -0
  71. package/src/util/ds_error.ts +15 -0
  72. package/src/util/duration.ts +17 -0
  73. package/src/util/endian.ts +53 -0
  74. package/src/util/json_pointer.ts +148 -0
  75. package/src/util/log.ts +25 -0
  76. package/src/util/lru.ts +45 -0
  77. package/src/util/retry.ts +35 -0
  78. package/src/util/siphash.ts +71 -0
  79. package/src/util/stream_paths.ts +31 -0
  80. package/src/util/time.ts +14 -0
  81. package/src/util/yield.ts +3 -0
  82. package/build/index.d.mts +0 -1
  83. package/build/index.d.ts +0 -1
  84. package/build/index.js +0 -0
  85. package/build/index.mjs +0 -1
@@ -0,0 +1,405 @@
1
+ import Ajv from "ajv";
2
+ import { createHash } from "node:crypto";
3
+ import type { SqliteDurableStore, StreamRow } from "../db/db";
4
+ import { Result } from "better-result";
5
+ import { LruCache } from "../util/lru";
6
+ import { DURABLE_LENS_V1_SCHEMA } from "./lens_schema";
7
+ import { compileLensResult, lensFromJson, type CompiledLens, type Lens } from "../lens/lens";
8
+ import { validateLensAgainstSchemasResult, fillLensDefaultsResult } from "./proof";
9
+ import { parseJsonPointerResult } from "../util/json_pointer";
10
+ import {
11
+ isTouchEnabled,
12
+ validateStreamInterpreterConfigResult,
13
+ type StreamInterpreterConfig,
14
+ } from "../touch/spec";
15
+ import { dsError } from "../util/ds_error.ts";
16
+
17
+ export type RoutingKeyConfig = {
18
+ jsonPointer: string;
19
+ required: boolean;
20
+ };
21
+
22
+ export type SchemaRegistry = {
23
+ apiVersion: "durable.streams/schema-registry/v1";
24
+ schema: string;
25
+ currentVersion: number;
26
+ routingKey?: RoutingKeyConfig;
27
+ interpreter?: StreamInterpreterConfig;
28
+ boundaries: Array<{ offset: number; version: number }>;
29
+ schemas: Record<string, any>;
30
+ lenses: Record<string, any>;
31
+ };
32
+
33
+ export type SchemaRegistryMutationError = {
34
+ kind: "version_mismatch" | "bad_request";
35
+ message: string;
36
+ code?: string;
37
+ };
38
+
39
+ export type SchemaRegistryReadError = {
40
+ kind: "invalid_registry" | "invalid_lens_chain";
41
+ message: string;
42
+ code?: string;
43
+ };
44
+
45
+ type RegistryRow = { stream: string; registry_json: string; updated_at_ms: bigint };
46
+
47
+ type Validator = ReturnType<Ajv["compile"]>;
48
+
49
+ const AJV = new Ajv({
50
+ allErrors: true,
51
+ strict: false,
52
+ allowUnionTypes: true,
53
+ validateSchema: false,
54
+ });
55
+
56
+ const LENS_VALIDATOR = AJV.compile(DURABLE_LENS_V1_SCHEMA);
57
+
58
+ function sha256Hex(input: string): string {
59
+ return createHash("sha256").update(input).digest("hex");
60
+ }
61
+
62
+ function defaultRegistry(stream: string): SchemaRegistry {
63
+ return {
64
+ apiVersion: "durable.streams/schema-registry/v1",
65
+ schema: stream,
66
+ currentVersion: 0,
67
+ boundaries: [],
68
+ schemas: {},
69
+ lenses: {},
70
+ };
71
+ }
72
+
73
+ function ensureNoRefResult(schema: any): Result<void, { message: string }> {
74
+ const stack: any[] = [schema];
75
+ while (stack.length > 0) {
76
+ const cur = stack.pop();
77
+ if (!cur || typeof cur !== "object") continue;
78
+ if (Object.prototype.hasOwnProperty.call(cur, "$ref")) {
79
+ return Result.err({ message: "external $ref is not supported" });
80
+ }
81
+ for (const v of Object.values(cur)) {
82
+ if (v && typeof v === "object") stack.push(v);
83
+ }
84
+ }
85
+ return Result.ok(undefined);
86
+ }
87
+
88
+ function validateJsonSchemaResult(schema: any): Result<void, { message: string }> {
89
+ const noRefRes = ensureNoRefResult(schema);
90
+ if (Result.isError(noRefRes)) return noRefRes;
91
+ try {
92
+ const validate = AJV.compile(schema);
93
+ if (!validate) return Result.err({ message: "schema validation failed" });
94
+ } catch (e: any) {
95
+ return Result.err({ message: String(e?.message ?? e) });
96
+ }
97
+ return Result.ok(undefined);
98
+ }
99
+
100
+ function parseRegistryResult(stream: string, json: string): Result<SchemaRegistry, { message: string }> {
101
+ let raw: any;
102
+ try {
103
+ raw = JSON.parse(json);
104
+ } catch (e: any) {
105
+ return Result.err({ message: String(e?.message ?? e) });
106
+ }
107
+ if (!raw || typeof raw !== "object") return Result.err({ message: "invalid schema registry" });
108
+ const reg = raw as SchemaRegistry;
109
+ if (reg.apiVersion !== "durable.streams/schema-registry/v1") return Result.err({ message: "invalid registry apiVersion" });
110
+ if (!reg.schema) reg.schema = stream;
111
+ if (!Array.isArray(reg.boundaries)) reg.boundaries = [];
112
+ if (!reg.schemas || typeof reg.schemas !== "object") reg.schemas = {};
113
+ if (!reg.lenses || typeof reg.lenses !== "object") reg.lenses = {};
114
+ if (typeof reg.currentVersion !== "number") reg.currentVersion = 0;
115
+ if ((reg as any).interpreter === null) delete (reg as any).interpreter;
116
+ return Result.ok(reg);
117
+ }
118
+
119
+ function serializeRegistry(reg: SchemaRegistry): string {
120
+ return JSON.stringify(reg);
121
+ }
122
+
123
+ function validateLensResult(raw: any): Result<Lens, { message: string }> {
124
+ const ok = LENS_VALIDATOR(raw);
125
+ if (!ok) {
126
+ const msg = AJV.errorsText(LENS_VALIDATOR.errors || undefined);
127
+ return Result.err({ message: `invalid lens: ${msg}` });
128
+ }
129
+ return Result.ok(raw as Lens);
130
+ }
131
+
132
+ function bigintToNumberSafeResult(v: bigint): Result<number, { message: string }> {
133
+ const max = BigInt(Number.MAX_SAFE_INTEGER);
134
+ if (v > max) return Result.err({ message: "offset exceeds MAX_SAFE_INTEGER" });
135
+ return Result.ok(Number(v));
136
+ }
137
+
138
+ export class SchemaRegistryStore {
139
+ private readonly db: SqliteDurableStore;
140
+ private readonly registryCache: LruCache<string, { reg: SchemaRegistry; updatedAtMs: bigint }>;
141
+ private readonly validatorCache: LruCache<string, Validator>;
142
+ private readonly lensCache: LruCache<string, CompiledLens>;
143
+ private readonly lensChainCache: LruCache<string, CompiledLens[]>;
144
+
145
+ constructor(db: SqliteDurableStore, opts?: { registryCacheEntries?: number; validatorCacheEntries?: number; lensCacheEntries?: number }) {
146
+ this.db = db;
147
+ this.registryCache = new LruCache(opts?.registryCacheEntries ?? 1024);
148
+ this.validatorCache = new LruCache(opts?.validatorCacheEntries ?? 256);
149
+ this.lensCache = new LruCache(opts?.lensCacheEntries ?? 256);
150
+ this.lensChainCache = new LruCache(opts?.lensCacheEntries ?? 256);
151
+ }
152
+
153
+ private loadRow(stream: string): RegistryRow | null {
154
+ return this.db.getSchemaRegistry(stream);
155
+ }
156
+
157
+ getRegistry(stream: string): SchemaRegistry {
158
+ const res = this.getRegistryResult(stream);
159
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
160
+ return res.value;
161
+ }
162
+
163
+ getRegistryResult(stream: string): Result<SchemaRegistry, SchemaRegistryReadError> {
164
+ const row = this.loadRow(stream);
165
+ if (!row) return Result.ok(defaultRegistry(stream));
166
+ const cached = this.registryCache.get(stream);
167
+ if (cached && cached.updatedAtMs === row.updated_at_ms) return Result.ok(cached.reg);
168
+ const parseRes = parseRegistryResult(stream, row.registry_json);
169
+ if (Result.isError(parseRes)) {
170
+ return Result.err({ kind: "invalid_registry", message: parseRes.error.message });
171
+ }
172
+ const reg = parseRes.value;
173
+ this.registryCache.set(stream, { reg, updatedAtMs: row.updated_at_ms });
174
+ return Result.ok(reg);
175
+ }
176
+
177
+ updateRegistry(
178
+ stream: string,
179
+ streamRow: StreamRow,
180
+ update: { schema: any; lens?: any; routingKey?: RoutingKeyConfig; interpreter?: StreamInterpreterConfig | null }
181
+ ): SchemaRegistry {
182
+ const res = this.updateRegistryResult(stream, streamRow, update);
183
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
184
+ return res.value;
185
+ }
186
+
187
+ updateRegistryResult(
188
+ stream: string,
189
+ streamRow: StreamRow,
190
+ update: { schema: any; lens?: any; routingKey?: RoutingKeyConfig; interpreter?: StreamInterpreterConfig | null }
191
+ ): Result<SchemaRegistry, SchemaRegistryMutationError> {
192
+ let validatedInterpreter: StreamInterpreterConfig | null | undefined = undefined;
193
+ if (update.routingKey) {
194
+ const pointerRes = parseJsonPointerResult(update.routingKey.jsonPointer);
195
+ if (Result.isError(pointerRes)) {
196
+ return Result.err({ kind: "bad_request", message: pointerRes.error.message });
197
+ }
198
+ if (typeof update.routingKey.required !== "boolean") {
199
+ return Result.err({ kind: "bad_request", message: "routingKey.required must be boolean" });
200
+ }
201
+ }
202
+ if (update.interpreter !== undefined) {
203
+ if (update.interpreter === null) {
204
+ validatedInterpreter = null;
205
+ } else {
206
+ const interpreterRes = validateStreamInterpreterConfigResult(update.interpreter);
207
+ if (Result.isError(interpreterRes)) {
208
+ return Result.err({ kind: "bad_request", message: interpreterRes.error.message });
209
+ }
210
+ validatedInterpreter = interpreterRes.value;
211
+ }
212
+ }
213
+ if (update.schema === undefined) return Result.err({ kind: "bad_request", message: "missing schema" });
214
+ const schemaRes = validateJsonSchemaResult(update.schema);
215
+ if (Result.isError(schemaRes)) return Result.err({ kind: "bad_request", message: schemaRes.error.message });
216
+
217
+ const regRes = this.getRegistryResult(stream);
218
+ if (Result.isError(regRes)) return Result.err({ kind: "bad_request", message: regRes.error.message, code: regRes.error.code });
219
+ const reg = regRes.value;
220
+ const currentVersion = reg.currentVersion ?? 0;
221
+ const streamEmpty = streamRow.next_offset === 0n;
222
+
223
+ if (currentVersion === 0) {
224
+ if (!streamEmpty) return Result.err({ kind: "bad_request", message: "first schema requires empty stream" });
225
+ if (update.lens) {
226
+ const lensRes = validateLensResult(update.lens);
227
+ if (Result.isError(lensRes)) return Result.err({ kind: "bad_request", message: lensRes.error.message });
228
+ if (lensRes.value.from !== 0 || lensRes.value.to !== 1) {
229
+ return Result.err({
230
+ kind: "version_mismatch",
231
+ message: "lens version mismatch",
232
+ code: "schema_lens_version_mismatch",
233
+ });
234
+ }
235
+ }
236
+ const nextReg: SchemaRegistry = {
237
+ apiVersion: "durable.streams/schema-registry/v1",
238
+ schema: stream,
239
+ currentVersion: 1,
240
+ routingKey: update.routingKey,
241
+ interpreter: update.interpreter === undefined ? reg.interpreter : validatedInterpreter ?? undefined,
242
+ boundaries: [{ offset: 0, version: 1 }],
243
+ schemas: { ...reg.schemas, ["1"]: update.schema },
244
+ lenses: { ...reg.lenses },
245
+ };
246
+ this.persist(stream, nextReg);
247
+ this.syncInterpreterState(stream, nextReg);
248
+ return Result.ok(nextReg);
249
+ }
250
+
251
+ if (!update.lens) return Result.err({ kind: "bad_request", message: "lens required" });
252
+ const lensRes = validateLensResult(update.lens);
253
+ if (Result.isError(lensRes)) return Result.err({ kind: "bad_request", message: lensRes.error.message });
254
+ const lens = lensRes.value;
255
+ if (lens.from !== currentVersion || lens.to !== currentVersion + 1) {
256
+ return Result.err({
257
+ kind: "version_mismatch",
258
+ message: "lens version mismatch",
259
+ code: "schema_lens_version_mismatch",
260
+ });
261
+ }
262
+ if (lens.schema && lens.schema !== reg.schema) return Result.err({ kind: "bad_request", message: "lens schema mismatch" });
263
+
264
+ const oldSchema = reg.schemas[String(currentVersion)];
265
+ if (!oldSchema) return Result.err({ kind: "bad_request", message: "missing current schema" });
266
+ const proofRes = validateLensAgainstSchemasResult(oldSchema, update.schema, lens);
267
+ if (Result.isError(proofRes)) return Result.err({ kind: "bad_request", message: proofRes.error.message });
268
+ const defaultsRes = fillLensDefaultsResult(lens, update.schema);
269
+ if (Result.isError(defaultsRes)) return Result.err({ kind: "bad_request", message: defaultsRes.error.message });
270
+
271
+ const boundaryRes = bigintToNumberSafeResult(streamRow.next_offset);
272
+ if (Result.isError(boundaryRes)) return Result.err({ kind: "bad_request", message: boundaryRes.error.message });
273
+
274
+ const nextVersion = currentVersion + 1;
275
+ const nextReg: SchemaRegistry = {
276
+ apiVersion: "durable.streams/schema-registry/v1",
277
+ schema: reg.schema ?? stream,
278
+ currentVersion: nextVersion,
279
+ routingKey: update.routingKey ?? reg.routingKey,
280
+ interpreter: update.interpreter === undefined ? reg.interpreter : validatedInterpreter ?? undefined,
281
+ boundaries: [...reg.boundaries, { offset: boundaryRes.value, version: nextVersion }],
282
+ schemas: { ...reg.schemas, [String(nextVersion)]: update.schema },
283
+ lenses: { ...reg.lenses, [String(currentVersion)]: defaultsRes.value },
284
+ };
285
+ this.persist(stream, nextReg);
286
+ this.syncInterpreterState(stream, nextReg);
287
+ return Result.ok(nextReg);
288
+ }
289
+
290
+ updateRoutingKey(stream: string, routingKey: RoutingKeyConfig | null): SchemaRegistry {
291
+ const res = this.updateRoutingKeyResult(stream, routingKey);
292
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
293
+ return res.value;
294
+ }
295
+
296
+ updateRoutingKeyResult(stream: string, routingKey: RoutingKeyConfig | null): Result<SchemaRegistry, SchemaRegistryMutationError> {
297
+ if (routingKey) {
298
+ const pointerRes = parseJsonPointerResult(routingKey.jsonPointer);
299
+ if (Result.isError(pointerRes)) {
300
+ return Result.err({ kind: "bad_request", message: pointerRes.error.message });
301
+ }
302
+ if (typeof routingKey.required !== "boolean") {
303
+ return Result.err({ kind: "bad_request", message: "routingKey.required must be boolean" });
304
+ }
305
+ }
306
+ const regRes = this.getRegistryResult(stream);
307
+ if (Result.isError(regRes)) return Result.err({ kind: "bad_request", message: regRes.error.message, code: regRes.error.code });
308
+ const nextReg: SchemaRegistry = {
309
+ ...regRes.value,
310
+ routingKey: routingKey ?? undefined,
311
+ };
312
+ this.persist(stream, nextReg);
313
+ this.syncInterpreterState(stream, nextReg);
314
+ return Result.ok(nextReg);
315
+ }
316
+
317
+ updateInterpreter(stream: string, interpreter: StreamInterpreterConfig | null): SchemaRegistry {
318
+ const res = this.updateInterpreterResult(stream, interpreter);
319
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
320
+ return res.value;
321
+ }
322
+
323
+ updateInterpreterResult(stream: string, interpreter: StreamInterpreterConfig | null): Result<SchemaRegistry, SchemaRegistryMutationError> {
324
+ let validatedInterpreter: StreamInterpreterConfig | null = interpreter;
325
+ if (interpreter) {
326
+ const interpreterRes = validateStreamInterpreterConfigResult(interpreter);
327
+ if (Result.isError(interpreterRes)) {
328
+ return Result.err({ kind: "bad_request", message: interpreterRes.error.message });
329
+ }
330
+ validatedInterpreter = interpreterRes.value;
331
+ }
332
+ const regRes = this.getRegistryResult(stream);
333
+ if (Result.isError(regRes)) return Result.err({ kind: "bad_request", message: regRes.error.message, code: regRes.error.code });
334
+ const nextReg: SchemaRegistry = {
335
+ ...regRes.value,
336
+ interpreter: validatedInterpreter ?? undefined,
337
+ };
338
+ this.persist(stream, nextReg);
339
+ this.syncInterpreterState(stream, nextReg);
340
+ return Result.ok(nextReg);
341
+ }
342
+
343
+ private persist(stream: string, reg: SchemaRegistry): void {
344
+ const json = serializeRegistry(reg);
345
+ this.db.upsertSchemaRegistry(stream, json);
346
+ this.registryCache.set(stream, { reg, updatedAtMs: this.db.nowMs() });
347
+ }
348
+
349
+ private syncInterpreterState(stream: string, reg: SchemaRegistry): void {
350
+ if (isTouchEnabled(reg.interpreter)) {
351
+ this.db.ensureStreamInterpreter(stream);
352
+ } else {
353
+ this.db.deleteStreamInterpreter(stream);
354
+ }
355
+ }
356
+
357
+ getValidatorForVersion(reg: SchemaRegistry, version: number): Validator | null {
358
+ const schema = reg.schemas[String(version)];
359
+ if (!schema) return null;
360
+ const hash = sha256Hex(JSON.stringify(schema));
361
+ const cached = this.validatorCache.get(hash);
362
+ if (cached) return cached;
363
+ const validate = AJV.compile(schema);
364
+ this.validatorCache.set(hash, validate);
365
+ return validate;
366
+ }
367
+
368
+ getLensChain(reg: SchemaRegistry, fromVersion: number, toVersion: number): CompiledLens[] {
369
+ const res = this.getLensChainResult(reg, fromVersion, toVersion);
370
+ if (Result.isError(res)) throw dsError(res.error.message, { code: res.error.code });
371
+ return res.value;
372
+ }
373
+
374
+ getLensChainResult(reg: SchemaRegistry, fromVersion: number, toVersion: number): Result<CompiledLens[], SchemaRegistryReadError> {
375
+ const key = `${reg.schema}:${fromVersion}->${toVersion}`;
376
+ const cached = this.lensChainCache.get(key);
377
+ if (cached) return Result.ok(cached);
378
+ const chain: CompiledLens[] = [];
379
+ for (let v = fromVersion; v < toVersion; v++) {
380
+ const lensRaw = reg.lenses[String(v)];
381
+ if (!lensRaw) {
382
+ return Result.err({
383
+ kind: "invalid_lens_chain",
384
+ message: `missing lens v${v}->v${v + 1}`,
385
+ });
386
+ }
387
+ const hash = sha256Hex(JSON.stringify(lensRaw));
388
+ let compiled = this.lensCache.get(hash);
389
+ if (!compiled) {
390
+ const compiledRes = compileLensResult(lensFromJson(lensRaw));
391
+ if (Result.isError(compiledRes)) {
392
+ return Result.err({
393
+ kind: "invalid_lens_chain",
394
+ message: compiledRes.error.message,
395
+ });
396
+ }
397
+ compiled = compiledRes.value;
398
+ this.lensCache.set(hash, compiled);
399
+ }
400
+ chain.push(compiled);
401
+ }
402
+ this.lensChainCache.set(key, chain);
403
+ return Result.ok(chain);
404
+ }
405
+ }
@@ -0,0 +1,179 @@
1
+ import { mkdirSync, readdirSync, statSync, unlinkSync, renameSync, existsSync, writeFileSync, readFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+
4
+ export type SegmentCacheStats = {
5
+ hits: number;
6
+ misses: number;
7
+ evictions: number;
8
+ bytesAdded: number;
9
+ usedBytes: number;
10
+ maxBytes: number;
11
+ entryCount: number;
12
+ };
13
+
14
+ export class SegmentDiskCache {
15
+ private readonly rootDir: string;
16
+ private readonly maxBytes: number;
17
+ private readonly entries = new Map<string, { path: string; size: number }>();
18
+ private totalBytes = 0;
19
+ private hits = 0;
20
+ private misses = 0;
21
+ private evictions = 0;
22
+ private bytesAdded = 0;
23
+
24
+ constructor(rootDir: string, maxBytes: number) {
25
+ this.rootDir = rootDir;
26
+ this.maxBytes = maxBytes;
27
+ if (this.maxBytes > 0) {
28
+ mkdirSync(this.rootDir, { recursive: true });
29
+ this.loadIndex();
30
+ }
31
+ }
32
+
33
+ private loadIndex(): void {
34
+ if (!existsSync(this.rootDir)) return;
35
+ const files: Array<{ key: string; path: string; size: number; mtimeMs: number }> = [];
36
+ const walk = (dir: string) => {
37
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
38
+ const full = join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ walk(full);
41
+ } else if (entry.isFile()) {
42
+ const stat = statSync(full);
43
+ const key = relative(this.rootDir, full);
44
+ files.push({ key, path: full, size: stat.size, mtimeMs: stat.mtimeMs });
45
+ }
46
+ }
47
+ };
48
+ walk(this.rootDir);
49
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs);
50
+ for (const f of files) {
51
+ this.entries.set(f.key, { path: f.path, size: f.size });
52
+ this.totalBytes += f.size;
53
+ }
54
+ this.evictIfNeeded(0);
55
+ }
56
+
57
+ getPath(objectKey: string): string {
58
+ return join(this.rootDir, objectKey);
59
+ }
60
+
61
+ has(objectKey: string): boolean {
62
+ const exists = this.entries.has(objectKey) && existsSync(this.getPath(objectKey));
63
+ if (!exists) this.entries.delete(objectKey);
64
+ return exists;
65
+ }
66
+
67
+ touch(objectKey: string): void {
68
+ const entry = this.entries.get(objectKey);
69
+ if (!entry) return;
70
+ this.entries.delete(objectKey);
71
+ this.entries.set(objectKey, entry);
72
+ }
73
+
74
+ recordHit(): void {
75
+ this.hits += 1;
76
+ }
77
+
78
+ recordMiss(): void {
79
+ this.misses += 1;
80
+ }
81
+
82
+ get(objectKey: string): Uint8Array | null {
83
+ if (!this.has(objectKey)) {
84
+ this.recordMiss();
85
+ return null;
86
+ }
87
+ this.recordHit();
88
+ this.touch(objectKey);
89
+ const path = this.getPath(objectKey);
90
+ return new Uint8Array(readFileSync(path));
91
+ }
92
+
93
+ put(objectKey: string, bytes: Uint8Array): boolean {
94
+ if (this.maxBytes <= 0) return false;
95
+ const sizeBytes = bytes.byteLength;
96
+ if (sizeBytes > this.maxBytes) return false;
97
+ this.evictIfNeeded(sizeBytes);
98
+ const dest = this.getPath(objectKey);
99
+ mkdirSync(dirname(dest), { recursive: true });
100
+ const tmp = `${dest}.tmp-${Date.now()}`;
101
+ try {
102
+ writeFileSync(tmp, bytes);
103
+ renameSync(tmp, dest);
104
+ } catch {
105
+ try {
106
+ unlinkSync(tmp);
107
+ } catch {
108
+ // ignore
109
+ }
110
+ return false;
111
+ }
112
+ const existing = this.entries.get(objectKey);
113
+ if (existing) this.totalBytes = Math.max(0, this.totalBytes - existing.size);
114
+ this.entries.set(objectKey, { path: dest, size: sizeBytes });
115
+ this.totalBytes += sizeBytes;
116
+ this.bytesAdded += sizeBytes;
117
+ return true;
118
+ }
119
+
120
+ putFromLocal(objectKey: string, localPath: string, sizeBytes: number): boolean {
121
+ if (this.maxBytes <= 0) return false;
122
+ if (sizeBytes > this.maxBytes) return false;
123
+ this.evictIfNeeded(sizeBytes);
124
+ const dest = this.getPath(objectKey);
125
+ mkdirSync(dirname(dest), { recursive: true });
126
+ try {
127
+ renameSync(localPath, dest);
128
+ } catch {
129
+ return false;
130
+ }
131
+ const existing = this.entries.get(objectKey);
132
+ if (existing) this.totalBytes = Math.max(0, this.totalBytes - existing.size);
133
+ this.entries.set(objectKey, { path: dest, size: sizeBytes });
134
+ this.totalBytes += sizeBytes;
135
+ this.bytesAdded += sizeBytes;
136
+ return true;
137
+ }
138
+
139
+ remove(objectKey: string): void {
140
+ const entry = this.entries.get(objectKey);
141
+ if (!entry) return;
142
+ try {
143
+ unlinkSync(entry.path);
144
+ } catch {
145
+ // ignore
146
+ }
147
+ this.totalBytes = Math.max(0, this.totalBytes - entry.size);
148
+ this.entries.delete(objectKey);
149
+ }
150
+
151
+ private evictIfNeeded(incomingBytes: number): void {
152
+ while (this.totalBytes + incomingBytes > this.maxBytes && this.entries.size > 0) {
153
+ const oldestKey = this.entries.keys().next().value as string;
154
+ const entry = this.entries.get(oldestKey);
155
+ if (entry) {
156
+ try {
157
+ unlinkSync(entry.path);
158
+ } catch {
159
+ // ignore
160
+ }
161
+ this.totalBytes = Math.max(0, this.totalBytes - entry.size);
162
+ this.evictions += 1;
163
+ }
164
+ this.entries.delete(oldestKey);
165
+ }
166
+ }
167
+
168
+ stats(): SegmentCacheStats {
169
+ return {
170
+ hits: this.hits,
171
+ misses: this.misses,
172
+ evictions: this.evictions,
173
+ bytesAdded: this.bytesAdded,
174
+ usedBytes: this.totalBytes,
175
+ maxBytes: this.maxBytes,
176
+ entryCount: this.entries.size,
177
+ };
178
+ }
179
+ }