@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/dist/prune.js CHANGED
@@ -7,6 +7,16 @@
7
7
  * least the newest `minRetainedCommits` commits are always retained.
8
8
  */
9
9
  import { emitEvent } from './events.js';
10
+ import { StorageQueryError } from './storage-errors.js';
11
+ /** Shared validation for the built-in atomic pruning adapters. */
12
+ export function validateCommitPruneQuery(query) {
13
+ if (!Number.isSafeInteger(query.throughSeq) ||
14
+ query.throughSeq < 0 ||
15
+ typeof query.logEpoch !== 'string' ||
16
+ query.logEpoch.length === 0) {
17
+ throw new StorageQueryError('sync.storage.invalid_prune_cursor');
18
+ }
19
+ }
10
20
  export const DEFAULT_RETENTION = {
11
21
  activeWindowMs: 14 * 24 * 60 * 60 * 1000,
12
22
  ageForceMs: 30 * 24 * 60 * 60 * 1000,
@@ -16,24 +26,18 @@ export const DEFAULT_RETENTION = {
16
26
  export async function pruneCommitLog(options) {
17
27
  const { storage, partition, nowMs } = options;
18
28
  const policy = { ...DEFAULT_RETENTION, ...options.retention };
29
+ const logEpoch = await storage.getPartitionLogEpoch(partition);
30
+ if (logEpoch === undefined)
31
+ throw new StorageQueryError('sync.storage.partition_unregistered');
19
32
  const maxSeq = await storage.getMaxCommitSeq(partition);
20
- const cursors = await storage.listClientCursors(partition);
21
- const activeCursors = cursors
22
- .filter((c) => c.updatedAtMs >= nowMs - policy.activeWindowMs)
23
- .map((c) => c.cursor);
24
- const cursorFloor = activeCursors.length > 0
25
- ? Math.min(...activeCursors)
26
- : Number.MAX_SAFE_INTEGER;
33
+ const cursorFloor = (await storage.getActiveClientCursorFloor(partition, nowMs - policy.activeWindowMs)) ?? Number.MAX_SAFE_INTEGER;
27
34
  const forcedSeq = await storage.getCommitSeqBefore(partition, nowMs - policy.ageForceMs);
28
35
  const retainFloor = maxSeq - policy.minRetainedCommits;
29
36
  const target = Math.min(Math.max(cursorFloor, forcedSeq), retainFloor);
30
- const current = await storage.getHorizonSeq(partition);
31
- const horizon = Math.max(current, Math.max(0, target));
32
- let removedCommits = 0;
33
- if (horizon > current) {
34
- await storage.setHorizonSeq(partition, horizon);
35
- removedCommits = await storage.pruneCommitsThrough(partition, horizon);
36
- }
37
+ const { previousHorizonSeq: current, horizonSeq: horizon, removedCommits, } = await storage.pruneCommitsThrough(partition, {
38
+ logEpoch,
39
+ throughSeq: Math.max(0, target),
40
+ });
37
41
  const events = options.events;
38
42
  if (events !== undefined) {
39
43
  emitEvent(events, {
package/dist/pull.js CHANGED
@@ -6,6 +6,8 @@ import { decodeRow, encodeRowsSegment, } from '@syncular/core';
6
6
  import { clockOf, limitsOf } from './context.js';
7
7
  import { scopeDigest } from './scopes.js';
8
8
  import { issueSegmentUrl } from './signed-url.js';
9
+ // One artifact build per owning storage pair and complete immutable identity.
10
+ const imageBuilds = new WeakMap();
9
11
  /**
10
12
  * Resolve the §5.3 image builder: the host-injected one if present, else the
11
13
  * in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
@@ -177,33 +179,17 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
177
179
  const buildImage = await resolveImageBuilder(ctx);
178
180
  if (buildImage === undefined)
179
181
  return false;
180
- // The probe rows are the snapshot's first page — keep them and scan on
181
- // from the probe's cursor instead of re-reading the whole prefix (the
182
- // scan is keyset-ordered by rowId, so the concatenation is exactly the
183
- // rows a single full scan would return).
184
- const rows = [...probe];
185
- let afterRowId = probe[probe.length - 1]?.rowId ?? null;
186
- for (;;) {
187
- const scanned = await storage.scanRows(partition, {
188
- table: plan.table.name,
189
- scopeFilter: plan.effective,
190
- afterRowId,
191
- limit: 50_000,
192
- });
193
- rows.push(...scanned);
194
- const last = scanned[scanned.length - 1];
195
- if (scanned.length < 50_000 || last === undefined)
196
- break;
197
- afterRowId = last.rowId;
182
+ let stores = imageBuilds.get(storage);
183
+ if (stores === undefined) {
184
+ stores = new WeakMap();
185
+ imageBuilds.set(storage, stores);
198
186
  }
199
- const bytes = buildImage({
200
- table: plan.table,
201
- schemaVersion: schema.version,
202
- asOfCommitSeq: asOf,
203
- scopeDigest: digest,
204
- rows,
205
- });
206
- const record = await segments.put({
187
+ let builds = stores.get(segments);
188
+ if (builds === undefined) {
189
+ builds = new Map();
190
+ stores.set(segments, builds);
191
+ }
192
+ const identity = {
207
193
  partition,
208
194
  logEpoch,
209
195
  table: plan.table.name,
@@ -211,18 +197,63 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
211
197
  mediaType: 'sqlite',
212
198
  scopeDigest: digest,
213
199
  asOfCommitSeq: asOf,
214
- rowCount: rows.length,
215
- rowCursor: null,
216
- nextRowCursor: null,
217
- }, bytes, now);
200
+ };
201
+ const key = JSON.stringify(identity);
202
+ let building = builds.get(key);
203
+ let builtHere = false;
204
+ if (building === undefined) {
205
+ building = (async () => {
206
+ // Another request can finish while this one's eligibility probe awaits.
207
+ const cached = await segments.find(identity, clockOf(ctx)());
208
+ if (cached !== undefined)
209
+ return cached;
210
+ builtHere = true;
211
+ let rowCount = 0;
212
+ const bytes = await buildImage({
213
+ table: plan.table,
214
+ schemaVersion: schema.version,
215
+ asOfCommitSeq: asOf,
216
+ scopeDigest: digest,
217
+ rowBatches: (async function* () {
218
+ rowCount += probe.length;
219
+ yield probe;
220
+ let afterRowId = probe[probe.length - 1].rowId;
221
+ for (;;) {
222
+ const rows = await storage.scanRows(partition, {
223
+ table: plan.table.name,
224
+ scopeFilter: plan.effective,
225
+ afterRowId,
226
+ limit: 5_000,
227
+ });
228
+ rowCount += rows.length;
229
+ yield rows;
230
+ const last = rows[rows.length - 1];
231
+ if (rows.length < 5_000 || last === undefined)
232
+ break;
233
+ afterRowId = last.rowId;
234
+ }
235
+ })(),
236
+ });
237
+ return segments.put({ ...identity, rowCount, rowCursor: null, nextRowCursor: null }, bytes, clockOf(ctx)());
238
+ })();
239
+ builds.set(key, building);
240
+ }
241
+ let record;
242
+ try {
243
+ record = await building;
244
+ }
245
+ finally {
246
+ if (builds.get(key) === building)
247
+ builds.delete(key);
248
+ }
218
249
  trace?.segments.push({
219
250
  mediaType: 'sqlite',
220
251
  delivery: 'ref',
221
- origin: 'built',
252
+ origin: builtHere ? 'built' : 'reused',
222
253
  bytes: record.byteLength,
223
254
  rows: record.rowCount,
224
255
  });
225
- yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
256
+ yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
226
257
  return true;
227
258
  }
228
259
  async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
package/dist/push.js CHANGED
@@ -98,7 +98,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
98
98
  // §6.7: a non-ValidationRejection throw is still a rejection, mapped to
99
99
  // the generic server-side constraint code (§10.2) — the validator's
100
100
  // failure never crashes the request or leaks its message as a code.
101
- return errorRecord(opIndex, 'sync.constraint_violation', `write validator for table ${JSON.stringify(table.name)} threw: ${error instanceof Error ? error.message : String(error)}`);
101
+ return errorRecord(opIndex, 'sync.constraint_violation', 'write validator failed');
102
102
  }
103
103
  return undefined;
104
104
  }
@@ -124,7 +124,7 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
124
124
  continue; // NULL clear or absent
125
125
  const merger = mergers?.[crdtType];
126
126
  if (merger === undefined) {
127
- return errorRecord(opIndex, 'sync.crdt_merge_failed', `no CRDT merger registered for crdtType ${JSON.stringify(crdtType)} (§5.10.2)`);
127
+ return errorRecord(opIndex, 'sync.crdt_merge_failed', 'no CRDT merger registered');
128
128
  }
129
129
  const storedRaw = storedValues?.[index];
130
130
  const stored = storedRaw instanceof Uint8Array ? storedRaw : null;
@@ -132,8 +132,8 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
132
132
  try {
133
133
  merged = await merger(stored, incoming);
134
134
  }
135
- catch (error) {
136
- return errorRecord(opIndex, 'sync.crdt_merge_failed', `CRDT merger for ${JSON.stringify(crdtType)} threw: ${error instanceof Error ? error.message : String(error)}`);
135
+ catch {
136
+ return errorRecord(opIndex, 'sync.crdt_merge_failed', 'CRDT merger failed');
137
137
  }
138
138
  values[index] = merged;
139
139
  changed = true;
@@ -452,7 +452,7 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
452
452
  if (error instanceof ValidationRejection) {
453
453
  return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
454
454
  }
455
- return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', `whole-commit validator threw: ${error instanceof Error ? error.message : String(error)}`);
455
+ return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', 'whole-commit validator failed');
456
456
  }
457
457
  return undefined;
458
458
  }
@@ -27,10 +27,10 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
27
27
  super(database(value), options);
28
28
  }
29
29
  }
30
- export const buildSqliteImage = (input) => {
30
+ export const buildSqliteImage = async (input) => {
31
31
  const db = new BunSqliteDatabase();
32
32
  try {
33
- writeSqliteImage(db, input);
33
+ await writeSqliteImage(db, input);
34
34
  return db.serialize();
35
35
  }
36
36
  finally {
@@ -10,7 +10,7 @@ export interface SqliteImageInput {
10
10
  readonly schemaVersion: number;
11
11
  readonly asOfCommitSeq: number;
12
12
  readonly scopeDigest: string;
13
- readonly rows: readonly StoredRow[];
13
+ readonly rowBatches: AsyncIterable<readonly StoredRow[]> | Iterable<readonly StoredRow[]>;
14
14
  }
15
15
  /**
16
16
  * The §5.3 image-builder capability, injected through
@@ -20,6 +20,6 @@ export interface SqliteImageInput {
20
20
  * on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
21
21
  * host omits it and serves the rows lane.
22
22
  */
23
- export type SqliteImageBuilder = (input: SqliteImageInput) => Uint8Array;
23
+ export type SqliteImageBuilder = (input: SqliteImageInput) => Promise<Uint8Array>;
24
24
  /** Populate a §5.3 image database for a whole-table snapshot. */
25
- export declare function writeSqliteImage(db: SqliteDatabase, input: SqliteImageInput): void;
25
+ export declare function writeSqliteImage(db: SqliteDatabase, input: SqliteImageInput): Promise<void>;
@@ -45,8 +45,8 @@ function toSql(value) {
45
45
  return value;
46
46
  }
47
47
  /** Populate a §5.3 image database for a whole-table snapshot. */
48
- export function writeSqliteImage(db, input) {
49
- const { table, rows } = input;
48
+ export async function writeSqliteImage(db, input) {
49
+ const { table, rowBatches } = input;
50
50
  const primaryKey = table.columns[table.primaryKeyIndex]?.name;
51
51
  const columnDefs = table.columns.map((column) => {
52
52
  const notNull = column.nullable ? '' : ' NOT NULL';
@@ -59,7 +59,6 @@ export function writeSqliteImage(db, input) {
59
59
  format INTEGER NOT NULL, "table" TEXT NOT NULL,
60
60
  "schemaVersion" INTEGER NOT NULL, "asOfCommitSeq" INTEGER NOT NULL,
61
61
  "scopeDigest" TEXT NOT NULL, "rowCount" INTEGER NOT NULL)`);
62
- db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(table.name, input.schemaVersion, input.asOfCommitSeq, input.scopeDigest, rows.length);
63
62
  const names = [
64
63
  ...table.columns.map((column) => quoteIdent(column.name)),
65
64
  quoteIdent(IMAGE_VERSION_COLUMN),
@@ -68,10 +67,15 @@ export function writeSqliteImage(db, input) {
68
67
  VALUES (${names.map(() => '?').join(', ')})`);
69
68
  db.exec('BEGIN');
70
69
  try {
71
- for (const row of rows) {
72
- const values = decodeRow(table.columns, row.payload);
73
- insert.run(...values.map(toSql), row.serverVersion);
70
+ let rowCount = 0;
71
+ for await (const rows of rowBatches) {
72
+ for (const row of rows) {
73
+ const values = decodeRow(table.columns, row.payload);
74
+ insert.run(...values.map(toSql), row.serverVersion);
75
+ }
76
+ rowCount += rows.length;
74
77
  }
78
+ db.query(`INSERT INTO ${IMAGE_METADATA_TABLE} VALUES (1, ?, ?, ?, ?, ?)`).run(table.name, input.schemaVersion, input.asOfCommitSeq, input.scopeDigest, rowCount);
75
79
  db.exec('COMMIT');
76
80
  }
77
81
  catch (error) {
@@ -30,13 +30,13 @@ export class SqliteLeaseStore extends SharedSqliteLeaseStore {
30
30
  super(database(value), options);
31
31
  }
32
32
  }
33
- export const buildSqliteImage = (input) => {
33
+ export const buildSqliteImage = async (input) => {
34
34
  const directory = mkdtempSync(join(tmpdir(), 'syncular-server-image-'));
35
35
  const path = join(directory, 'segment.db');
36
36
  const db = new NodeSqliteDatabase(path);
37
37
  try {
38
38
  try {
39
- writeSqliteImage(db, input);
39
+ await writeSqliteImage(db, input);
40
40
  }
41
41
  finally {
42
42
  db.close();
@@ -1,3 +1,4 @@
1
+ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
1
2
  import type { CompiledSchema, CompiledTable } from './schema.js';
2
3
  import { type SqliteDatabase } from './sqlite-driver.js';
3
4
  import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
@@ -16,9 +17,10 @@ export declare class SqliteServerStorage implements ServerStorage {
16
17
  writeRow(partition: string, table: string, row: StoredRow): void;
17
18
  getMaxCommitSeq(partition: string): Promise<number>;
18
19
  queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
20
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
19
21
  getHorizonSeq(partition: string): Promise<number>;
20
22
  setHorizonSeq(partition: string, seq: number): Promise<void>;
21
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
23
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
22
24
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
23
25
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
24
26
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
@@ -35,6 +37,7 @@ export declare class SqliteServerStorage implements ServerStorage {
35
37
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
36
38
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
37
39
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
40
+ getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
38
41
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
39
42
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{
40
43
  readonly table: string;
@@ -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) => {
@@ -327,7 +329,7 @@ export class SqliteServerStorage {
327
329
  async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
328
330
  if (logEpoch.length === 0)
329
331
  throw new Error('log epoch must be non-empty');
330
- return this.#serializeReactionWrite(() => {
332
+ return this.#serializeWrite(() => {
331
333
  this.db.exec('BEGIN IMMEDIATE');
332
334
  try {
333
335
  this.db
@@ -443,7 +445,7 @@ export class SqliteServerStorage {
443
445
  if (this.#tables === undefined) {
444
446
  throw new Error('ensureSchema(schema) must run before registered queries');
445
447
  }
446
- 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);
447
449
  const previous = this.#transactionTail;
448
450
  let release;
449
451
  this.#transactionTail = new Promise((resolve) => {
@@ -473,6 +475,11 @@ export class SqliteServerStorage {
473
475
  release();
474
476
  }
475
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
+ }
476
483
  async getHorizonSeq(partition) {
477
484
  const row = this.db
478
485
  .query('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -480,24 +487,52 @@ export class SqliteServerStorage {
480
487
  return row?.horizon_seq ?? 0;
481
488
  }
482
489
  async setHorizonSeq(partition, seq) {
483
- this.db
484
- .query('INSERT OR IGNORE INTO sync_partitions(partition) VALUES (?)')
485
- .run(partition);
486
- this.db
487
- .query('UPDATE sync_partitions SET horizon_seq=? WHERE partition=?')
488
- .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
+ });
489
496
  }
490
- async pruneCommitsThrough(partition, seq) {
491
- const removed = this.db
492
- .query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
493
- .run(partition, seq);
494
- this.db
495
- .query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
496
- .run(partition, seq);
497
- this.db
498
- .query('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
499
- .run(partition, seq);
500
- 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
+ });
501
536
  }
502
537
  async getCommitSeqBefore(partition, createdBeforeMs) {
503
538
  const row = this.db
@@ -527,7 +562,7 @@ export class SqliteServerStorage {
527
562
  async claimReactions(partition, query) {
528
563
  if (query.types.length === 0 || query.limit <= 0)
529
564
  return [];
530
- return this.#serializeReactionWrite(() => {
565
+ return this.#serializeWrite(() => {
531
566
  const typeParams = query.types.map(() => '?').join(',');
532
567
  const records = this.db
533
568
  .query(`UPDATE sync_reactions
@@ -553,7 +588,7 @@ export class SqliteServerStorage {
553
588
  });
554
589
  }
555
590
  async completeReaction(partition, idempotencyKey, leaseOwner, completedAtMs) {
556
- return this.#serializeReactionWrite(() => {
591
+ return this.#serializeWrite(() => {
557
592
  const result = this.db
558
593
  .query(`UPDATE sync_reactions
559
594
  SET status='completed', completed_at_ms=?,
@@ -565,7 +600,7 @@ export class SqliteServerStorage {
565
600
  });
566
601
  }
567
602
  async extendReactionLease(partition, idempotencyKey, leaseOwner, leaseExpiresAtMs) {
568
- return this.#serializeReactionWrite(() => {
603
+ return this.#serializeWrite(() => {
569
604
  const result = this.db
570
605
  .query(`UPDATE sync_reactions SET lease_expires_at_ms=?
571
606
  WHERE partition=? AND idempotency_key=?
@@ -576,7 +611,7 @@ export class SqliteServerStorage {
576
611
  }
577
612
  async failReaction(partition, idempotencyKey, update) {
578
613
  const retry = update.retryAtMs !== undefined;
579
- return this.#serializeReactionWrite(() => {
614
+ return this.#serializeWrite(() => {
580
615
  const result = this.db
581
616
  .query(`UPDATE sync_reactions
582
617
  SET status=?, available_at_ms=?, last_failure=?,
@@ -588,7 +623,7 @@ export class SqliteServerStorage {
588
623
  });
589
624
  }
590
625
  async retryReaction(partition, idempotencyKey, nowMs) {
591
- return this.#serializeReactionWrite(() => {
626
+ return this.#serializeWrite(() => {
592
627
  const result = this.db
593
628
  .query(`UPDATE sync_reactions
594
629
  SET status='pending', attempts=0, available_at_ms=?,
@@ -626,7 +661,7 @@ export class SqliteServerStorage {
626
661
  async pruneReactions(partition, query) {
627
662
  if (query.limit <= 0)
628
663
  return { completed: 0, deadLetter: 0 };
629
- return this.#serializeReactionWrite(() => {
664
+ return this.#serializeWrite(() => {
630
665
  const records = this.db
631
666
  .query(`DELETE FROM sync_reactions
632
667
  WHERE partition=? AND idempotency_key IN (
@@ -751,6 +786,12 @@ export class SqliteServerStorage {
751
786
  .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
752
787
  .run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
753
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;
794
+ }
754
795
  async listClientCursors(partition) {
755
796
  const records = this.db
756
797
  .query('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=?')
@@ -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;
@@ -244,7 +254,7 @@ export interface ScopeActivityQuery {
244
254
  export type AuthoritativeQueryValue = string | number | bigint | boolean | Uint8Array | null;
245
255
  export interface AuthoritativeQueryRequest {
246
256
  /** Generated, positional SQLite-family SQL. It never comes from the request. */
247
- readonly sql: string;
257
+ readonly plan: AuthoritativeRelationPlan;
248
258
  readonly params: readonly AuthoritativeQueryValue[];
249
259
  /** Generated dependency set, used to validate and partition every relation. */
250
260
  readonly tables: readonly string[];
@@ -355,15 +365,18 @@ export interface ServerStorage {
355
365
  rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
356
366
  /** Registry entries ordered by partition for maintenance loops. */
357
367
  listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
368
+ /** Read continuity without refreshing authenticated activity. */
369
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
358
370
  begin(partition: string): Promise<StorageTransaction>;
359
371
  getMaxCommitSeq(partition: string): Promise<number>;
360
372
  getHorizonSeq(partition: string): Promise<number>;
373
+ /** Monotonic within the current epoch; use atomic pruning for maintenance. */
361
374
  setHorizonSeq(partition: string, seq: number): Promise<void>;
362
375
  /**
363
- * Deletes commits with `commitSeq <= seq` (log, changes, scope index).
364
- * 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.
365
378
  */
366
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
379
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
367
380
  /** Newest commitSeq created strictly before the timestamp; 0 if none. */
368
381
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
369
382
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
@@ -412,7 +425,9 @@ export interface ServerStorage {
412
425
  scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
413
426
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
414
427
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
415
- /** 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. */
416
431
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
417
432
  /**
418
433
  * Blob reference index reads (§5.9.4) — ADDITIVE, optional (mirrors the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.15.48",
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.48"
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,