@tungthedev/streams-server 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/CODE_OF_CONDUCT.md +45 -0
- package/CONTRIBUTING.md +76 -0
- package/LICENSE +201 -0
- package/README.md +58 -0
- package/SECURITY.md +42 -0
- package/bin/prisma-streams-server +2 -0
- package/package.json +46 -0
- package/src/app.ts +583 -0
- package/src/app_core.ts +3144 -0
- package/src/app_local.ts +206 -0
- package/src/auth.ts +124 -0
- package/src/auto_tune.ts +69 -0
- package/src/backpressure.ts +66 -0
- package/src/bootstrap.ts +613 -0
- package/src/compute/demo_entry.ts +415 -0
- package/src/compute/demo_site.ts +1242 -0
- package/src/compute/entry.ts +19 -0
- package/src/compute/package_entry.ts +4 -0
- package/src/compute/virtual-modules.d.ts +15 -0
- package/src/compute/worker_module_url.ts +9 -0
- package/src/concurrency_gate.ts +108 -0
- package/src/config.ts +402 -0
- package/src/db/bootstrap_store.ts +9 -0
- package/src/db/db.ts +2424 -0
- package/src/db/schema.ts +925 -0
- package/src/db/sqlite_manifest_snapshot.ts +81 -0
- package/src/db/sqlite_touch_store.ts +491 -0
- package/src/db/sqlite_wal_store.ts +472 -0
- package/src/details/full_mode_details.ts +568 -0
- package/src/expiry_sweeper.ts +47 -0
- package/src/foreground_activity.ts +55 -0
- package/src/hist.ts +169 -0
- package/src/index/binary_fuse.ts +379 -0
- package/src/index/indexer.ts +947 -0
- package/src/index/lexicon_file_cache.ts +261 -0
- package/src/index/lexicon_format.ts +93 -0
- package/src/index/lexicon_indexer.ts +863 -0
- package/src/index/run_cache.ts +84 -0
- package/src/index/run_format.ts +213 -0
- package/src/index/schedule.ts +28 -0
- package/src/index/secondary_indexer.ts +901 -0
- package/src/index/secondary_schema.ts +105 -0
- package/src/ingest.ts +309 -0
- package/src/lens/lens.ts +501 -0
- package/src/manifest.ts +249 -0
- package/src/memory.ts +334 -0
- package/src/metrics.ts +147 -0
- package/src/metrics_emitter.ts +83 -0
- package/src/notifier.ts +180 -0
- package/src/objectstore/accounting.ts +151 -0
- package/src/objectstore/interface.ts +13 -0
- package/src/objectstore/mock_r2.ts +269 -0
- package/src/objectstore/null.ts +32 -0
- package/src/objectstore/r2.ts +318 -0
- package/src/observe/pairing.ts +61 -0
- package/src/observe/request.ts +772 -0
- package/src/offset.ts +70 -0
- package/src/postgres/bootstrap.ts +269 -0
- package/src/postgres/companions.ts +197 -0
- package/src/postgres/control_restore.ts +109 -0
- package/src/postgres/details.ts +189 -0
- package/src/postgres/lexicon_index.ts +260 -0
- package/src/postgres/routing_index.ts +189 -0
- package/src/postgres/rows.ts +132 -0
- package/src/postgres/schema.ts +355 -0
- package/src/postgres/secondary_index.ts +238 -0
- package/src/postgres/segments.ts +900 -0
- package/src/postgres/stats.ts +103 -0
- package/src/postgres/store.ts +947 -0
- package/src/postgres/touch.ts +591 -0
- package/src/postgres/types.ts +32 -0
- package/src/profiles/evlog/schema.ts +234 -0
- package/src/profiles/evlog.ts +473 -0
- package/src/profiles/generic.ts +51 -0
- package/src/profiles/index.ts +237 -0
- package/src/profiles/metrics/block_format.ts +109 -0
- package/src/profiles/metrics/normalize.ts +366 -0
- package/src/profiles/metrics/schema.ts +319 -0
- package/src/profiles/metrics.ts +83 -0
- package/src/profiles/otelTraces/normalize.ts +955 -0
- package/src/profiles/otelTraces/otlp.ts +1002 -0
- package/src/profiles/otelTraces/schema.ts +408 -0
- package/src/profiles/otelTraces.ts +390 -0
- package/src/profiles/profile.ts +284 -0
- package/src/profiles/stateProtocol/change_event_conformance.typecheck.ts +35 -0
- package/src/profiles/stateProtocol/changes.ts +24 -0
- package/src/profiles/stateProtocol/ingest.ts +115 -0
- package/src/profiles/stateProtocol/routes.ts +511 -0
- package/src/profiles/stateProtocol/types.ts +6 -0
- package/src/profiles/stateProtocol/validation.ts +51 -0
- package/src/profiles/stateProtocol.ts +107 -0
- package/src/read_filter.ts +468 -0
- package/src/reader.ts +2986 -0
- package/src/runtime/hash.ts +156 -0
- package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
- package/src/runtime/hash_vendor/NOTICE.md +8 -0
- package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
- package/src/runtime/host_runtime.ts +5 -0
- package/src/runtime_memory.ts +200 -0
- package/src/runtime_memory_sampler.ts +237 -0
- package/src/schema/lens_schema.ts +290 -0
- package/src/schema/proof.ts +547 -0
- package/src/schema/read_json.ts +51 -0
- package/src/schema/registry.ts +966 -0
- package/src/search/agg_format.ts +638 -0
- package/src/search/aggregate.ts +409 -0
- package/src/search/binary/codec.ts +162 -0
- package/src/search/binary/docset.ts +67 -0
- package/src/search/binary/restart_strings.ts +181 -0
- package/src/search/binary/varint.ts +34 -0
- package/src/search/bitset.ts +19 -0
- package/src/search/col_format.ts +382 -0
- package/src/search/col_runtime.ts +59 -0
- package/src/search/column_encoding.ts +43 -0
- package/src/search/companion_file_cache.ts +319 -0
- package/src/search/companion_format.ts +327 -0
- package/src/search/companion_manager.ts +1305 -0
- package/src/search/companion_plan.ts +229 -0
- package/src/search/exact_format.ts +281 -0
- package/src/search/exact_runtime.ts +55 -0
- package/src/search/fts_format.ts +423 -0
- package/src/search/fts_runtime.ts +333 -0
- package/src/search/query.ts +875 -0
- package/src/search/schema.ts +245 -0
- package/src/segment/cache.ts +270 -0
- package/src/segment/cached_segment.ts +89 -0
- package/src/segment/format.ts +403 -0
- package/src/segment/segmenter.ts +412 -0
- package/src/segment/segmenter_worker.ts +72 -0
- package/src/segment/segmenter_workers.ts +130 -0
- package/src/server.ts +264 -0
- package/src/server_auto_tune.ts +158 -0
- package/src/sqlite/adapter.ts +335 -0
- package/src/sqlite/runtime_stats.ts +163 -0
- package/src/stats.ts +205 -0
- package/src/store/append.ts +50 -0
- package/src/store/bootstrap_restore_store.ts +71 -0
- package/src/store/capabilities.ts +86 -0
- package/src/store/full_mode_details_store.ts +71 -0
- package/src/store/index_store.ts +104 -0
- package/src/store/profile_touch_store.ts +1 -0
- package/src/store/rows.ts +144 -0
- package/src/store/schema_profile_store.ts +73 -0
- package/src/store/schema_publication.ts +6 -0
- package/src/store/segment_manifest_store.ts +129 -0
- package/src/store/segment_read_store.ts +22 -0
- package/src/store/stats_accounting_store.ts +83 -0
- package/src/store/touch_store.ts +98 -0
- package/src/store/wal_store.ts +21 -0
- package/src/stream_size_reconciler.ts +100 -0
- package/src/touch/canonical_change.ts +7 -0
- package/src/touch/live_keys.ts +158 -0
- package/src/touch/live_metrics.ts +841 -0
- package/src/touch/live_templates.ts +449 -0
- package/src/touch/manager.ts +1292 -0
- package/src/touch/process_batch.ts +576 -0
- package/src/touch/processor_worker.ts +85 -0
- package/src/touch/spec.ts +459 -0
- package/src/touch/touch_journal.ts +771 -0
- package/src/touch/touch_key_id.ts +20 -0
- package/src/touch/worker_pool.ts +191 -0
- package/src/touch/worker_protocol.ts +57 -0
- package/src/types/proper-lockfile.d.ts +1 -0
- package/src/uploader.ts +358 -0
- package/src/util/base32_crockford.ts +81 -0
- package/src/util/bloom256.ts +67 -0
- package/src/util/byte_lru.ts +73 -0
- package/src/util/cleanup.ts +22 -0
- package/src/util/crc32c.ts +29 -0
- package/src/util/ds_error.ts +15 -0
- package/src/util/duration.ts +17 -0
- package/src/util/endian.ts +53 -0
- package/src/util/json_pointer.ts +148 -0
- package/src/util/log.ts +25 -0
- package/src/util/lru.ts +53 -0
- package/src/util/retry.ts +35 -0
- package/src/util/siphash.ts +71 -0
- package/src/util/stream_paths.ts +50 -0
- package/src/util/time.ts +14 -0
- package/src/util/yield.ts +3 -0
- package/src/util/zstd.ts +24 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync, closeSync, readSync, copyFileSync, createReadStream } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { GetOptions, ObjectStore, PutResult } from "./interface";
|
|
5
|
+
import { dsError } from "../util/ds_error.ts";
|
|
6
|
+
|
|
7
|
+
export type MockR2Faults = {
|
|
8
|
+
putDelayMs?: number;
|
|
9
|
+
getDelayMs?: number;
|
|
10
|
+
headDelayMs?: number;
|
|
11
|
+
listDelayMs?: number;
|
|
12
|
+
failPutPrefix?: string; // fail PUT when key starts with prefix
|
|
13
|
+
failPutEvery?: number; // fail every Nth PUT
|
|
14
|
+
failGetEvery?: number; // fail every Nth GET
|
|
15
|
+
failHeadEvery?: number;
|
|
16
|
+
failListEvery?: number;
|
|
17
|
+
timeoutPutEvery?: number;
|
|
18
|
+
timeoutGetEvery?: number;
|
|
19
|
+
timeoutHeadEvery?: number;
|
|
20
|
+
timeoutListEvery?: number;
|
|
21
|
+
partialGetEvery?: number; // return truncated bytes every Nth GET
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type MockR2Options = {
|
|
25
|
+
faults?: MockR2Faults;
|
|
26
|
+
maxInMemoryBytes?: number; // spill when object exceeds this
|
|
27
|
+
spillDir?: string; // optional directory for large objects
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type StoredObject = {
|
|
31
|
+
etag: string;
|
|
32
|
+
size: number;
|
|
33
|
+
bytes?: Uint8Array;
|
|
34
|
+
path?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function sleep(ms: number): Promise<void> {
|
|
38
|
+
if (ms <= 0) return Promise.resolve();
|
|
39
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class MockR2Store implements ObjectStore {
|
|
43
|
+
private readonly data = new Map<string, StoredObject>();
|
|
44
|
+
private readonly faults: MockR2Faults;
|
|
45
|
+
private readonly maxInMemoryBytes: number;
|
|
46
|
+
private readonly spillDir?: string;
|
|
47
|
+
|
|
48
|
+
private putCount = 0;
|
|
49
|
+
private getCount = 0;
|
|
50
|
+
private headCount = 0;
|
|
51
|
+
private listCount = 0;
|
|
52
|
+
private memBytes = 0;
|
|
53
|
+
|
|
54
|
+
constructor(opts: MockR2Options | MockR2Faults = {}) {
|
|
55
|
+
if ("failPutEvery" in opts || "putDelayMs" in opts) {
|
|
56
|
+
this.faults = opts as MockR2Faults;
|
|
57
|
+
this.maxInMemoryBytes = Number.POSITIVE_INFINITY;
|
|
58
|
+
} else {
|
|
59
|
+
const o = opts as MockR2Options;
|
|
60
|
+
this.faults = o.faults ?? {};
|
|
61
|
+
this.maxInMemoryBytes = o.maxInMemoryBytes ?? Number.POSITIVE_INFINITY;
|
|
62
|
+
this.spillDir = o.spillDir;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private mkEtag(bytes: Uint8Array): string {
|
|
67
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async hashFile(path: string): Promise<string> {
|
|
71
|
+
const hash = createHash("sha256");
|
|
72
|
+
await new Promise<void>((resolve, reject) => {
|
|
73
|
+
const stream = createReadStream(path);
|
|
74
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
75
|
+
stream.on("error", (err) => reject(err));
|
|
76
|
+
stream.on("end", () => resolve());
|
|
77
|
+
});
|
|
78
|
+
return hash.digest("hex");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private mkPath(key: string): string {
|
|
82
|
+
const hex = createHash("sha256").update(key).digest("hex");
|
|
83
|
+
return this.spillDir ? join(this.spillDir, `${hex}.bin`) : hex;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private shouldSpill(len: number): boolean {
|
|
87
|
+
return this.spillDir != null && len > this.maxInMemoryBytes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private maybeFail(count: number, every?: number, msg?: string): void {
|
|
91
|
+
if (every && count % every === 0) throw dsError(msg ?? "MockR2: injected failure");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private maybeTimeout(count: number, every?: number, msg?: string): void {
|
|
95
|
+
if (every && count % every === 0) throw dsError(msg ?? "MockR2: injected timeout");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async put(key: string, bytes: Uint8Array): Promise<PutResult> {
|
|
99
|
+
this.putCount++;
|
|
100
|
+
if (this.faults.failPutPrefix && key.startsWith(this.faults.failPutPrefix)) {
|
|
101
|
+
throw dsError(`MockR2: injected PUT failure for ${key}`);
|
|
102
|
+
}
|
|
103
|
+
this.maybeFail(this.putCount, this.faults.failPutEvery, `MockR2: injected PUT failure for ${key}`);
|
|
104
|
+
this.maybeTimeout(this.putCount, this.faults.timeoutPutEvery, `MockR2: injected PUT timeout for ${key}`);
|
|
105
|
+
await sleep(this.faults.putDelayMs ?? 0);
|
|
106
|
+
|
|
107
|
+
const copy = new Uint8Array(bytes);
|
|
108
|
+
const etag = this.mkEtag(copy);
|
|
109
|
+
const size = copy.byteLength;
|
|
110
|
+
|
|
111
|
+
const existing = this.data.get(key);
|
|
112
|
+
if (existing?.bytes) this.memBytes -= existing.bytes.byteLength;
|
|
113
|
+
if (existing?.path) {
|
|
114
|
+
try { unlinkSync(existing.path); } catch { /* ignore */ }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (this.shouldSpill(size)) {
|
|
118
|
+
const path = this.mkPath(key);
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
+
writeFileSync(path, copy);
|
|
121
|
+
this.data.set(key, { etag, size, path });
|
|
122
|
+
} else {
|
|
123
|
+
this.memBytes += size;
|
|
124
|
+
this.data.set(key, { etag, size, bytes: copy });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { etag };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async putFile(key: string, path: string, size: number): Promise<PutResult> {
|
|
131
|
+
this.putCount++;
|
|
132
|
+
if (this.faults.failPutPrefix && key.startsWith(this.faults.failPutPrefix)) {
|
|
133
|
+
throw dsError(`MockR2: injected PUT failure for ${key}`);
|
|
134
|
+
}
|
|
135
|
+
this.maybeFail(this.putCount, this.faults.failPutEvery, `MockR2: injected PUT failure for ${key}`);
|
|
136
|
+
this.maybeTimeout(this.putCount, this.faults.timeoutPutEvery, `MockR2: injected PUT timeout for ${key}`);
|
|
137
|
+
await sleep(this.faults.putDelayMs ?? 0);
|
|
138
|
+
|
|
139
|
+
const etag = await this.hashFile(path);
|
|
140
|
+
|
|
141
|
+
const existing = this.data.get(key);
|
|
142
|
+
if (existing?.bytes) this.memBytes -= existing.bytes.byteLength;
|
|
143
|
+
if (existing?.path) {
|
|
144
|
+
try { unlinkSync(existing.path); } catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this.shouldSpill(size)) {
|
|
148
|
+
const dest = this.mkPath(key);
|
|
149
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
150
|
+
copyFileSync(path, dest);
|
|
151
|
+
this.data.set(key, { etag, size, path: dest });
|
|
152
|
+
} else {
|
|
153
|
+
const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
|
|
154
|
+
this.memBytes += bytes.byteLength;
|
|
155
|
+
this.data.set(key, { etag, size, bytes });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { etag };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async get(key: string, opts: GetOptions = {}): Promise<Uint8Array | null> {
|
|
162
|
+
this.getCount++;
|
|
163
|
+
this.maybeFail(this.getCount, this.faults.failGetEvery, `MockR2: injected GET failure for ${key}`);
|
|
164
|
+
this.maybeTimeout(this.getCount, this.faults.timeoutGetEvery, `MockR2: injected GET timeout for ${key}`);
|
|
165
|
+
await sleep(this.faults.getDelayMs ?? 0);
|
|
166
|
+
|
|
167
|
+
const entry = this.data.get(key);
|
|
168
|
+
if (!entry) return null;
|
|
169
|
+
|
|
170
|
+
const range = opts.range;
|
|
171
|
+
const total = entry.size;
|
|
172
|
+
const start = range?.start ?? 0;
|
|
173
|
+
const end = range?.end ?? total - 1;
|
|
174
|
+
if (start >= total) return new Uint8Array(0);
|
|
175
|
+
const clampEnd = Math.min(end, total - 1);
|
|
176
|
+
const length = Math.max(0, clampEnd - start + 1);
|
|
177
|
+
|
|
178
|
+
let out: Uint8Array;
|
|
179
|
+
if (entry.bytes) {
|
|
180
|
+
out = entry.bytes.slice(start, start + length);
|
|
181
|
+
} else if (entry.path) {
|
|
182
|
+
if (length === total) {
|
|
183
|
+
out = readFileSync(entry.path);
|
|
184
|
+
} else {
|
|
185
|
+
const fd = openSync(entry.path, "r");
|
|
186
|
+
try {
|
|
187
|
+
const buf = new Uint8Array(length);
|
|
188
|
+
readSync(fd, buf, 0, length, start);
|
|
189
|
+
out = buf;
|
|
190
|
+
} finally {
|
|
191
|
+
closeSync(fd);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.faults.partialGetEvery && this.getCount % this.faults.partialGetEvery === 0) {
|
|
199
|
+
const half = Math.max(0, Math.floor(out.byteLength / 2));
|
|
200
|
+
return out.slice(0, half);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async head(key: string): Promise<{ etag: string; size: number } | null> {
|
|
207
|
+
this.headCount++;
|
|
208
|
+
this.maybeFail(this.headCount, this.faults.failHeadEvery, `MockR2: injected HEAD failure for ${key}`);
|
|
209
|
+
this.maybeTimeout(this.headCount, this.faults.timeoutHeadEvery, `MockR2: injected HEAD timeout for ${key}`);
|
|
210
|
+
await sleep(this.faults.headDelayMs ?? 0);
|
|
211
|
+
|
|
212
|
+
const v = this.data.get(key);
|
|
213
|
+
if (!v) return null;
|
|
214
|
+
return { etag: v.etag, size: v.size };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async delete(key: string): Promise<void> {
|
|
218
|
+
const v = this.data.get(key);
|
|
219
|
+
if (v?.bytes) this.memBytes -= v.bytes.byteLength;
|
|
220
|
+
if (v?.path) {
|
|
221
|
+
try { unlinkSync(v.path); } catch { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
this.data.delete(key);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async list(prefix: string): Promise<string[]> {
|
|
227
|
+
this.listCount++;
|
|
228
|
+
this.maybeFail(this.listCount, this.faults.failListEvery, "MockR2: injected LIST failure");
|
|
229
|
+
this.maybeTimeout(this.listCount, this.faults.timeoutListEvery, "MockR2: injected LIST timeout");
|
|
230
|
+
await sleep(this.faults.listDelayMs ?? 0);
|
|
231
|
+
|
|
232
|
+
const out: string[] = [];
|
|
233
|
+
for (const k of this.data.keys()) {
|
|
234
|
+
if (k.startsWith(prefix)) out.push(k);
|
|
235
|
+
}
|
|
236
|
+
out.sort();
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Helpers for tests
|
|
241
|
+
has(key: string): boolean {
|
|
242
|
+
return this.data.has(key);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
size(): number {
|
|
246
|
+
return this.data.size;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
memoryBytes(): number {
|
|
250
|
+
return this.memBytes;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
stats(): { puts: number; gets: number; heads: number; lists: number; memoryBytes: number } {
|
|
254
|
+
return {
|
|
255
|
+
puts: this.putCount,
|
|
256
|
+
gets: this.getCount,
|
|
257
|
+
heads: this.headCount,
|
|
258
|
+
lists: this.listCount,
|
|
259
|
+
memoryBytes: this.memBytes,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
resetStats(): void {
|
|
264
|
+
this.putCount = 0;
|
|
265
|
+
this.getCount = 0;
|
|
266
|
+
this.headCount = 0;
|
|
267
|
+
this.listCount = 0;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { GetOptions, ObjectStore, PutResult } from "./interface";
|
|
2
|
+
import { dsError } from "../util/ds_error.ts";
|
|
3
|
+
|
|
4
|
+
function disabled(op: string, key?: string): never {
|
|
5
|
+
throw dsError(`object store disabled in local mode (${op}${key ? `: ${key}` : ""})`);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class NullObjectStore implements ObjectStore {
|
|
9
|
+
async put(key: string, _data: Uint8Array, _opts?: { contentType?: string; contentLength?: number }): Promise<PutResult> {
|
|
10
|
+
return disabled("put", key);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async putFile(key: string, _path: string, _size: number, _opts?: { contentType?: string }): Promise<PutResult> {
|
|
14
|
+
return disabled("putFile", key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async get(key: string, _opts?: GetOptions): Promise<Uint8Array | null> {
|
|
18
|
+
return disabled("get", key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async head(key: string): Promise<{ etag: string; size: number } | null> {
|
|
22
|
+
return disabled("head", key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async delete(key: string): Promise<void> {
|
|
26
|
+
return disabled("delete", key);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async list(prefix: string): Promise<string[]> {
|
|
30
|
+
return disabled("list", prefix);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import type { GetOptions, ObjectStore, PutResult } from "./interface";
|
|
5
|
+
import { dsError } from "../util/ds_error.ts";
|
|
6
|
+
|
|
7
|
+
export type R2Config = {
|
|
8
|
+
accountId: string;
|
|
9
|
+
bucket: string;
|
|
10
|
+
accessKeyId: string;
|
|
11
|
+
secretAccessKey: string;
|
|
12
|
+
region?: string;
|
|
13
|
+
endpoint?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const EMPTY_SHA256 = sha256Hex(new Uint8Array(0));
|
|
17
|
+
const XML_DECODER = new TextDecoder();
|
|
18
|
+
|
|
19
|
+
function sha256Hex(data: Uint8Array | string): string {
|
|
20
|
+
return createHash("sha256").update(data).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readResponseBytes(res: Response): Promise<Uint8Array> {
|
|
24
|
+
if (!res.body) return new Uint8Array(0);
|
|
25
|
+
const reader = res.body.getReader();
|
|
26
|
+
const chunks: Uint8Array[] = [];
|
|
27
|
+
let total = 0;
|
|
28
|
+
try {
|
|
29
|
+
for (;;) {
|
|
30
|
+
const next = await reader.read();
|
|
31
|
+
if (next.done) break;
|
|
32
|
+
const chunk = next.value;
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
total += chunk.byteLength;
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
reader.releaseLock();
|
|
38
|
+
}
|
|
39
|
+
if (chunks.length === 1) return chunks[0]!;
|
|
40
|
+
const out = new Uint8Array(total);
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (const chunk of chunks) {
|
|
43
|
+
out.set(chunk, offset);
|
|
44
|
+
offset += chunk.byteLength;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readResponseText(res: Response): Promise<string> {
|
|
50
|
+
return XML_DECODER.decode(await readResponseBytes(res));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fileStreamBody(path: string): BodyInit {
|
|
54
|
+
return Readable.toWeb(createReadStream(path)) as unknown as BodyInit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function sha256FileHex(path: string): Promise<string> {
|
|
58
|
+
const hash = createHash("sha256");
|
|
59
|
+
await new Promise<void>((resolve, reject) => {
|
|
60
|
+
const stream = createReadStream(path);
|
|
61
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
62
|
+
stream.on("error", (err) => reject(err));
|
|
63
|
+
stream.on("end", () => resolve());
|
|
64
|
+
});
|
|
65
|
+
return hash.digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stripQuotes(value: string | null): string {
|
|
69
|
+
if (!value) return "";
|
|
70
|
+
return value.replace(/^\"|\"$/g, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function encodePathPart(part: string): string {
|
|
74
|
+
return encodeURIComponent(part).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function encodeKeyPath(key: string): string {
|
|
78
|
+
return key.split("/").map(encodePathPart).join("/");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function encodeQueryPart(part: string): string {
|
|
82
|
+
return encodePathPart(part).replace(/%7E/g, "~");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hmac(key: string | Buffer, data: string): Buffer {
|
|
86
|
+
return createHmac("sha256", key).update(data).digest();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function xmlDecode(value: string): string {
|
|
90
|
+
return value
|
|
91
|
+
.replace(/</g, "<")
|
|
92
|
+
.replace(/>/g, ">")
|
|
93
|
+
.replace(/"/g, "\"")
|
|
94
|
+
.replace(/'/g, "'")
|
|
95
|
+
.replace(/&/g, "&");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function firstXmlTag(xml: string, tag: string): string | null {
|
|
99
|
+
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
|
|
100
|
+
return match ? xmlDecode(match[1] ?? "") : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function allXmlTags(xml: string, tag: string): string[] {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g");
|
|
106
|
+
for (;;) {
|
|
107
|
+
const match = re.exec(xml);
|
|
108
|
+
if (!match) break;
|
|
109
|
+
out.push(xmlDecode(match[1] ?? ""));
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class R2ObjectStore implements ObjectStore {
|
|
115
|
+
private readonly endpoint: URL;
|
|
116
|
+
private readonly bucket: string;
|
|
117
|
+
private readonly accessKeyId: string;
|
|
118
|
+
private readonly secretAccessKey: string;
|
|
119
|
+
private readonly region: string;
|
|
120
|
+
|
|
121
|
+
constructor(cfg: R2Config) {
|
|
122
|
+
this.endpoint = new URL(cfg.endpoint ?? `https://${cfg.accountId}.r2.cloudflarestorage.com`);
|
|
123
|
+
this.bucket = cfg.bucket;
|
|
124
|
+
this.accessKeyId = cfg.accessKeyId;
|
|
125
|
+
this.secretAccessKey = cfg.secretAccessKey;
|
|
126
|
+
this.region = cfg.region ?? "auto";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private objectPath(key: string): string {
|
|
130
|
+
return `/${encodePathPart(this.bucket)}/${encodeKeyPath(key)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private requestUrl(path: string, query: Array<[string, string]> = []): URL {
|
|
134
|
+
const url = new URL(this.endpoint.href);
|
|
135
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
136
|
+
url.pathname = `${basePath}${path}`;
|
|
137
|
+
url.search = "";
|
|
138
|
+
for (const [key, value] of query) url.searchParams.append(key, value);
|
|
139
|
+
return url;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private authorization(method: string, url: URL, headers: Headers, payloadHash: string, amzDate: string): string {
|
|
143
|
+
const date = amzDate.slice(0, 8);
|
|
144
|
+
const host = url.host;
|
|
145
|
+
headers.set("host", host);
|
|
146
|
+
headers.set("x-amz-content-sha256", payloadHash);
|
|
147
|
+
headers.set("x-amz-date", amzDate);
|
|
148
|
+
|
|
149
|
+
const signedHeaderNames = [...headers.keys()].map((h) => h.toLowerCase()).sort();
|
|
150
|
+
const canonicalHeaders = signedHeaderNames
|
|
151
|
+
.map((name) => `${name}:${headers.get(name)?.trim().replace(/\s+/g, " ") ?? ""}\n`)
|
|
152
|
+
.join("");
|
|
153
|
+
const signedHeaders = signedHeaderNames.join(";");
|
|
154
|
+
const queryEntries = [...url.searchParams.entries()].sort(([ak, av], [bk, bv]) => {
|
|
155
|
+
if (ak === bk) return av < bv ? -1 : av > bv ? 1 : 0;
|
|
156
|
+
return ak < bk ? -1 : 1;
|
|
157
|
+
});
|
|
158
|
+
const canonicalQuery = queryEntries
|
|
159
|
+
.map(([key, value]) => `${encodeQueryPart(key)}=${encodeQueryPart(value)}`)
|
|
160
|
+
.join("&");
|
|
161
|
+
const canonicalRequest = [
|
|
162
|
+
method,
|
|
163
|
+
url.pathname,
|
|
164
|
+
canonicalQuery,
|
|
165
|
+
canonicalHeaders,
|
|
166
|
+
signedHeaders,
|
|
167
|
+
payloadHash,
|
|
168
|
+
].join("\n");
|
|
169
|
+
const scope = `${date}/${this.region}/s3/aws4_request`;
|
|
170
|
+
const stringToSign = [
|
|
171
|
+
"AWS4-HMAC-SHA256",
|
|
172
|
+
amzDate,
|
|
173
|
+
scope,
|
|
174
|
+
sha256Hex(canonicalRequest),
|
|
175
|
+
].join("\n");
|
|
176
|
+
const kDate = hmac(`AWS4${this.secretAccessKey}`, date);
|
|
177
|
+
const kRegion = hmac(kDate, this.region);
|
|
178
|
+
const kService = hmac(kRegion, "s3");
|
|
179
|
+
const kSigning = hmac(kService, "aws4_request");
|
|
180
|
+
const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
|
|
181
|
+
return `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async request(
|
|
185
|
+
method: string,
|
|
186
|
+
path: string,
|
|
187
|
+
opts: {
|
|
188
|
+
query?: Array<[string, string]>;
|
|
189
|
+
headers?: HeadersInit;
|
|
190
|
+
body?: BodyInit;
|
|
191
|
+
payloadHash?: string;
|
|
192
|
+
} = {}
|
|
193
|
+
): Promise<Response> {
|
|
194
|
+
const url = this.requestUrl(path, opts.query ?? []);
|
|
195
|
+
const headers = new Headers(opts.headers);
|
|
196
|
+
const now = new Date();
|
|
197
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
198
|
+
const payloadHash = opts.payloadHash ?? EMPTY_SHA256;
|
|
199
|
+
headers.set("authorization", this.authorization(method, url, headers, payloadHash, amzDate));
|
|
200
|
+
return fetch(url, {
|
|
201
|
+
method,
|
|
202
|
+
headers,
|
|
203
|
+
body: opts.body,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private wrapStatus(op: string, key: string, res: Response): never {
|
|
208
|
+
throw dsError(`R2 ${op} failed for ${key}: HTTP ${res.status} ${res.statusText}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private wrapError(op: string, key: string, err: unknown): never {
|
|
212
|
+
const message = String((err as any)?.message ?? err);
|
|
213
|
+
throw dsError(`R2 ${op} failed for ${key}: ${message}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async put(key: string, data: Uint8Array, opts: { contentType?: string; contentLength?: number } = {}): Promise<PutResult> {
|
|
217
|
+
const payloadHash = sha256Hex(data);
|
|
218
|
+
try {
|
|
219
|
+
const headers: Record<string, string> = {
|
|
220
|
+
"content-length": String(opts.contentLength ?? data.byteLength),
|
|
221
|
+
};
|
|
222
|
+
if (opts.contentType) headers["content-type"] = opts.contentType;
|
|
223
|
+
const res = await this.request("PUT", this.objectPath(key), {
|
|
224
|
+
headers,
|
|
225
|
+
body: data as unknown as BodyInit,
|
|
226
|
+
payloadHash,
|
|
227
|
+
});
|
|
228
|
+
if (!res.ok) this.wrapStatus("PUT", key, res);
|
|
229
|
+
return { etag: payloadHash };
|
|
230
|
+
} catch (err) {
|
|
231
|
+
this.wrapError("PUT", key, err);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async putFile(key: string, path: string, size: number, opts: { contentType?: string } = {}): Promise<PutResult> {
|
|
236
|
+
const payloadHash = await sha256FileHex(path);
|
|
237
|
+
try {
|
|
238
|
+
const headers: Record<string, string> = {
|
|
239
|
+
"content-length": String(size),
|
|
240
|
+
};
|
|
241
|
+
if (opts.contentType) headers["content-type"] = opts.contentType;
|
|
242
|
+
const res = await this.request("PUT", this.objectPath(key), {
|
|
243
|
+
headers,
|
|
244
|
+
body: fileStreamBody(path),
|
|
245
|
+
payloadHash,
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok) this.wrapStatus("PUT", key, res);
|
|
248
|
+
return { etag: payloadHash };
|
|
249
|
+
} catch (err) {
|
|
250
|
+
this.wrapError("PUT", key, err);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async get(key: string, opts: GetOptions = {}): Promise<Uint8Array | null> {
|
|
255
|
+
try {
|
|
256
|
+
const headers: Record<string, string> = {};
|
|
257
|
+
if (opts.range) {
|
|
258
|
+
const end = opts.range.end == null ? "" : String(opts.range.end);
|
|
259
|
+
headers.range = `bytes=${opts.range.start}-${end}`;
|
|
260
|
+
}
|
|
261
|
+
const res = await this.request("GET", this.objectPath(key), { headers });
|
|
262
|
+
if (res.status === 404) return null;
|
|
263
|
+
if (!res.ok && res.status !== 206) this.wrapStatus("GET", key, res);
|
|
264
|
+
return readResponseBytes(res);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
this.wrapError("GET", key, err);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async head(key: string): Promise<{ etag: string; size: number } | null> {
|
|
271
|
+
try {
|
|
272
|
+
const res = await this.request("HEAD", this.objectPath(key));
|
|
273
|
+
if (res.status === 404) return null;
|
|
274
|
+
if (!res.ok) this.wrapStatus("HEAD", key, res);
|
|
275
|
+
return {
|
|
276
|
+
etag: stripQuotes(res.headers.get("etag")),
|
|
277
|
+
size: Number(res.headers.get("content-length") ?? "0"),
|
|
278
|
+
};
|
|
279
|
+
} catch (err) {
|
|
280
|
+
this.wrapError("HEAD", key, err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async delete(key: string): Promise<void> {
|
|
285
|
+
try {
|
|
286
|
+
const res = await this.request("DELETE", this.objectPath(key));
|
|
287
|
+
if (res.status === 404) return;
|
|
288
|
+
if (!res.ok && res.status !== 204) this.wrapStatus("DELETE", key, res);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
this.wrapError("DELETE", key, err);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async list(prefix: string): Promise<string[]> {
|
|
295
|
+
try {
|
|
296
|
+
const keys: string[] = [];
|
|
297
|
+
let continuationToken: string | null = null;
|
|
298
|
+
for (;;) {
|
|
299
|
+
const query: Array<[string, string]> = [
|
|
300
|
+
["list-type", "2"],
|
|
301
|
+
["prefix", prefix],
|
|
302
|
+
];
|
|
303
|
+
if (continuationToken) query.push(["continuation-token", continuationToken]);
|
|
304
|
+
const res = await this.request("GET", `/${encodePathPart(this.bucket)}`, { query });
|
|
305
|
+
if (!res.ok) this.wrapStatus("LIST", prefix, res);
|
|
306
|
+
const xml = await readResponseText(res);
|
|
307
|
+
keys.push(...allXmlTags(xml, "Key"));
|
|
308
|
+
const truncated = firstXmlTag(xml, "IsTruncated") === "true";
|
|
309
|
+
if (!truncated) break;
|
|
310
|
+
continuationToken = firstXmlTag(xml, "NextContinuationToken");
|
|
311
|
+
if (!continuationToken) break;
|
|
312
|
+
}
|
|
313
|
+
return keys;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
this.wrapError("LIST", prefix, err);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { StreamProfileSpec } from "../profiles";
|
|
2
|
+
|
|
3
|
+
export type RequestObservabilityPairingDescriptor = {
|
|
4
|
+
request: {
|
|
5
|
+
events_stream: string;
|
|
6
|
+
traces_stream: string;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function readRequestPairing(
|
|
11
|
+
profile: StreamProfileSpec
|
|
12
|
+
): Record<string, unknown> | null {
|
|
13
|
+
const observability = profile.observability;
|
|
14
|
+
if (
|
|
15
|
+
!observability ||
|
|
16
|
+
typeof observability !== "object" ||
|
|
17
|
+
Array.isArray(observability)
|
|
18
|
+
) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const request = (observability as Record<string, unknown>).request;
|
|
22
|
+
return request && typeof request === "object" && !Array.isArray(request)
|
|
23
|
+
? (request as Record<string, unknown>)
|
|
24
|
+
: null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nonEmptyString(value: unknown): string | null {
|
|
28
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildRequestObservabilityPairingDescriptor(
|
|
32
|
+
stream: string,
|
|
33
|
+
profile: StreamProfileSpec
|
|
34
|
+
): RequestObservabilityPairingDescriptor | null {
|
|
35
|
+
const request = readRequestPairing(profile);
|
|
36
|
+
if (!request) return null;
|
|
37
|
+
|
|
38
|
+
if (profile.kind === "evlog") {
|
|
39
|
+
const tracesStream = nonEmptyString(request.tracesStream);
|
|
40
|
+
if (!tracesStream) return null;
|
|
41
|
+
return {
|
|
42
|
+
request: {
|
|
43
|
+
events_stream: stream,
|
|
44
|
+
traces_stream: tracesStream,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (profile.kind === "otel-traces") {
|
|
50
|
+
const eventsStream = nonEmptyString(request.eventsStream);
|
|
51
|
+
if (!eventsStream) return null;
|
|
52
|
+
return {
|
|
53
|
+
request: {
|
|
54
|
+
events_stream: eventsStream,
|
|
55
|
+
traces_stream: stream,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|