@syncular/server 0.15.47 → 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.
Files changed (80) hide show
  1. package/README.md +48 -5
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +3 -12
  4. package/dist/authoritative-query.d.ts +14 -6
  5. package/dist/authoritative-query.js +60 -82
  6. package/dist/blob-handlers.js +4 -1
  7. package/dist/context.d.ts +3 -1
  8. package/dist/context.js +4 -0
  9. package/dist/d1-storage.d.ts +8 -2
  10. package/dist/d1-storage.js +134 -26
  11. package/dist/errors.js +6 -0
  12. package/dist/events.d.ts +2 -1
  13. package/dist/frame-bytes.d.ts +2 -2
  14. package/dist/frame-bytes.js +33 -14
  15. package/dist/handler.js +58 -14
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/operations.d.ts +2 -0
  19. package/dist/operations.js +25 -5
  20. package/dist/postgres-storage.d.ts +9 -3
  21. package/dist/postgres-storage.js +110 -20
  22. package/dist/prune.d.ts +3 -1
  23. package/dist/prune.js +18 -14
  24. package/dist/pull.d.ts +1 -1
  25. package/dist/pull.js +74 -37
  26. package/dist/push.js +5 -5
  27. package/dist/realtime.d.ts +4 -1
  28. package/dist/realtime.js +33 -9
  29. package/dist/restore.d.ts +13 -0
  30. package/dist/restore.js +13 -0
  31. package/dist/s3-segment-store.js +10 -1
  32. package/dist/seed.js +36 -4
  33. package/dist/segment-download.js +5 -2
  34. package/dist/segment-store.d.ts +3 -0
  35. package/dist/segment-store.js +1 -0
  36. package/dist/sqlite-bun-driver.d.ts +2 -1
  37. package/dist/sqlite-bun-driver.js +5 -2
  38. package/dist/sqlite-bun.js +2 -2
  39. package/dist/sqlite-dialect.d.ts +1 -1
  40. package/dist/sqlite-dialect.js +7 -0
  41. package/dist/sqlite-image.d.ts +3 -3
  42. package/dist/sqlite-image.js +10 -6
  43. package/dist/sqlite-node.js +2 -2
  44. package/dist/sqlite-segment-store.js +14 -5
  45. package/dist/sqlite-storage.d.ts +8 -2
  46. package/dist/sqlite-storage.js +147 -36
  47. package/dist/storage-errors.d.ts +1 -1
  48. package/dist/storage-errors.js +3 -0
  49. package/dist/storage.d.ts +38 -10
  50. package/package.json +2 -2
  51. package/src/admin.ts +7 -15
  52. package/src/authoritative-query.ts +89 -94
  53. package/src/blob-handlers.ts +8 -1
  54. package/src/context.ts +16 -1
  55. package/src/d1-storage.ts +193 -29
  56. package/src/errors.ts +6 -0
  57. package/src/events.ts +2 -1
  58. package/src/frame-bytes.ts +40 -14
  59. package/src/handler.ts +102 -29
  60. package/src/index.ts +1 -0
  61. package/src/operations.ts +37 -4
  62. package/src/postgres-storage.ts +184 -38
  63. package/src/prune.ts +29 -15
  64. package/src/pull.ts +90 -41
  65. package/src/push.ts +5 -5
  66. package/src/realtime.ts +46 -7
  67. package/src/restore.ts +28 -0
  68. package/src/s3-segment-store.ts +10 -1
  69. package/src/seed.ts +46 -4
  70. package/src/segment-download.ts +11 -2
  71. package/src/segment-store.ts +4 -0
  72. package/src/sqlite-bun-driver.ts +6 -2
  73. package/src/sqlite-bun.ts +2 -2
  74. package/src/sqlite-dialect.ts +7 -0
  75. package/src/sqlite-image.ts +26 -15
  76. package/src/sqlite-node.ts +2 -2
  77. package/src/sqlite-segment-store.ts +18 -4
  78. package/src/sqlite-storage.ts +203 -39
  79. package/src/storage-errors.ts +10 -1
  80. package/src/storage.ts +57 -10
@@ -1,3 +1,5 @@
1
+ import { validateCommitPruneQuery } from './prune.js';
2
+ import { StorageQueryError } from './storage-errors.js';
1
3
  /**
2
4
  * SQLite server storage over the shared synchronous driver.
3
5
  *
@@ -199,7 +201,7 @@ export class SqliteServerStorage {
199
201
  /** Set by `ensureSchema`: app-table lookup for the relational row store. */
200
202
  #tables;
201
203
  #schemaVersion;
202
- async #serializeReactionWrite(operation) {
204
+ async #serializeWrite(operation) {
203
205
  const previous = this.#transactionTail;
204
206
  let release;
205
207
  this.#transactionTail = new Promise((resolve) => {
@@ -219,6 +221,12 @@ export class SqliteServerStorage {
219
221
  }
220
222
  this.db = db;
221
223
  this.db.exec(SQLITE_DDL);
224
+ const clientColumns = this.db
225
+ .query('PRAGMA table_info("sync_clients")')
226
+ .all();
227
+ if (!clientColumns.some((column) => column.name === 'wire_version')) {
228
+ this.db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1');
229
+ }
222
230
  }
223
231
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
224
232
  table(name) {
@@ -294,6 +302,74 @@ export class SqliteServerStorage {
294
302
  this.#tables = schema.tables;
295
303
  this.#schemaVersion = schema.version;
296
304
  }
305
+ async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
306
+ if (initialLogEpoch.length === 0) {
307
+ throw new Error('initial log epoch must be non-empty');
308
+ }
309
+ this.db
310
+ .query(`INSERT INTO sync_partition_registry(
311
+ partition, log_epoch, last_authenticated_at_ms
312
+ ) VALUES (?,?,?)
313
+ ON CONFLICT(partition) DO UPDATE SET
314
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
315
+ .run(partition, initialLogEpoch, authenticatedAtMs);
316
+ const row = this.db
317
+ .query(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
318
+ FROM sync_partition_registry WHERE partition=?`)
319
+ .get(partition);
320
+ if (row === null)
321
+ throw new Error('partition registry write did not persist');
322
+ return {
323
+ partition,
324
+ logEpoch: row.log_epoch,
325
+ epochRequired: row.epoch_required === 1,
326
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
327
+ };
328
+ }
329
+ async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
330
+ if (logEpoch.length === 0)
331
+ throw new Error('log epoch must be non-empty');
332
+ return this.#serializeWrite(() => {
333
+ this.db.exec('BEGIN IMMEDIATE');
334
+ try {
335
+ this.db
336
+ .query(`INSERT INTO sync_partition_registry(
337
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
338
+ ) VALUES (?,?,1,?)
339
+ ON CONFLICT(partition) DO UPDATE SET
340
+ log_epoch=excluded.log_epoch,
341
+ epoch_required=1,
342
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
343
+ .run(partition, logEpoch, authenticatedAtMs);
344
+ this.db
345
+ .query('DELETE FROM sync_clients WHERE partition=?')
346
+ .run(partition);
347
+ this.db.exec('COMMIT');
348
+ }
349
+ catch (error) {
350
+ this.db.exec('ROLLBACK');
351
+ throw error;
352
+ }
353
+ return {
354
+ partition,
355
+ logEpoch,
356
+ epochRequired: true,
357
+ lastAuthenticatedAtMs: authenticatedAtMs,
358
+ };
359
+ });
360
+ }
361
+ async listPartitionRegistry() {
362
+ return this.db
363
+ .query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
364
+ FROM sync_partition_registry ORDER BY partition`)
365
+ .all()
366
+ .map((row) => ({
367
+ partition: row.partition,
368
+ logEpoch: row.log_epoch,
369
+ epochRequired: row.epoch_required === 1,
370
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
371
+ }));
372
+ }
297
373
  /**
298
374
  * Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
299
375
  * given every payload re-encodes under
@@ -369,7 +445,7 @@ export class SqliteServerStorage {
369
445
  if (this.#tables === undefined) {
370
446
  throw new Error('ensureSchema(schema) must run before registered queries');
371
447
  }
372
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
448
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
373
449
  const previous = this.#transactionTail;
374
450
  let release;
375
451
  this.#transactionTail = new Promise((resolve) => {
@@ -399,6 +475,11 @@ export class SqliteServerStorage {
399
475
  release();
400
476
  }
401
477
  }
478
+ async getPartitionLogEpoch(partition) {
479
+ return this.db
480
+ .query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
481
+ .get(partition)?.log_epoch;
482
+ }
402
483
  async getHorizonSeq(partition) {
403
484
  const row = this.db
404
485
  .query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -406,24 +487,52 @@ export class SqliteServerStorage {
406
487
  return row?.horizon_seq ?? 0;
407
488
  }
408
489
  async setHorizonSeq(partition, seq) {
409
- this.db
410
- .query('INSERT OR IGNORE INTO sync_partitions(partition) VALUES (?)')
411
- .run(partition);
412
- this.db
413
- .query('UPDATE sync_partitions SET horizon_seq=? WHERE partition=?')
414
- .run(seq, partition);
490
+ await this.#serializeWrite(() => {
491
+ this.db
492
+ .query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
493
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
494
+ .run(partition, seq);
495
+ });
415
496
  }
416
- async pruneCommitsThrough(partition, seq) {
417
- const removed = this.db
418
- .query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
419
- .run(partition, seq);
420
- this.db
421
- .query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
422
- .run(partition, seq);
423
- this.db
424
- .query('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
425
- .run(partition, seq);
426
- return Number(removed.changes);
497
+ async pruneCommitsThrough(partition, query) {
498
+ validateCommitPruneQuery(query);
499
+ return this.#serializeWrite(() => {
500
+ this.db.exec('BEGIN IMMEDIATE');
501
+ try {
502
+ const epoch = this.db
503
+ .query('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
504
+ .get(partition)?.log_epoch;
505
+ if (epoch !== query.logEpoch)
506
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
507
+ const previousHorizonSeq = this.db
508
+ .query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
509
+ .get(partition)?.horizon_seq ?? 0;
510
+ const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
511
+ this.db
512
+ .query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
513
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq`)
514
+ .run(partition, horizonSeq);
515
+ const removed = this.db
516
+ .query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
517
+ .run(partition, horizonSeq);
518
+ this.db
519
+ .query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
520
+ .run(partition, horizonSeq);
521
+ this.db
522
+ .query('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
523
+ .run(partition, horizonSeq);
524
+ this.db.exec('COMMIT');
525
+ return {
526
+ previousHorizonSeq,
527
+ horizonSeq,
528
+ removedCommits: Number(removed.changes),
529
+ };
530
+ }
531
+ catch (error) {
532
+ this.db.exec('ROLLBACK');
533
+ throw error;
534
+ }
535
+ });
427
536
  }
428
537
  async getCommitSeqBefore(partition, createdBeforeMs) {
429
538
  const row = this.db
@@ -453,7 +562,7 @@ export class SqliteServerStorage {
453
562
  async claimReactions(partition, query) {
454
563
  if (query.types.length === 0 || query.limit <= 0)
455
564
  return [];
456
- return this.#serializeReactionWrite(() => {
565
+ return this.#serializeWrite(() => {
457
566
  const typeParams = query.types.map(() => '?').join(',');
458
567
  const records = this.db
459
568
  .query(`UPDATE sync_reactions
@@ -479,7 +588,7 @@ export class SqliteServerStorage {
479
588
  });
480
589
  }
481
590
  async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
482
- return this.#serializeReactionWrite(() => {
591
+ return this.#serializeWrite(() => {
483
592
  const result = this.db
484
593
  .query(`UPDATE sync_reactions
485
594
  SET status='completed', completed_at_ms=?,
@@ -491,7 +600,7 @@ export class SqliteServerStorage {
491
600
  });
492
601
  }
493
602
  async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
494
- return this.#serializeReactionWrite(() => {
603
+ return this.#serializeWrite(() => {
495
604
  const result = this.db
496
605
  .query(`UPDATE sync_reactions SET lease_expires_at_ms=?
497
606
  WHERE partition=? AND idempotency_key=?
@@ -502,7 +611,7 @@ export class SqliteServerStorage {
502
611
  }
503
612
  async failReaction(partition, idempotencyKey, update) {
504
613
  const retry = update.retryAtMs !== undefined;
505
- return this.#serializeReactionWrite(() => {
614
+ return this.#serializeWrite(() => {
506
615
  const result = this.db
507
616
  .query(`UPDATE sync_reactions
508
617
  SET status=?, available_at_ms=?, last_failure=?,
@@ -514,7 +623,7 @@ export class SqliteServerStorage {
514
623
  });
515
624
  }
516
625
  async retryReaction(partition, idempotencyKey, nowMs) {
517
- return this.#serializeReactionWrite(() => {
626
+ return this.#serializeWrite(() => {
518
627
  const result = this.db
519
628
  .query(`UPDATE sync_reactions
520
629
  SET status='pending', attempts=0, available_at_ms=?,
@@ -552,7 +661,7 @@ export class SqliteServerStorage {
552
661
  async pruneReactions(partition, query) {
553
662
  if (query.limit <= 0)
554
663
  return { completed: 0, deadLetter: 0 };
555
- return this.#serializeReactionWrite(() => {
664
+ return this.#serializeWrite(() => {
556
665
  const records = this.db
557
666
  .query(`DELETE FROM sync_reactions
558
667
  WHERE partition=? AND idempotency_key IN (
@@ -659,13 +768,14 @@ export class SqliteServerStorage {
659
768
  }
660
769
  async getClientRecord(partition, clientId) {
661
770
  const record = this.db
662
- .query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
771
+ .query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
663
772
  .get(partition, clientId);
664
773
  if (record === null)
665
774
  return undefined;
666
775
  return {
667
776
  clientId: record.client_id,
668
777
  actorId: record.actor_id,
778
+ wireVersion: record.wire_version,
669
779
  cursor: record.cursor,
670
780
  updatedAtMs: record.updated_at_ms,
671
781
  subscriptions: JSON.parse(record.subscriptions),
@@ -673,8 +783,14 @@ export class SqliteServerStorage {
673
783
  }
674
784
  async putClientRecord(partition, record) {
675
785
  this.db
676
- .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?)')
677
- .run(partition, record.clientId, record.actorId, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
786
+ .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
787
+ .run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
788
+ }
789
+ async getActiveClientCursorFloor(partition, cutoffMs) {
790
+ const row = this.db
791
+ .query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
792
+ .get(partition, cutoffMs);
793
+ return row.cursor;
678
794
  }
679
795
  async listClientCursors(partition) {
680
796
  const records = this.db
@@ -719,11 +835,12 @@ export class SqliteServerStorage {
719
835
  // -- admin/console read surface --------------------------------------------
720
836
  async listClientRecords(partition) {
721
837
  const records = this.db
722
- .query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
838
+ .query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
723
839
  .all(partition);
724
840
  return records.map((record) => ({
725
841
  clientId: record.client_id,
726
842
  actorId: record.actor_id,
843
+ wireVersion: record.wire_version,
727
844
  cursor: record.cursor,
728
845
  updatedAtMs: record.updated_at_ms,
729
846
  subscriptions: JSON.parse(record.subscriptions),
@@ -800,12 +917,6 @@ export class SqliteServerStorage {
800
917
  };
801
918
  }
802
919
  async listPartitions() {
803
- // Union: the registry row appears on first commit, the client row on
804
- // first pull — a partition with only one of the two still shows up.
805
- const rows = this.db
806
- .query(`SELECT partition FROM sync_partitions
807
- UNION SELECT partition FROM sync_clients ORDER BY partition`)
808
- .all();
809
- return rows.map((r) => r.partition);
920
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
810
921
  }
811
922
  }
@@ -9,7 +9,7 @@ export declare class StorageConstraintError extends Error {
9
9
  constructor(cause: unknown, opIndex?: number);
10
10
  }
11
11
  /** Stable, privacy-safe failures for trusted server storage queries. */
12
- export type StorageQueryErrorCode = 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit';
12
+ export type StorageQueryErrorCode = 'sync.storage.scan_requires_scope' | 'sync.storage.index_not_found' | 'sync.storage.index_not_materialized' | 'sync.storage.index_value_count_mismatch' | 'sync.storage.invalid_limit' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor';
13
13
  /**
14
14
  * Host-only query error. Messages never include identifiers, values, SQL,
15
15
  * paths, or row data; callers branch on `code`, never message text.
@@ -12,6 +12,9 @@ export class StorageConstraintError extends Error {
12
12
  }
13
13
  }
14
14
  const STORAGE_QUERY_MESSAGES = {
15
+ 'sync.storage.prune_epoch_mismatch': 'partition log epoch changed; recompute retention inputs',
16
+ 'sync.storage.partition_unregistered': 'pruning requires a registered partition',
17
+ 'sync.storage.invalid_prune_cursor': 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
15
18
  'sync.storage.scan_requires_scope': 'scope-indexed row scans require at least one scope variable',
16
19
  'sync.storage.index_not_found': 'trusted row lookup requires a declared relational index',
17
20
  'sync.storage.index_not_materialized': 'trusted row lookup requires a materialized relational table',
package/dist/storage.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AuthoritativeRelationPlan } from './authoritative-query.js';
1
2
  /**
2
3
  * Storage interface (defined by the SPEC's needs, implementation-agnostic).
3
4
  *
@@ -18,6 +19,15 @@
18
19
  */
19
20
  import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
20
21
  import type { CompiledSchema } from './schema.js';
22
+ export interface CommitPruneQuery {
23
+ readonly logEpoch: string;
24
+ readonly throughSeq: number;
25
+ }
26
+ export interface CommitPruneResult {
27
+ readonly previousHorizonSeq: number;
28
+ readonly horizonSeq: number;
29
+ readonly removedCommits: number;
30
+ }
21
31
  /** The current stored state of a synced row. */
22
32
  export interface StoredRow {
23
33
  readonly rowId: string;
@@ -144,6 +154,8 @@ export interface ClientSubscription {
144
154
  export interface ClientRecord {
145
155
  readonly clientId: string;
146
156
  readonly actorId: string;
157
+ /** SSP2 version last accepted from this client; selects realtime deltas. */
158
+ readonly wireVersion: number;
147
159
  /** Minimum `nextCursor` across the last pull's active subscriptions. */
148
160
  readonly cursor: number;
149
161
  readonly updatedAtMs: number;
@@ -195,6 +207,13 @@ export interface ClientCursorInfo {
195
207
  readonly cursor: number;
196
208
  readonly updatedAtMs: number;
197
209
  }
210
+ /** Durable partition identity refreshed after host authentication (§2.1). */
211
+ export interface PartitionRegistryEntry {
212
+ readonly partition: string;
213
+ readonly logEpoch: string;
214
+ readonly epochRequired: boolean;
215
+ readonly lastAuthenticatedAtMs: number;
216
+ }
198
217
  /**
199
218
  * Commit-log metadata (no change payloads) for the admin/console read
200
219
  * surface. `changeCount` is the number of changes the commit carries;
@@ -235,7 +254,7 @@ export interface ScopeActivityQuery {
235
254
  export type AuthoritativeQueryValue = string | number | bigint | boolean | Uint8Array | null;
236
255
  export interface AuthoritativeQueryRequest {
237
256
  /** Generated, positional SQLite-family SQL. It never comes from the request. */
238
- readonly sql: string;
257
+ readonly plan: AuthoritativeRelationPlan;
239
258
  readonly params: readonly AuthoritativeQueryValue[];
240
259
  /** Generated dependency set, used to validate and partition every relation. */
241
260
  readonly tables: readonly string[];
@@ -340,15 +359,24 @@ export interface ServerStorage {
340
359
  * lazily as a defensive backstop.
341
360
  */
342
361
  ensureSchema(schema: CompiledSchema): Promise<void>;
362
+ /** Create or refresh the authenticated partition registry row (§2.1). */
363
+ touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
364
+ /** Rotate log continuity after restore and discard stale cursor records. */
365
+ rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
366
+ /** Registry entries ordered by partition for maintenance loops. */
367
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
368
+ /** Read continuity without refreshing authenticated activity. */
369
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
343
370
  begin(partition: string): Promise<StorageTransaction>;
344
371
  getMaxCommitSeq(partition: string): Promise<number>;
345
372
  getHorizonSeq(partition: string): Promise<number>;
373
+ /** Monotonic within the current epoch; use atomic pruning for maintenance. */
346
374
  setHorizonSeq(partition: string, seq: number): Promise<void>;
347
375
  /**
348
- * Deletes commits with `commitSeq <= seq` (log, changes, scope index).
349
- * Returns the number of commits removed (ops observability).
376
+ * Atomically verifies the log epoch, advances the horizon monotonically,
377
+ * and removes log/change/scope records through the effective horizon.
350
378
  */
351
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
379
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
352
380
  /** Newest commitSeq created strictly before the timestamp; 0 if none. */
353
381
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
354
382
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
@@ -397,7 +425,9 @@ export interface ServerStorage {
397
425
  scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
398
426
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
399
427
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
400
- /** Cursor records feeding the §4.6 retention watermark. */
428
+ /** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
429
+ getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
430
+ /** Cursor records for client listings and administrative counts. */
401
431
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
402
432
  /**
403
433
  * Blob reference index reads (§5.9.4) — ADDITIVE, optional (mirrors the
@@ -427,10 +457,8 @@ export interface ServerStorage {
427
457
  * change scope index (never a log scan).
428
458
  * `getRowScopes`: the (table, rowId) row's current server_version and
429
459
  * stored scopes without decoding its payload — the row inspector.
430
- * `listPartitions`: every partition this storage holds state for — the
431
- * union of the partition registry (commit log counters) and client
432
- * records, sorted. Powers the console's fleet view / partition picker;
433
- * deliberately NOT partition-scoped (the one cross-partition read).
460
+ * `listPartitions`: the partition-only compatibility view of
461
+ * `listPartitionRegistry`, sorted.
434
462
  */
435
463
  listClientRecords?(partition: string): Promise<ClientRecord[]>;
436
464
  listCommitMetadata?(partition: string, query: CommitMetadataQuery): Promise<CommitMetadata[]>;
@@ -439,7 +467,7 @@ export interface ServerStorage {
439
467
  serverVersion: number;
440
468
  scopes: Record<string, string>;
441
469
  } | undefined>;
442
- listPartitions?(): Promise<string[]>;
470
+ listPartitions(): Promise<string[]>;
443
471
  }
444
472
  /** A row referencing a blob, with the scopes needed to authorize download. */
445
473
  export interface BlobReferencingRow {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.47",
3
+ "version": "0.16.1",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.15.47"
71
+ "@syncular/core": "0.16.1"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/admin.ts CHANGED
@@ -396,12 +396,10 @@ export class SyncularAdmin {
396
396
  const nowMs = this.#clock();
397
397
  const maxCommitSeq = await this.#storage.getMaxCommitSeq(partition);
398
398
  const horizonSeq = await this.#storage.getHorizonSeq(partition);
399
- const cursors = await this.#storage.listClientCursors(partition);
400
- const activeCursors = cursors
401
- .filter((c) => c.updatedAtMs >= nowMs - this.#retention.activeWindowMs)
402
- .map((c) => c.cursor);
403
- const activeCursorFloor =
404
- activeCursors.length > 0 ? Math.min(...activeCursors) : null;
399
+ const activeCursorFloor = await this.#storage.getActiveClientCursorFloor(
400
+ partition,
401
+ nowMs - this.#retention.activeWindowMs,
402
+ );
405
403
  const cursorFloor = activeCursorFloor ?? Number.MAX_SAFE_INTEGER;
406
404
  const forcedSeq = await this.#storage.getCommitSeqBefore(
407
405
  partition,
@@ -520,17 +518,11 @@ export class SyncularAdmin {
520
518
  };
521
519
  }
522
520
 
523
- /**
524
- * Every partition the storage knows (commit log + client records) — the
525
- * fleet-view backing and the console's partition picker. Fails loud when
526
- * the backend omits the optional `listPartitions`.
527
- */
521
+ /** Every authenticated partition in the storage-backed registry. */
528
522
  async listPartitions(): Promise<string[]> {
529
- const list = required(
530
- this.#storage.listPartitions?.bind(this.#storage),
531
- 'storage',
523
+ return (await this.#storage.listPartitionRegistry()).map(
524
+ (entry) => entry.partition,
532
525
  );
533
- return list();
534
526
  }
535
527
 
536
528
  /**