@syncular/server 0.16.1 → 0.17.0

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.
@@ -110,6 +110,11 @@ CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
110
110
  ON sync_blob_refs(partition, blob_id);
111
111
  `;
112
112
  async function lockPartitionOn(client, partition) {
113
+ const locked = await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
114
+ if (locked.rows.length > 0)
115
+ return;
116
+ // The first writer initializes the partition. Concurrent initializers may
117
+ // wait on this insert, so acquire the row lock again before applying writes.
113
118
  await client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
114
119
  ON CONFLICT (partition) DO NOTHING`, [partition]);
115
120
  await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
@@ -438,26 +443,37 @@ class PostgresTransaction {
438
443
  // Allocate the next dense commitSeq under a per-partition row lock: the
439
444
  // UPDATE … RETURNING serializes concurrent pushes to this partition and
440
445
  // never leaves a gap on rollback (see the file header).
441
- const { rows } = await q.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
442
- ON CONFLICT (partition) DO UPDATE
443
- SET max_commit_seq = sync_partitions.max_commit_seq + 1
444
- RETURNING max_commit_seq`, [p]);
445
- const commitSeq = asNumber(rows[0]?.max_commit_seq);
446
- await q.query(`INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
447
- VALUES ($1,$2,$3,$4,$5,$6)`, [
446
+ const { rows } = await q.query(`WITH allocated AS (
447
+ INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
448
+ ON CONFLICT (partition) DO UPDATE
449
+ SET max_commit_seq = sync_partitions.max_commit_seq + 1
450
+ RETURNING max_commit_seq
451
+ )
452
+ INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
453
+ SELECT $1, max_commit_seq, $2, $3, $4, $5 FROM allocated
454
+ RETURNING commit_seq`, [
448
455
  p,
449
- commitSeq,
450
456
  commit.clientId,
451
457
  commit.clientCommitId,
452
458
  commit.actorId,
453
459
  commit.createdAtMs,
454
460
  ]);
461
+ const commitSeq = asNumber(rows[0]?.commit_seq);
455
462
  for (let idx = 0; idx < commit.changes.length; idx++) {
456
463
  const change = commit.changes[idx];
457
464
  if (change === undefined)
458
465
  continue;
459
- await q.query(`INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
460
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [
466
+ // Bind serialized scopes as text before parsing JSONB. Drivers that
467
+ // encode JSONB parameters would otherwise store this string as a scalar.
468
+ await q.query(`WITH inserted AS (
469
+ INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
470
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::text::jsonb,$9)
471
+ RETURNING partition, tbl, commit_seq, scopes
472
+ )
473
+ INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
474
+ SELECT inserted.partition, inserted.tbl, scope.key, scope.value, inserted.commit_seq
475
+ FROM inserted CROSS JOIN LATERAL jsonb_each_text(inserted.scopes) AS scope
476
+ ON CONFLICT DO NOTHING`, [
461
477
  p,
462
478
  commitSeq,
463
479
  idx,
@@ -468,10 +484,6 @@ class PostgresTransaction {
468
484
  JSON.stringify(change.scopes),
469
485
  change.payload ?? null,
470
486
  ]);
471
- for (const [variable, value] of Object.entries(change.scopes)) {
472
- await q.query(`INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
473
- VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, [p, change.table, variable, value, commitSeq]);
474
- }
475
487
  }
476
488
  return commitSeq;
477
489
  }
@@ -622,6 +634,9 @@ export class PostgresServerStorage {
622
634
  await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
623
635
  tableName,
624
636
  ]);
637
+ await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
638
+ tableName,
639
+ ]);
625
640
  await client.query(dropTableDdl(tableName));
626
641
  }
627
642
  for (const statement of schemaDdl(schema, existing, 'postgres', existingIndexes)) {
@@ -1094,6 +1109,18 @@ export class PostgresServerStorage {
1094
1109
  record.updatedAtMs,
1095
1110
  ]);
1096
1111
  }
1112
+ async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
1113
+ await this.#exec.query(`UPDATE sync_clients
1114
+ SET cursor=GREATEST(cursor, $1), updated_at_ms=GREATEST(updated_at_ms, $2)
1115
+ WHERE partition=$3 AND client_id=$4 AND actor_id=$5
1116
+ AND EXISTS (SELECT 1 FROM sync_partition_registry
1117
+ WHERE partition=sync_clients.partition AND log_epoch=$6)`, [cursor, updatedAtMs, partition, clientId, actorId, logEpoch]);
1118
+ }
1119
+ async updateClientCursor(partition, clientId, cursor, updatedAtMs) {
1120
+ await this.#exec.query(`UPDATE sync_clients
1121
+ SET cursor=GREATEST(cursor, $3), updated_at_ms=GREATEST(updated_at_ms, $4)
1122
+ WHERE partition=$1 AND client_id=$2`, [partition, clientId, cursor, updatedAtMs]);
1123
+ }
1097
1124
  async getActiveClientCursorFloor(partition, cutoffMs) {
1098
1125
  const { rows } = await this.#exec.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=$1 AND updated_at_ms>=$2', [partition, cutoffMs]);
1099
1126
  return rows[0].cursor === null ? null : asNumber(rows[0].cursor);
package/dist/pull.js CHANGED
@@ -361,6 +361,22 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
361
361
  return { nextCursor: sub.cursor, active: false };
362
362
  }
363
363
  const token = parseBootstrapToken(sub.bootstrapState, sub.table);
364
+ let commits = [];
365
+ if (token === undefined &&
366
+ sub.cursor >= 0 &&
367
+ sub.cursor >= horizonSeq &&
368
+ sub.cursor <= maxSeq) {
369
+ commits = await ctx.storage.readCommitWindow(ctx.partition, {
370
+ table: sub.table,
371
+ scopeFilter: plan.effective,
372
+ afterSeq: sub.cursor,
373
+ throughSeq: maxSeq,
374
+ limitChanges: limits.limitCommits + 1,
375
+ });
376
+ // Validate continuity before committing to an active section. A prune
377
+ // during a paged read can otherwise make an incomplete window look empty.
378
+ horizonSeq = Math.max(horizonSeq, await ctx.storage.getHorizonSeq(ctx.partition));
379
+ }
364
380
  // §4.6: a cursor behind the horizon (and not resuming a bootstrap)
365
381
  // cannot compute deltas — answer `reset` and echo the cursor.
366
382
  if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
@@ -421,13 +437,6 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
421
437
  effectiveScopes: plan.effective,
422
438
  bootstrap: false,
423
439
  };
424
- const commits = await ctx.storage.readCommitWindow(ctx.partition, {
425
- table: sub.table,
426
- scopeFilter: plan.effective,
427
- afterSeq: sub.cursor,
428
- throughSeq: maxSeq,
429
- limitChanges: limits.limitCommits + 1,
430
- });
431
440
  let delivered = 0;
432
441
  let deliveredCommits = 0;
433
442
  let lastDeliveredSeq = sub.cursor;
@@ -137,7 +137,7 @@ export declare class RealtimeSession {
137
137
  handleBinary(bytes: Uint8Array): Promise<void> | undefined;
138
138
  sendHeartbeat(): void;
139
139
  sendWake(reason: WakeReason): void;
140
- /** Called by the hub for every applied commit, in commitSeq order. */
140
+ /** Called by the hub for every applied commit notification. */
141
141
  deliverCommit(commit: StoredCommit): void;
142
142
  close(): void;
143
143
  }
package/dist/realtime.js CHANGED
@@ -191,6 +191,10 @@ export class RealtimeSession {
191
191
  if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor))
192
192
  return;
193
193
  this.cursor = Math.max(this.cursor, cursor);
194
+ // The client may have caught up through another binding (§8.4). Its ack
195
+ // proves those commits no longer need socket notifications, so the next
196
+ // notification must be adjacent to the acknowledged cursor.
197
+ this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
194
198
  if (this.cursor >= this.lastKnownSeq)
195
199
  this.wakePending = false;
196
200
  // §8.2: acks update the client cursor record without an HTTP pull.
@@ -468,14 +472,7 @@ export class RealtimeSession {
468
472
  }
469
473
  async #persistCursor() {
470
474
  try {
471
- const record = await this.#storage.getClientRecord(this.partition, this.clientId);
472
- if (record === undefined)
473
- return;
474
- await this.#storage.putClientRecord(this.partition, {
475
- ...record,
476
- cursor: Math.max(record.cursor, this.cursor),
477
- updatedAtMs: this.#clock(),
478
- });
475
+ await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
479
476
  }
480
477
  catch {
481
478
  // Cursor persistence is best-effort; the next pull repairs it.
@@ -523,8 +520,9 @@ export class RealtimeSession {
523
520
  });
524
521
  }
525
522
  }
526
- /** Called by the hub for every applied commit, in commitSeq order. */
523
+ /** Called by the hub for every applied commit notification. */
527
524
  deliverCommit(commit) {
525
+ const contiguous = commit.commitSeq === this.lastKnownSeq + 1;
528
526
  this.lastKnownSeq = Math.max(this.lastKnownSeq, commit.commitSeq);
529
527
  const sections = [];
530
528
  for (const registration of this.registrations) {
@@ -544,6 +542,14 @@ export class RealtimeSession {
544
542
  if (changes.length > 0)
545
543
  sections.push({ registration, changes });
546
544
  }
545
+ if (!contiguous) {
546
+ // Pushes allocate commitSeq under the partition lock but notify after
547
+ // commit, so racing notifications may arrive out of order. A gap,
548
+ // duplicate, or regression cannot be a delta: the client would apply
549
+ // and acknowledge a cursor that does not prove the intervening log.
550
+ this.sendWake('catchup-required');
551
+ return;
552
+ }
547
553
  if (sections.length === 0)
548
554
  return;
549
555
  if (this.#activeRound !== undefined) {
@@ -600,7 +606,7 @@ export class RealtimeSession {
600
606
  tagged[0] = REALTIME_TAG_DELTA;
601
607
  tagged.set(bytes, 1);
602
608
  this.#sendSafe(tagged);
603
- this.cursor = commit.commitSeq;
609
+ this.cursor = Math.max(this.cursor, commit.commitSeq);
604
610
  const events = this.#events;
605
611
  if (events !== undefined) {
606
612
  emitEvent(events, {
@@ -87,7 +87,7 @@ export declare const SYNC_INDEX_PREFIX = "sync_ix_";
87
87
  export declare function physicalIndexName(declaredName: string): string;
88
88
  /**
89
89
  * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
90
- * the same declared names and columns the client materializes. Cross-table
90
+ * the declared columns with a leading server partition column. Cross-table
91
91
  * index-name uniqueness is the user's schema concern, as it is client-side.
92
92
  * Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
93
93
  * ownership marker.
@@ -181,6 +181,12 @@ export declare function commitWindowPageSql(valueCount: number, dialect: Relatio
181
181
  export declare function selectRowScopesSql(table: CompiledTable, dialect: RelationalDialect): string;
182
182
  /** DELETE one stored row. Params: [partition, rowId]. */
183
183
  export declare function deleteRowSql(table: CompiledTable, dialect: RelationalDialect): string;
184
+ /**
185
+ * SQLite: remove the old row's exact scope keys before replacing or deleting the row.
186
+ * Params: [partition, table, rowId, partition, rowId]. The caller keeps this
187
+ * statement and the row write in the same transaction (or D1 atomic batch).
188
+ */
189
+ export declare function deleteSqliteRowScopesSql(table: CompiledTable): string;
184
190
  /**
185
191
  * The schema-version marker table gates DDL work. `ensureSchema` compares the
186
192
  * stored version and skips
@@ -177,7 +177,7 @@ export function physicalIndexName(declaredName) {
177
177
  }
178
178
  /**
179
179
  * CREATE INDEX IF NOT EXISTS for the table's user-declared indexes. These use
180
- * the same declared names and columns the client materializes. Cross-table
180
+ * the declared columns with a leading server partition column. Cross-table
181
181
  * index-name uniqueness is the user's schema concern, as it is client-side.
182
182
  * Server-side the physical name carries the {@link SYNC_INDEX_PREFIX}
183
183
  * ownership marker.
@@ -188,7 +188,7 @@ export function createIndexDdl(table) {
188
188
  return [];
189
189
  return table.indexes.map((index) => {
190
190
  const unique = index.unique ? 'UNIQUE ' : '';
191
- const columns = index.columns
191
+ const columns = [SYNC_PARTITION_COLUMN, ...index.columns]
192
192
  .map((column) => quoteIdent(column))
193
193
  .join(', ');
194
194
  return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(physicalIndexName(index.name))} ON ${quoteIdent(table.name)} (${columns})`;
@@ -401,6 +401,21 @@ export function deleteRowSql(table, dialect) {
401
401
  const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
402
402
  return `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
403
403
  }
404
+ /**
405
+ * SQLite: remove the old row's exact scope keys before replacing or deleting the row.
406
+ * Params: [partition, table, rowId, partition, rowId]. The caller keeps this
407
+ * statement and the row write in the same transaction (or D1 atomic batch).
408
+ */
409
+ export function deleteSqliteRowScopesSql(table) {
410
+ return `DELETE FROM sync_row_scopes
411
+ WHERE partition=? AND tbl=? AND row_id=?
412
+ AND (var, value) IN (
413
+ SELECT key, value FROM json_each((
414
+ SELECT ${quoteIdent(SYNC_SCOPES_COLUMN)} FROM ${quoteIdent(table.name)}
415
+ WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?
416
+ ))
417
+ )`;
418
+ }
404
419
  /**
405
420
  * The schema-version marker table gates DDL work. `ensureSchema` compares the
406
421
  * stored version and skips
@@ -37,6 +37,8 @@ export declare class SqliteServerStorage implements ServerStorage {
37
37
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
38
38
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
39
39
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
40
+ advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
41
+ updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
40
42
  getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
41
43
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
42
44
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{
@@ -10,7 +10,7 @@ import { StorageQueryError } from './storage-errors.js';
10
10
  */
11
11
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
12
12
  import { syncError } from './errors.js';
13
- import { commitWindowPageSql, deleteRowSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
13
+ import { commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
14
14
  import { matchesEffective } from './scopes.js';
15
15
  import { collectCommitWindowPage, deserializePushResult, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
16
16
  import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
@@ -108,8 +108,9 @@ class SqliteTransaction {
108
108
  async deleteRow(table, rowId) {
109
109
  this.#assertOpen();
110
110
  const db = this.#storage.db;
111
- db.query(deleteRowSql(this.#storage.table(table), 'sqlite')).run(this.#partition, rowId);
112
- db.query('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?').run(this.#partition, table, rowId);
111
+ const compiled = this.#storage.table(table);
112
+ db.query(deleteSqliteRowScopesSql(compiled)).run(this.#partition, table, rowId, this.#partition, rowId);
113
+ db.query(deleteRowSql(compiled, 'sqlite')).run(this.#partition, rowId);
113
114
  // §5.9.4: a deleted row references no blobs.
114
115
  db.query('DELETE FROM sync_blob_refs WHERE partition=? AND tbl=? AND row_id=?').run(this.#partition, table, rowId);
115
116
  }
@@ -277,6 +278,9 @@ export class SqliteServerStorage {
277
278
  this.db
278
279
  .query('DELETE FROM sync_row_scopes WHERE tbl=?')
279
280
  .run(tableName);
281
+ this.db
282
+ .query('DELETE FROM sync_blob_refs WHERE tbl=?')
283
+ .run(tableName);
280
284
  this.db.exec(dropTableDdl(tableName));
281
285
  }
282
286
  for (const statement of schemaDdl(schema, existing, 'sqlite', existingIndexes)) {
@@ -423,12 +427,12 @@ export class SqliteServerStorage {
423
427
  /** Internal: write a row + refresh its scope-index entries. */
424
428
  writeRow(partition, table, row) {
425
429
  const compiled = this.table(table);
430
+ this.db
431
+ .query(deleteSqliteRowScopesSql(compiled))
432
+ .run(partition, table, row.rowId, partition, row.rowId);
426
433
  this.db
427
434
  .query(upsertSql(compiled, 'sqlite'))
428
435
  .run(...upsertValues(compiled, partition, row, 'sqlite'));
429
- this.db
430
- .query('DELETE FROM sync_row_scopes WHERE partition=? AND tbl=? AND row_id=?')
431
- .run(partition, table, row.rowId);
432
436
  for (const [variable, value] of Object.entries(row.scopes)) {
433
437
  this.db
434
438
  .query('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)')
@@ -786,6 +790,22 @@ export class SqliteServerStorage {
786
790
  .query('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
787
791
  .run(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs);
788
792
  }
793
+ async advanceClientCursor(partition, clientId, actorId, logEpoch, cursor, updatedAtMs) {
794
+ this.db
795
+ .query(`UPDATE sync_clients
796
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
797
+ WHERE partition=? AND client_id=? AND actor_id=?
798
+ AND EXISTS (SELECT 1 FROM sync_partition_registry
799
+ WHERE partition=sync_clients.partition AND log_epoch=?)`)
800
+ .run(cursor, updatedAtMs, partition, clientId, actorId, logEpoch);
801
+ }
802
+ async updateClientCursor(partition, clientId, cursor, updatedAtMs) {
803
+ this.db
804
+ .query(`UPDATE sync_clients
805
+ SET cursor=MAX(cursor, ?), updated_at_ms=MAX(updated_at_ms, ?)
806
+ WHERE partition=? AND client_id=?`)
807
+ .run(cursor, updatedAtMs, partition, clientId);
808
+ }
789
809
  async getActiveClientCursorFloor(partition, cutoffMs) {
790
810
  const row = this.db
791
811
  .query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
@@ -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' | 'sync.storage.prune_epoch_mismatch' | 'sync.storage.partition_unregistered' | 'sync.storage.invalid_prune_cursor';
12
+ export type StorageQueryErrorCode = 'sync.storage.schema_migration_pending' | 'sync.storage.schema_migration_conflict' | 'sync.storage.schema_changed' | 'sync.storage.invalid_migration_budget' | '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,10 @@ export class StorageConstraintError extends Error {
12
12
  }
13
13
  }
14
14
  const STORAGE_QUERY_MESSAGES = {
15
+ 'sync.storage.schema_migration_pending': 'schema migration requires another invocation',
16
+ 'sync.storage.schema_migration_conflict': 'another schema migration target is already pending',
17
+ 'sync.storage.schema_changed': 'storage schema is not ready for this operation',
18
+ 'sync.storage.invalid_migration_budget': 'migration statement budget must be an integer from 10 through 1000',
15
19
  'sync.storage.prune_epoch_mismatch': 'partition log epoch changed; recompute retention inputs',
16
20
  'sync.storage.partition_unregistered': 'pruning requires a registered partition',
17
21
  'sync.storage.invalid_prune_cursor': 'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
package/dist/storage.d.ts CHANGED
@@ -425,6 +425,15 @@ export interface ServerStorage {
425
425
  scanRowsByIndex?(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
426
426
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
427
427
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
428
+ /**
429
+ * Atomically advance an existing record's cursor and activity timestamp.
430
+ * Preserve registration fields; require matching actor and current log epoch.
431
+ * Missing records and mismatched identities are unchanged (§8.2).
432
+ */
433
+ advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
434
+ /** Advance an existing client's ACK cursor and timestamp atomically,
435
+ * preserving actor, wire version, and subscriptions. Missing records stay absent. */
436
+ updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
428
437
  /** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
429
438
  getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
430
439
  /** Cursor records for client listings and administrative counts. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
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.16.1"
71
+ "@syncular/core": "0.17.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"