@lunora/do 1.0.0-alpha.97 → 1.0.0-alpha.99

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.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { SchemaLike, DatabaseWriterLike, CrossShardReadArgs, CdcChange, FilterClause, ExportRow, MigrationDirection, ReactiveCache, ReactiveCacheOptions, ShardSocketLike, TransactionHeadroomTracker, LifecycleDispatchInfo, MigrationRunResult, TableIndexInfo, ColumnMeta, AdvisoryFinding, AdvisorProcedure, RlsPoliciesResult, MaskPoliciesResult, StorageRulesResult, StudioFeaturesResult, FlagsResult, SubscriptionIdentity, QueuesResult, WorkflowsResult, ImportShardResult, ShardRankPageResult, SubscriptionQuery, ShapeSubscriptionQuery, MutationDelta, KeyRange, ResolvedShape, ShapeRow, TtlSweepSpec, TransactionLimits, IndexKeyEntry, SqlExec } from '@lunora/shard-engine';
2
- export { type AdvisorProcedure, type AdvisoryFinding, type AggregateIndexDefinitionLike, type DataMigrationLike, type DatabaseWriterLike, type ExportRow, type ExternalSourceLike, type FlagsResult, type ImportShardResult, type KeyRange, type MaskPoliciesResult, type MigrationRunResult, type MutationDelta, type QueuesResult, REPROJECTION_MIGRATION_PREFIX, type RankIndexDefinitionLike, type RlsPoliciesResult, type SchedulerLike, type SchemaLike, type ShardRankPageResult, type SourceClientLike, type SqlExec, type StorageRulesResult, type StudioFeaturesResult, type SystemReaderStorageLike, type TransactionHeadroomTracker, type ValidatorLike, type WhereInput, type WorkflowsResult, type WriteHook, applyCdcChanges, assertShapeShardable, buildReprojectionMigration, clearMemoryTables, countLegacyRows, createReadFootprint, createShardCtxDb, exportShardRows, importShardRows, isSourceDue, pullExternalSourceIncrementalTick, pullExternalSourceTick, reprojectionMigrationId, reprojectionTables, runDataMigration, runShardMigrations, subscriptionListDeltas } from '@lunora/shard-engine';
1
+ import { SchemaLike, DatabaseWriterLike, CrossShardReadArgs, CdcChange, FilterClause, ExportRow, MigrationDirection, ReactiveCache, ShapeProbeCounters, GlobalPollCounters, ReactiveCacheOptions, ShardSocketLike, TransactionHeadroomTracker, LifecycleDispatchInfo, MigrationRunResult, SearchBackfillProgress, TableIndexInfo, ColumnMeta, AdvisoryFinding, AdvisorProcedure, RlsPoliciesResult, MaskPoliciesResult, StorageRulesResult, StudioFeaturesResult, FlagsResult, SubscriptionIdentity, QueuesResult, WorkflowsResult, ImportShardResult, ShardRankPageResult, SubscriptionQuery, ShapeSubscriptionQuery, MutationDelta, KeyRange, ResolvedShape, ShapeRow, TtlSweepSpec, TransactionLimits, IndexKeyEntry, SqlExec, CdcChangeKey } from '@lunora/shard-engine';
2
+ export { type AdvisorProcedure, type AdvisoryFinding, type AggregateIndexDefinitionLike, type DataMigrationLike, type DatabaseWriterLike, type ExportRow, type ExternalSourceLike, type FlagsResult, type ImportShardResult, type KeyRange, type MaskPoliciesResult, type MigrationRunResult, type MutationDelta, type QueuesResult, REPROJECTION_MIGRATION_PREFIX, type RankIndexDefinitionLike, type RlsPoliciesResult, type SchedulerLike, type SchemaLike, type SearchBackfillProgress, type ShardRankPageResult, type SourceClientLike, type SqlExec, type StorageRulesResult, type StudioFeaturesResult, type SystemReaderStorageLike, type TransactionHeadroomTracker, UNVOUCHABLE_DEP, type ValidatorLike, type WhereInput, type WorkflowsResult, type WriteHook, applyCdcChanges, assertShapeShardable, backfillSearchIndexes, buildReprojectionMigration, clearMemoryTables, countLegacyRows, createReadFootprint, createShardCtxDb, exportShardRows, importShardRows, isSourceDue, markUnvouchableReads, pullExternalSourceIncrementalTick, pullExternalSourceTick, reprojectionMigrationId, reprojectionTables, runDataMigration, runShardMigrations, subscriptionListDeltas } from '@lunora/shard-engine';
3
3
  import { DatabaseInstrumentation, MetricHistoryOptions, LogEventInput, ContextLogLevel, TraceAnchor, ContextTracer, ContextFetch, ContextMetrics } from '@lunora/observability';
4
4
  import { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite';
5
5
  export { type ShardPlatform, type WorkerPlatform, type WorkerPlatformOptions, createShardAlarms, createShardDirectory, createShardHost, createShardKvStore, createShardPlatform, createSocketHost, createWorkerPlatform } from '@lunora/platform-cloudflare';
@@ -842,6 +842,14 @@ declare abstract class ShardDO {
842
842
  * never trip it.
843
843
  */
844
844
  protected static readonly GLOBAL_SHAPE_MAX_ROWS = 5e4;
845
+ /**
846
+ * How long a `.global()` poll may go without an unconditional membership
847
+ * pass. Between resyncs the poll trusts the global changelog and skips
848
+ * unchanged tables; a resync re-reads regardless, so a write that left no
849
+ * changelog row (another system writing the same database) still surfaces
850
+ * within this window instead of never.
851
+ */
852
+ protected static readonly GLOBAL_SHAPE_RESYNC_MS = 3e4;
845
853
  /**
846
854
  * Per-socket whisper-topic cap. Topic membership rides the same hibernation
847
855
  * attachment as `subs`, so bound it for the same reason — a runaway
@@ -903,6 +911,28 @@ declare abstract class ShardDO {
903
911
  * the query on the first call, just like it does today.
904
912
  */
905
913
  protected readonly reactiveCache: ReactiveCache | undefined;
914
+ /**
915
+ * Running read tallies for the shape-poke path, surfaced next to
916
+ * {@link ShardDO.fanout} on `getFanoutMetrics`. `run` counts the reads this
917
+ * instance issued to SQLite; `served` counts the ones the per-flush
918
+ * {@link ShapeDiffCache} answered because another socket had already asked the
919
+ * identical question. BOTH halves of the diff are counted — the changed-key
920
+ * scan keyed by `(table, op range)` and the membership probe keyed by
921
+ * `(effectiveWhere, that same range)` — so the reported sharing rate covers
922
+ * the work the cache actually does rather than half of it.
923
+ *
924
+ * Without the split the sharing is invisible: a flush that collapsed a
925
+ * hundred reads into one looks exactly like a flush that only ever had one
926
+ * shape.
927
+ */
928
+ protected shapeProbe: ShapeProbeCounters;
929
+ /**
930
+ * Running `.global()` poll tallies, reported alongside {@link ShardDO.shapeProbe}.
931
+ * `run` counts membership drains actually issued to the global backend;
932
+ * `served` counts the (socket, shape) pairs a tick skipped because the global
933
+ * changelog proved their table had not moved.
934
+ */
935
+ protected globalPoll: GlobalPollCounters;
906
936
  /**
907
937
  * The host-neutral engine runner. `fetch` and `alarm` delegate through it, so
908
938
  * the dispatch entry points name a platform contract rather than a Durable
@@ -1095,6 +1125,13 @@ declare abstract class ShardDO {
1095
1125
  * hot path without needing a separate alarm/cron.
1096
1126
  */
1097
1127
  private lastIdempotencyTrimAt;
1128
+ /**
1129
+ * Wall-clock millis of the last `__cdc_log` retention sweep on this warm
1130
+ * instance, throttling {@link ShardDO.sweepCdcRetention} to at most once per
1131
+ * {@link CDC_SWEEP_INTERVAL_MS}. In-memory like its `__idempotency` twin, so
1132
+ * a fresh instance sweeps on its first coalesced flush.
1133
+ */
1134
+ private lastCdcSweepAt;
1098
1135
  /**
1099
1136
  * Per-request identity envelope forwarded from the runtime via the
1100
1137
  * `x-lunora-identity` JSON header. Stores claims like `email`,
@@ -1235,6 +1272,32 @@ declare abstract class ShardDO {
1235
1272
  * threshold is grounded in real numbers, with no behavior change.
1236
1273
  */
1237
1274
  private readonly fanout;
1275
+ /**
1276
+ * The `.global()` changelog position the last poll tick observed, or
1277
+ * `undefined` on a cold instance. In-memory on purpose: losing it costs one
1278
+ * full re-read pass, which is the safe direction — a stale persisted cursor
1279
+ * would let a tick skip a table that HAD changed.
1280
+ */
1281
+ private globalPollCursor;
1282
+ /**
1283
+ * Set when a shape failed to settle in the last poll tick, forcing the next one
1284
+ * to read every shape unconditionally. See {@link GlobalPollTick.resyncRequested}
1285
+ * for why a shared cursor leaves no cheaper recovery.
1286
+ */
1287
+ private globalResyncRequested;
1288
+ /**
1289
+ * Whether this wake has already re-minted the epoch to seal a rolled-back
1290
+ * timeline. In-memory on purpose — see {@link ShardDO.sealForkedTimeline} for
1291
+ * why the seal is capped at one per wake, and why losing the flag on eviction
1292
+ * is the correct direction.
1293
+ */
1294
+ private forkSealed;
1295
+ /**
1296
+ * Wall-clock millis of the last unconditional `.global()` membership pass.
1297
+ * Bounds how long a write this deployment cannot see in its own changelog —
1298
+ * an out-of-band writer against the global database — can go unnoticed.
1299
+ */
1300
+ private lastGlobalResyncAt;
1238
1301
  /**
1239
1302
  * The runtime's Durable Object namespace binding name (e.g. `"SHARD"`),
1240
1303
  * forwarded as `x-lunora-shard-binding` on every request so a DO can address
@@ -1384,6 +1447,18 @@ declare abstract class ShardDO {
1384
1447
  * attribute only SELECT result sizes as `rowsRead`.
1385
1448
  */
1386
1449
  private currentStmtSamples;
1450
+ /**
1451
+ * The instrumented `sql` proxy built for {@link ShardDO.currentStmtSamples},
1452
+ * and the samples map it was built for.
1453
+ *
1454
+ * `get sql()` is read on essentially every storage call, and a fresh `Proxy`
1455
+ * per read is not only an allocation: the resume path memoizes this shard's
1456
+ * table catalog in a `WeakMap` keyed by the handle, so a new object each read
1457
+ * meant that memo never once hit during a dispatch — it re-scanned
1458
+ * `sqlite_master` every time, which is the 18% of `evaluateResume` the memo
1459
+ * exists to remove.
1460
+ */
1461
+ private instrumentedSql;
1387
1462
  /**
1388
1463
  * Set when `currentStmtSamples` hit {@link MAX_STMT_SAMPLES_PER_DISPATCH}
1389
1464
  * distinct statements and a brand-new shape was dropped this dispatch.
@@ -1701,6 +1776,20 @@ declare abstract class ShardDO {
1701
1776
  * look the migration up and invoke `runDataMigration`.
1702
1777
  */
1703
1778
  protected runShardDataMigration(args: RunShardMigrationArgs): Promise<MigrationRunResult>;
1779
+ /**
1780
+ * Index the rows that predate a `.searchIndex()` into its companion, a
1781
+ * bounded number of pages per call, and report whether anything is left.
1782
+ *
1783
+ * This is the exit from `staged: true`. A staged index is skipped by every
1784
+ * migration pass by design — the option exists for tables too large to walk
1785
+ * during a cold start — so without an explicit run its pre-existing rows are
1786
+ * unsearchable forever. The base class can't reach the project's generated
1787
+ * `schema`, so it reports the op unsupported; the codegen subclass overrides
1788
+ * it to call `backfillSearchIndexes`.
1789
+ */
1790
+ protected runShardSearchBackfill(_options: {
1791
+ maxPages?: number;
1792
+ }): SearchBackfillProgress;
1704
1793
  /**
1705
1794
  * Lazily provision the shard's physical tables before an operation that
1706
1795
  * depends on them existing. The base class has no `schema.ts`, so it does
@@ -1968,19 +2057,64 @@ declare abstract class ShardDO {
1968
2057
  * the wire byte-identical to the pre-epoch format for non-CDC apps.
1969
2058
  */
1970
2059
  protected currentCdcEpoch(): string | undefined;
2060
+ /**
2061
+ * Mint a fresh CDC epoch because a resume claim just proved this shard's
2062
+ * changelog forked under it, and return the new value for the outgoing frame.
2063
+ *
2064
+ * The trigger is a client presenting `sinceEpoch === epoch` with `sinceSeq >
2065
+ * cursor`: it holds a cursor this shard once issued on THIS timeline and can
2066
+ * no longer account for. Only a rollback produces that — in practice a native
2067
+ * PITR restore, which is armed in {@link handlePitrAdminOp}.
2068
+ *
2069
+ * **Why the signal has to come from a client.** A restore reverts the whole
2070
+ * SQLite database, `__cdc_meta` included, so the proactive bump `pitrRestore`
2071
+ * performs is rolled back along with everything else — the epoch cannot
2072
+ * detect the one event it exists for. Nothing durable inside a SQLite-backed
2073
+ * Durable Object escapes that: the KV half of `state.storage` is the same
2074
+ * database, and an alarm is a row in it. The only record of the pre-restore
2075
+ * timeline that the restore cannot reach is the cursor each CLIENT cached, so
2076
+ * that is what this reads. Turning one client's refusal into a shard-wide
2077
+ * epoch bump is what extends the protection to clients that reconnect later,
2078
+ * after post-restore writes have climbed the AUTOINCREMENT back past their
2079
+ * own `sinceSeq` and the `sinceSeq > cursor` guard no longer fires for them.
2080
+ *
2081
+ * **What it detects.** Any rollback under a live subscriber base, promptly: a
2082
+ * restore restarts the object (`ctx.abort()`, or the eviction that lets a
2083
+ * deferred restore apply), which drops every hibernated socket, so the whole
2084
+ * subscriber set reconnects with pre-restore cursors while the restored
2085
+ * cursor is still low. The first of them seals the fork for the rest.
2086
+ *
2087
+ * **What it cannot detect.** A rollback on a shard whose clients ALL stay
2088
+ * offline until the cursor has climbed back past their cursors — nobody is
2089
+ * left to present the proof. A shard with no subscribers at all is the
2090
+ * degenerate case of that, and is also the case where nothing is stale.
2091
+ *
2092
+ * **On trusting `sinceSeq`.** It is client-supplied, so a caller that already
2093
+ * knows this shard's epoch can force a bump, and a bump is shard-wide: every
2094
+ * subscriber takes a full snapshot instead of a resume on its NEXT reconnect.
2095
+ * The cost is bounded (the once-per-wake latch below) and deferred — nothing
2096
+ * is pushed and no live subscription is interrupted — and it stays strictly
2097
+ * less than what the same caller can already spend by subscribing without a
2098
+ * `sinceSeq` at all.
2099
+ * @returns the freshly minted epoch, to stamp on the frame this verdict produces
2100
+ */
2101
+ protected sealForkedTimeline(): string;
1971
2102
  /**
1972
2103
  * Decide whether a reconnecting subscription can resume from `sinceSeq`
1973
2104
  * without a full snapshot. Returns the current high-watermark `cursor` plus
1974
2105
  * a `resumable` verdict.
1975
2106
  *
1976
- * `resumable: true` means `sinceSeq` is within the CDC retention window and
1977
- * no table in the query's `readSet` changed in `(sinceSeq, cursor]` the
1978
- * client's cached value is still current, so the caller emits a lightweight
1979
- * `resume` frame instead of re-shipping the snapshot.
2107
+ * `resumable: true` means `sinceSeq` is within the CDC retention window,
2108
+ * every entry in the query's `readSet` is one the changelog can speak for,
2109
+ * and none of them changed in `(sinceSeq, cursor]` the client's cached
2110
+ * value is still current, so the caller emits a lightweight `resume` frame
2111
+ * instead of re-shipping the snapshot.
1980
2112
  *
1981
- * `resumable: false` means either the log was compacted past `sinceSeq` (a
2113
+ * `resumable: false` means the log was compacted past `sinceSeq` (a
1982
2114
  * retention gap), a read table changed (the client needs the fresh value),
1983
- * or CDC is off the caller falls back to the full-snapshot seed.
2115
+ * the read-set contains something the changelog cannot vouch for (see
2116
+ * {@link cdcCanVouchFor}), or CDC is off — the caller falls back to the
2117
+ * full-snapshot seed.
1984
2118
  */
1985
2119
  protected evaluateResume(sinceSeq: number, readSet: Set<string>, sinceEpoch?: string): {
1986
2120
  cursor: number | undefined;
@@ -2989,8 +3123,8 @@ declare abstract class ShardDO {
2989
3123
  private handleAdminRpc;
2990
3124
  /**
2991
3125
  * Dispatch the side-effecting / non-read admin ops that `handleAdminRpc`
2992
- * doesn't handle inline: the auth-event + mail-capture writes and the native
2993
- * PITR ops. Returns the op's `Response`, or `undefined` when `functionPath`
3126
+ * doesn't handle inline: the auth-event + mail-capture writes, the search
3127
+ * backfill, and the native PITR ops. Returns the op's `Response`, or `undefined` when `functionPath`
2994
3128
  * isn't one of these (so the caller answers 404). Kept out of
2995
3129
  * `handleAdminRpc` to hold that dispatcher under the complexity budget,
2996
3130
  * mirroring `handlePitrAdminOp`.
@@ -3031,6 +3165,18 @@ declare abstract class ShardDO {
3031
3165
  * untag); a missing or malformed value is a 400 rather than a silent no-op.
3032
3166
  */
3033
3167
  private parseIssueTriagePatch;
3168
+ /**
3169
+ * `__lunora_admin__:backfillSearch` — index the rows that predate this
3170
+ * shard's `.searchIndex()` declarations, `maxPages` pages at a time.
3171
+ *
3172
+ * Bounded rather than run-to-completion because a DO request has a CPU and
3173
+ * wall-clock budget, and the tables `staged: true` exists for are precisely
3174
+ * the ones a single unbounded walk cannot finish. Progress is durable, so an
3175
+ * operator drives it with repeated calls until `done` — `lunora run
3176
+ * '__lunora_admin__:backfillSearch' --args '{"maxPages":20}'` needs no new
3177
+ * CLI surface. Admin-gated by `handleAdminRpc`'s caller.
3178
+ */
3179
+ private handleBackfillSearch;
3034
3180
  /**
3035
3181
  * Record one app-level auth attempt for the auth-failure SLO (PLAN3 §2.3).
3036
3182
  * The worker calls this fire-and-forget (via `waitUntil`) after a top-level
@@ -3247,6 +3393,8 @@ declare abstract class ShardDO {
3247
3393
  * is still an operator asking the database a question.
3248
3394
  */
3249
3395
  private handleGenerateSql;
3396
+ /** The single-shape admin writes (decode args → Response), keyed by function path. */
3397
+ private simpleAdminHandlers;
3250
3398
  /** The AI-assistant admin writes, keyed by function path. */
3251
3399
  private aiAdminHandlers;
3252
3400
  /**
@@ -3858,7 +4006,12 @@ declare abstract class ShardDO {
3858
4006
  * epoch, its checkpoint doesn't run ahead of ours, and the log still covers it;
3859
4007
  * else a full re-seed. A fully-compacted log only proves "nothing missed" when
3860
4008
  * the client is already at `cursor`.
3861
- * @returns the cursor/epoch, the resume base (`baseCheckpoint`), and the membership patch
4009
+ *
4010
+ * `reset` is the inverse of the resume decision and MUST ride the wire: the
4011
+ * re-seed branch returns the whole membership as inserts, which can only ever
4012
+ * add rows, so a client that splices it onto a stale view keeps every row that
4013
+ * left the shape while it was disconnected. Callers stamp it on the poke part.
4014
+ * @returns the cursor/epoch, the resume base (`baseCheckpoint`), whether this is a full re-seed, and the membership patch
3862
4015
  */
3863
4016
  private computeOpLogShapeSeed;
3864
4017
  /**
@@ -3872,6 +4025,95 @@ declare abstract class ShardDO {
3872
4025
  * carrying a part per changed shape. No-op when no socket holds a shape.
3873
4026
  */
3874
4027
  private pokeShapeSubscribers;
4028
+ /**
4029
+ * Enforce the configured `__cdc_log` retention, at most once per interval per
4030
+ * warm instance. Two independent, independently-configured levels:
4031
+ *
4032
+ * **Payload compaction** (`LUNORA_CDC_PAYLOAD_RETENTION`, in rows) nulls the
4033
+ * post-images of older entries while keeping their `(seq, table, id, op)`
4034
+ * keys. Post-images are essentially all of the log's bytes, and since the
4035
+ * shape diff reads its values from the table rather than the log
4036
+ * (`selectShapeMembers`), dropping them costs a live subscriber
4037
+ * nothing — a client past the payload floor still gets an exact key-level
4038
+ * delta instead of a full re-seed.
4039
+ *
4040
+ * **Row deletion** (`LUNORA_CDC_LOG_RETENTION`, in rows) drops the entries
4041
+ * outright. This is the level that ends resumability for anyone below it:
4042
+ * `minCdcSeq` then reports a floor above their cursor and they re-seed.
4043
+ *
4044
+ * Both levels are enforced on the READ paths, not merely intended by the
4045
+ * sweep — `evaluateResume` and `computeOpLogShapeSeed` gate on `minCdcSeq`,
4046
+ * and {@link ShardDO.runShardCdcSync} refuses a page below either floor
4047
+ * (`CDC_LOG_TRIMMED` / `CDC_PAYLOAD_COMPACTED`). That matters because the
4048
+ * failure this sweep can cause is silent by nature: a consumer handed the
4049
+ * surviving tail with an advanced cursor has no way to notice a range went
4050
+ * missing, so every path that can serve one has to refuse instead.
4051
+ *
4052
+ * **Both are opt-in, and that is a deliberate answer rather than caution.**
4053
+ * The log's in-shard consumers record durable cursors this sweep can read
4054
+ * ({@link ShardDO.retentionFloor}), but its out-of-shard consumers do not: a
4055
+ * warehouse connector holds an opaque cursor token issued by the Worker, and
4056
+ * nothing in this shard knows where it is. Trimming to a floor computed only
4057
+ * from what SQLite can see would silently drop rows a connector had not read
4058
+ * — so a deployment that wants retention states the window it can afford, and
4059
+ * gets a sweep that additionally never crosses the in-shard floor. A shard
4060
+ * that configures neither behaves exactly as before.
4061
+ *
4062
+ * Best-effort throughout: a stub `sql` handle or a shard without CDC is a
4063
+ * no-op, and a failure here must never surface on a write path whose data
4064
+ * already committed.
4065
+ */
4066
+ private sweepCdcRetention;
4067
+ /**
4068
+ * The highest `seq` this sweep may compact or delete through without stranding
4069
+ * an in-shard consumer: the lowest cursor any durable local consumer has
4070
+ * durably reached, or the log's head when there is none.
4071
+ *
4072
+ * The direction is what matters. Every input can only pull the floor DOWN, and
4073
+ * a consumer whose cursor cannot be read contributes nothing rather than a
4074
+ * guess — a floor that is too low leaves rows around for one more sweep, while
4075
+ * a floor that is too high deletes a range a live subscription still has to be
4076
+ * told about.
4077
+ *
4078
+ * Two in-shard consumers, and they are tracked in different places. Local
4079
+ * sockets record `__shape_poke_cursor` per `(connection, subscription)` on
4080
+ * every delivered poke, so that table carries their position. **Relayed
4081
+ * subscribers are not in it**: a relay's cohort frontier and its per-socket
4082
+ * proxies are the owner's shape registry (`__lunora_relay_shapes`, read back
4083
+ * through `RelayLink.minShapeCursor`). Reading only the local table would
4084
+ * therefore see a fully relayed shard — the high-fan-out case, i.e. exactly
4085
+ * the shard an operator turns retention on for — as having no subscribers,
4086
+ * and delete the rows the next relayed diff had to read. Both are folded in
4087
+ * here.
4088
+ *
4089
+ * **The floor is each consumer's `cursor`, not its `delivered`** (see
4090
+ * {@link ShapeMemo.delivered}), and the two differ: a client's own position is
4091
+ * `delivered`, which can sit BELOW the `cursor` this floor is computed from.
4092
+ * Trimming to the higher of the two is nonetheless safe, and only for one
4093
+ * reason: `cursor` runs ahead of `delivered` exclusively across ranges where
4094
+ * that shape's table saw no change at all — that is what an empty diff means,
4095
+ * and empty diffs are the only thing that advances `cursor` alone. So the rows
4096
+ * this deletes in `(delivered, cursor]` are never rows that shape still owes
4097
+ * its client. Change what advances `cursor` and this stops holding.
4098
+ *
4099
+ * **Known ceiling: a relayed cohort on a quiet table pins this floor.** A
4100
+ * scalar MIN over every consumer is only as good as the slowest consumer's
4101
+ * ability to advance, and the relay tier's cannot: `OwnerRelay.buildShapePoke`
4102
+ * must leave a cohort's cursor where the last DELIVERED poke reached, because
4103
+ * advancing it without a poke puts every relay socket's memo below the next
4104
+ * poke's `fromCursor` and freezes them (the failure `relay-hub.test.ts` pins).
4105
+ * Local sockets have no such constraint — the shard computes their diffs from
4106
+ * its own memo — so `collectShapePokeParts` advances them on every flush. The
4107
+ * asymmetry means a shard whose shapes are all relayed can still see retention
4108
+ * do nothing.
4109
+ *
4110
+ * The upgrade is a floor PER TABLE rather than one scalar: `readCdcChangeKeys`
4111
+ * already filters by table, so a cohort watching a quiet table never needs the
4112
+ * busy table's rows and should not be holding them. That is a real change to
4113
+ * how the sweep is expressed, not a tweak here, so it waits for a deployment
4114
+ * that needs it.
4115
+ */
4116
+ private retentionFloor;
3875
4117
  /**
3876
4118
  * Diff every op-log-backed shape a socket holds against this flush, splitting
3877
4119
  * the results into the poke parts to send and the per-shape memo advances. A
@@ -3884,37 +4126,38 @@ declare abstract class ShardDO {
3884
4126
  */
3885
4127
  private collectShapePokeParts;
3886
4128
  /**
3887
- * Drain the op-log range `(sinceSeq, upTo]` for `table` into the latest op per
3888
- * row id (collapsing multiple ops on the same row to the newest). Within one
3889
- * flush, every shape over the SAME `(table, sinceSeq, upTo)` reads the
3890
- * identical changelog slice, so the drained map is memoized in the
3891
- * caller-supplied `cache` (created fresh per flush) N shapes on a table
3892
- * share ONE changelog drain instead of re-scanning it per shape. The
3893
- * per-shape membership probe still runs per shape (its predicate is
3894
- * identity/args-specific), so only the shared op read is collapsed.
4129
+ * Read the changed row keys for a shape diff. A thin protected seam over
4130
+ * {@link readCdcChangeKeys}, kept for the one thing nothing else observes:
4131
+ * the `sinceSeq` each diff resumed FROM. Which baseline a poke picked — the
4132
+ * durable memo, the attachment's subscribe-time cursor, or a clamped one
4133
+ * after a PITR rollback is the assertion in three tests, and it is not
4134
+ * recoverable from the outside.
4135
+ *
4136
+ * It is NOT the read counter. `ShapeDiffCache.probesRun`/`probesServed` count
4137
+ * the reads the per-flush memo collapses, and that is what the sharing tests
4138
+ * assert against.
3895
4139
  */
3896
- private readShapeOpRange;
4140
+ protected readShapeCdcKeys(sql: SqlExec, table: string, sinceSeq: number, upTo: number): CdcChangeKey[];
3897
4141
  /**
3898
- * Read one page of the `__cdc_log` for a shape diff (table-scoped). A thin
3899
- * protected seam over {@link readCdcChanges}: it isolates the single
3900
- * changelog read that {@link readShapeOpRange} memoizes per flush, and gives
3901
- * tests a point to count the reads the op-range cache collapses.
4142
+ * One relayed shape's diff, on its own cache.
4143
+ *
4144
+ * The relay tier computes one delta per cohort or proxy rather than per
4145
+ * socket, so there is nothing to share WITHIN a call — the cache exists
4146
+ * because the pipeline requires one. Its counters are still folded back into
4147
+ * {@link ShardDO.shapeProbe}, because they are reads this instance issued:
4148
+ * discarding them would under-report exactly the shards the fan-out panel
4149
+ * exists for, since a shard is relayed precisely when its fan-out is large.
3902
4150
  */
3903
- protected readShapeCdcPage(sql: SqlExec, sinceSeq: number, tables: ReadonlySet<string>): {
3904
- changes: CdcChange[];
3905
- cursor: number;
3906
- };
4151
+ private diffRelayedShape;
3907
4152
  /**
3908
- * Build the row-ops for a shape over the op range `(sinceSeq, upTo]`. Reads
3909
- * the changelog (drained across pages via {@link readShapeOpRange}, shared
3910
- * across same-range shapes in a flush), collapses to the latest op per row,
3911
- * then runs ONE membership probe ({@link selectShapeMemberIds}) over the
3912
- * changed ids: a row still in the set → upsert with its post-image doc
3913
- * (projected to the shape's columns); a row that left the set, or any delete,
3914
- * → `delete(key)` (a delete carries no post-image, so membership is
3915
- * unknowable from the op alone — the client no-ops an unknown key).
4153
+ * This shard's shape diff: {@link buildShapeDiff} with the changelog read
4154
+ * routed through {@link ShardDO.readShapeCdcKeys}, so the DO's one seam over
4155
+ * that read stays the DO's.
4156
+ *
4157
+ * The pipeline itself is host-neutral and lives in `@lunora/shard-engine`
4158
+ * it reads through the `sql` handle and touches no instance state.
3916
4159
  */
3917
- private buildShapeDiff;
4160
+ private diffShape;
3918
4161
  /** Build the full insert-poke of a shape's current membership — the first-seed/full-reseed rowset. */
3919
4162
  private buildShapeSeed;
3920
4163
  /**
@@ -4007,6 +4250,88 @@ declare abstract class ShardDO {
4007
4250
  * snapshotted per socket.
4008
4251
  */
4009
4252
  private withinGlobalShapeBound;
4253
+ /**
4254
+ * The changelog settings the generated code hands to the `.global()` writer.
4255
+ *
4256
+ * `cdc` is the app's own opt-in, forwarded from the shard config so the two
4257
+ * logs are governed by ONE switch: a deployment that turns CDC on gets a
4258
+ * changelog on both tiers, and one that leaves it off pays for neither. Until
4259
+ * this was threaded, the global writer was built without it unconditionally —
4260
+ * so the global `__cdc_log` was never written, and the poll's
4261
+ * changed-tables fast path was unreachable in every generated app while
4262
+ * looking, from the shard side, exactly like a backend that had CDC disabled.
4263
+ *
4264
+ * `cdcRetentionMs` is read here rather than in the generated factory so every
4265
+ * deployment knob goes through one strict parser (see
4266
+ * {@link ShardDO.sweepCdcRetention} for why lenient parsing on a delete path
4267
+ * is a footgun). Absent means the global log is never trimmed.
4268
+ */
4269
+ protected globalCdcOptions(cdc: boolean): {
4270
+ cdc: boolean;
4271
+ cdcRetentionMs?: number;
4272
+ };
4273
+ /**
4274
+ * Ask the `.global()` backend which tables it recorded a write to after
4275
+ * `sinceSeq`. The base class has no global backend, so it reports no
4276
+ * visibility (`undefined`) and every poll tick falls back to re-reading
4277
+ * membership; the codegen-generated subclass overrides this to forward the
4278
+ * question to the global store's changelog. Emitted only for a project that
4279
+ * has both shapes and `.global()` tables.
4280
+ *
4281
+ * `cursorOnly` says the caller has already committed to reading everything
4282
+ * this pass and wants only the cursor — see the contract on
4283
+ * `DatabaseWriterLike.cdcChangedTables`.
4284
+ */
4285
+ protected readGlobalChangedTables(_sinceSeq: number, _cursorOnly?: boolean): Promise<{
4286
+ cursor: number;
4287
+ floor?: number;
4288
+ tables: string[];
4289
+ } | undefined>;
4290
+ /**
4291
+ * Open one `.global()` poll tick: ask the global changelog what moved since
4292
+ * the last tick, and decide whether this tick may use that answer to skip
4293
+ * membership reads.
4294
+ *
4295
+ * Two things force a full pass regardless of what the changelog says. The
4296
+ * first is having no cursor to compare against — a cold instance, or a
4297
+ * backend with CDC disabled, has no basis for "unchanged" and must read. The
4298
+ * second is the resync interval, and it is the honest part of this design: a
4299
+ * `.global()` table can be written by something that is not this deployment,
4300
+ * and such a write leaves no row in our changelog. Trusting the changelog
4301
+ * forever would mean a shape silently frozen against an out-of-band writer,
4302
+ * so the fast path is a bounded skip — at worst {@link ShardDO.GLOBAL_SHAPE_RESYNC_MS}
4303
+ * of staleness for a change we could not see, against the full membership
4304
+ * re-read of every shape on every socket every two seconds that it replaces.
4305
+ *
4306
+ * That same bound now covers a second case worth naming: a shape whose
4307
+ * membership moves WITHOUT a write — a predicate over wall-clock, like
4308
+ * `_creationTime > now - 1h`. It used to converge on the poll interval
4309
+ * because every tick re-read it; it now converges on the resync interval,
4310
+ * because no changelog row marks its table as having moved. Bounded and
4311
+ * intended, but a different guarantee than the one this path used to give.
4312
+ */
4313
+ private openGlobalPollTick;
4314
+ /**
4315
+ * Read a `.global()` shape's membership through this tick's cache.
4316
+ *
4317
+ * The key is the resolved predicate **and the caller's identity**, and the
4318
+ * second half is not redundant with the first. The op-log path can share a
4319
+ * probe on the predicate alone because it reads this shard's own SQLite with
4320
+ * nothing but that predicate — identity has no other channel into the query.
4321
+ * A `.global()` read does not have that property: the backend writer is built
4322
+ * per request from `{ identity, userId }`, so an application's own `d1` /
4323
+ * `hyperdriveGlobal` factory may scope rows by the caller before this code
4324
+ * ever sees them. Two sockets with equal predicates and different identities
4325
+ * can therefore be entitled to different rows, and sharing one read between
4326
+ * them would hand one user the other's.
4327
+ *
4328
+ * What remains shareable is what is genuinely identical: every socket of the
4329
+ * same user (tabs, devices, reconnects), and every socket of an anonymous or
4330
+ * public shape — which is where the fan-out that made this path expensive
4331
+ * lives. An identity or predicate that cannot be stably keyed falls back to
4332
+ * an un-shared read.
4333
+ */
4334
+ private readGlobalShapeRowsCached;
4010
4335
  /**
4011
4336
  * Refresh every `.global()`-table shape held across all live sockets, one
4012
4337
  * diff-poke per (socket, shape). Returns the number of global shapes still