@syncular/server 0.15.48 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pull.ts CHANGED
@@ -17,10 +17,16 @@ import { clockOf, limitsOf } from './context';
17
17
  import type { PullSegmentSummary } from './events';
18
18
  import type { CompiledSchema, CompiledTable } from './schema';
19
19
  import { scopeDigest } from './scopes';
20
- import type { SegmentRecord } from './segment-store';
20
+ import type { SegmentRecord, SegmentStore } from './segment-store';
21
21
  import { issueSegmentUrl } from './signed-url';
22
22
  import type { SqliteImageBuilder } from './sqlite-image';
23
- import type { StoredCommit, StoredRow } from './storage';
23
+ import type { ServerStorage, StoredCommit, StoredRow } from './storage';
24
+
25
+ // One artifact build per owning storage pair and complete immutable identity.
26
+ const imageBuilds = new WeakMap<
27
+ ServerStorage,
28
+ WeakMap<SegmentStore, Map<string, Promise<SegmentRecord>>>
29
+ >();
24
30
 
25
31
  /**
26
32
  * Resolve the §5.3 image builder: the host-injected one if present, else the
@@ -260,57 +266,89 @@ async function* sqliteImageSegment(
260
266
  // support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
261
267
  const buildImage = await resolveImageBuilder(ctx);
262
268
  if (buildImage === undefined) return false;
263
- // The probe rows are the snapshot's first page — keep them and scan on
264
- // from the probe's cursor instead of re-reading the whole prefix (the
265
- // scan is keyset-ordered by rowId, so the concatenation is exactly the
266
- // rows a single full scan would return).
267
- const rows: StoredRow[] = [...probe];
268
- let afterRowId: string | null = probe[probe.length - 1]?.rowId ?? null;
269
- for (;;) {
270
- const scanned: StoredRow[] = await storage.scanRows(partition, {
271
- table: plan.table.name,
272
- scopeFilter: plan.effective,
273
- afterRowId,
274
- limit: 50_000,
275
- });
276
- rows.push(...scanned);
277
- const last = scanned[scanned.length - 1];
278
- if (scanned.length < 50_000 || last === undefined) break;
279
- afterRowId = last.rowId;
269
+ let stores = imageBuilds.get(storage);
270
+ if (stores === undefined) {
271
+ stores = new WeakMap();
272
+ imageBuilds.set(storage, stores);
280
273
  }
281
- const bytes = buildImage({
282
- table: plan.table,
274
+ let builds = stores.get(segments);
275
+ if (builds === undefined) {
276
+ builds = new Map();
277
+ stores.set(segments, builds);
278
+ }
279
+ const identity = {
280
+ partition,
281
+ logEpoch,
282
+ table: plan.table.name,
283
283
  schemaVersion: schema.version,
284
- asOfCommitSeq: asOf,
284
+ mediaType: 'sqlite' as const,
285
285
  scopeDigest: digest,
286
- rows,
287
- });
288
- const record = await segments.put(
289
- {
290
- partition,
291
- logEpoch,
292
- table: plan.table.name,
293
- schemaVersion: schema.version,
294
- mediaType: 'sqlite',
295
- scopeDigest: digest,
296
- asOfCommitSeq: asOf,
297
- rowCount: rows.length,
298
- rowCursor: null,
299
- nextRowCursor: null,
300
- },
301
- bytes,
302
- now,
303
- );
286
+ asOfCommitSeq: asOf,
287
+ };
288
+ const key = JSON.stringify(identity);
289
+ let building = builds.get(key);
290
+ let builtHere = false;
291
+ if (building === undefined) {
292
+ building = (async () => {
293
+ // Another request can finish while this one's eligibility probe awaits.
294
+ const cached = await segments.find(identity, clockOf(ctx)());
295
+ if (cached !== undefined) return cached;
296
+ builtHere = true;
297
+ let rowCount = 0;
298
+ const bytes = await buildImage({
299
+ table: plan.table,
300
+ schemaVersion: schema.version,
301
+ asOfCommitSeq: asOf,
302
+ scopeDigest: digest,
303
+ rowBatches: (async function* () {
304
+ rowCount += probe.length;
305
+ yield probe;
306
+ let afterRowId = probe[probe.length - 1]!.rowId;
307
+ for (;;) {
308
+ const rows = await storage.scanRows(partition, {
309
+ table: plan.table.name,
310
+ scopeFilter: plan.effective,
311
+ afterRowId,
312
+ limit: 5_000,
313
+ });
314
+ rowCount += rows.length;
315
+ yield rows;
316
+ const last = rows[rows.length - 1];
317
+ if (rows.length < 5_000 || last === undefined) break;
318
+ afterRowId = last.rowId;
319
+ }
320
+ })(),
321
+ });
322
+ return segments.put(
323
+ { ...identity, rowCount, rowCursor: null, nextRowCursor: null },
324
+ bytes,
325
+ clockOf(ctx)(),
326
+ );
327
+ })();
328
+ builds.set(key, building);
329
+ }
330
+ let record: SegmentRecord;
331
+ try {
332
+ record = await building;
333
+ } finally {
334
+ if (builds.get(key) === building) builds.delete(key);
335
+ }
304
336
  trace?.segments.push({
305
337
  mediaType: 'sqlite',
306
338
  delivery: 'ref',
307
- origin: 'built',
339
+ origin: builtHere ? 'built' : 'reused',
308
340
  bytes: record.byteLength,
309
341
  rows: record.rowCount,
310
342
  });
311
343
  yield segmentRefFrame(
312
344
  record,
313
- await signedUrlFields(ctx, limits, record.segmentId, digest, now),
345
+ await signedUrlFields(
346
+ ctx,
347
+ limits,
348
+ record.segmentId,
349
+ digest,
350
+ clockOf(ctx)(),
351
+ ),
314
352
  );
315
353
  return true;
316
354
  }
package/src/push.ts CHANGED
@@ -185,7 +185,7 @@ async function runValidator(
185
185
  return errorRecord(
186
186
  opIndex,
187
187
  'sync.constraint_violation',
188
- `write validator for table ${JSON.stringify(table.name)} threw: ${error instanceof Error ? error.message : String(error)}`,
188
+ 'write validator failed',
189
189
  );
190
190
  }
191
191
  return undefined;
@@ -220,7 +220,7 @@ async function mergeCrdtColumns(
220
220
  return errorRecord(
221
221
  opIndex,
222
222
  'sync.crdt_merge_failed',
223
- `no CRDT merger registered for crdtType ${JSON.stringify(crdtType)} (§5.10.2)`,
223
+ 'no CRDT merger registered',
224
224
  );
225
225
  }
226
226
  const storedRaw = storedValues?.[index];
@@ -228,11 +228,11 @@ async function mergeCrdtColumns(
228
228
  let merged: Uint8Array;
229
229
  try {
230
230
  merged = await merger(stored, incoming);
231
- } catch (error) {
231
+ } catch {
232
232
  return errorRecord(
233
233
  opIndex,
234
234
  'sync.crdt_merge_failed',
235
- `CRDT merger for ${JSON.stringify(crdtType)} threw: ${error instanceof Error ? error.message : String(error)}`,
235
+ 'CRDT merger failed',
236
236
  );
237
237
  }
238
238
  values[index] = merged;
@@ -732,7 +732,7 @@ async function runCommitValidator(
732
732
  return errorRecord(
733
733
  operations[0]?.opIndex ?? 0,
734
734
  'sync.constraint_violation',
735
- `whole-commit validator threw: ${error instanceof Error ? error.message : String(error)}`,
735
+ 'whole-commit validator failed',
736
736
  );
737
737
  }
738
738
  return undefined;
package/src/sqlite-bun.ts CHANGED
@@ -40,10 +40,10 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
40
40
  }
41
41
  }
42
42
 
43
- export const buildSqliteImage: SqliteImageBuilder = (input) => {
43
+ export const buildSqliteImage: SqliteImageBuilder = async (input) => {
44
44
  const db = new BunSqliteDatabase();
45
45
  try {
46
- writeSqliteImage(db, input);
46
+ await writeSqliteImage(db, input);
47
47
  return db.serialize();
48
48
  } finally {
49
49
  db.close();
@@ -57,7 +57,9 @@ export interface SqliteImageInput {
57
57
  readonly schemaVersion: number;
58
58
  readonly asOfCommitSeq: number;
59
59
  readonly scopeDigest: string;
60
- readonly rows: readonly StoredRow[];
60
+ readonly rowBatches:
61
+ | AsyncIterable<readonly StoredRow[]>
62
+ | Iterable<readonly StoredRow[]>;
61
63
  }
62
64
 
63
65
  /**
@@ -68,14 +70,16 @@ export interface SqliteImageInput {
68
70
  * on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
69
71
  * host omits it and serves the rows lane.
70
72
  */
71
- export type SqliteImageBuilder = (input: SqliteImageInput) => Uint8Array;
73
+ export type SqliteImageBuilder = (
74
+ input: SqliteImageInput,
75
+ ) => Promise<Uint8Array>;
72
76
 
73
77
  /** Populate a §5.3 image database for a whole-table snapshot. */
74
- export function writeSqliteImage(
78
+ export async function writeSqliteImage(
75
79
  db: SqliteDatabase,
76
80
  input: SqliteImageInput,
77
- ): void {
78
- const { table, rows } = input;
81
+ ): Promise<void> {
82
+ const { table, rowBatches } = input;
79
83
  const primaryKey = table.columns[table.primaryKeyIndex]?.name;
80
84
  const columnDefs = table.columns.map((column) => {
81
85
  const notNull = column.nullable ? '' : ' NOT NULL';
@@ -90,13 +94,6 @@ export function writeSqliteImage(
90
94
  "schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
91
95
  "scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`,
92
96
  );
93
- db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(
94
- table.name,
95
- input.schemaVersion,
96
- input.asOfCommitSeq,
97
- input.scopeDigest,
98
- rows.length,
99
- );
100
97
  const names = [
101
98
  ...table.columns.map((column) => quoteIdent(column.name)),
102
99
  quoteIdent(IMAGE_VERSION_COLUMN),
@@ -107,10 +104,24 @@ export function writeSqliteImage(
107
104
  );
108
105
  db.exec('BEGIN');
109
106
  try {
110
- for (const row of rows) {
111
- const values = decodeRow(table.columns, row.payload);
112
- insert.run(...values.map(toSql), row.serverVersion);
107
+ let rowCount = 0;
108
+ for await (const rows of rowBatches) {
109
+ for (const row of rows) {
110
+ const values = decodeRow(table.columns, row.payload);
111
+ insert.run(...values.map(toSql), row.serverVersion);
112
+ }
113
+ rowCount += rows.length;
113
114
  }
115
+ db.query(
116
+ `INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`,
117
+ ).run(
118
+ table.name,
119
+ input.schemaVersion,
120
+ input.asOfCommitSeq,
121
+ input.scopeDigest,
122
+ rowCount,
123
+ );
124
+
114
125
  db.exec('COMMIT');
115
126
  } catch (error) {
116
127
  db.exec('ROLLBACK');
@@ -43,13 +43,13 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
43
43
  }
44
44
  }
45
45
 
46
- export const buildSqliteImage: SqliteImageBuilder = (input) => {
46
+ export const buildSqliteImage: SqliteImageBuilder = async (input) => {
47
47
  const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
48
48
  const path = join(directory, 'segment.db');
49
49
  const db = new NodeSqliteDatabase(path);
50
50
  try {
51
51
  try {
52
- writeSqliteImage(db, input);
52
+ await writeSqliteImage(db, input);
53
53
  } finally {
54
54
  db.close();
55
55
  }
@@ -1,3 +1,6 @@
1
+ import { validateCommitPruneQuery } from './prune';
2
+ import { StorageQueryError } from './storage-errors';
3
+ import type { CommitPruneQuery, CommitPruneResult } from './storage';
1
4
  /**
2
5
  * SQLite server storage over the shared synchronous driver.
3
6
  *
@@ -390,7 +393,7 @@ export class SqliteServerStorage implements ServerStorage {
390
393
  #tables: ReadonlyMap<string, CompiledTable> | undefined;
391
394
  #schemaVersion: number | undefined;
392
395
 
393
- async #serializeReactionWrite<T>(operation: () => T): Promise<T> {
396
+ async #serializeWrite<T>(operation: () => T): Promise<T> {
394
397
  const previous = this.#transactionTail;
395
398
  let release!: () => void;
396
399
  this.#transactionTail = new Promise<void>((resolve) => {
@@ -559,7 +562,7 @@ export class SqliteServerStorage implements ServerStorage {
559
562
  authenticatedAtMs: number,
560
563
  ): Promise<PartitionRegistryEntry> {
561
564
  if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
562
- return this.#serializeReactionWrite(() => {
565
+ return this.#serializeWrite(() => {
563
566
  this.db.exec('BEGIN IMMEDIATE');
564
567
  try {
565
568
  this.db
@@ -725,7 +728,7 @@ export class SqliteServerStorage implements ServerStorage {
725
728
  }
726
729
  const prepared = bindAuthoritativePartition(
727
730
  prepareAuthoritativeQuery(
728
- query.sql,
731
+ query.plan,
729
732
  query.params,
730
733
  query.tables,
731
734
  this.#tables,
@@ -763,6 +766,14 @@ export class SqliteServerStorage implements ServerStorage {
763
766
  }
764
767
  }
765
768
 
769
+ async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
770
+ return this.db
771
+ .query<{ log_epoch: string }, [string]>(
772
+ 'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
773
+ )
774
+ .get(partition)?.log_epoch;
775
+ }
776
+
766
777
  async getHorizonSeq(partition: string): Promise<number> {
767
778
  const row = this.db
768
779
  .query<{ horizon_seq: number }, [string]>(
@@ -773,27 +784,62 @@ export class SqliteServerStorage implements ServerStorage {
773
784
  }
774
785
 
775
786
  async setHorizonSeq(partition: string, seq: number): Promise<void> {
776
- this.db
777
- .query('INSERT OR IGNORE INTO sync_partitions(partition) VALUES (?)')
778
- .run(partition);
779
- this.db
780
- .query('UPDATE sync_partitions SET horizon_seq=? WHERE partition=?')
781
- .run(seq, partition);
787
+ await this.#serializeWrite(() => {
788
+ this.db
789
+ .query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
790
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
791
+ .run(partition, seq);
792
+ });
782
793
  }
783
794
 
784
- async pruneCommitsThrough(partition: string, seq: number): Promise<number> {
785
- const removed = this.db
786
- .query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
787
- .run(partition, seq);
788
- this.db
789
- .query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
790
- .run(partition, seq);
791
- this.db
792
- .query(
793
- 'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
794
- )
795
- .run(partition, seq);
796
- return Number(removed.changes);
795
+ async pruneCommitsThrough(
796
+ partition: string,
797
+ query: CommitPruneQuery,
798
+ ): Promise<CommitPruneResult> {
799
+ validateCommitPruneQuery(query);
800
+ return this.#serializeWrite(() => {
801
+ this.db.exec('BEGIN IMMEDIATE');
802
+ try {
803
+ const epoch = this.db
804
+ .query<{ log_epoch: string }, [string]>(
805
+ 'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
806
+ )
807
+ .get(partition)?.log_epoch;
808
+ if (epoch !== query.logEpoch)
809
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
810
+ const previousHorizonSeq =
811
+ this.db
812
+ .query<{ horizon_seq: number }, [string]>(
813
+ 'SELECT horizon_seq FROM sync_partitions WHERE partition=?',
814
+ )
815
+ .get(partition)?.horizon_seq ?? 0;
816
+ const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
817
+ this.db
818
+ .query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
819
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq`)
820
+ .run(partition, horizonSeq);
821
+ const removed = this.db
822
+ .query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
823
+ .run(partition, horizonSeq);
824
+ this.db
825
+ .query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
826
+ .run(partition, horizonSeq);
827
+ this.db
828
+ .query(
829
+ 'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
830
+ )
831
+ .run(partition, horizonSeq);
832
+ this.db.exec('COMMIT');
833
+ return {
834
+ previousHorizonSeq,
835
+ horizonSeq,
836
+ removedCommits: Number(removed.changes),
837
+ };
838
+ } catch (error) {
839
+ this.db.exec('ROLLBACK');
840
+ throw error;
841
+ }
842
+ });
797
843
  }
798
844
 
799
845
  async getCommitSeqBefore(
@@ -847,7 +893,7 @@ export class SqliteServerStorage implements ServerStorage {
847
893
  query: ReactionClaimQuery,
848
894
  ): Promise<StoredReaction[]> {
849
895
  if (query.types.length === 0 || query.limit <= 0) return [];
850
- return this.#serializeReactionWrite(() => {
896
+ return this.#serializeWrite(() => {
851
897
  const typeParams = query.types.map(() => '?').join(',');
852
898
  const records = this.db
853
899
  .query<SqliteReactionRecord, (string | number)[]>(
@@ -895,7 +941,7 @@ export class SqliteServerStorage implements ServerStorage {
895
941
  leaseOwner: string,
896
942
  completedAtMs: number,
897
943
  ): Promise<boolean> {
898
- return this.#serializeReactionWrite(() => {
944
+ return this.#serializeWrite(() => {
899
945
  const result = this.db
900
946
  .query(
901
947
  `UPDATE sync_reactions
@@ -915,7 +961,7 @@ export class SqliteServerStorage implements ServerStorage {
915
961
  leaseOwner: string,
916
962
  leaseExpiresAtMs: number,
917
963
  ): Promise<boolean> {
918
- return this.#serializeReactionWrite(() => {
964
+ return this.#serializeWrite(() => {
919
965
  const result = this.db
920
966
  .query(
921
967
  `UPDATE sync_reactions SET lease_expires_at_ms=?
@@ -933,7 +979,7 @@ export class SqliteServerStorage implements ServerStorage {
933
979
  update: ReactionFailureUpdate,
934
980
  ): Promise<boolean> {
935
981
  const retry = update.retryAtMs !== undefined;
936
- return this.#serializeReactionWrite(() => {
982
+ return this.#serializeWrite(() => {
937
983
  const result = this.db
938
984
  .query(
939
985
  `UPDATE sync_reactions
@@ -959,7 +1005,7 @@ export class SqliteServerStorage implements ServerStorage {
959
1005
  idempotencyKey: string,
960
1006
  nowMs: number,
961
1007
  ): Promise<boolean> {
962
- return this.#serializeReactionWrite(() => {
1008
+ return this.#serializeWrite(() => {
963
1009
  const result = this.db
964
1010
  .query(
965
1011
  `UPDATE sync_reactions
@@ -1014,7 +1060,7 @@ export class SqliteServerStorage implements ServerStorage {
1014
1060
  query: ReactionPruneQuery,
1015
1061
  ): Promise<PrunedReactionCounts> {
1016
1062
  if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
1017
- return this.#serializeReactionWrite(() => {
1063
+ return this.#serializeWrite(() => {
1018
1064
  const records = this.db
1019
1065
  .query<
1020
1066
  { status: 'completed' | 'dead-letter' },
@@ -1226,6 +1272,18 @@ export class SqliteServerStorage implements ServerStorage {
1226
1272
  );
1227
1273
  }
1228
1274
 
1275
+ async getActiveClientCursorFloor(
1276
+ partition: string,
1277
+ cutoffMs: number,
1278
+ ): Promise<number | null> {
1279
+ const row = this.db
1280
+ .query<{ cursor: number | null }, [string, number]>(
1281
+ 'SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?',
1282
+ )
1283
+ .get(partition, cutoffMs);
1284
+ return row!.cursor;
1285
+ }
1286
+
1229
1287
  async listClientCursors(partition: string): Promise<ClientCursorInfo[]> {
1230
1288
  const records = this.db
1231
1289
  .query<
@@ -19,10 +19,19 @@ export type StorageQueryErrorCode =
19
19
  | 'sync.storage.index_not_found'
20
20
  | 'sync.storage.index_not_materialized'
21
21
  | 'sync.storage.index_value_count_mismatch'
22
- | 'sync.storage.invalid_limit';
22
+ | 'sync.storage.invalid_limit'
23
+ | 'sync.storage.prune_epoch_mismatch'
24
+ | 'sync.storage.partition_unregistered'
25
+ | 'sync.storage.invalid_prune_cursor';
23
26
 
24
27
  const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
25
28
  {
29
+ 'sync.storage.prune_epoch_mismatch':
30
+ 'partition log epoch changed; recompute retention inputs',
31
+ 'sync.storage.partition_unregistered':
32
+ 'pruning requires a registered partition',
33
+ 'sync.storage.invalid_prune_cursor':
34
+ 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
26
35
  'sync.storage.scan_requires_scope':
27
36
  'scope-indexed row scans require at least one scope variable',
28
37
  'sync.storage.index_not_found':
package/src/storage.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AuthoritativeRelationPlan } from './authoritative-query';
1
2
  /**
2
3
  * Storage interface (defined by the SPEC's needs, implementation-agnostic).
3
4
  *
@@ -19,6 +20,17 @@
19
20
  import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
20
21
  import type { CompiledSchema } from './schema';
21
22
 
23
+ export interface CommitPruneQuery {
24
+ readonly logEpoch: string;
25
+ readonly throughSeq: number;
26
+ }
27
+
28
+ export interface CommitPruneResult {
29
+ readonly previousHorizonSeq: number;
30
+ readonly horizonSeq: number;
31
+ readonly removedCommits: number;
32
+ }
33
+
22
34
  /** The current stored state of a synced row. */
23
35
  export interface StoredRow {
24
36
  readonly rowId: string;
@@ -281,7 +293,7 @@ export type AuthoritativeQueryValue =
281
293
 
282
294
  export interface AuthoritativeQueryRequest {
283
295
  /** Generated, positional SQLite-family SQL. It never comes from the request. */
284
- readonly sql: string;
296
+ readonly plan: AuthoritativeRelationPlan;
285
297
  readonly params: readonly AuthoritativeQueryValue[];
286
298
  /** Generated dependency set, used to validate and partition every relation. */
287
299
  readonly tables: readonly string[];
@@ -422,16 +434,23 @@ export interface ServerStorage {
422
434
  /** Registry entries ordered by partition for maintenance loops. */
423
435
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
424
436
 
437
+ /** Read continuity without refreshing authenticated activity. */
438
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
439
+
425
440
  begin(partition: string): Promise<StorageTransaction>;
426
441
 
427
442
  getMaxCommitSeq(partition: string): Promise<number>;
428
443
  getHorizonSeq(partition: string): Promise<number>;
444
+ /** Monotonic within the current epoch; use atomic pruning for maintenance. */
429
445
  setHorizonSeq(partition: string, seq: number): Promise<void>;
430
446
  /**
431
- * Deletes commits with `commitSeq <= seq` (log, changes, scope index).
432
- * Returns the number of commits removed (ops observability).
447
+ * Atomically verifies the log epoch, advances the horizon monotonically,
448
+ * and removes log/change/scope records through the effective horizon.
433
449
  */
434
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
450
+ pruneCommitsThrough(
451
+ partition: string,
452
+ query: CommitPruneQuery,
453
+ ): Promise<CommitPruneResult>;
435
454
  /** Newest commitSeq created strictly before the timestamp; 0 if none. */
436
455
  getCommitSeqBefore(
437
456
  partition: string,
@@ -541,7 +560,12 @@ export interface ServerStorage {
541
560
  clientId: string,
542
561
  ): Promise<ClientRecord | undefined>;
543
562
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
544
- /** Cursor records feeding the §4.6 retention watermark. */
563
+ /** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
564
+ getActiveClientCursorFloor(
565
+ partition: string,
566
+ cutoffMs: number,
567
+ ): Promise<number | null>;
568
+ /** Cursor records for client listings and administrative counts. */
545
569
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
546
570
 
547
571
  /**