@syncular/server 0.31.0 → 0.32.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/README.md CHANGED
@@ -128,7 +128,7 @@ After restoring an authoritative database, keep traffic stopped and call
128
128
  `rotatePartitionLogEpoch({ storage, partition })` for every restored
129
129
  partition. The rotation clears stale client cursors and requires version 2
130
130
  clients to reset their server-derived rows while preserving the outbox. Follow
131
- the complete [backup and restore runbook](https://syncular.dev/server-backup-restore/).
131
+ the complete [backup and restore runbook](https://syncular.dev/server-operations/#backup-and-restore).
132
132
 
133
133
  ## Adapter error hooks
134
134
 
@@ -142,7 +142,7 @@ adapter answers instead. Return `undefined` to keep `sync.internal_error`. A
142
142
  throw, a non-`SyncError` return, a code that is not an own catalog entry, or
143
143
  `details` that is not a JSON document is contained as `sync.internal_error`.
144
144
  The hook covers the sync, operations, segments, blobs, admin, and realtime
145
- surfaces; `createSyncularAdminRoutes` takes its own `mapError`. It does not
145
+ surfaces. It does not
146
146
  change a remote operation's `operation.*` envelope, and a mapped `retryAfterMs`
147
147
  is delivery metadata. Client retry scheduling remains unchanged.
148
148
 
@@ -533,10 +533,10 @@ context). The demo server wires it behind `SYNCULAR_DEMO_EVENTS=1`.
533
533
 
534
534
  | Event | When | Key fields |
535
535
  | --- | --- | --- |
536
- | `request.handled` | Once per `POST /sync`, after the response bytes are fully produced (or the request was rejected up front) | `kind` (`sync`), `partition`, `actorId`, `durationMs`, `bytesIn`, `bytesOut`, `outcome` (`ok` \| `schema_floor` \| `rejected` \| `error`), `errorCode?`, `pushCommits`, `pulled`, `subscriptions` |
536
+ | `request.handled` | Once per `POST /sync`, after the response bytes are fully produced (or the request was rejected up front) | `kind` (`sync`), `partition`, `actorId`, `durationMs`, `bytesIn`, `bytesOut`, `outcome` (`ok` \| `schema_floor` \| `epoch_acquired` \| `reset` \| `rejected` \| `error`; `epoch_acquired` answers a first round without `logEpoch`, `reset` a `logEpoch` the log no longer matches), `errorCode?`, `clientId?` (absent when the request did not decode), `pushCommits`, `pulled`, `subscriptions` |
537
537
  | `push.applied` | A `PUSH_COMMIT` applied, or replayed from the idempotency cache (§2.3) | `clientId`, `clientCommitId`, `operations`, `commitSeq?`, `replay` |
538
- | `push.rejected` | A commit rejected (§6.3) | `clientId`, `clientCommitId`, `operations`, `code` (§10.2), `opIndex` |
539
- | `push.conflicted` | A commit terminated by a version conflict (§6.2) | `clientId`, `clientCommitId`, `operations`, `opIndex` |
538
+ | `push.rejected` | A commit rejected (§6.3) | `clientId`, `clientCommitId`, `operations`, `code` (§10.2), `opIndex`, `table?` and `rowId?` of the terminating operation |
539
+ | `push.conflicted` | A commit terminated by a version conflict (§6.2) | `clientId`, `clientCommitId`, `operations`, `opIndex`, `table?` and `rowId?` of the conflicting operation |
540
540
  | `reaction.queued` | A planned reaction committed with its source push | `clientId`, `clientCommitId`, `commitSeq`, `idempotencyKey`, `reactionType`, `version` |
541
541
  | `reaction.started` | A worker claimed and began one attempt | `workerId`, `idempotencyKey`, `reactionType`, `version`, `attempt` |
542
542
  | `reaction.retried` | A retryable attempt failed and was rescheduled | started fields plus `nextAttemptAtMs`, `errorCode` |
@@ -554,26 +554,31 @@ context). The demo server wires it behind `SYNCULAR_DEMO_EVENTS=1`.
554
554
  | `scopes.resolve_failed` | The host `resolveScopes` callback threw — the §3.2/§3.4 fail-loud path | `phase` (`request` \| `realtime` \| `segment-download`), `message` |
555
555
 
556
556
  All events also carry `type`, `atMs`, and (where a request identity
557
- exists) `partition` / `actorId`.
557
+ exists) `partition` / `actorId`. The events emitted while serving one sync
558
+ request (`request.handled`, `push.*`, `pull.served`, `lease.issued`,
559
+ `reaction.queued`, `scopes.resolve_failed`) share one `requestId`, so a sink
560
+ can group a request's events.
558
561
 
559
562
  ## Admin / console surface (`SyncularAdmin`)
560
563
 
561
- The operator-facing read surface over the server core lives in this package
562
- and adds **zero** wire protocol. Authorization for these reads is entirely the
563
- host's. It delivers the 80% operator value (who's
564
- connected, what's flowing,
565
- horizon health, the event tail) as a handful of read-only, partition-scoped,
566
- JSON-able queries.
564
+ The operator-facing surface over the server core lives in this package and
565
+ adds no wire protocol. Authorization is entirely the host's. It answers
566
+ who is connected, what is flowing and failing, why a client does or does not
567
+ see a row, and how far the horizon trails, through partition-scoped JSON-able
568
+ queries plus three operator actions.
567
569
 
568
570
  ### The event ring (the "event stream")
569
571
 
570
572
  `RingBufferEvents` is a `SyncularServerEvents` sink that retains the last N
571
573
  events in memory (bounded — oldest dropped when full) with a
572
- `query({type?, sinceMs?, clientId?, actorId?, limit})` and a
574
+ `query({type?, partition?, sinceMs?, clientId?, actorId?, limit})` and a
573
575
  `subscribe(listener)` hook for live tails (the SSE route below). It is the
574
- event stream without any infrastructure dependency. Compose it with any
575
- other sink so the console tail and your logs/metrics see the same
576
- emissions:
576
+ event stream without any infrastructure dependency. `evictedThroughMs` is
577
+ the newest `atMs` the ring dropped; the admin reports a window that starts
578
+ at or before it as incomplete. The `admin` option of `createSyncularHono`
579
+ and the Workers Durable Object create and compose the ring themselves. A
580
+ script that queries `SyncularAdmin` directly composes it with any other sink
581
+ so the ring and your logs/metrics see the same emissions:
577
582
 
578
583
  ```ts
579
584
  import {
@@ -590,28 +595,51 @@ const admin = SyncularAdmin.fromConfig(config, { ring });
590
595
 
591
596
  ### Query surface
592
597
 
593
- Every method is read-only. All are partition-scoped except the two fleet
594
- reads (`listPartitions` / `partitionsOverview`), which enumerate partitions
595
- by design:
598
+ All methods are partition-scoped except the two fleet reads
599
+ (`listPartitions` / `partitionsOverview`), which enumerate partitions by
600
+ design. Row reads need the server `schema` (`fromConfig` passes it).
596
601
 
597
602
  | Method | Returns |
598
603
  | --- | --- |
599
- | `listClients(partition)` | Known clients: `clientId`, `actorId`, `cursor`, `lag` (commits not yet pulled: `maxCommitSeq − max(cursor, 0)`), `updatedAtMs`, `subscriptions[]`, and an `active` flag (cursor touched within the §4.6 active window). |
600
- | `clientDetail(partition, clientId, {eventLimit?})` | One client's drill-down: `{exists, client?, lease?, events}` — the record (with lag), its §7.3 lease when a lease store is wired, and its slice of the event tail. Answers "why is this client stale" in one read. |
601
- | `listCommits(partition, {afterSeq?, limit?, table?})` | Commit-log **metadata** (never payloads), newest first: `commitSeq`, `clientId`, `clientCommitId`, `actorId`, `createdAtMs`, `changeCount`, `tables[]`. |
604
+ | `tables()` / `schemaVersion` | The schema tables with `columns[]` (`name`, `type`, `nullable`, `primaryKey`), and the server schema version. |
605
+ | `search(partition, query)` | Resolves one pasted id: clients whose id contains it or whose actor equals it, actors containing it, rows with that rowId in any table, the commit with that seq, and write outcomes with that clientCommitId. |
606
+ | `actors(partition)` | Each actor with its devices (clients), open sockets, worst lag, and failures in the last 5 minutes (the `errors().byClient` counts summed per actor). |
607
+ | `tableCounts(partition)` | Row count per schema table. |
608
+ | `listRows(partition, table, {afterRowId?, limit?, scope?})` | One page of decoded rows ordered by rowId, optionally only rows whose stored `scope` (`{variable, value}`) matches, read through the scope index; `nextAfterRowId` continues. |
609
+ | `rowDelivery(partition, table, rowId)` | The trace verdict for every known client on one row, sorted by verdict then last seen: who has the row and who does not. |
610
+ | `findWrite(partition, clientCommitId)` | Every stored idempotency outcome for that clientCommitId (applied with `commitSeq`, or rejected with per-operation results; conflict bytes as base64) and the ring events that carry it. |
611
+ | `listClients(partition, {limit?, sort?})` | `{clients, total}`. Each client: `clientId`, `actorId`, `wireVersion`, `schemaVersion?` (highest among its subscriptions), `cursor`, `lag` (`maxCommitSeq − max(cursor, 0)`), `updatedAtMs`, `subscriptions[]`, `active` (touched within the §4.6 active window), `connected?` and `connectedSinceMs?` (open realtime sockets on this process; present when a hub is wired, which `fromConfig` does when `config.realtime` is a `RealtimeHub`). `sort`: `seen` (default) or `lag`; `limit` default 200. |
612
+ | `clientDetail(partition, clientId, {eventLimit?})` | `{exists, client?, lease?, events}`: the record, its §7.3 lease when a lease store is wired, and its slice of the event tail. |
613
+ | `listCommits(partition, {afterSeq?, beforeSeq?, limit?, table?, changeSample?})` | Commit metadata, newest first: `commitSeq`, `clientId`, `clientCommitId`, `actorId`, `createdAtMs`, `changeCount`, `tables[]`, and with `changeSample` the first N changes as `sample[]` (`table`, `rowId`, `op`). Pass the oldest `commitSeq` of a page as `beforeSeq` for the next older page. |
614
+ | `commitDetail(partition, commitSeq)` | The commit's metadata plus `changes[]` (`table`, `rowId`, `op`, `rowVersion?`, `scopes`, `values?` rendered per SPEC §11, `undecodable?` for a payload written under an older column set). Throws `sync.not_found` for a missing or pruned commit. |
602
615
  | `listReactions(partition, {statuses?, types?, limit?})` | Durable reaction lifecycle rows, newest first, including source commit, attempts, lease, completion, and bounded failure information. |
603
- | `inspectRow(partition, table, rowId)` | `{exists, serverVersion?, scopes?}` — current row version + stored scopes, payload **not** decoded. |
616
+ | `inspectRow(partition, table, rowId, {historyLimit?})` | `{exists, serverVersion?, scopes?, values?, history}`: the row decoded per SPEC §11 and its retained changes, newest first (`commitSeq`, `op`, `rowVersion?`, `actorId`, `clientId`, `createdAtMs`). Throws `sync.unknown_table` for a table outside the schema. |
617
+ | `traceRow(partition, {table, rowId, clientId})` | Whether the client receives the row: a `verdict` (`client-unknown`, `row-missing`, `no-subscription`, `row-deleted`, `scope-mismatch`, `scope-denied`, `pending-pull`, `delivered`, evaluated in that order) with the row, its newest change, the horizon, the client, the actor's `allowed` scopes from `resolveScopes` (`outage` for the §7.3.3 sentinel), each subscription on the table with `requestedMatch`, `mismatches[]` and `effectiveMatch` (requested ∩ allowed, §3.2), and the client's retained push rejections. |
604
618
  | `scopeActivity(partition, {variable, value}, {limit?})` | Recent commits touching one scope key, via the §3.1 change-scope index (never a log scan). |
605
- | `horizonStatus(partition)` | `{maxCommitSeq, horizonSeq, retainedCommits, activeCursorFloor, recommendedHorizonSeq, recommendation}` — the horizon a prune pass would reach now (§4.6) + a coarse `up-to-date` / `prune-recommended`. |
606
- | `segmentStats()` / `blobStats(partition)` / `stats(partition)` | Counts/bytes where the stores expose them (segments split rows/sqlite). `undefined` when a store omits `stats()`. |
607
- | `metrics(partition, {windowMs?, buckets?})` | Ring-derived request/push health over a trailing window (default 5 min): request count/rate, error share, p50/p95 duration, push applied/rejected/conflicted, and per-bucket counts for a sparkline. Zero new server state; all zeros when no ring is wired. |
608
- | `listPartitionRegistry()` / `partitionsOverview()` | Every authenticated partition; the registry carries log continuity and last-authenticated time, while the overview adds retained backlog, client counts, and the prune recommendation. |
609
- | `events({type?, sinceMs?, clientId?, actorId?, limit?})` | The ring tail, newest first. Empty when no ring is wired (`hasEventStream` reports which). |
619
+ | `horizonStatus(partition)` | `{maxCommitSeq, horizonSeq, retainedCommits, activeCursorFloor, recommendedHorizonSeq, recommendation}`: the horizon a prune pass would reach now (§4.6) and `up-to-date` / `prune-recommended`. |
620
+ | `stats(partition)` | `{segments?, blobs?}` counts/bytes where the stores expose them (segments split rows/sqlite). |
621
+ | `metrics(partition, {windowMs?, buckets?})` | Ring-derived request/push health over a trailing window (default 5 min): request count/rate, error share, p50/p95 duration, push applied/rejected/conflicted, and per-bucket counts, errors, p95 and push outcomes. `coverage: {complete, fromMs}` is false when the ring dropped events inside the window. All zeros when no ring is wired. |
622
+ | `errors(partition, {windowMs?})` | Ring failures in the window: `byCode[]` (`source` `request`/`push`/`reaction`/`scopes`, `code`, `count`, distinct `clients` and `actors`, `lastAtMs`, `sampleClientIds`, `sampleRows`) and every client with a request or push failure in `byClient[]`, most failures first, with `coverage`. |
623
+ | `listPartitionRegistry()` / `partitionsOverview({limit?, offset?})` | Every authenticated partition; the overview pages them (default 100) as `{partitions, total}` with retained backlog, client counts, and the prune recommendation. |
624
+ | `events({type?, partition?, requestId?, sinceMs?, clientId?, actorId?, limit?})` | The ring tail, newest first. Empty when no ring is wired (`hasEventStream` reports which). |
610
625
  | `subscribeEvents(listener)` | Live events as they land in the ring; returns the unsubscribe function, `undefined` when no ring is wired. |
611
626
 
627
+ Operator actions:
628
+
629
+ | Method | Effect |
630
+ | --- | --- |
631
+ | `retryReaction(partition, idempotencyKey)` | Requeues a dead-lettered reaction and returns it. Throws `sync.invalid_request` when the key names no dead-lettered reaction. |
632
+ | `prune(partition)` | One §4.6 prune pass now; returns `{previousHorizonSeq, horizonSeq}` and emits `prune.completed`. |
633
+ | `revokeLease(partition, clientId)` | Revokes the client's §7.3 lease (§7.3.4), emits `lease.revoked`, returns the revoked record. Throws `sync.invalid_request` when the client holds none. |
634
+
612
635
  The query surface leans on **additive, optional** storage/store methods
613
- (`ServerStorage.listClientRecords` / `listCommitMetadata` / `scopeActivity` /
614
- `getRowScopes` / `listPartitions`; `SegmentStore.stats`; `BlobStore.stats`)
636
+ (`ServerStorage.listClientRecords` / `listCommitMetadata` / `getCommit` /
637
+ `listRowChanges` / `listRows` / `countRows` / `listPushResults` /
638
+ `scopeActivity` / `listPartitions`; `SegmentStore.stats`;
639
+ `BlobStore.stats`). `listRowChanges` reads the partition's retained log
640
+ and `listPushResults` reads the stored idempotency outcomes, both without a
641
+ dedicated index, so the write path pays nothing for them and their cost is
642
+ bounded by retention
615
643
  — the established
616
644
  optional-method pattern. `SqliteServerStorage`, `PostgresServerStorage`,
617
645
  `D1ServerStorage`, and the memory/sqlite stores implement them; the shared
@@ -625,72 +653,94 @@ stores (memory/sqlite) omit the marker.
625
653
 
626
654
  ### HTTP routes + the single console page
627
655
 
628
- `@syncular/server-hono` exports `createSyncularAdminRoutes(admin, opts)`,
629
- a mountable Hono sub-app. **The auth seam is required**: the factory throws
630
- if you omit the `authorize` guard — there is no default-open admin. Every
631
- endpoint (including the page) runs the guard first; a falsy result is a 401.
656
+ `createSyncularHono` mounts the console with its `admin` option, and
657
+ `createWorkersFetchHandler` takes the same guards (see the
658
+ `@syncular/server-workers` README). The option throws without the
659
+ `authorize` guard, so the admin never mounts open. Every endpoint, including
660
+ the page, runs the guard first; a falsy result is a 401. The `POST` actions
661
+ also run `authorizeActions`; without it, or when it returns falsy, they
662
+ answer 403 `sync.forbidden`.
632
663
 
633
664
  ```ts
634
- import { createSyncularAdminRoutes } from '@syncular/server-hono';
635
-
636
- const routes = createSyncularAdminRoutes(admin, {
637
- defaultPartition: 'main',
638
- authorize: ({ request }) => isOperator(request), // YOUR check — mandatory
665
+ import { createSyncularHono } from '@syncular/server-hono';
666
+
667
+ const app = createSyncularHono({
668
+ config,
669
+ authenticate,
670
+ admin: {
671
+ authorize: ({ request }) => isOperator(request), // YOUR check, mandatory
672
+ authorizeActions: ({ request }) => isOnCall(request), // optional actions
673
+ // path: '/admin', ringCapacity: 1000 (the defaults)
674
+ },
639
675
  });
640
- app.route('/admin', routes);
641
676
  ```
642
677
 
678
+ The option creates a `RingBufferEvents` of `ringCapacity` events and
679
+ composes it into `config.events` and, when `config.realtime` is a
680
+ `RealtimeHub`, into the hub's events (`RealtimeHub.addEventSink`), so the
681
+ console sees HTTP and socket rounds. Admin errors go to `config.onError` and
682
+ `config.mapError`.
683
+
643
684
  | Route | Mirrors |
644
685
  | --- | --- |
645
686
  | `GET /` | The console page (see below). |
646
- | `GET /clients` | `listClients` |
687
+ | `GET /info` | `{partition, hasEventStream, actions, realtime, schemaVersion?, tables}` |
688
+ | `GET /search?q` | `search` |
689
+ | `GET /actors` | `actors` |
690
+ | `GET /tables` | `tables()` with each table's `rowCount` |
691
+ | `GET /rows/:table?afterRowId&limit&scope=variable:value` | `listRows` |
692
+ | `GET /rows/:table/:rowId/delivery` | `rowDelivery` |
693
+ | `GET /writes/:clientCommitId` | `findWrite` |
694
+ | `GET /clients?limit&sort` | `listClients` |
647
695
  | `GET /clients/:clientId?eventLimit` | `clientDetail` |
648
- | `GET /commits?afterSeq&limit&table` | `listCommits` |
696
+ | `GET /commits?afterSeq&beforeSeq&limit&table&sample` | `listCommits` (`sample` is `changeSample`) |
697
+ | `GET /commits/:commitSeq` | `commitDetail` |
649
698
  | `GET /reactions?status&type&limit` | `listReactions` |
650
- | `GET /rows/:table/:rowId` | `inspectRow` |
699
+ | `GET /rows/:table/:rowId?historyLimit` | `inspectRow` |
700
+ | `GET /trace?table&rowId&clientId` | `traceRow` |
651
701
  | `GET /scope-activity?variable&value&limit` | `scopeActivity` |
652
702
  | `GET /horizon` | `horizonStatus` |
653
703
  | `GET /stats` | `stats` |
654
704
  | `GET /metrics?windowMs` | `metrics` |
655
- | `GET /partitions` | `partitionsOverview` (fleet view) |
656
- | `GET /events?type&sinceMs&clientId&actorId&limit` | `events` (ring tail) |
657
- | `GET /events/stream?type&clientId&actorId&limit` | live SSE tail (backlog replay, then `subscribeEvents`) |
658
-
659
- `?partition=` selects the partition (falls back to `defaultPartition`).
660
- `GET /partitions` is the one cross-partition endpoint: the guard still runs
661
- on it, with an empty `partition` in its context unless the query passes one
662
- — a host that authorizes per partition should treat it accordingly.
663
-
664
- `GET /` (or `/admin`) serves a **single static HTML page** — zero
665
- framework, no build step, no React. It fetches the sibling JSON endpoints
666
- (relative to its own mount path, so it works under any prefix and the same
667
- guard covers its XHRs) and renders: a metrics statusbar (push rate,
668
- conflicts, error share, p95, ASCII sparkline), the fleet view (which doubles
669
- as the partition picker), horizon, store stats, clients with cursor lag and
670
- a per-client drill-down (subscriptions with full scope sets, lease status,
671
- the client's event slice), recent commits, the row inspector, scope
672
- activity, and the event tail — with an auto-refresh toggle (2 s poll).
673
- This is a deliberate one-file console: 5% of the code a full console app
674
- would cost, the 80% operator value.
705
+ | `GET /errors?windowMs` | `errors` |
706
+ | `GET /partitions?limit&offset` | `partitionsOverview` (fleet view) |
707
+ | `GET /events?type&requestId&sinceMs&clientId&actorId&limit` | `events` (ring tail) |
708
+ | `GET /events/stream?type&requestId&clientId&actorId&limit` | live SSE tail (backlog replay, then `subscribeEvents`) |
709
+ | `POST /reactions/:idempotencyKey/retry` | `retryReaction` |
710
+ | `POST /prune` | `prune` |
711
+ | `POST /clients/:clientId/revoke-lease` | `revokeLease` |
712
+
713
+ `?partition=` selects the partition. Without it, a deployment with exactly
714
+ one partition in its registry uses that one; any other count answers 400
715
+ `sync.invalid_request` with `details.parameter: 'partition'`, after the
716
+ guard ran with an empty partition. Every endpoint reads only the resolved
717
+ partition, event tail included. A malformed integer query parameter answers
718
+ 400 `sync.invalid_request`. `GET /partitions` is the one cross-partition
719
+ endpoint: its guard receives an empty `partition`, so a guard that grants
720
+ single partitions does not grant the fleet view.
721
+
722
+ `GET /` serves a **single static HTML page**: no framework, no build step,
723
+ no external assets (about 110 KiB, 31 KiB gzipped). It fetches the sibling
724
+ JSON endpoints relative to its own mount path, so it works under any prefix
725
+ and the same guard covers its requests. Operator actions appear only when
726
+ `/info` reports `actions: true`. The views and the debugging workflows are
727
+ on the docs page [Admin console](https://syncular.dev/server-admin-console/).
675
728
 
676
729
  **Live tail over SSE.** `GET /events/stream` streams the ring as
677
730
  server-sent events: a recent backlog replays first, then events arrive as
678
731
  they land (a `subscribe` hook on the ring), with a comment ping every 5 s
679
732
  (under Bun.serve's 10 s default idle timeout, so a quiet stream survives).
680
733
  It is plain web-streams, so it serves identically on Bun, Node, and
681
- Workers. The page upgrades its event panel to the stream automatically and
682
- falls back to the 2-second poll when the stream is unavailable (no ring, or
683
- `EventSource` cannot pass the host's auth headers — cookie-authorized
684
- setups stream fine). The tail can carry sensitive identifiers (actorIds,
734
+ Workers. The page opens the stream when `/info` reports `hasEventStream`;
735
+ when the stream fails (for example, `EventSource` cannot send the host's
736
+ auth headers, while cookie-authorized setups stream fine), events reload
737
+ with every refresh. The tail can carry sensitive identifiers (actorIds,
685
738
  session ids), the same as `GET /events`; the admin guard gates both.
686
739
 
687
740
  The demo server (`apps/demo`) mounts the admin behind a dev guard:
688
741
  `SYNCULAR_DEMO_ADMIN=1` enables `/admin` (optionally token-gated with
689
- `SYNCULAR_DEMO_ADMIN_TOKEN`), so the console is inspectable live.
690
-
691
- > Docs-site coverage of the console is a follow-up: the docs app is owned
692
- > by a concurrent workstream this round (the schema-bump page), so this
693
- > README is the console's documentation home for now.
742
+ `SYNCULAR_DEMO_ADMIN_TOKEN`; `SYNCULAR_DEMO_ADMIN_WRITES=1` adds the
743
+ operator actions), so the console is inspectable live.
694
744
 
695
745
  ## Segment storage on S3 / R2 (`S3SegmentStore`)
696
746
 
@@ -795,7 +845,7 @@ honor these conditional headers.
795
845
 
796
846
  Even so, the counters are **approximate**, and `stats()` marks them
797
847
  `approximate: true` (an additive field the admin surface carries through, so
798
- `admin.segmentStats()` / `admin.stats()` expose it and the console labels the
848
+ `admin.stats()` exposes it and the console labels the
799
849
  numbers honestly). They can drift: a crash between the segment PUT and the
800
850
  accumulator CAS, lifecycle GC deleting objects the accumulator still counts,
801
851
  or enough concurrent writers to exhaust the CAS retry budget. They are a
@@ -957,7 +1007,7 @@ GET/HEAD.
957
1007
  object (`{keyPrefix}stats/blobs.json`) folded read-modify-write on `put` under
958
1008
  the same ETag compare-and-swap as segments, and marks the result
959
1009
  `approximate: true` (the marker already present on `BlobStoreStats`, carried
960
- through `admin.blobStats()`). A `HEAD` before the PUT detects an idempotent
1010
+ through `admin.stats()`). A `HEAD` before the PUT detects an idempotent
961
1011
  re-upload so distinct blobs count once; the sweep decrements on delete. Same
962
1012
  caveats as segment stats — a health gauge, not an invoice. The in-process
963
1013
  stores (memory / sqlite) count on demand and omit the marker.