@cloudbitmaps/roaring 0.26.0 → 0.27.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/PRIVACY.md CHANGED
@@ -42,7 +42,7 @@ from constructing a cross-region topology. The points where personal data moves
42
42
 
43
43
  | Location | What's there | Residency note |
44
44
  |---|---|---|
45
- | **Storage** (object store) | immutable `.crbm` generations — every generation a segment has had, until a superseded one is collected. Every generation object a cloud driver writes (S3, GCS and Azure Blob) also carries a random write id in its metadata (`cbwid`; `x-amz-meta-cbwid` on S3): 128 random bits, no data from the bitmap or the source. A write that fails or is killed part way can leave the uploaded part of a generation behind (S3 multipart parts, Azure Blob uncommitted blocks, an unfinished GCS resumable session, a `LocalFsStorage` `.tmp` file); no listing shows it, so no erasure or drop reaches it. What each backend leaves, and when it is removed, is in [the backup checklist](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/disaster-recovery.md#backup-checklist) | the region of the bucket you wire |
45
+ | **Storage** (object store) | immutable `.crbm` generations — every generation a segment has had, until a superseded one is collected. Every generation object a cloud driver writes (S3, GCS and Azure Blob) also carries a random write id in its metadata (`cbwid`; `x-amz-meta-cbwid` on S3): 128 random bits, no data from the bitmap or the source. A write that fails or is killed part way can leave the uploaded part of a generation behind (S3 multipart parts, Azure Blob uncommitted blocks, an unfinished GCS resumable session, a `LocalFsStorage` `.tmp` file); no listing shows it, so no erasure or drop reaches it. What each backend leaves, and how to remove it, is in [the backup checklist](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/disaster-recovery.md#backup-checklist). The library sweeps a `LocalFsStorage` `.tmp` leftover more than 24 hours old only when something writes in that directory again, and after a drop or an erasure nothing does: delete those yourself | the region of the bucket you wire |
46
46
  | **Registry** (S3 / GCS / Azure Blob / local) | one row per segment: its namespace and name, its status and timestamps, the current-generation pointer, wrapped keys and any key id, its `retention` and `residency` metadata, the current generation's id count, its object's size, footer checksum and tail size, and your metadata (those sealed when the segment is encrypted), a ledger of the generations the segment knows (each one's number and its object's footer checksum, in the clear even on an encrypted segment), any leases, and a token and `pointerId` — no IDs, unless you put one in the metadata or a name, which you must not. An Azure Blob row also carries the random write id in its metadata. On `LocalFsStorage`, a write killed part way leaves a `.tmp` copy of the row beside it, wrapped keys included, until a later write in that directory removes it once it is more than a day old, so a crypto-shred there can be outlived by that copy for that long | the region of the bucket you wire |
47
47
  | **cache** (process RAM) | decoded chunks, and the stored chunk bytes (ciphertext on an encrypted segment) a reader keeps of a small generation it read whole; bounded LRU | **wherever your process/Lambda runs** — an EU segment queried from a US function is processed in the US |
48
48
  | **Loads and rewrites** (`store.load()`, the `*Into` verbs, `store.materializeMany()`, `eraseSubject`) | read your source (or existing generations), write a new generation; a fed operand of `materializeMany` is read from your own process's memory | run wherever you run them — a loader in one region writing to a bucket in another is a transfer |
@@ -60,7 +60,7 @@ CloudBitmaps gives you three levers with different guarantees. Use them delibera
60
60
 
61
61
  | Lever | API | Guarantee | Use for |
62
62
  |---|---|---|---|
63
- | **Subject erasure** | `store.eraseSubject(id, { namespace })` | *Physical on return* — the segment's current generation is rewritten without the id (every chunk streamed through, one bit cleared), published **fenced on the generation it streamed**, and **the generation that held the bit is deleted before the call returns**. So is every other generation still holding the id: a retained *superseded* one (an ex-member dropped by a re-seed), and each one above the pointer after a `rollback` — generations a rollback can move the pointer onto again — while, when the current generation does not hold the id, the ones there that never held it stay as rollback targets, but for any object no read of the segment can open, which goes whatever the id. (When the current generation holds the id, the rewrite is numbered above everything, so its `keep: 0` collection takes every older generation, above the pointer or below. A holder found only above the pointer likewise takes every generation below the pointer with it, clean ones included ([every holder goes](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/correctness.md#every-holder-goes)); above the pointer, a generation with no copy of the id stays, and so does the current one.) Before it says `erased: true` the call lists the bucket and reads what is left, so **`erased: true` means no generation of the segment holds the id**, above the pointer or below it. `eraseSubject` reports a per-segment fault as an `error: …` ledger entry rather than throwing it, and a call that a racing writer overtook (a load, another erasure, or a rollback) as `erased: false, note: 'superseded'`, so "on return" is a claim about every segment whose entry says **`erased: true`** (a fault is an `error: …` entry, never `erased: true`). The store that performs the call stops serving the id immediately; **other processes converge on their own read TTL** — see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). Does **not** reach backups, replicas or noncurrent object versions — those hold the old generation until their own lifecycle removes it. It carries a generation's metadata over unchanged and does **not** scan it, so **never put a subject's id in metadata**. The scope is one `namespace` or `{ allNamespaces: true }`, never both (both throws `ValidationError`); a segment loaded with no `namespace` is reached only by `{ allNamespaces: true }`, and `'_default'` is an ordinary namespace name, not those segments. | "forget this person" — GDPR Art. 17 |
63
+ | **Subject erasure** | `store.eraseSubject(id, { namespace })` | *Physical on return* — the segment's current generation is rewritten without the id (every chunk streamed through, one bit cleared), published **fenced on the generation it streamed**, and **the generation that held the bit is deleted before the call returns**. So is every other generation still holding the id: a retained *superseded* one (an ex-member dropped by a re-seed), and each one above the pointer after a `rollback` — generations a rollback can move the pointer onto again — while, when the current generation does not hold the id, the ones there that never held it stay as rollback targets, but for any object no read of the segment can open, which goes whatever the id. (When the current generation holds the id, the rewrite is numbered above every object near the row's own numbers, so its `keep: 0` collection takes every older generation, above the pointer or below. A holder found only above the pointer likewise takes every generation below the pointer with it, clean ones included ([every holder goes](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/correctness.md#every-holder-goes)); above the pointer, a generation with no copy of the id stays, and so does the current one.) Before it says `erased: true` the call lists the bucket and reads what is left, so **`erased: true` means no generation of the segment holds the id**, above the pointer or below it. `eraseSubject` reports a per-segment fault as an `error: …` ledger entry rather than throwing it, and a call that a racing writer overtook (a load, another erasure, or a rollback) as `erased: false, note: 'superseded'`, so "on return" is a claim about every segment whose entry says **`erased: true`** (a fault is an `error: …` entry, never `erased: true`). The store that performs the call stops serving the id immediately; **other processes converge on their own read TTL** — see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). Does **not** reach backups, replicas or noncurrent object versions — those hold the old generation until their own lifecycle removes it. It carries a generation's metadata over unchanged and does **not** scan it, so **never put a subject's id in metadata**. The scope is one `namespace` or `{ allNamespaces: true }`, never both (both throws `ValidationError`); a segment loaded with no `namespace` is reached only by `{ allNamespaces: true }`, and `'_default'` is an ordinary namespace name, not those segments. | "forget this person" — GDPR Art. 17 |
64
64
  | **Dispose** | `store.dropSegment(ref, { confirmSegment })` | *Immediate* — the segment is tombstoned and its Storage generations deleted, reclaiming the space. **Check `generationsRemaining`:** if it is non-empty the space was *not* fully reclaimed and the drop should be re-run (a load that had read the segment before the tombstone landed, even one still consuming its ids, or an `eraseSubject` rewrite already under way, can write its object after the drop's sweep; it deletes that object itself once its publish is refused, and only one whose process stops in between, or whose publish fails without a definite answer (a lost response, a timeout), leaves it, for a re-run of the drop). Works on cleartext; on an encrypted segment it *also* crypto-shreds, on the terms in the next row. Does **not** reach noncurrent versions / replicas / PITR snapshots — deleting an object is weaker than removing a key. | retiring a dated bucket; rolling-window retention |
65
65
  | **Crypto-shred** | `destroySegment` / `eraseNamespace` | *At rest, complete once no copy of the wrapped key survives* — removes the segment's wrapped DEK(s) from its registry row in one compare-and-swap, without touching the bytes or any KEK. **Every** copy of the objects (current, prior generations, backups, WORM-locked objects) is then unreadable once no retained copy of that row still holds the wrapped key — noncurrent object versions, backups and PITR copies of the row included — or once every KEK that wrapped it is destroyed. Until then, a retained copy of the row and a KEK that wrapped it decrypt the segment, and a registry restore to a point before the shred makes it readable again; see *Erasure vs. backups / WORM* below, and [an optional registry expiry rule](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/disaster-recovery.md#optional-make-a-shred-durable-with-a-registry-expiry-rule) that makes a shred durable after a set number of days. Requires the segment to be encrypted. A process that had already opened the segment holds the **unwrapped** key in memory and keeps reading until it is told — call `store.invalidate(ref)` there; see [One process, and the rest of your fleet](#one-process-and-the-rest-of-your-fleet). | whole-segment / tenant offboarding; erasure under immutable backups (see below) |
66
66
 
@@ -173,7 +173,7 @@ A call with a feed reads operands the library never stores, which no erasure can
173
173
  Erasure and crypto-shred are **immediate in storage and immediate in the store whose verb performed them**, for
174
174
  every read that starts after the verb returns. A read already in progress in that store can still yield an erased id
175
175
  from a chunk it had requested before the verb ran: up to 32 chunks for `iterate` and `count`, and up to `concurrency` + 1
176
- keys (33 by default) for a combine or a count of a combine. That is the bound: the chunk or key a read is handing out and what it had already requested, which a `count` that reads chunks (on a source with no index of its own) reaches. A running `iterate` or combine on the library's own sources stops sooner: the verb invalidates the segment, so before the next key it takes the read lists its chunk keys again from the generation current then and drops what it had requested, and so yields the id only from the chunk or key it is handing out. A combine or `iterate` reads ranges of the object, and resolves the segment again before it serves each chunk, one held in its cache included, as a read of that chunk alone does, so the ranges it had already requested are dropped, not served. A pinned read in progress does not move to the rewritten generation: before its next chunk it opens its object again, and fails with `NotFoundError` once the erasure has deleted it, so it yields no more than that bound either. They are not immediate in any *other* store, in the same process or another, and this library ships nothing that could
176
+ keys (33 by default) for a combine or a count of a combine. That is the bound: the chunk or key a read is handing out and what it had already requested, which a `count` that reads chunks (on a source with no index of its own) reaches. A running `iterate` or combine on the library's own sources stops sooner: the verb invalidates the segment, so before the next key it takes the read lists its chunk keys again from the generation current then and drops what it had requested, and so yields the id only from the chunk or key it is handing out. A combine or `iterate` reads ranges of the object, and resolves the segment again before it serves each chunk, one held in its cache included, as a read of that chunk alone does, so the ranges it had already requested are dropped, not served. A pinned read in progress does not move to the rewritten generation: before its next chunk it opens its object again, and fails with `NotFoundError` once the erasure has deleted it, so it yields no more than that bound either. In another store it stops at its next chunk once `cache.genTtlMs` has passed since the pin last looked at its segment's row and the segment has been destroyed or its generation erased. They are not immediate in any *other* store, in the same process or another, and this library ships nothing that could
177
177
  make them so — there is no daemon, no bus, and no connection between two stores that happen to point at the same
178
178
  bucket.
179
179
 
@@ -185,8 +185,7 @@ bucket.
185
185
  | another store with **no registry** (a bare `IStorageDriver`), with `cache: { genTtlMs: 0 }`, or on a `StorageChunkSource` of your own, which the store times no refresh on | **no bound** — only when its caches happen to let the segment go, or something tells it |
186
186
  | `seg.pinAt` of a generation the erasure rewrote, in any store | on return it fails with `NotFoundError`, since the object is deleted (and, with a registry, the row has moved on); during the erasure, between the rewrite's publish and its delete of the old generation, it can still open that generation, and a handle opened then is a pin, covered by the rows for the store whose verb made the call and for a pinned handle in another store |
187
187
  | a `materializeMany` call with a feed, and an erasure by any path but this store's `eraseSubject` | **no bound**: the call is refused only by this store's `eraseSubject` |
188
- | a pinned handle (`seg.pin()`) in another store | **no bound** — until that store's reader cache evicts the pin's reader (in a store with a timed pointer refresh, a small generation's reader holds all of its chunks, decoded or not) and its chunk cache evicts the chunks the pin decoded, or something tells it |
189
- | a leased pinned handle (`seg.pin({ leaseUntil })`) in another store | until its lease ends, at most 14 days after it was taken: its reads then throw `LeaseExpiredError`, whether or not its reader still holds the chunks. Until then, as for any pinned handle in the row above |
188
+ | a pinned handle (`seg.pin()`) in another store, leased or not | within `cache.genTtlMs` (default 2 s), while the registry can be read and storage answers the read of the pinned object's footer, whether or not its reader still holds the chunks; an outage of the registry, or a transient storage fault on that read, stretches it. A lease does not lengthen it: once a lease has lapsed, reads throw `LeaseExpiredError` first. A pin of a generation the erasure could not remove (its ledger entry has `erased: false`) reads it, the id included, until a re-run of the erasure removes it |
190
189
 
191
190
  **An outage of the registry extends that bound.** A refresh that cannot read the row because of a transient fault
192
191
  (throttling, a 5xx, a dropped connection) keeps serving what the store resolved before the outage, for a segment
@@ -202,12 +201,13 @@ that error, and the reader is dropped with the key it held.
202
201
  `destroySegment` and `eraseNamespace` are free functions over raw drivers, not verbs of a store, so for them every
203
202
  store is another store, one in the same process included.
204
203
 
205
- The last two rows are the ones to design around. `cache: { genTtlMs: 0 }` turns the timed refresh off, and is a
204
+ The no-timed-refresh row and the feed row are the ones to design around. `cache: { genTtlMs: 0 }` turns the timed refresh off, and is a
206
205
  legitimate setting for a read-only replica of immutable data — but a store set that way has no bound on when it
207
206
  observes a shred: until its reader cache lets the segment go, it keeps decrypting with the key it already unwrapped.
208
- A pinned handle is that case in any store, clock or none, and holds on longer: its reader keeps the key until the
209
- reader cache evicts it, and after that the pin still answers `has()` for the ids in chunks it decoded, until the
210
- chunk cache drops them. If a
207
+ A pinned handle in such a store is that case too: its reader keeps the key until the reader cache evicts it, and after
208
+ that the pin still answers `has()` for the ids in chunks it decoded, until the chunk cache drops them. A pinned handle
209
+ in a store with a timed refresh is bounded like any other reader: it re-reads its segment's row every `cache.genTtlMs`,
210
+ counted from the moment it was taken, and fails with `NotFoundError` once the segment is destroyed or its generation erased. If a
211
211
  compliance deadline depends on every reader converging, fan the reference out to your fleet and have each
212
212
  process call `store.invalidate(ref)`; that is the hook, and delivering it is yours because the transport is
213
213
  yours.
@@ -451,6 +451,12 @@ it (the request and response it kept, and a credential the service quotes back o
451
451
  in another form reaches you as the SDK wrote it; and `eraseSubject` copies each message into its ledger's `note`.
452
452
  Treat an error message, and the ledger, as carrying those names wherever you report or keep them.
453
453
 
454
+ The names also reach your metrics sink: every `storage.get` event carries the segment and namespace of the read, as the
455
+ audit events do (ids never appear in either). **The library does not redact a name.** If a name carries tenant or customer
456
+ meaning, pass a name that does not: for example your own keyed hash of the tenant id (an HMAC under a key you hold) as the
457
+ namespace or segment, with the mapping from a hash back to the tenant kept in your own system. Then every error, event and
458
+ registry row names only the hash.
459
+
454
460
 
455
461
  ## Shared-responsibility matrix
456
462
 
@@ -4,7 +4,7 @@
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { realpathSync } from "node:fs";
6
6
  import { lstat, mkdir, open, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
7
- import { join } from "node:path";
7
+ import { join, resolve } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { encodeNameForPath, namespacePathPart } from "@cloudbitmaps/core/driver-kit";
10
10
 
@@ -3172,8 +3172,8 @@ var SystemClock = class {
3172
3172
  }
3173
3173
  sleep(ms) {
3174
3174
  if (ms <= 0) return Promise.resolve();
3175
- return new Promise((resolve) => {
3176
- setTimeout(resolve, ms);
3175
+ return new Promise((resolve2) => {
3176
+ setTimeout(resolve2, ms);
3177
3177
  });
3178
3178
  }
3179
3179
  /**
@@ -3200,8 +3200,8 @@ var SystemClock = class {
3200
3200
  * "there is outstanding work" — and `setImmediate` cannot outlive the turn that scheduled it anyway.
3201
3201
  */
3202
3202
  yieldNow() {
3203
- return new Promise((resolve) => {
3204
- setImmediate(resolve);
3203
+ return new Promise((resolve2) => {
3204
+ setImmediate(resolve2);
3205
3205
  });
3206
3206
  }
3207
3207
  };
@@ -4499,7 +4499,7 @@ var CloudRoaring = class _CloudRoaring {
4499
4499
  * duration, or fix the source first and load after. A writer that lands *during* the rewrite is caught: the
4500
4500
  * rewrite's publish is refused **by the fence** — the publish lands its compare-and-swap only
4501
4501
  * while the pointer is still on the generation the rewrite streamed — and the entry says `note: 'superseded'`,
4502
- * so re-run. Forward-only alone would NOT refuse it: the rewrite's number goes above everything in the bucket,
4502
+ * so re-run. Forward-only alone would NOT refuse it: the rewrite's number goes above everything listed near the row's own numbers,
4503
4503
  * so the rewrite would out-rank the newer generation and then collect it.
4504
4504
  *
4505
4505
  * A racing **erasure** is caught before that, and reported the same way. It collects with `keep: 0`, taking
@@ -4737,7 +4737,8 @@ var CloudRoaring = class _CloudRoaring {
4737
4737
  * It deletes nothing. The generations above the new pointer stay put — which is what makes this reversible —
4738
4738
  * and are then *above* `currentGen`, where collection never looks, and still known to the row, so rolling forward onto
4739
4739
  * one needs no flag. They remain until loads pass them (the first load whose
4740
- * number one of them holds numbers above them all, and collection then keeps `keep` of what is below its pointer, the
4740
+ * number one of them holds numbers above them all, those within 1,024 of what the row names and any contiguous run
4741
+ * touching them, and collection then keeps `keep` of what is below its pointer, the
4741
4742
  * generation that load superseded first), {@link CloudRoaring.dropSegment}
4742
4743
  * deletes them, or {@link CloudRoaring.eraseSubject} does: all of those present when it rewrites, and, when the
4743
4744
  * current generation does not hold the id, only those holding it and any no read of the segment can open, whatever
@@ -5114,7 +5115,8 @@ var CloudRoaring = class _CloudRoaring {
5114
5115
  * the unwrapped DEK, and decrypts with them, chunks it had never fetched before the shred included, until its
5115
5116
  * snapshot re-resolves: within `cache.genTtlMs` when the store has a registry and a `genTtlMs` above 0, and
5116
5117
  * otherwise only when its caches let the segment go or a read has to fetch from a generation a sweep collected.
5117
- * A pin answers with no timed bound, until it is told or both caches let it go.
5118
+ * A pin on a store with a timed refresh fails within `cache.genTtlMs` once its segment is destroyed or its
5119
+ * generation erased; on a store with none it answers until it is told or both caches let it go.
5118
5120
  * - **Another process.** Erasing on one box invalidates nothing on the others; each store bounds its own
5119
5121
  * staleness by `cache.genTtlMs`, and a store with no registry (a bare `IStorageDriver`), with
5120
5122
  * `cache.genTtlMs: 0`, or on a `StorageChunkSource` of your own has no bound at all: it converges only when a read happens to miss its caches. If a compliance deadline depends on every
@@ -5196,6 +5198,7 @@ var CloudRoaring = class _CloudRoaring {
5196
5198
  throw err;
5197
5199
  }
5198
5200
  const pinnedAt = at === null ? { generation: null, version: null, fingerprint: null } : { generation: at.generation, version: at.version, fingerprint: at.fingerprint };
5201
+ if (at !== null) crbm.startPinChecks(pinnedAt, at.checkedAtMs);
5199
5202
  const pins = /* @__PURE__ */ new Map([[segmentKey(ref), pinnedAt]]);
5200
5203
  return makeSegment({
5201
5204
  engine: this.engineWithPins(pins),
@@ -5563,7 +5566,9 @@ var Segment = class _Segment {
5563
5566
  * `*Into` of one throws before it reads anything. Materialise one side first, with `materializeMany({ operands: { src: seg }, outputs: [{ dest, expr: { or: ['src'] } }], keep })`.
5564
5567
  *
5565
5568
  * **A pin knows its object, not only its number.** A name purged and loaded again starts again at generation
5566
- * 0, so a pin of the old segment never reads the new one: what it has already read still answers, as the
5569
+ * 0, so a pin of the old segment never reads the new one. On a store with a timed refresh the pin fails with
5570
+ * `NotFoundError` at its next check, within `cache.genTtlMs`, what it had already read included, and `invalidate`
5571
+ * does not bring it back: take a new pin. On a store with none, what it has already read still answers, as the
5567
5572
  * instant it pinned, and anything it would have to fetch fails with `NotFoundError`, as a swept pin's does.
5568
5573
  *
5569
5574
  * **It is a hold, and `pin({ leaseUntil })` makes it a bounded one.** Without a lease nothing stops a collection
@@ -5585,7 +5590,11 @@ var Segment = class _Segment {
5585
5590
  * segment whose row is later dropped or destroyed fails once it must open its object again, rather than go empty
5586
5591
  * part-way through a call. **A pin
5587
5592
  * keeps the key its reader unwrapped for as long as that reader stays open, and answers from the chunks it
5588
- * decoded for as long as they stay cached.** This store invalidates a pin:
5593
+ * decoded for as long as they stay cached, until its next check.** On a store with a timed refresh a pin checks its
5594
+ * segment's row every `cache.genTtlMs` (from the moment it was taken, whether or not it was read) and fails with
5595
+ * `NotFoundError` once the segment is destroyed, is another incarnation, or its generation is collected or recorded by its row as another object (an object lost or replaced out of band
5596
+ * under a row that still names it is found only when the pin must fetch). This
5597
+ * store invalidates a pin:
5589
5598
  *
5590
5599
  * - a `load`, a `rollback` or an `*Into` invalidates a pin of the segment it writes;
5591
5600
  * - `dropSegment` invalidates a pin of the segment it drops, and `retireExpired` a pin of each segment its ledger
@@ -5598,16 +5607,18 @@ var Segment = class _Segment {
5598
5607
  * where the object is still there, and fails with `NotFoundError` where it is gone. It caches nothing more of what it
5599
5608
  * began reading before the invalidation, so the pin's next read opens the object again too.
5600
5609
  * Anything else leaves the pin as it is: after a `destroySegment` beside this store, or an erasure, a drop or a
5601
- * retirement through another store, in this process or another, the pin answers from what it holds until this
5610
+ * retirement through another store, in this process or another, the pin answers from what it holds for at most
5611
+ * `cache.genTtlMs` on a store with a timed refresh, and then fails. On a store with none it answers until this
5602
5612
  * store's reader cache evicts the pin's reader and its chunk cache evicts the chunks the pin decoded, or
5603
5613
  * {@link CloudRoaring.invalidate} is called on this store. A combine on another store refuses the pin. Where the
5604
5614
  * object the pin reads has been deleted, by an erasure, a drop or a sweep, a chunk the pin has not cached fails at
5605
- * once. No timed refresh bounds any of that, as none bounds anything else a pin holds.
5615
+ * once. With no timed refresh nothing bounds any of that, as nothing bounds anything else a pin holds.
5606
5616
  *
5607
- * A replacement a pin has found is remembered, so its later chunk reads fail with no request, until the store
5617
+ * A replacement a read of the pin found is remembered, so its later chunk reads fail with no request, until the store
5608
5618
  * forgets it: an invalidation of the segment does, as does a later `pin()` of the same version that opens the object
5609
- * again, and the store remembers at most `cache.readerMax` of them. So once a restore puts the pinned object back,
5610
- * call {@link CloudRoaring.invalidate} on this store: the pin then reads the object again.
5619
+ * again; a replacement the pin's own check found is forgotten only by an invalidation. The store remembers at most `cache.readerMax` of them. So once a restore puts the pinned object back
5620
+ * into the same row, call {@link CloudRoaring.invalidate} on this store: the pin then reads the object again. A
5621
+ * restore into a purged and re-created row does not revive a pin on a store with a timed refresh: take a new pin.
5611
5622
  *
5612
5623
  * `pin()` reads the registry row and opens the generation at once, so the pin knows its object before its first
5613
5624
  * read. With a registry, pins of one generation taken while its row is unchanged share one reader while the store
@@ -6327,7 +6338,7 @@ Exports every registered segment's current generation from a local-filesystem st
6327
6338
  Settings come from the environment:
6328
6339
 
6329
6340
  CR_EXPORT_ROOT (required) the LocalFsStorage root, holding storage/ and registry/
6330
- CR_EXPORT_OUT (required) the output directory for the dump
6341
+ CR_EXPORT_OUT (required) the output directory for the dump: new or existing (never a symlink)
6331
6342
  CR_EXPORT_FORMAT roaring | ndjson (default: roaring)
6332
6343
  CR_EXPORT_NAMESPACE (optional) scope the export to one namespace
6333
6344
 
@@ -6384,6 +6395,7 @@ function parseConfig(env) {
6384
6395
  return { root, out, format, namespace };
6385
6396
  }
6386
6397
  async function ensureOwnDirectory(out, dir) {
6398
+ await checkOutDirectory(out);
6387
6399
  await mkdir(out, { recursive: true, mode: 448 });
6388
6400
  try {
6389
6401
  await mkdir(dir, { mode: 448 });
@@ -6402,6 +6414,20 @@ async function ensureOwnDirectory(out, dir) {
6402
6414
  if ((found.mode & 63) !== 0) refuse("a namespace directory is open to group or others");
6403
6415
  }
6404
6416
  }
6417
+ async function checkOutDirectory(out) {
6418
+ let found;
6419
+ try {
6420
+ found = await lstat(resolve(out));
6421
+ } catch (err) {
6422
+ if (err.code === "ENOENT") return;
6423
+ throw err;
6424
+ }
6425
+ const refuse = (why) => {
6426
+ throw new ValidationError6(`the output directory is refused: ${why}`);
6427
+ };
6428
+ if (found.isSymbolicLink()) refuse("it is a symbolic link");
6429
+ if (!found.isDirectory()) refuse("it is not a directory");
6430
+ }
6405
6431
  var MANIFEST_NAME = "manifest.json";
6406
6432
  function fsSink(out) {
6407
6433
  return {
@@ -6470,6 +6496,7 @@ async function main(env = process.env, now = () => Date.now()) {
6470
6496
  await requireStoreRoot(config.root);
6471
6497
  const storage = new LocalFsStorage(config.root);
6472
6498
  const store = new CloudRoaring({ storage });
6499
+ await checkOutDirectory(config.out);
6473
6500
  const priorManifest = await lstat(join(config.out, MANIFEST_NAME)).catch(() => null);
6474
6501
  if (priorManifest?.isDirectory() === true) {
6475
6502
  throw new ValidationError6(