@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,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
  *
@@ -62,6 +65,7 @@ import type {
62
65
  IndexRowScanQuery,
63
66
  NewCommit,
64
67
  NewReaction,
68
+ PartitionRegistryEntry,
65
69
  PrunedReactionCounts,
66
70
  ReactionClaimQuery,
67
71
  ReactionFailure,
@@ -389,7 +393,7 @@ export class SqliteServerStorage implements ServerStorage {
389
393
  #tables: ReadonlyMap<string, CompiledTable> | undefined;
390
394
  #schemaVersion: number | undefined;
391
395
 
392
- async #serializeReactionWrite<T>(operation: () => T): Promise<T> {
396
+ async #serializeWrite<T>(operation: () => T): Promise<T> {
393
397
  const previous = this.#transactionTail;
394
398
  let release!: () => void;
395
399
  this.#transactionTail = new Promise<void>((resolve) => {
@@ -409,6 +413,14 @@ export class SqliteServerStorage implements ServerStorage {
409
413
  }
410
414
  this.db = db;
411
415
  this.db.exec(SQLITE_DDL);
416
+ const clientColumns = this.db
417
+ .query<{ name: string }, []>('PRAGMA table_info("sync_clients")')
418
+ .all();
419
+ if (!clientColumns.some((column) => column.name === 'wire_version')) {
420
+ this.db.exec(
421
+ 'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
422
+ );
423
+ }
412
424
  }
413
425
 
414
426
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -504,6 +516,106 @@ export class SqliteServerStorage implements ServerStorage {
504
516
  this.#schemaVersion = schema.version;
505
517
  }
506
518
 
519
+ async touchPartition(
520
+ partition: string,
521
+ authenticatedAtMs: number,
522
+ initialLogEpoch: string,
523
+ ): Promise<PartitionRegistryEntry> {
524
+ if (initialLogEpoch.length === 0) {
525
+ throw new Error('initial log epoch must be non-empty');
526
+ }
527
+ this.db
528
+ .query(
529
+ `INSERT INTO sync_partition_registry(
530
+ partition, log_epoch, last_authenticated_at_ms
531
+ ) VALUES (?,?,?)
532
+ ON CONFLICT(partition) DO UPDATE SET
533
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
534
+ )
535
+ .run(partition, initialLogEpoch, authenticatedAtMs);
536
+ const row = this.db
537
+ .query<
538
+ {
539
+ log_epoch: string;
540
+ epoch_required: number;
541
+ last_authenticated_at_ms: number;
542
+ },
543
+ [string]
544
+ >(
545
+ `SELECT log_epoch, epoch_required, last_authenticated_at_ms
546
+ FROM sync_partition_registry WHERE partition=?`,
547
+ )
548
+ .get(partition);
549
+ if (row === null)
550
+ throw new Error('partition registry write did not persist');
551
+ return {
552
+ partition,
553
+ logEpoch: row.log_epoch,
554
+ epochRequired: row.epoch_required === 1,
555
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
556
+ };
557
+ }
558
+
559
+ async rotatePartitionLogEpoch(
560
+ partition: string,
561
+ logEpoch: string,
562
+ authenticatedAtMs: number,
563
+ ): Promise<PartitionRegistryEntry> {
564
+ if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
565
+ return this.#serializeWrite(() => {
566
+ this.db.exec('BEGIN IMMEDIATE');
567
+ try {
568
+ this.db
569
+ .query(
570
+ `INSERT INTO sync_partition_registry(
571
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
572
+ ) VALUES (?,?,1,?)
573
+ ON CONFLICT(partition) DO UPDATE SET
574
+ log_epoch=excluded.log_epoch,
575
+ epoch_required=1,
576
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
577
+ )
578
+ .run(partition, logEpoch, authenticatedAtMs);
579
+ this.db
580
+ .query('DELETE FROM sync_clients WHERE partition=?')
581
+ .run(partition);
582
+ this.db.exec('COMMIT');
583
+ } catch (error) {
584
+ this.db.exec('ROLLBACK');
585
+ throw error;
586
+ }
587
+ return {
588
+ partition,
589
+ logEpoch,
590
+ epochRequired: true,
591
+ lastAuthenticatedAtMs: authenticatedAtMs,
592
+ };
593
+ });
594
+ }
595
+
596
+ async listPartitionRegistry(): Promise<PartitionRegistryEntry[]> {
597
+ return this.db
598
+ .query<
599
+ {
600
+ partition: string;
601
+ log_epoch: string;
602
+ epoch_required: number;
603
+ last_authenticated_at_ms: number;
604
+ },
605
+ []
606
+ >(
607
+ `SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
608
+ FROM sync_partition_registry ORDER BY partition`,
609
+ )
610
+ .all()
611
+ .map((row) => ({
612
+ partition: row.partition,
613
+ logEpoch: row.log_epoch,
614
+ epochRequired: row.epoch_required === 1,
615
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
616
+ }));
617
+ }
618
+
507
619
  /**
508
620
  * Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
509
621
  * given every payload re-encodes under
@@ -616,7 +728,7 @@ export class SqliteServerStorage implements ServerStorage {
616
728
  }
617
729
  const prepared = bindAuthoritativePartition(
618
730
  prepareAuthoritativeQuery(
619
- query.sql,
731
+ query.plan,
620
732
  query.params,
621
733
  query.tables,
622
734
  this.#tables,
@@ -654,6 +766,14 @@ export class SqliteServerStorage implements ServerStorage {
654
766
  }
655
767
  }
656
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
+
657
777
  async getHorizonSeq(partition: string): Promise<number> {
658
778
  const row = this.db
659
779
  .query<{ horizon_seq: number }, [string]>(
@@ -664,27 +784,62 @@ export class SqliteServerStorage implements ServerStorage {
664
784
  }
665
785
 
666
786
  async setHorizonSeq(partition: string, seq: number): Promise<void> {
667
- this.db
668
- .query('INSERT OR IGNORE INTO sync_partitions(partition) VALUES (?)')
669
- .run(partition);
670
- this.db
671
- .query('UPDATE sync_partitions SET horizon_seq=? WHERE partition=?')
672
- .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
+ });
673
793
  }
674
794
 
675
- async pruneCommitsThrough(partition: string, seq: number): Promise<number> {
676
- const removed = this.db
677
- .query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
678
- .run(partition, seq);
679
- this.db
680
- .query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
681
- .run(partition, seq);
682
- this.db
683
- .query(
684
- 'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
685
- )
686
- .run(partition, seq);
687
- 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
+ });
688
843
  }
689
844
 
690
845
  async getCommitSeqBefore(
@@ -738,7 +893,7 @@ export class SqliteServerStorage implements ServerStorage {
738
893
  query: ReactionClaimQuery,
739
894
  ): Promise<StoredReaction[]> {
740
895
  if (query.types.length === 0 || query.limit <= 0) return [];
741
- return this.#serializeReactionWrite(() => {
896
+ return this.#serializeWrite(() => {
742
897
  const typeParams = query.types.map(() => '?').join(',');
743
898
  const records = this.db
744
899
  .query<SqliteReactionRecord, (string | number)[]>(
@@ -786,7 +941,7 @@ export class SqliteServerStorage implements ServerStorage {
786
941
  leaseOwner: string,
787
942
  completedAtMs: number,
788
943
  ): Promise<boolean> {
789
- return this.#serializeReactionWrite(() => {
944
+ return this.#serializeWrite(() => {
790
945
  const result = this.db
791
946
  .query(
792
947
  `UPDATE sync_reactions
@@ -806,7 +961,7 @@ export class SqliteServerStorage implements ServerStorage {
806
961
  leaseOwner: string,
807
962
  leaseExpiresAtMs: number,
808
963
  ): Promise<boolean> {
809
- return this.#serializeReactionWrite(() => {
964
+ return this.#serializeWrite(() => {
810
965
  const result = this.db
811
966
  .query(
812
967
  `UPDATE sync_reactions SET lease_expires_at_ms=?
@@ -824,7 +979,7 @@ export class SqliteServerStorage implements ServerStorage {
824
979
  update: ReactionFailureUpdate,
825
980
  ): Promise<boolean> {
826
981
  const retry = update.retryAtMs !== undefined;
827
- return this.#serializeReactionWrite(() => {
982
+ return this.#serializeWrite(() => {
828
983
  const result = this.db
829
984
  .query(
830
985
  `UPDATE sync_reactions
@@ -850,7 +1005,7 @@ export class SqliteServerStorage implements ServerStorage {
850
1005
  idempotencyKey: string,
851
1006
  nowMs: number,
852
1007
  ): Promise<boolean> {
853
- return this.#serializeReactionWrite(() => {
1008
+ return this.#serializeWrite(() => {
854
1009
  const result = this.db
855
1010
  .query(
856
1011
  `UPDATE sync_reactions
@@ -905,7 +1060,7 @@ export class SqliteServerStorage implements ServerStorage {
905
1060
  query: ReactionPruneQuery,
906
1061
  ): Promise<PrunedReactionCounts> {
907
1062
  if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
908
- return this.#serializeReactionWrite(() => {
1063
+ return this.#serializeWrite(() => {
909
1064
  const records = this.db
910
1065
  .query<
911
1066
  { status: 'completed' | 'dead-letter' },
@@ -1077,19 +1232,21 @@ export class SqliteServerStorage implements ServerStorage {
1077
1232
  {
1078
1233
  client_id: string;
1079
1234
  actor_id: string;
1235
+ wire_version: number;
1080
1236
  cursor: number;
1081
1237
  subscriptions: string;
1082
1238
  updated_at_ms: number;
1083
1239
  },
1084
1240
  [string, string]
1085
1241
  >(
1086
- 'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1242
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1087
1243
  )
1088
1244
  .get(partition, clientId);
1089
1245
  if (record === null) return undefined;
1090
1246
  return {
1091
1247
  clientId: record.client_id,
1092
1248
  actorId: record.actor_id,
1249
+ wireVersion: record.wire_version,
1093
1250
  cursor: record.cursor,
1094
1251
  updatedAtMs: record.updated_at_ms,
1095
1252
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
@@ -1102,18 +1259,31 @@ export class SqliteServerStorage implements ServerStorage {
1102
1259
  ): Promise<void> {
1103
1260
  this.db
1104
1261
  .query(
1105
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?)',
1262
+ 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
1106
1263
  )
1107
1264
  .run(
1108
1265
  partition,
1109
1266
  record.clientId,
1110
1267
  record.actorId,
1268
+ record.wireVersion,
1111
1269
  record.cursor,
1112
1270
  JSON.stringify(record.subscriptions),
1113
1271
  record.updatedAtMs,
1114
1272
  );
1115
1273
  }
1116
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
+
1117
1287
  async listClientCursors(partition: string): Promise<ClientCursorInfo[]> {
1118
1288
  const records = this.db
1119
1289
  .query<
@@ -1187,18 +1357,20 @@ export class SqliteServerStorage implements ServerStorage {
1187
1357
  {
1188
1358
  client_id: string;
1189
1359
  actor_id: string;
1360
+ wire_version: number;
1190
1361
  cursor: number;
1191
1362
  subscriptions: string;
1192
1363
  updated_at_ms: number;
1193
1364
  },
1194
1365
  [string]
1195
1366
  >(
1196
- 'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
1367
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
1197
1368
  )
1198
1369
  .all(partition);
1199
1370
  return records.map((record) => ({
1200
1371
  clientId: record.client_id,
1201
1372
  actorId: record.actor_id,
1373
+ wireVersion: record.wire_version,
1202
1374
  cursor: record.cursor,
1203
1375
  updatedAtMs: record.updated_at_ms,
1204
1376
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
@@ -1324,14 +1496,6 @@ export class SqliteServerStorage implements ServerStorage {
1324
1496
  }
1325
1497
 
1326
1498
  async listPartitions(): Promise<string[]> {
1327
- // Union: the registry row appears on first commit, the client row on
1328
- // first pull — a partition with only one of the two still shows up.
1329
- const rows = this.db
1330
- .query<{ partition: string }, []>(
1331
- `SELECT partition FROM sync_partitions
1332
- UNION SELECT partition FROM sync_clients ORDER BY partition`,
1333
- )
1334
- .all();
1335
- return rows.map((r) => r.partition);
1499
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1336
1500
  }
1337
1501
  }
@@ -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;
@@ -164,6 +176,8 @@ export interface ClientSubscription {
164
176
  export interface ClientRecord {
165
177
  readonly clientId: string;
166
178
  readonly actorId: string;
179
+ /** SSP2 version last accepted from this client; selects realtime deltas. */
180
+ readonly wireVersion: number;
167
181
  /** Minimum `nextCursor` across the last pull's active subscriptions. */
168
182
  readonly cursor: number;
169
183
  readonly updatedAtMs: number;
@@ -220,6 +234,14 @@ export interface ClientCursorInfo {
220
234
  readonly updatedAtMs: number;
221
235
  }
222
236
 
237
+ /** Durable partition identity refreshed after host authentication (§2.1). */
238
+ export interface PartitionRegistryEntry {
239
+ readonly partition: string;
240
+ readonly logEpoch: string;
241
+ readonly epochRequired: boolean;
242
+ readonly lastAuthenticatedAtMs: number;
243
+ }
244
+
223
245
  /**
224
246
  * Commit-log metadata (no change payloads) for the admin/console read
225
247
  * surface. `changeCount` is the number of changes the commit carries;
@@ -271,7 +293,7 @@ export type AuthoritativeQueryValue =
271
293
 
272
294
  export interface AuthoritativeQueryRequest {
273
295
  /** Generated, positional SQLite-family SQL. It never comes from the request. */
274
- readonly sql: string;
296
+ readonly plan: AuthoritativeRelationPlan;
275
297
  readonly params: readonly AuthoritativeQueryValue[];
276
298
  /** Generated dependency set, used to validate and partition every relation. */
277
299
  readonly tables: readonly string[];
@@ -397,16 +419,38 @@ export interface ServerStorage {
397
419
  */
398
420
  ensureSchema(schema: CompiledSchema): Promise<void>;
399
421
 
422
+ /** Create or refresh the authenticated partition registry row (§2.1). */
423
+ touchPartition(
424
+ partition: string,
425
+ authenticatedAtMs: number,
426
+ initialLogEpoch: string,
427
+ ): Promise<PartitionRegistryEntry>;
428
+ /** Rotate log continuity after restore and discard stale cursor records. */
429
+ rotatePartitionLogEpoch(
430
+ partition: string,
431
+ logEpoch: string,
432
+ authenticatedAtMs: number,
433
+ ): Promise<PartitionRegistryEntry>;
434
+ /** Registry entries ordered by partition for maintenance loops. */
435
+ listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
436
+
437
+ /** Read continuity without refreshing authenticated activity. */
438
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
439
+
400
440
  begin(partition: string): Promise<StorageTransaction>;
401
441
 
402
442
  getMaxCommitSeq(partition: string): Promise<number>;
403
443
  getHorizonSeq(partition: string): Promise<number>;
444
+ /** Monotonic within the current epoch; use atomic pruning for maintenance. */
404
445
  setHorizonSeq(partition: string, seq: number): Promise<void>;
405
446
  /**
406
- * Deletes commits with `commitSeq <= seq` (log, changes, scope index).
407
- * 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.
408
449
  */
409
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
450
+ pruneCommitsThrough(
451
+ partition: string,
452
+ query: CommitPruneQuery,
453
+ ): Promise<CommitPruneResult>;
410
454
  /** Newest commitSeq created strictly before the timestamp; 0 if none. */
411
455
  getCommitSeqBefore(
412
456
  partition: string,
@@ -516,7 +560,12 @@ export interface ServerStorage {
516
560
  clientId: string,
517
561
  ): Promise<ClientRecord | undefined>;
518
562
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
519
- /** 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. */
520
569
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
521
570
 
522
571
  /**
@@ -551,10 +600,8 @@ export interface ServerStorage {
551
600
  * change scope index (never a log scan).
552
601
  * `getRowScopes`: the (table, rowId) row's current server_version and
553
602
  * stored scopes without decoding its payload — the row inspector.
554
- * `listPartitions`: every partition this storage holds state for — the
555
- * union of the partition registry (commit log counters) and client
556
- * records, sorted. Powers the console's fleet view / partition picker;
557
- * deliberately NOT partition-scoped (the one cross-partition read).
603
+ * `listPartitions`: the partition-only compatibility view of
604
+ * `listPartitionRegistry`, sorted.
558
605
  */
559
606
  listClientRecords?(partition: string): Promise<ClientRecord[]>;
560
607
  listCommitMetadata?(
@@ -572,7 +619,7 @@ export interface ServerStorage {
572
619
  ): Promise<
573
620
  { serverVersion: number; scopes: Record<string, string> } | undefined
574
621
  >;
575
- listPartitions?(): Promise<string[]>;
622
+ listPartitions(): Promise<string[]>;
576
623
  }
577
624
 
578
625
  /** A row referencing a blob, with the scopes needed to authorize download. */