@kici-dev/shared 0.0.0 → 0.1.1

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 (105) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +1 -6
  3. package/dist/chunk-gOLHoazu.js +4 -0
  4. package/dist/cold-store/bucket.d.ts +26 -0
  5. package/dist/cold-store/bucket.js +40 -0
  6. package/dist/cold-store/bucket.test.d.ts +2 -0
  7. package/dist/cold-store/chunk-encoder.d.ts +63 -0
  8. package/dist/cold-store/chunk-encoder.js +94 -0
  9. package/dist/cold-store/chunk-encoder.test.d.ts +2 -0
  10. package/dist/cold-store/chunk-id.d.ts +10 -0
  11. package/dist/cold-store/chunk-id.js +29 -0
  12. package/dist/cold-store/chunk-id.test.d.ts +2 -0
  13. package/dist/cold-store/cold-store.d.ts +345 -0
  14. package/dist/cold-store/cold-store.js +1078 -0
  15. package/dist/cold-store/cold-store.test.d.ts +2 -0
  16. package/dist/cold-store/config.d.ts +62 -0
  17. package/dist/cold-store/config.js +28 -0
  18. package/dist/cold-store/index.d.ts +20 -0
  19. package/dist/cold-store/index.js +11 -0
  20. package/dist/cold-store/key.d.ts +89 -0
  21. package/dist/cold-store/key.js +88 -0
  22. package/dist/cold-store/key.test.d.ts +2 -0
  23. package/dist/cold-store/lru.d.ts +33 -0
  24. package/dist/cold-store/lru.js +59 -0
  25. package/dist/cold-store/lru.test.d.ts +2 -0
  26. package/dist/cold-store/manifest.d.ts +11 -0
  27. package/dist/cold-store/manifest.js +57 -0
  28. package/dist/cold-store/manifest.test.d.ts +2 -0
  29. package/dist/cold-store/metrics.d.ts +103 -0
  30. package/dist/cold-store/metrics.js +179 -0
  31. package/dist/cold-store/table-adapter.d.ts +229 -0
  32. package/dist/cold-store/table-adapter.js +2 -0
  33. package/dist/cold-store/types.d.ts +111 -0
  34. package/dist/cold-store/types.js +2 -0
  35. package/dist/crypto.d.ts +33 -0
  36. package/dist/crypto.js +67 -0
  37. package/dist/db-admin.d.ts +1422 -0
  38. package/dist/db-admin.js +2525 -0
  39. package/dist/db-admin.test.d.ts +2 -0
  40. package/dist/db.d.ts +14 -0
  41. package/dist/db.js +23 -0
  42. package/dist/env/allowlist.d.ts +79 -0
  43. package/dist/env/allowlist.js +86 -0
  44. package/dist/env/define-env.d.ts +172 -0
  45. package/dist/env/define-env.js +295 -0
  46. package/dist/env/define-env.test.d.ts +2 -0
  47. package/dist/env/env-rule-allowlist.test.d.ts +2 -0
  48. package/dist/env/index.d.ts +11 -0
  49. package/dist/env/index.js +4 -0
  50. package/dist/env/logger-env.d.ts +30 -0
  51. package/dist/env/logger-env.js +95 -0
  52. package/dist/error.d.ts +16 -0
  53. package/dist/error.js +58 -0
  54. package/dist/error.test.d.ts +2 -0
  55. package/dist/format-bytes.d.ts +5 -0
  56. package/dist/format-bytes.js +15 -0
  57. package/dist/format-bytes.test.d.ts +2 -0
  58. package/dist/format-duration.d.ts +11 -0
  59. package/dist/format-duration.js +32 -0
  60. package/dist/format-duration.test.d.ts +2 -0
  61. package/dist/graceful-shutdown.d.ts +58 -0
  62. package/dist/graceful-shutdown.js +71 -0
  63. package/dist/graceful-shutdown.test.d.ts +2 -0
  64. package/dist/idempotency-files.d.ts +113 -0
  65. package/dist/idempotency-files.js +190 -0
  66. package/dist/idempotency-files.test.d.ts +2 -0
  67. package/dist/idempotency.d.ts +68 -0
  68. package/dist/idempotency.js +49 -0
  69. package/dist/idempotency.test.d.ts +2 -0
  70. package/dist/index.d.ts +19 -0
  71. package/dist/index.js +31 -0
  72. package/dist/logger.d.ts +57 -0
  73. package/dist/logger.js +175 -0
  74. package/dist/logger.test.d.ts +2 -0
  75. package/dist/reconnect-delay.d.ts +11 -0
  76. package/dist/reconnect-delay.js +22 -0
  77. package/dist/request-context.d.ts +42 -0
  78. package/dist/request-context.js +37 -0
  79. package/dist/ring-buffer.d.ts +35 -0
  80. package/dist/ring-buffer.js +62 -0
  81. package/dist/ring-buffer.test.d.ts +2 -0
  82. package/dist/routes/health.d.ts +26 -0
  83. package/dist/routes/health.js +45 -0
  84. package/dist/routes/health.test.d.ts +2 -0
  85. package/dist/routes/metrics.d.ts +20 -0
  86. package/dist/routes/metrics.js +38 -0
  87. package/dist/s3-client.d.ts +42 -0
  88. package/dist/s3-client.js +25 -0
  89. package/dist/telemetry/index.d.ts +3 -0
  90. package/dist/telemetry/index.js +4 -0
  91. package/dist/telemetry/init.d.ts +20 -0
  92. package/dist/telemetry/init.js +42 -0
  93. package/dist/telemetry/init.test.d.ts +2 -0
  94. package/dist/telemetry/metrics.d.ts +8 -0
  95. package/dist/telemetry/metrics.js +16 -0
  96. package/dist/tool-check.d.ts +33 -0
  97. package/dist/tool-check.js +60 -0
  98. package/dist/tool-check.test.d.ts +2 -0
  99. package/dist/ts-loader-hook.d.ts +26 -0
  100. package/dist/ts-loader-hook.js +48 -0
  101. package/dist/zx.d.ts +8 -0
  102. package/dist/zx.js +78 -0
  103. package/package.json +69 -5
  104. package/sbom.spdx.json +8775 -0
  105. package/index.js +0 -3
@@ -0,0 +1,1078 @@
1
+ import "../chunk-gOLHoazu.js";
2
+ import { sha256 } from "../crypto.js";
3
+ import { createS3Client } from "../s3-client.js";
4
+ import { chunkObjectKey, encodeKeySegment, tablePrefix, tenantDayPrefix } from "./key.js";
5
+ import { coldDaysToBucket, isLongerColdRetention } from "./bucket.js";
6
+ import { computeChunkId } from "./chunk-id.js";
7
+ import { decodeChunk, encodeChunk } from "./chunk-encoder.js";
8
+ import { parseManifest, serializeManifest } from "./manifest.js";
9
+ import { coldStoreArchiveBytesTotal, coldStoreArchiveCyclesTotal, coldStoreArchiveDurationSeconds, coldStoreArchiveRowsTotal, coldStorePurgeBytesTotal, coldStorePurgeChunksTotal, coldStorePurgeDurationSeconds, coldStoreRehydrateBytesTotal, coldStoreRehydrateDurationSeconds, coldStoreRehydrateRequestsTotal, coldStoreReplayDurationSeconds, coldStoreReplayRowsTotal, coldStoreVerifyFailuresTotal } from "./metrics.js";
10
+ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
11
+ //#region src/cold-store/cold-store.ts
12
+ /**
13
+ * BaseColdStore — the DB-agnostic framework class.
14
+ *
15
+ * Multiple concrete subclasses extend this one (e.g., `PlatformColdStore`,
16
+ * `OrchestratorColdStore`). Each wires the adapter map with its
17
+ * package-specific adapters.
18
+ *
19
+ * Phase B: `runArchiveCycle()` now drives the per-adapter archive loop
20
+ * (design §4). `fetchRange` / `hasRange` / `countRange` are real — they
21
+ * resolve manifests via `ListObjectsV2` and GET overlapping chunks
22
+ * through an in-process LRU.
23
+ *
24
+ * ## Advisory-lock namespace
25
+ *
26
+ * All Postgres advisory locks acquired by the cold-store framework use
27
+ * keys derived from `hashtext('cold-store|...')`. The `cold-store|`
28
+ * prefix is reserved — do NOT reuse it in pipeline or engine code
29
+ * without coordinating to avoid collisions.
30
+ *
31
+ * ## S3 bucket versioning caveat
32
+ *
33
+ * When the cold-store bucket has versioning ON (recommended default),
34
+ * retried chunk PUTs produce new object versions at the same key.
35
+ * Reconciliation (the `reconcile` CLI) MUST use `ListObjectVersions`,
36
+ * not `ListObjects`, to detect orphaned objects correctly. The live
37
+ * read-through uses `ListObjectsV2` because it only cares about the
38
+ * current version of each manifest.
39
+ */
40
+ const CONTENT_HASH_META = "content-hash";
41
+ var BaseColdStore = class {
42
+ db;
43
+ config;
44
+ instanceId;
45
+ log;
46
+ chunkCache;
47
+ s3;
48
+ /** Registered table adapters by `table` name. */
49
+ adapters = /* @__PURE__ */ new Map();
50
+ constructor(deps) {
51
+ this.db = deps.db;
52
+ this.config = deps.config;
53
+ this.instanceId = deps.instanceId;
54
+ this.log = deps.log;
55
+ this.chunkCache = deps.chunkCache;
56
+ this.s3 = deps.s3Client ?? createS3Client({
57
+ region: deps.config.storage.region,
58
+ endpoint: deps.config.storage.endpoint,
59
+ forcePathStyle: deps.config.storage.forcePathStyle
60
+ });
61
+ }
62
+ /**
63
+ * Register a concrete table adapter. Subclasses typically call this
64
+ * from their constructor.
65
+ */
66
+ registerAdapter(adapter) {
67
+ if (adapter.db !== this.db) throw new Error(`BaseColdStore: adapter db=${adapter.db} does not match cold-store db=${this.db}`);
68
+ if (this.adapters.has(adapter.table)) throw new Error(`BaseColdStore: duplicate adapter for table ${adapter.table}`);
69
+ this.adapters.set(adapter.table, adapter);
70
+ }
71
+ /** Test / CLI helper — adapter-map read access. */
72
+ getAdapter(table) {
73
+ return this.adapters.get(table);
74
+ }
75
+ listAdapters() {
76
+ return Array.from(this.adapters.values());
77
+ }
78
+ /**
79
+ * CLI-accessible S3 primitives. Exposed so admin CLI commands can run
80
+ * inspection / reconciliation operations without a direct dependency
81
+ * on `@aws-sdk/client-s3`. Not intended for hot-path use.
82
+ */
83
+ get bucket() {
84
+ return this.config.storage.bucket;
85
+ }
86
+ get storagePrefix() {
87
+ return this.config.storage.prefix;
88
+ }
89
+ /** List all object keys under a prefix, paging internally. */
90
+ async listObjectKeys(prefix) {
91
+ const keys = [];
92
+ let continuationToken;
93
+ do {
94
+ const resp = await this.s3.send(new ListObjectsV2Command({
95
+ Bucket: this.config.storage.bucket,
96
+ Prefix: prefix,
97
+ ContinuationToken: continuationToken,
98
+ MaxKeys: 1e3
99
+ }));
100
+ for (const obj of resp.Contents ?? []) if (obj.Key) keys.push(obj.Key);
101
+ continuationToken = resp.IsTruncated ? resp.NextContinuationToken : void 0;
102
+ } while (continuationToken);
103
+ return keys;
104
+ }
105
+ /** Fetch an object's raw body as a Buffer. */
106
+ async getObjectBody(key) {
107
+ const resp = await this.s3.send(new GetObjectCommand({
108
+ Bucket: this.config.storage.bucket,
109
+ Key: key
110
+ }));
111
+ if (!resp.Body) throw new Error(`cold-store: empty body for ${key}`);
112
+ const bytes = await resp.Body.transformToByteArray();
113
+ return Buffer.from(bytes);
114
+ }
115
+ /** Upload a manifest JSON object at `key`. */
116
+ async putManifestObject(key, manifest) {
117
+ return this.putManifest(key, manifest);
118
+ }
119
+ /**
120
+ * Delete a single object by key. CLI-accessible companion to
121
+ * `listObjectKeys` / `getObjectBody`, used by tenant-scoped sweep
122
+ * paths (e.g. the post-`deleteOrgCascade` S3 cleanup) that operate
123
+ * on chunks the index already forgot about. Does NOT touch the
124
+ * `cold_store_chunks` index — callers that need both must follow
125
+ * the `purgeExpiredChunks` pattern with `withPurgeLock` +
126
+ * `adapter.purgeChunkRecord`.
127
+ */
128
+ async deleteObject(key) {
129
+ await this.s3.send(new DeleteObjectCommand({
130
+ Bucket: this.config.storage.bucket,
131
+ Key: key
132
+ }));
133
+ }
134
+ async runArchiveCycle(opts) {
135
+ const start = Date.now();
136
+ const summary = {
137
+ tablesProcessed: 0,
138
+ chunksWritten: 0,
139
+ rowsArchived: 0,
140
+ rowsFailed: 0,
141
+ skipped: {
142
+ disabled: 0,
143
+ min_chunk: 0,
144
+ min_warm: 0,
145
+ no_tables: 0
146
+ }
147
+ };
148
+ if (!this.config.enabled) {
149
+ this.log("info", "cold-store cycle skipped: disabled by config", {
150
+ db: this.db,
151
+ instanceId: this.instanceId
152
+ });
153
+ summary.skipped.disabled += 1;
154
+ coldStoreArchiveCyclesTotal().add(1, {
155
+ db: this.db,
156
+ result: "disabled"
157
+ });
158
+ return summary;
159
+ }
160
+ if (this.adapters.size === 0) {
161
+ coldStoreArchiveCyclesTotal().add(1, {
162
+ db: this.db,
163
+ result: "no_tables"
164
+ });
165
+ summary.skipped.no_tables += 1;
166
+ this.log("info", "cold-store cycle: no adapters registered", {
167
+ db: this.db,
168
+ instanceId: this.instanceId,
169
+ durationMs: Date.now() - start
170
+ });
171
+ return summary;
172
+ }
173
+ const tableFilter = opts?.tableFilter;
174
+ if (tableFilter && !this.adapters.has(tableFilter)) throw new Error(`runArchiveCycle: tableFilter='${tableFilter}' has no registered adapter on db=${this.db} (registered: ${[...this.adapters.keys()].join(", ") || "(none)"})`);
175
+ for (const adapter of this.adapters.values()) {
176
+ if (tableFilter && adapter.table !== tableFilter) continue;
177
+ await this.processAdapter(adapter, summary);
178
+ summary.tablesProcessed += 1;
179
+ }
180
+ const cycleResult = summary.rowsFailed > 0 ? "failure" : summary.chunksWritten > 0 ? "success" : "no_tables";
181
+ coldStoreArchiveCyclesTotal().add(1, {
182
+ db: this.db,
183
+ result: cycleResult
184
+ });
185
+ this.log("info", "cold-store cycle complete", {
186
+ db: this.db,
187
+ instanceId: this.instanceId,
188
+ durationMs: Date.now() - start,
189
+ summary
190
+ });
191
+ return summary;
192
+ }
193
+ async processAdapter(adapter, summary) {
194
+ if (!adapter.config.enabled) {
195
+ summary.skipped.disabled += 1;
196
+ return;
197
+ }
198
+ const warmCutoff = /* @__PURE__ */ new Date(Date.now() - adapter.config.warmTtlDays * 864e5);
199
+ let rowsThisCycle = 0;
200
+ for await (const { tenantId, partitionDate } of adapter.listEligiblePartitions({ warmCutoff })) {
201
+ if (rowsThisCycle >= adapter.config.maxRowsPerCycle) {
202
+ this.log("info", "cold-store: per-cycle row cap reached; yielding", {
203
+ db: this.db,
204
+ table: adapter.table,
205
+ rowsThisCycle
206
+ });
207
+ break;
208
+ }
209
+ if (await adapter.countTenantWarmBytes({
210
+ tenantId,
211
+ warmCutoff
212
+ }) < adapter.config.minWarmTenantBytes) {
213
+ summary.skipped.min_warm += 1;
214
+ coldStoreArchiveRowsTotal().add(0, {
215
+ db: this.db,
216
+ table: adapter.table,
217
+ result: "skipped_min_warm"
218
+ });
219
+ continue;
220
+ }
221
+ const lockResult = await adapter.withPartitionLock({
222
+ tenantId,
223
+ partitionDate
224
+ }, async () => {
225
+ return this.archivePartition(adapter, tenantId, partitionDate, summary);
226
+ });
227
+ if (lockResult === null) {
228
+ this.log("info", "cold-store: another archiver holds the partition lock; skipping", {
229
+ db: this.db,
230
+ table: adapter.table,
231
+ tenantId,
232
+ partitionDate
233
+ });
234
+ continue;
235
+ }
236
+ rowsThisCycle += lockResult.rowsArchived;
237
+ }
238
+ }
239
+ /**
240
+ * Archive one (tenant, partitionDate).
241
+ *
242
+ * - Adapters WITHOUT `coldTtlDays`: legacy single-chunk path. Writes one
243
+ * chunk per partition at the day-prefix root with a v1 manifest. The
244
+ * GC sweep treats v1 chunks as `'forever'`.
245
+ * - Adapters WITH `coldTtlDays`: Phase 2 per-bucket path. Buffers all
246
+ * eligible rows, groups by `coldDaysToBucket(coldTtlDays(row))`,
247
+ * then emits one chunk per non-empty bucket under the bucket
248
+ * subprefix with a v2 manifest carrying `bucket` + `maxColdDays`.
249
+ *
250
+ * In both paths the caller holds the advisory lock for the
251
+ * partition. Returns the total row count archived across all chunks
252
+ * so the outer loop can honor `maxRowsPerCycle`.
253
+ */
254
+ async archivePartition(adapter, tenantId, partitionDate, summary) {
255
+ if (adapter.coldTtlDays) return this.archivePartitionBucketed(adapter, tenantId, partitionDate, summary);
256
+ return this.archivePartitionLegacy(adapter, tenantId, partitionDate, summary);
257
+ }
258
+ /**
259
+ * Legacy single-chunk-per-partition flow. Adapters that do not
260
+ * implement `coldTtlDays` get a v1 manifest at the day-prefix root.
261
+ */
262
+ async archivePartitionLegacy(adapter, tenantId, partitionDate, summary) {
263
+ const partitionStart = Date.now();
264
+ const label = {
265
+ db: this.db,
266
+ table: adapter.table
267
+ };
268
+ let encoded;
269
+ try {
270
+ encoded = await encodeChunk({
271
+ rows: adapter.selectEligible({
272
+ tenantId,
273
+ partitionDate,
274
+ limit: adapter.config.maxRowsPerCycle
275
+ }),
276
+ encodeRow: adapter.encodeRow.bind(adapter),
277
+ rowId: adapter.rowId.bind(adapter),
278
+ rowTimestamp: adapter.rowTimestamp.bind(adapter),
279
+ replayLookupKey: adapter.replayLookupKey?.bind(adapter)
280
+ });
281
+ } catch (err) {
282
+ if (err instanceof Error && /empty row stream/.test(err.message)) {
283
+ summary.skipped.min_chunk += 1;
284
+ return { rowsArchived: 0 };
285
+ }
286
+ throw err;
287
+ }
288
+ if (encoded.gzipByteCount < adapter.config.minChunkBytes) {
289
+ summary.skipped.min_chunk += 1;
290
+ coldStoreArchiveRowsTotal().add(0, {
291
+ ...label,
292
+ result: "skipped_min_chunk"
293
+ });
294
+ return { rowsArchived: 0 };
295
+ }
296
+ return this.writeChunk(adapter, {
297
+ tenantId,
298
+ partitionDate,
299
+ encoded,
300
+ partitionStart,
301
+ summary,
302
+ bucket: void 0,
303
+ maxColdDays: void 0
304
+ });
305
+ }
306
+ /**
307
+ * Phase 2 per-bucket flow. Buffers all eligible rows, groups by
308
+ * `coldDaysToBucket(coldTtlDays(row))`, then emits one chunk per
309
+ * non-empty bucket.
310
+ */
311
+ async archivePartitionBucketed(adapter, tenantId, partitionDate, summary) {
312
+ const label = {
313
+ db: this.db,
314
+ table: adapter.table
315
+ };
316
+ const coldTtlDays = adapter.coldTtlDays.bind(adapter);
317
+ const buckets = /* @__PURE__ */ new Map();
318
+ let bufferedRows = 0;
319
+ for await (const row of adapter.selectEligible({
320
+ tenantId,
321
+ partitionDate,
322
+ limit: adapter.config.maxRowsPerCycle
323
+ })) {
324
+ const ttl = coldTtlDays(row);
325
+ const bucket = coldDaysToBucket(ttl);
326
+ const entry = buckets.get(bucket);
327
+ if (entry) {
328
+ entry.rows.push(row);
329
+ if (isLongerColdRetention(ttl, entry.maxColdDays)) entry.maxColdDays = ttl;
330
+ } else buckets.set(bucket, {
331
+ rows: [row],
332
+ maxColdDays: ttl
333
+ });
334
+ bufferedRows += 1;
335
+ }
336
+ if (bufferedRows === 0) {
337
+ summary.skipped.min_chunk += 1;
338
+ return { rowsArchived: 0 };
339
+ }
340
+ let totalArchived = 0;
341
+ for (const [bucket, { rows, maxColdDays }] of buckets) {
342
+ const partitionStart = Date.now();
343
+ let encoded;
344
+ try {
345
+ encoded = await encodeChunk({
346
+ rows: arrayToAsync(rows),
347
+ encodeRow: adapter.encodeRow.bind(adapter),
348
+ rowId: adapter.rowId.bind(adapter),
349
+ rowTimestamp: adapter.rowTimestamp.bind(adapter),
350
+ replayLookupKey: adapter.replayLookupKey?.bind(adapter)
351
+ });
352
+ } catch (err) {
353
+ throw err;
354
+ }
355
+ if (encoded.gzipByteCount < adapter.config.minChunkBytes) {
356
+ summary.skipped.min_chunk += 1;
357
+ coldStoreArchiveRowsTotal().add(0, {
358
+ ...label,
359
+ result: "skipped_min_chunk"
360
+ });
361
+ this.log("info", "cold-store: bucket below minChunkBytes; skipping this cycle", {
362
+ ...label,
363
+ tenantId,
364
+ partitionDate,
365
+ bucket,
366
+ rowCount: rows.length,
367
+ gzipByteCount: encoded.gzipByteCount,
368
+ minChunkBytes: adapter.config.minChunkBytes
369
+ });
370
+ continue;
371
+ }
372
+ const result = await this.writeChunk(adapter, {
373
+ tenantId,
374
+ partitionDate,
375
+ encoded,
376
+ partitionStart,
377
+ summary,
378
+ bucket,
379
+ maxColdDays
380
+ });
381
+ totalArchived += result.rowsArchived;
382
+ }
383
+ return { rowsArchived: totalArchived };
384
+ }
385
+ /**
386
+ * Common chunk-write flow: PUT data, verify, PUT manifest, transactional
387
+ * mark-archived-and-delete on the adapter, increment metrics.
388
+ *
389
+ * Shared by `archivePartitionLegacy` (v1 manifests) and
390
+ * `archivePartitionBucketed` (v2 manifests with bucket + maxColdDays).
391
+ */
392
+ async writeChunk(adapter, args) {
393
+ const { tenantId, partitionDate, encoded, partitionStart, summary, bucket, maxColdDays } = args;
394
+ const label = {
395
+ db: this.db,
396
+ table: adapter.table
397
+ };
398
+ if (bucket === void 0 !== (maxColdDays === void 0)) throw new Error(`cold-store writeChunk: bucket and maxColdDays must both be set or both undefined; got bucket=${JSON.stringify(bucket)} maxColdDays=${JSON.stringify(maxColdDays)}`);
399
+ const chunkId = computeChunkId({
400
+ db: this.db,
401
+ table: adapter.table,
402
+ tenantId,
403
+ partitionDate,
404
+ minRowId: encoded.minRowId,
405
+ maxRowId: encoded.maxRowId
406
+ });
407
+ const keyArgs = {
408
+ prefix: this.config.storage.prefix,
409
+ db: this.db,
410
+ table: adapter.table,
411
+ tenantId,
412
+ partitionDate,
413
+ chunkId,
414
+ bucket
415
+ };
416
+ const dataKey = chunkObjectKey({
417
+ ...keyArgs,
418
+ kind: "data"
419
+ });
420
+ const manifestKey = chunkObjectKey({
421
+ ...keyArgs,
422
+ kind: "manifest"
423
+ });
424
+ try {
425
+ await this.putChunkData(dataKey, encoded.data, encoded.contentHash);
426
+ await this.verifyChunkData(dataKey, encoded.contentHash, adapter.table);
427
+ const manifest = bucket !== void 0 && maxColdDays !== void 0 ? {
428
+ schemaVersion: 2,
429
+ db: this.db,
430
+ table: adapter.table,
431
+ tenantId,
432
+ partitionDate,
433
+ rowCount: encoded.rowCount,
434
+ byteCount: encoded.byteCount,
435
+ gzipByteCount: encoded.gzipByteCount,
436
+ minTimestamp: encoded.minTimestamp,
437
+ maxTimestamp: encoded.maxTimestamp,
438
+ minRowId: encoded.minRowId,
439
+ maxRowId: encoded.maxRowId,
440
+ contentHash: encoded.contentHash,
441
+ chunkId,
442
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
443
+ archiverInstanceId: this.instanceId,
444
+ replayLookupKeys: encoded.replayLookupKeys,
445
+ bucket,
446
+ maxColdDays
447
+ } : {
448
+ schemaVersion: 1,
449
+ db: this.db,
450
+ table: adapter.table,
451
+ tenantId,
452
+ partitionDate,
453
+ rowCount: encoded.rowCount,
454
+ byteCount: encoded.byteCount,
455
+ gzipByteCount: encoded.gzipByteCount,
456
+ minTimestamp: encoded.minTimestamp,
457
+ maxTimestamp: encoded.maxTimestamp,
458
+ minRowId: encoded.minRowId,
459
+ maxRowId: encoded.maxRowId,
460
+ contentHash: encoded.contentHash,
461
+ chunkId,
462
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
463
+ archiverInstanceId: this.instanceId,
464
+ replayLookupKeys: encoded.replayLookupKeys
465
+ };
466
+ await this.putManifest(manifestKey, manifest);
467
+ await adapter.markArchivedAndDelete({
468
+ rowIds: encoded.rowIds,
469
+ chunkMeta: {
470
+ chunkId,
471
+ tenantId,
472
+ partitionDate,
473
+ rowCount: encoded.rowCount,
474
+ byteCount: encoded.byteCount,
475
+ gzipByteCount: encoded.gzipByteCount,
476
+ objectKey: dataKey,
477
+ bucket,
478
+ maxColdDays
479
+ }
480
+ });
481
+ summary.chunksWritten += 1;
482
+ summary.rowsArchived += encoded.rowCount;
483
+ coldStoreArchiveRowsTotal().add(encoded.rowCount, {
484
+ ...label,
485
+ result: "success"
486
+ });
487
+ coldStoreArchiveBytesTotal().add(encoded.byteCount, {
488
+ ...label,
489
+ kind: "raw"
490
+ });
491
+ coldStoreArchiveBytesTotal().add(encoded.gzipByteCount, {
492
+ ...label,
493
+ kind: "gzipped"
494
+ });
495
+ coldStoreArchiveDurationSeconds().record((Date.now() - partitionStart) / 1e3, label);
496
+ this.log("info", "cold-store chunk archived", {
497
+ event: "chunk_archived",
498
+ ...label,
499
+ tenantId,
500
+ partitionDate,
501
+ chunkId,
502
+ bucket,
503
+ maxColdDays,
504
+ rowCount: encoded.rowCount,
505
+ byteCount: encoded.byteCount,
506
+ gzipByteCount: encoded.gzipByteCount,
507
+ durationMs: Date.now() - partitionStart
508
+ });
509
+ return { rowsArchived: encoded.rowCount };
510
+ } catch (err) {
511
+ summary.rowsFailed += encoded.rowCount;
512
+ coldStoreArchiveRowsTotal().add(encoded.rowCount, {
513
+ ...label,
514
+ result: "failure"
515
+ });
516
+ coldStoreArchiveDurationSeconds().record((Date.now() - partitionStart) / 1e3, label);
517
+ this.log("error", "cold-store chunk archive failed", {
518
+ ...label,
519
+ tenantId,
520
+ partitionDate,
521
+ bucket,
522
+ chunkId,
523
+ error: err instanceof Error ? err.message : String(err)
524
+ });
525
+ return { rowsArchived: 0 };
526
+ }
527
+ }
528
+ async putChunkData(key, body, contentHash) {
529
+ await this.s3.send(new PutObjectCommand({
530
+ Bucket: this.config.storage.bucket,
531
+ Key: key,
532
+ Body: body,
533
+ ContentType: "application/gzip",
534
+ Metadata: { [CONTENT_HASH_META]: contentHash }
535
+ }));
536
+ }
537
+ async verifyChunkData(key, expectedHash, table, attempt = 0) {
538
+ const gotHash = (await this.s3.send(new HeadObjectCommand({
539
+ Bucket: this.config.storage.bucket,
540
+ Key: key
541
+ }))).Metadata?.[CONTENT_HASH_META];
542
+ if (gotHash === expectedHash) return;
543
+ coldStoreVerifyFailuresTotal().add(1, {
544
+ db: this.db,
545
+ table
546
+ });
547
+ if (attempt >= 2) throw new Error(`cold-store: HeadObject content-hash mismatch for ${key} after 3 attempts (got ${gotHash ?? "<missing>"}, expected ${expectedHash})`);
548
+ this.log("warn", "cold-store: verify hash mismatch; retrying", {
549
+ db: this.db,
550
+ table,
551
+ key,
552
+ attempt
553
+ });
554
+ return this.verifyChunkData(key, expectedHash, table, attempt + 1);
555
+ }
556
+ async putManifest(key, manifest) {
557
+ await this.s3.send(new PutObjectCommand({
558
+ Bucket: this.config.storage.bucket,
559
+ Key: key,
560
+ Body: Buffer.from(serializeManifest(manifest), "utf-8"),
561
+ ContentType: "application/json"
562
+ }));
563
+ }
564
+ async *fetchRange(args) {
565
+ if (!this.config.enabled) return;
566
+ const adapter = this.adapters.get(args.table);
567
+ const decodeLine = args.decode ?? (adapter ? (line) => adapter.decodeRow(line) : (line) => JSON.parse(line));
568
+ const rowTimestamp = adapter ? (row) => this.toDate(adapter.rowTimestamp(row)) : null;
569
+ const manifests = await this.listRelevantManifests({
570
+ db: args.db,
571
+ table: args.table,
572
+ tenantId: args.tenantId,
573
+ fromTs: args.fromTs,
574
+ toTs: args.toTs
575
+ });
576
+ const label = {
577
+ db: args.db,
578
+ table: args.table
579
+ };
580
+ for (const manifest of manifests) {
581
+ const dataKey = chunkObjectKey({
582
+ prefix: this.config.storage.prefix,
583
+ db: args.db,
584
+ table: args.table,
585
+ tenantId: args.tenantId,
586
+ partitionDate: manifest.partitionDate,
587
+ chunkId: manifest.chunkId,
588
+ kind: "data",
589
+ bucket: manifest.schemaVersion === 2 ? manifest.bucket : void 0
590
+ });
591
+ const fetchStart = Date.now();
592
+ const gzipped = await this.getChunkData(dataKey, manifest.contentHash);
593
+ coldStoreRehydrateDurationSeconds().record((Date.now() - fetchStart) / 1e3, label);
594
+ for await (const row of decodeChunk({
595
+ gzipped,
596
+ decodeLine
597
+ })) {
598
+ if (rowTimestamp) {
599
+ const ts = rowTimestamp(row);
600
+ if (ts < args.fromTs || ts >= args.toTs) continue;
601
+ }
602
+ yield row;
603
+ }
604
+ }
605
+ }
606
+ async hasRange(args) {
607
+ if (!this.config.enabled) return false;
608
+ return (await this.listRelevantManifests(args)).length > 0;
609
+ }
610
+ async countRange(args) {
611
+ if (!this.config.enabled) return 0;
612
+ const manifests = await this.listRelevantManifests(args);
613
+ let total = 0;
614
+ for (const m of manifests) total += m.rowCount;
615
+ return total;
616
+ }
617
+ /**
618
+ * Resolve all manifest sidecars whose time-range overlaps
619
+ * [fromTs, toTs). Uses one LIST walk under the tenant prefix, then
620
+ * GETs each `.manifest.json`.
621
+ */
622
+ async listRelevantManifests(args) {
623
+ const tenantPrefix = tablePrefix({
624
+ prefix: this.config.storage.prefix,
625
+ db: args.db,
626
+ table: args.table
627
+ }) + `/${encodeKeySegment(args.tenantId)}/`;
628
+ const manifestKeys = [];
629
+ let continuationToken;
630
+ do {
631
+ const resp = await this.s3.send(new ListObjectsV2Command({
632
+ Bucket: this.config.storage.bucket,
633
+ Prefix: tenantPrefix,
634
+ ContinuationToken: continuationToken,
635
+ MaxKeys: 1e3
636
+ }));
637
+ for (const obj of resp.Contents ?? []) if (obj.Key && obj.Key.endsWith(".manifest.json")) manifestKeys.push(obj.Key);
638
+ continuationToken = resp.IsTruncated ? resp.NextContinuationToken : void 0;
639
+ } while (continuationToken);
640
+ const manifests = [];
641
+ for (const key of manifestKeys) {
642
+ const m = await this.getManifest(key);
643
+ const min = new Date(m.minTimestamp);
644
+ if (new Date(m.maxTimestamp) < args.fromTs) continue;
645
+ if (min >= args.toTs) continue;
646
+ manifests.push(m);
647
+ }
648
+ manifests.sort((a, b) => a.minTimestamp < b.minTimestamp ? -1 : 1);
649
+ return manifests;
650
+ }
651
+ async getManifest(key) {
652
+ const resp = await this.s3.send(new GetObjectCommand({
653
+ Bucket: this.config.storage.bucket,
654
+ Key: key
655
+ }));
656
+ if (!resp.Body) throw new Error(`cold-store: empty manifest body for ${key}`);
657
+ const bytes = await resp.Body.transformToByteArray();
658
+ return parseManifest(Buffer.from(bytes));
659
+ }
660
+ async getChunkData(key, expectedHash) {
661
+ const label = {
662
+ db: this.db,
663
+ table: keyToTable(key)
664
+ };
665
+ const cached = this.chunkCache?.get(key);
666
+ if (cached) {
667
+ coldStoreRehydrateRequestsTotal().add(1, {
668
+ ...label,
669
+ cache_outcome: "hit"
670
+ });
671
+ return cached;
672
+ }
673
+ coldStoreRehydrateRequestsTotal().add(1, {
674
+ ...label,
675
+ cache_outcome: "miss"
676
+ });
677
+ const resp = await this.s3.send(new GetObjectCommand({
678
+ Bucket: this.config.storage.bucket,
679
+ Key: key
680
+ }));
681
+ if (!resp.Body) throw new Error(`cold-store: empty chunk body for ${key}`);
682
+ const bytes = await resp.Body.transformToByteArray();
683
+ const body = Buffer.from(bytes);
684
+ coldStoreRehydrateBytesTotal().add(body.byteLength, label);
685
+ const got = sha256(body);
686
+ if (got !== expectedHash) {
687
+ coldStoreVerifyFailuresTotal().add(1, label);
688
+ throw new Error(`cold-store: chunk content-hash mismatch for ${key} (got ${got}, expected ${expectedHash})`);
689
+ }
690
+ this.chunkCache?.set(key, body);
691
+ return body;
692
+ }
693
+ /**
694
+ * Promote every row in a chunk back into PG. The adapter owns the
695
+ * actual INSERT + audit + chunk-counter decrement (transactional).
696
+ *
697
+ * Throws when:
698
+ * - the cold store is disabled
699
+ * - the adapter is unregistered or doesn't implement `replayInsert`
700
+ * - the manifest is missing
701
+ * - the data file's sha256 doesn't match the manifest's
702
+ * `contentHash` (refuse to replay a tampered chunk)
703
+ *
704
+ * Idempotent on re-run: the adapter's `ON CONFLICT DO NOTHING`
705
+ * collapses the second pass to `inserted=0, skipped=rowCount`.
706
+ */
707
+ async replayChunk(args) {
708
+ if (!this.config.enabled) throw new Error("cold-store: replayChunk called while cold-store is disabled");
709
+ const adapter = this.adapters.get(args.table);
710
+ if (!adapter) throw new Error(`cold-store: replay-chunk requires a registered adapter for table '${args.table}' on db=${this.db}`);
711
+ if (!adapter.replayInsert) throw new Error(`cold-store: replay-into-pg not supported for table '${args.table}' (adapter has no replayInsert)`);
712
+ if (adapter.db !== args.db) throw new Error(`cold-store: replayChunk db=${args.db} does not match adapter db=${adapter.db}`);
713
+ const start = Date.now();
714
+ const label = {
715
+ db: args.db,
716
+ table: args.table
717
+ };
718
+ try {
719
+ const dayPrefix = tenantDayPrefix({
720
+ prefix: this.config.storage.prefix,
721
+ db: args.db,
722
+ table: args.table,
723
+ tenantId: args.tenantId,
724
+ partitionDate: args.partitionDate
725
+ });
726
+ const dayKeys = await this.listObjectKeys(dayPrefix);
727
+ const manifestSuffix = `/${args.chunkId}.manifest.json`;
728
+ const manifestKey = dayKeys.find((k) => k.endsWith(manifestSuffix));
729
+ if (!manifestKey) throw new Error(`cold-store: replay-chunk ${args.chunkId} not found under ${dayPrefix} (db=${args.db} table=${args.table} tenantId=${args.tenantId} partitionDate=${args.partitionDate})`);
730
+ const dataKey = manifestKey.replace(/\.manifest\.json$/, ".jsonl.gz");
731
+ const [data, manifest] = await Promise.all([this.getObjectBody(dataKey), this.getManifest(manifestKey)]);
732
+ const got = sha256(data);
733
+ if (got !== manifest.contentHash) {
734
+ coldStoreVerifyFailuresTotal().add(1, label);
735
+ throw new Error(`cold-store: replay-chunk ${args.chunkId} refused — contentHash mismatch (got ${got}, manifest ${manifest.contentHash})`);
736
+ }
737
+ const rows = [];
738
+ for await (const row of decodeChunk({
739
+ gzipped: data,
740
+ decodeLine: (line) => adapter.decodeRow(line)
741
+ })) rows.push(row);
742
+ const chunkMeta = {
743
+ chunkId: manifest.chunkId,
744
+ tenantId: manifest.tenantId,
745
+ partitionDate: manifest.partitionDate,
746
+ rowCount: manifest.rowCount,
747
+ byteCount: manifest.byteCount,
748
+ gzipByteCount: manifest.gzipByteCount,
749
+ objectKey: dataKey
750
+ };
751
+ const result = await adapter.replayInsert.bind(adapter)({
752
+ rows,
753
+ chunkMeta
754
+ });
755
+ coldStoreReplayRowsTotal().add(result.inserted, {
756
+ ...label,
757
+ result: "success"
758
+ });
759
+ if (result.skipped > 0) coldStoreReplayRowsTotal().add(result.skipped, {
760
+ ...label,
761
+ result: "idempotent_skip"
762
+ });
763
+ coldStoreReplayDurationSeconds().record((Date.now() - start) / 1e3, label);
764
+ this.log("info", "cold-store chunk replayed", {
765
+ event: "chunk_replayed",
766
+ ...label,
767
+ tenantId: manifest.tenantId,
768
+ partitionDate: manifest.partitionDate,
769
+ chunkId: args.chunkId,
770
+ rowsReplayed: result.inserted,
771
+ rowsSkipped: result.skipped,
772
+ durationMs: Date.now() - start
773
+ });
774
+ return {
775
+ inserted: result.inserted,
776
+ skipped: result.skipped,
777
+ chunkId: args.chunkId
778
+ };
779
+ } catch (err) {
780
+ coldStoreReplayRowsTotal().add(0, {
781
+ ...label,
782
+ result: "failure"
783
+ });
784
+ coldStoreReplayDurationSeconds().record((Date.now() - start) / 1e3, label);
785
+ this.log("error", "cold-store chunk replay failed", {
786
+ ...label,
787
+ chunkId: args.chunkId,
788
+ tenantId: args.tenantId,
789
+ partitionDate: args.partitionDate,
790
+ error: err instanceof Error ? err.message : String(err)
791
+ });
792
+ throw err;
793
+ }
794
+ }
795
+ /**
796
+ * Locate the chunk whose `[minRowId, maxRowId]` window covers
797
+ * `rowId` and replay it. Scans manifests under the tenant prefix
798
+ * (LIST is one round-trip; manifests are tiny). When several manifests
799
+ * match (overlapping ranges from re-archives), the one with the
800
+ * smallest range is preferred — it's the most recently re-archived
801
+ * shard and therefore the one whose row contents are freshest.
802
+ *
803
+ * Returns `chunkId: null` when no manifest matches — the caller
804
+ * decides whether that means "row never existed" (rerun → 404) or
805
+ * "S3 outage" (rerun → 410 replayFailed). This method only fails
806
+ * loudly on infrastructure errors (missing chunk after manifest
807
+ * found, hash mismatch).
808
+ */
809
+ async replayRow(args) {
810
+ if (!this.config.enabled) return {
811
+ inserted: 0,
812
+ skipped: 0,
813
+ chunkId: null
814
+ };
815
+ const tenantPrefix = tablePrefix({
816
+ prefix: this.config.storage.prefix,
817
+ db: args.db,
818
+ table: args.table
819
+ }) + `/${encodeKeySegment(args.tenantId)}/`;
820
+ const manifestKeys = [];
821
+ let continuationToken;
822
+ do {
823
+ const resp = await this.s3.send(new ListObjectsV2Command({
824
+ Bucket: this.config.storage.bucket,
825
+ Prefix: tenantPrefix,
826
+ ContinuationToken: continuationToken,
827
+ MaxKeys: 1e3
828
+ }));
829
+ for (const obj of resp.Contents ?? []) if (obj.Key && obj.Key.endsWith(".manifest.json")) manifestKeys.push(obj.Key);
830
+ continuationToken = resp.IsTruncated ? resp.NextContinuationToken : void 0;
831
+ } while (continuationToken);
832
+ const wanted = String(args.rowId);
833
+ let best = null;
834
+ let exact = null;
835
+ for (const key of manifestKeys) {
836
+ const m = await this.getManifest(key);
837
+ if (m.replayLookupKeys && m.replayLookupKeys.includes(wanted)) {
838
+ exact = m;
839
+ break;
840
+ }
841
+ const lo = m.minRowId;
842
+ const hi = m.maxRowId;
843
+ if (compareIds(args.rowId, lo) < 0) continue;
844
+ if (compareIds(args.rowId, hi) > 0) continue;
845
+ const width = idDistance(lo, hi);
846
+ if (best === null || width < best.rangeWidth) best = {
847
+ manifest: m,
848
+ rangeWidth: width
849
+ };
850
+ }
851
+ const picked = exact ?? best?.manifest ?? null;
852
+ if (!picked) return {
853
+ inserted: 0,
854
+ skipped: 0,
855
+ chunkId: null
856
+ };
857
+ return this.replayChunk({
858
+ db: args.db,
859
+ table: args.table,
860
+ tenantId: picked.tenantId,
861
+ partitionDate: picked.partitionDate,
862
+ chunkId: picked.chunkId
863
+ });
864
+ }
865
+ toDate(v) {
866
+ return v instanceof Date ? v : new Date(v);
867
+ }
868
+ /**
869
+ * Subclass hook — query the per-DB `cold_store_chunks` index for
870
+ * purge candidates. Concrete subclasses (PlatformColdStore /
871
+ * OrchestratorColdStore) implement this with one Kysely SQL query
872
+ * keyed off `archived_at + max_cold_days * INTERVAL '1 day' < now()`
873
+ * and `max_cold_days != 'forever'`.
874
+ *
875
+ * Returning an empty array makes `purgeExpiredChunks` a no-op.
876
+ * Default implementation returns `[]` so non-purge subclasses (or
877
+ * test fixtures) can opt out trivially.
878
+ */
879
+ async listPurgeableChunks(_opts) {
880
+ return [];
881
+ }
882
+ /**
883
+ * Acquire a per-chunk advisory lock so concurrent sweeps can't
884
+ * race on the same `DeleteObject` + `cold_store_chunks` row.
885
+ *
886
+ * Default implementation is a no-op (always succeeds). Subclasses
887
+ * with a Kysely instance override to use `pg_try_advisory_lock` on
888
+ * `hashtext('cold-store-purge|<db>|<table>|<chunkId>')` and release
889
+ * inside `fn`'s `finally`. Returns `null` when another worker holds
890
+ * the lock; returns the `fn` result on success.
891
+ */
892
+ async withPurgeLock(_args, fn) {
893
+ return fn();
894
+ }
895
+ async purgeExpiredChunks(opts) {
896
+ const start = Date.now();
897
+ if (!this.config.enabled) return {
898
+ results: [],
899
+ bytesPurged: 0,
900
+ chunksPurged: 0,
901
+ durationMs: 0
902
+ };
903
+ const limit = opts?.limit ?? 1e3;
904
+ const dryRun = opts?.dryRun ?? false;
905
+ const candidates = await this.listPurgeableChunks({
906
+ tableFilter: opts?.tableFilter,
907
+ bucketFilter: opts?.bucketFilter,
908
+ limit
909
+ });
910
+ const results = [];
911
+ let bytesPurged = 0;
912
+ let chunksPurged = 0;
913
+ for (const c of candidates) {
914
+ const baseResult = {
915
+ table: c.table,
916
+ tenantId: c.tenantId,
917
+ chunkId: c.chunkId,
918
+ bucket: c.bucket,
919
+ gzipBytes: c.gzipBytes,
920
+ rowCount: c.rowCount
921
+ };
922
+ const label = {
923
+ db: this.db,
924
+ table: c.table
925
+ };
926
+ if (dryRun) {
927
+ results.push({
928
+ ...baseResult,
929
+ outcome: "dry_run"
930
+ });
931
+ coldStorePurgeChunksTotal().add(1, {
932
+ ...label,
933
+ result: "dry_run"
934
+ });
935
+ continue;
936
+ }
937
+ const adapter = this.adapters.get(c.table);
938
+ if (!adapter) {
939
+ results.push({
940
+ ...baseResult,
941
+ outcome: "failure",
942
+ error: `no adapter registered for table=${c.table}`
943
+ });
944
+ coldStorePurgeChunksTotal().add(1, {
945
+ ...label,
946
+ result: "failure"
947
+ });
948
+ continue;
949
+ }
950
+ if (!adapter.purgeChunkRecord) {
951
+ results.push({
952
+ ...baseResult,
953
+ outcome: "failure",
954
+ error: `adapter ${c.table} does not implement purgeChunkRecord`
955
+ });
956
+ coldStorePurgeChunksTotal().add(1, {
957
+ ...label,
958
+ result: "failure"
959
+ });
960
+ continue;
961
+ }
962
+ const lockResult = await this.withPurgeLock({
963
+ table: c.table,
964
+ chunkId: c.chunkId
965
+ }, async () => {
966
+ const manifestKey = c.objectKey.replace(/\.jsonl\.gz$/, ".manifest.json");
967
+ await this.s3.send(new DeleteObjectCommand({
968
+ Bucket: this.config.storage.bucket,
969
+ Key: c.objectKey
970
+ }));
971
+ await this.s3.send(new DeleteObjectCommand({
972
+ Bucket: this.config.storage.bucket,
973
+ Key: manifestKey
974
+ }));
975
+ await adapter.purgeChunkRecord({
976
+ tenantId: c.tenantId,
977
+ chunkId: c.chunkId,
978
+ gzipBytes: c.gzipBytes,
979
+ rowCount: c.rowCount,
980
+ bucket: c.bucket,
981
+ maxColdDays: c.maxColdDays,
982
+ objectKey: c.objectKey
983
+ });
984
+ return true;
985
+ }).catch((err) => {
986
+ results.push({
987
+ ...baseResult,
988
+ outcome: "failure",
989
+ error: err instanceof Error ? err.message : String(err)
990
+ });
991
+ coldStorePurgeChunksTotal().add(1, {
992
+ ...label,
993
+ result: "failure"
994
+ });
995
+ return false;
996
+ });
997
+ if (lockResult === null) {
998
+ results.push({
999
+ ...baseResult,
1000
+ outcome: "skipped_locked"
1001
+ });
1002
+ coldStorePurgeChunksTotal().add(1, {
1003
+ ...label,
1004
+ result: "skipped_locked"
1005
+ });
1006
+ continue;
1007
+ }
1008
+ if (lockResult === true) {
1009
+ results.push({
1010
+ ...baseResult,
1011
+ outcome: "purged"
1012
+ });
1013
+ bytesPurged += c.gzipBytes;
1014
+ chunksPurged += 1;
1015
+ coldStorePurgeChunksTotal().add(1, {
1016
+ ...label,
1017
+ result: "purged"
1018
+ });
1019
+ coldStorePurgeBytesTotal().add(c.gzipBytes, label);
1020
+ }
1021
+ }
1022
+ coldStorePurgeDurationSeconds().record((Date.now() - start) / 1e3, { db: this.db });
1023
+ const summary = {
1024
+ results,
1025
+ bytesPurged,
1026
+ chunksPurged,
1027
+ durationMs: Date.now() - start
1028
+ };
1029
+ this.log("info", "cold-store purge sweep complete", {
1030
+ db: this.db,
1031
+ dryRun,
1032
+ candidates: candidates.length,
1033
+ chunksPurged,
1034
+ bytesPurged,
1035
+ durationMs: summary.durationMs
1036
+ });
1037
+ return summary;
1038
+ }
1039
+ };
1040
+ function compareIds(a, b) {
1041
+ if (typeof a === "number" && typeof b === "number") return a - b;
1042
+ const as = String(a);
1043
+ const bs = String(b);
1044
+ if (as < bs) return -1;
1045
+ if (as > bs) return 1;
1046
+ return 0;
1047
+ }
1048
+ function idDistance(lo, hi) {
1049
+ if (typeof lo === "number" && typeof hi === "number") return Math.max(0, hi - lo);
1050
+ const lenDelta = String(hi).length - String(lo).length;
1051
+ if (lenDelta !== 0) return Math.max(0, lenDelta);
1052
+ return String(hi) < String(lo) ? 0 : 1;
1053
+ }
1054
+ /**
1055
+ * Best-effort extraction of the table name from a chunk key of shape
1056
+ * `<prefix>/<db>/<table>/<tenant>/<YYYY>/<MM>/<DD>/<chunk>.ext` (v1) or
1057
+ * `<prefix>/<db>/<table>/<tenant>/<YYYY>/<MM>/<DD>/<bucket>/<chunk>.ext`
1058
+ * (v2 with bucket subprefix). Used for metric labels on the read-through
1059
+ * path; returns `'unknown'` if the key doesn't parse.
1060
+ */
1061
+ function keyToTable(key) {
1062
+ const parts = key.split("/");
1063
+ for (let i = parts.length - 4; i >= 2; i--) if (/^\d{4}$/.test(parts[i] ?? "")) return parts[i - 2] ?? "unknown";
1064
+ return "unknown";
1065
+ }
1066
+ /**
1067
+ * Lift an in-memory array into an `AsyncIterable` so it can feed
1068
+ * `encodeChunk` (which expects a stream). Used by the per-bucket
1069
+ * archive flow to re-stream each bucket's row subset through the
1070
+ * existing single-stream encoder without further refactoring.
1071
+ */
1072
+ async function* arrayToAsync(items) {
1073
+ for (const item of items) yield item;
1074
+ }
1075
+ //#endregion
1076
+ export { BaseColdStore };
1077
+
1078
+ //# sourceMappingURL=cold-store.js.map