@cloudbitmaps/core 0.4.1 → 0.6.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/dist/index.d.cts CHANGED
@@ -7,7 +7,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
7
7
  * `core/` is **codec-agnostic**: `SegmentEngine`, compaction, and the `.crbm` read/write helpers only ever
8
8
  * construct and combine bitmaps through the {@link CodecInterface} factory + the {@link CodecBitmap} value type
9
9
  * defined here — never a concrete implementation. The flagship codec is roaring (`roaringCodec`, today in
10
- * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitmap`
10
+ * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitset`
11
11
  * (plain bitset) and `@cloudbitmaps/soaring` plug in behind the same seam with zero engine or driver changes.
12
12
  *
13
13
  * **Homogeneity contract:** a single store uses a single codec, so every {@link CodecBitmap} an operation sees
@@ -18,7 +18,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
18
18
  * mints the cache per store, so this cannot arise in normal use.)
19
19
  *
20
20
  * **Interface surface = exactly what the engine needs**, kept general enough for the known fast-follow codecs
21
- * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitmap` will add live on that
21
+ * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitset` will add live on that
22
22
  * package's own extended value type, not here — the engine never calls them). The one codec-linked concern
23
23
  * this seam intentionally leaves in the format layer is the `.crbm` **serialization id** (`ROARING_PORTABLE_ID`
24
24
  * in `crbm/format.ts`): it is stamped in the footer and validated on read, and generalizes to a per-codec id
@@ -61,6 +61,27 @@ interface CodecBitmap {
61
61
  * Roaring answers it in O(1) from its container index, so the flagship codec implements it.
62
62
  */
63
63
  maximum?(): number | undefined;
64
+ /**
65
+ * Re-encode for size, in place, immediately before a **cold** write. Representation only — this must never
66
+ * change membership, and `serialize()` afterwards must decode back to exactly the same set.
67
+ *
68
+ * **Optional, like {@link maximum}.** A codec whose encoding has no size decision to make (a plain bitset has
69
+ * one representation and nothing to choose) simply omits it, and the engine skips the call.
70
+ *
71
+ * WHY THIS EXISTS. Roaring picks per container between an array, a bitset and a RUN — but the run choice is
72
+ * not automatic in any implementation: it is a `runOptimize()` pass you have to ask for, and nothing here was
73
+ * asking. So two of the three container types were ever used, and run-shaped data paid list or bitmap prices
74
+ * for a run. Measured on the shipped codec: a contiguous 1,000,000-id range serialized to 128.1 KiB where
75
+ * run-encoding needs **0.2 KiB** (570×), and a 2,000-run shape 536.5 KiB against **8.5 KiB** (63×). Sparse
76
+ * data is unchanged, because there are no runs to find — the pass is not a gamble.
77
+ *
78
+ * WHY ONLY ON THE COLD PATH. This is called where a whole immutable generation is written, so its cost
79
+ * amortizes over a write that is already serializing and checksumming every chunk. It is deliberately NOT
80
+ * called on the warm delta path (`chunk.ts`), which runs per operation: the hot path must not pay for a
81
+ * rare-ish win, per KISS/YAGNI. Warm rows are short-lived and get folded into a cold generation by
82
+ * compaction, where they are optimized then.
83
+ */
84
+ optimize?(): void;
64
85
  }
65
86
  /**
66
87
  * A pluggable bitmap codec — the factory the codec-agnostic engine constructs {@link CodecBitmap}s through.
@@ -2021,7 +2042,15 @@ interface DestroyResult {
2021
2042
  * `segment.erase` audit event keys off — a cleartext "erase" is not an irreversible destruction.
2022
2043
  */
2023
2044
  readonly cryptoShredded: boolean;
2024
- /** `'absent'` (no registry row), `'already'` (already destroyed), or `'cleartext'` (no DEK to shred). */
2045
+ /**
2046
+ * Why this segment was not a fresh crypto-shred. `'absent'` (no registry row), `'already'` (already
2047
+ * destroyed), `'cleartext'` (no DEK to shred) — and from `eraseNamespace` only, where per-segment faults are
2048
+ * isolated so one failure cannot discard the ledger: `'contended'` (warm rows were rewritten during every
2049
+ * erase pass) or `` `failed: <message>` `` (any other fault).
2050
+ *
2051
+ * The last two come with `destroyed: false` and mean **the segment still holds data**. A namespace erase
2052
+ * returns them rather than throwing, so its entries have to be inspected — see {@link eraseNamespace}.
2053
+ */
2025
2054
  readonly reason?: string;
2026
2055
  /** Warm rows physically deleted as part of the erase. */
2027
2056
  readonly warmRowsDeleted: number;
@@ -2131,11 +2160,23 @@ declare function runConsistencyCheck(deps: {
2131
2160
  * I/O of their own); the wrapped driver is responsible for *classifying* its transient faults (raising
2132
2161
  * {@link TransientError}); these decorators decide *whether and when* to retry.
2133
2162
  *
2134
- * **Streaming methods** (`listChunks`, `list`) are retried by **re-enumerating from the start**, buffering
2135
- * the result — a partially-consumed async iterator can't be safely resumed mid-stream (it would re-yield
2136
- * earlier items). Their consumers in the engine already collect fully (the Warm dirty-set is small under
2137
- * Topology-A; the cold `list` is a discovery scan), so buffering preserves current semantics. Point methods
2138
- * are retried in place with no buffering.
2163
+ * **Streaming methods split into two shapes**, and the difference is deliberate — a partially-consumed async
2164
+ * iterator cannot be resumed mid-stream (it would re-yield earlier items), so each method picks between
2165
+ * buffering and bounded memory:
2166
+ *
2167
+ * · `RetryingColdDriver.list` / `RetryingRegistryDriver.list` **buffer**, inside `withRetry`, so the whole
2168
+ * enumeration is retried by re-running it from the start. Both are discovery scans over a generation or
2169
+ * namespace listing, where the result set is small and whole-scan retry is worth the memory.
2170
+ * · `RetryingWarmDriver.listChunks` does **not** buffer. It retries only until the first row arrives and
2171
+ * then streams live, because buffering it defeated the engine's resident-memory bound outright — see the
2172
+ * measured account on the method itself. A mid-stream fault therefore propagates rather than being
2173
+ * retried, which is the documented trade there.
2174
+ *
2175
+ * Point methods are retried in place, with nothing to buffer.
2176
+ *
2177
+ * (This paragraph previously said streaming methods buffer, full stop. That described `listChunks` as it was
2178
+ * before the bound was fixed, and the stale wording had also been copied into the Postgres and MySQL warm
2179
+ * drivers — so three comments promised a mid-stream resilience that the hot read path does not have.)
2139
2180
  *
2140
2181
  * What is **not** retried here: {@link WriteConflictError} (OCC — the engine's read-modify-write loop owns
2141
2182
  * that; a blind replay would re-apply against a stale token), and every deterministic error
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
7
7
  * `core/` is **codec-agnostic**: `SegmentEngine`, compaction, and the `.crbm` read/write helpers only ever
8
8
  * construct and combine bitmaps through the {@link CodecInterface} factory + the {@link CodecBitmap} value type
9
9
  * defined here — never a concrete implementation. The flagship codec is roaring (`roaringCodec`, today in
10
- * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitmap`
10
+ * `core/bitmap.ts`; it moves to `@cloudbitmaps/roaring` when the package split lands); `@cloudbitmaps/bitset`
11
11
  * (plain bitset) and `@cloudbitmaps/soaring` plug in behind the same seam with zero engine or driver changes.
12
12
  *
13
13
  * **Homogeneity contract:** a single store uses a single codec, so every {@link CodecBitmap} an operation sees
@@ -18,7 +18,7 @@ export { o as BufferReader, p as BufferSink, q as GovernanceMeta, r as NO_ROW, s
18
18
  * mints the cache per store, so this cannot arise in normal use.)
19
19
  *
20
20
  * **Interface surface = exactly what the engine needs**, kept general enough for the known fast-follow codecs
21
- * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitmap` will add live on that
21
+ * (positional/rank-select access + raw-bitset interop that `@cloudbitmaps/bitset` will add live on that
22
22
  * package's own extended value type, not here — the engine never calls them). The one codec-linked concern
23
23
  * this seam intentionally leaves in the format layer is the `.crbm` **serialization id** (`ROARING_PORTABLE_ID`
24
24
  * in `crbm/format.ts`): it is stamped in the footer and validated on read, and generalizes to a per-codec id
@@ -61,6 +61,27 @@ interface CodecBitmap {
61
61
  * Roaring answers it in O(1) from its container index, so the flagship codec implements it.
62
62
  */
63
63
  maximum?(): number | undefined;
64
+ /**
65
+ * Re-encode for size, in place, immediately before a **cold** write. Representation only — this must never
66
+ * change membership, and `serialize()` afterwards must decode back to exactly the same set.
67
+ *
68
+ * **Optional, like {@link maximum}.** A codec whose encoding has no size decision to make (a plain bitset has
69
+ * one representation and nothing to choose) simply omits it, and the engine skips the call.
70
+ *
71
+ * WHY THIS EXISTS. Roaring picks per container between an array, a bitset and a RUN — but the run choice is
72
+ * not automatic in any implementation: it is a `runOptimize()` pass you have to ask for, and nothing here was
73
+ * asking. So two of the three container types were ever used, and run-shaped data paid list or bitmap prices
74
+ * for a run. Measured on the shipped codec: a contiguous 1,000,000-id range serialized to 128.1 KiB where
75
+ * run-encoding needs **0.2 KiB** (570×), and a 2,000-run shape 536.5 KiB against **8.5 KiB** (63×). Sparse
76
+ * data is unchanged, because there are no runs to find — the pass is not a gamble.
77
+ *
78
+ * WHY ONLY ON THE COLD PATH. This is called where a whole immutable generation is written, so its cost
79
+ * amortizes over a write that is already serializing and checksumming every chunk. It is deliberately NOT
80
+ * called on the warm delta path (`chunk.ts`), which runs per operation: the hot path must not pay for a
81
+ * rare-ish win, per KISS/YAGNI. Warm rows are short-lived and get folded into a cold generation by
82
+ * compaction, where they are optimized then.
83
+ */
84
+ optimize?(): void;
64
85
  }
65
86
  /**
66
87
  * A pluggable bitmap codec — the factory the codec-agnostic engine constructs {@link CodecBitmap}s through.
@@ -2021,7 +2042,15 @@ interface DestroyResult {
2021
2042
  * `segment.erase` audit event keys off — a cleartext "erase" is not an irreversible destruction.
2022
2043
  */
2023
2044
  readonly cryptoShredded: boolean;
2024
- /** `'absent'` (no registry row), `'already'` (already destroyed), or `'cleartext'` (no DEK to shred). */
2045
+ /**
2046
+ * Why this segment was not a fresh crypto-shred. `'absent'` (no registry row), `'already'` (already
2047
+ * destroyed), `'cleartext'` (no DEK to shred) — and from `eraseNamespace` only, where per-segment faults are
2048
+ * isolated so one failure cannot discard the ledger: `'contended'` (warm rows were rewritten during every
2049
+ * erase pass) or `` `failed: <message>` `` (any other fault).
2050
+ *
2051
+ * The last two come with `destroyed: false` and mean **the segment still holds data**. A namespace erase
2052
+ * returns them rather than throwing, so its entries have to be inspected — see {@link eraseNamespace}.
2053
+ */
2025
2054
  readonly reason?: string;
2026
2055
  /** Warm rows physically deleted as part of the erase. */
2027
2056
  readonly warmRowsDeleted: number;
@@ -2131,11 +2160,23 @@ declare function runConsistencyCheck(deps: {
2131
2160
  * I/O of their own); the wrapped driver is responsible for *classifying* its transient faults (raising
2132
2161
  * {@link TransientError}); these decorators decide *whether and when* to retry.
2133
2162
  *
2134
- * **Streaming methods** (`listChunks`, `list`) are retried by **re-enumerating from the start**, buffering
2135
- * the result — a partially-consumed async iterator can't be safely resumed mid-stream (it would re-yield
2136
- * earlier items). Their consumers in the engine already collect fully (the Warm dirty-set is small under
2137
- * Topology-A; the cold `list` is a discovery scan), so buffering preserves current semantics. Point methods
2138
- * are retried in place with no buffering.
2163
+ * **Streaming methods split into two shapes**, and the difference is deliberate — a partially-consumed async
2164
+ * iterator cannot be resumed mid-stream (it would re-yield earlier items), so each method picks between
2165
+ * buffering and bounded memory:
2166
+ *
2167
+ * · `RetryingColdDriver.list` / `RetryingRegistryDriver.list` **buffer**, inside `withRetry`, so the whole
2168
+ * enumeration is retried by re-running it from the start. Both are discovery scans over a generation or
2169
+ * namespace listing, where the result set is small and whole-scan retry is worth the memory.
2170
+ * · `RetryingWarmDriver.listChunks` does **not** buffer. It retries only until the first row arrives and
2171
+ * then streams live, because buffering it defeated the engine's resident-memory bound outright — see the
2172
+ * measured account on the method itself. A mid-stream fault therefore propagates rather than being
2173
+ * retried, which is the documented trade there.
2174
+ *
2175
+ * Point methods are retried in place, with nothing to buffer.
2176
+ *
2177
+ * (This paragraph previously said streaming methods buffer, full stop. That described `listChunks` as it was
2178
+ * before the bound was fixed, and the stale wording had also been copied into the Postgres and MySQL warm
2179
+ * drivers — so three comments promised a mid-stream resilience that the hot read path does not have.)
2139
2180
  *
2140
2181
  * What is **not** retried here: {@link WriteConflictError} (OCC — the engine's read-modify-write loop owns
2141
2182
  * that; a blind replay would re-apply against a stale token), and every deterministic error
package/dist/index.js CHANGED
@@ -2074,6 +2074,7 @@ function writeCrbmGeneration(driver, key, chunks, options = {}) {
2074
2074
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
2075
2075
  for (const { chunkKey, bitmap } of sorted) {
2076
2076
  if (bitmap.isEmpty) continue;
2077
+ bitmap.optimize?.();
2077
2078
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
2078
2079
  const pause = tick();
2079
2080
  if (pause !== null) await pause;
@@ -2089,6 +2090,7 @@ async function writeCrbmGenerationStream(driver, key, chunks, options = {}) {
2089
2090
  const writer = new CrbmWriter(sink, { generation: key.generation, crypto: options.crypto });
2090
2091
  for await (const { chunkKey, bitmap } of chunks) {
2091
2092
  if (bitmap.isEmpty) continue;
2093
+ bitmap.optimize?.();
2092
2094
  await writer.addChunk(chunkKey, bitmap.serialize(), bitmap.size);
2093
2095
  chunkKeys.push(chunkKey);
2094
2096
  cardinality += bitmap.size;
@@ -2108,6 +2110,10 @@ async function publishGeneration(registry, key, options = {}) {
2108
2110
  currentGen: key.generation,
2109
2111
  wrappedDeks: options.wrappedDeks
2110
2112
  });
2113
+ } else if (record.status === "destroyed") {
2114
+ throw new ValidationError(
2115
+ `segment "${key.segment}" was destroyed (crypto-shredded) while generation ${key.generation} was being written \u2014 refusing to publish it; the written object is unreadable. Use a new segment.`
2116
+ );
2111
2117
  } else if (record.currentGen > key.generation) {
2112
2118
  return false;
2113
2119
  } else if (record.currentGen === key.generation) {
@@ -3667,7 +3673,22 @@ async function eraseNamespace(namespace, deps, options) {
3667
3673
  const destroyed = [];
3668
3674
  let segmentsShredded = 0;
3669
3675
  for (const ref of refs) {
3670
- const result = await shredSegment(ref, deps, options.allowCleartext ?? false);
3676
+ let result;
3677
+ try {
3678
+ result = await shredSegment(ref, deps, options.allowCleartext ?? false);
3679
+ } catch (err) {
3680
+ result = {
3681
+ segment: ref.segment,
3682
+ namespace: ref.namespace,
3683
+ destroyed: false,
3684
+ cryptoShredded: false,
3685
+ reason: isWriteConflictError(err) ? "contended" : `failed: ${err instanceof Error ? err.message : String(err)}`,
3686
+ // Deliberately 0, and not a partial count. `eraseWarm` may have deleted rows before it threw, but the
3687
+ // erase did not complete as a unit, so a partial figure on a failed entry would invite reading it as
3688
+ // progress toward a destruction that did not happen. The attestation that matters is `destroyed`.
3689
+ warmRowsDeleted: 0
3690
+ };
3691
+ }
3671
3692
  destroyed.push(result);
3672
3693
  if (result.cryptoShredded) {
3673
3694
  segmentsShredded += 1;
@@ -3730,12 +3751,13 @@ async function shredSegment(ref, deps, allowCleartext) {
3730
3751
  }
3731
3752
  async function eraseWarm(warm, ref) {
3732
3753
  let deleted = 0;
3754
+ let conflicts = 0;
3733
3755
  for (let pass = 0; pass < MAX_WARM_PASSES; pass++) {
3756
+ conflicts = 0;
3734
3757
  const rows = [];
3735
3758
  for await (const row of warm.listChunks(ref))
3736
3759
  rows.push({ chunkKey: row.chunkKey, token: row.token });
3737
3760
  if (rows.length === 0) break;
3738
- let conflicts = 0;
3739
3761
  for (const { chunkKey, token } of rows) {
3740
3762
  const chunkRef = { namespace: ref.namespace, segment: ref.segment, chunkKey };
3741
3763
  try {
@@ -3748,6 +3770,11 @@ async function eraseWarm(warm, ref) {
3748
3770
  }
3749
3771
  if (conflicts === 0) break;
3750
3772
  }
3773
+ if (conflicts > 0) {
3774
+ throw new WriteConflictError(
3775
+ `destroySegment: ${conflicts} warm row(s) of "${ref.segment}" were rewritten during every one of ${MAX_WARM_PASSES} erase passes \u2014 the segment was NOT destroyed and its cleartext warm deltas are still readable. Stop writes to this segment and retry.`
3776
+ );
3777
+ }
3751
3778
  return deleted;
3752
3779
  }
3753
3780
 
@@ -3878,11 +3905,15 @@ var RetryingWarmDriver = class {
3878
3905
  this.deps
3879
3906
  );
3880
3907
  if (started.first.done === true) return;
3881
- yield started.first.value;
3882
- for (; ; ) {
3883
- const next = await started.iterator.next();
3884
- if (next.done === true) return;
3885
- yield next.value;
3908
+ try {
3909
+ yield started.first.value;
3910
+ for (; ; ) {
3911
+ const next = await started.iterator.next();
3912
+ if (next.done === true) return;
3913
+ yield next.value;
3914
+ }
3915
+ } finally {
3916
+ await started.iterator.return?.();
3886
3917
  }
3887
3918
  }
3888
3919
  };