@syncular/server 0.15.48 → 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.
Files changed (44) hide show
  1. package/README.md +66 -9
  2. package/dist/admin.js +1 -5
  3. package/dist/authoritative-query.d.ts +14 -6
  4. package/dist/authoritative-query.js +60 -82
  5. package/dist/d1-storage.d.ts +13 -1
  6. package/dist/d1-storage.js +468 -121
  7. package/dist/operations.d.ts +2 -0
  8. package/dist/operations.js +23 -4
  9. package/dist/postgres-storage.d.ts +6 -1
  10. package/dist/postgres-storage.js +80 -24
  11. package/dist/prune.d.ts +3 -1
  12. package/dist/prune.js +18 -14
  13. package/dist/pull.js +79 -39
  14. package/dist/push.js +5 -5
  15. package/dist/realtime.d.ts +1 -1
  16. package/dist/realtime.js +16 -10
  17. package/dist/relational-rows.d.ts +7 -1
  18. package/dist/relational-rows.js +17 -2
  19. package/dist/sqlite-bun.js +2 -2
  20. package/dist/sqlite-image.d.ts +3 -3
  21. package/dist/sqlite-image.js +10 -6
  22. package/dist/sqlite-node.js +2 -2
  23. package/dist/sqlite-storage.d.ts +6 -1
  24. package/dist/sqlite-storage.js +93 -32
  25. package/dist/storage-errors.d.ts +1 -1
  26. package/dist/storage-errors.js +7 -0
  27. package/dist/storage.d.ts +29 -5
  28. package/package.json +2 -2
  29. package/src/admin.ts +4 -6
  30. package/src/authoritative-query.ts +89 -94
  31. package/src/d1-storage.ts +641 -159
  32. package/src/operations.ts +31 -3
  33. package/src/postgres-storage.ts +146 -46
  34. package/src/prune.ts +29 -15
  35. package/src/pull.ts +102 -49
  36. package/src/push.ts +5 -5
  37. package/src/realtime.ts +20 -9
  38. package/src/relational-rows.ts +18 -2
  39. package/src/sqlite-bun.ts +2 -2
  40. package/src/sqlite-image.ts +26 -15
  41. package/src/sqlite-node.ts +2 -2
  42. package/src/sqlite-storage.ts +131 -37
  43. package/src/storage-errors.ts +22 -1
  44. package/src/storage.ts +50 -5
@@ -1,3 +1,4 @@
1
+ import { type AuthoritativeRelationPlan } from './authoritative-query.js';
1
2
  import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
2
3
  import type { SyncRequestContext } from './context.js';
3
4
  import type { AuthoritativeQueryValue } from './storage.js';
@@ -20,6 +21,7 @@ export interface AuthoritativeQueryDescriptor<Params = undefined> {
20
21
  readonly hasParams: boolean;
21
22
  readonly sql: string;
22
23
  readonly tables: readonly string[];
24
+ readonly relationPlans: readonly AuthoritativeRelationPlan[];
23
25
  readonly resultColumns: readonly {
24
26
  readonly name: string;
25
27
  readonly type: 'string' | 'integer' | 'float' | 'boolean' | 'json' | 'bytes' | 'blob_ref' | 'crdt';
@@ -1,3 +1,4 @@
1
+ import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
1
2
  import { decodeRow, decodeRemoteOperationRequest, encodeRow, encodeRemoteOperationResponse, } from '@syncular/core';
2
3
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
3
4
  import { SyncError, syncError } from './errors.js';
@@ -97,13 +98,19 @@ function normalizeQueryRows(rows, columns) {
97
98
  }
98
99
  /** Register one generated named query as an authoritative remote operation. */
99
100
  export function registerRemoteQuery(descriptor, options) {
100
- if (descriptor.id.length === 0 ||
101
+ if (!Array.isArray(descriptor.relationPlans) ||
102
+ !descriptor.relationPlans.some((plan) => plan.sql === descriptor.sql) ||
103
+ descriptor.relationPlans.some((plan) => !Array.isArray(plan.relations)) ||
104
+ descriptor.id.length === 0 ||
101
105
  new Set(descriptor.tables).size !== descriptor.tables.length ||
102
106
  !Array.isArray(descriptor.resultColumns) ||
103
107
  descriptor.resultColumns.length === 0 ||
104
108
  new Set(descriptor.resultColumns.map((column) => column.name)).size !==
105
109
  descriptor.resultColumns.length) {
106
- throw new Error('remote query requires a non-empty id and unique tables and result columns');
110
+ throw new Error('remote query requires generated relation plans, a non-empty id, and unique tables and result columns; regenerate queries');
111
+ }
112
+ for (const plan of descriptor.relationPlans) {
113
+ validateAuthoritativeRelationPlan(plan, descriptor.tables);
107
114
  }
108
115
  if (!Number.isSafeInteger(options.maxRows) ||
109
116
  options.maxRows < 1 ||
@@ -173,12 +180,24 @@ export function registerRemoteQuery(descriptor, options) {
173
180
  if (ctx.storage.queryAuthoritative === undefined) {
174
181
  throw syncError('operation.storage_unsupported', 'configured storage does not implement authoritative queries');
175
182
  }
176
- await ctx.storage.ensureSchema(schema);
177
183
  const selectedSql = descriptor.sqlFor?.(params) ?? descriptor.sql;
184
+ const plan = descriptor.relationPlans.find((candidate) => candidate.sql === selectedSql);
185
+ if (plan === undefined) {
186
+ throw syncError('operation.invalid_request', 'selected SQL has no generated relation plan; regenerate queries');
187
+ }
188
+ await ctx.storage.ensureSchema(schema);
189
+ const prefix = 'SELECT * FROM (';
178
190
  let result;
179
191
  try {
180
192
  result = await ctx.storage.queryAuthoritative(ctx.partition, {
181
- sql: `SELECT * FROM (${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
193
+ plan: {
194
+ sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
195
+ relations: plan.relations.map((relation) => ({
196
+ ...relation,
197
+ start: relation.start + prefix.length,
198
+ end: relation.end + prefix.length,
199
+ })),
200
+ },
182
201
  params: [...descriptor.bind(params), options.maxRows + 1],
183
202
  tables: descriptor.tables,
184
203
  });
@@ -1,3 +1,4 @@
1
+ import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
1
2
  import { type PgExecutor } from './pg-executor.js';
2
3
  import type { CompiledSchema, CompiledTable } from './schema.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';
@@ -46,9 +47,10 @@ export declare class PostgresServerStorage implements ServerStorage {
46
47
  begin(partition: string): Promise<StorageTransaction>;
47
48
  getMaxCommitSeq(partition: string): Promise<number>;
48
49
  queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
50
+ getPartitionLogEpoch(partition: string): Promise<string | undefined>;
49
51
  getHorizonSeq(partition: string): Promise<number>;
50
52
  setHorizonSeq(partition: string, seq: number): Promise<void>;
51
- pruneCommitsThrough(partition: string, seq: number): Promise<number>;
53
+ pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
52
54
  getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
53
55
  getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
54
56
  getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
@@ -65,6 +67,9 @@ export declare class PostgresServerStorage implements ServerStorage {
65
67
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
66
68
  getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
67
69
  putClientRecord(partition: string, record: ClientRecord): Promise<void>;
70
+ advanceClientCursor(partition: string, clientId: string, actorId: string, logEpoch: string, cursor: number, updatedAtMs: number): Promise<void>;
71
+ updateClientCursor(partition: string, clientId: string, cursor: number, updatedAtMs: number): Promise<void>;
72
+ getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
68
73
  listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
69
74
  listRowsReferencingBlob(partition: string, blobId: string): Promise<{
70
75
  readonly table: string;
@@ -1,3 +1,5 @@
1
+ import { validateCommitPruneQuery } from './prune.js';
2
+ import { StorageQueryError } from './storage-errors.js';
1
3
  import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
2
4
  import { syncError } from './errors.js';
3
5
  import { asBytes, asNumber, } from './pg-executor.js';
@@ -107,6 +109,16 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
107
109
  CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
108
110
  ON sync_blob_refs(partition, blob_id);
109
111
  `;
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.
118
+ await client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
119
+ ON CONFLICT (partition) DO NOTHING`, [partition]);
120
+ await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
121
+ }
110
122
  function toBase64(bytes) {
111
123
  return Buffer.from(bytes).toString('base64');
112
124
  }
@@ -381,9 +393,7 @@ class PostgresTransaction {
381
393
  }
382
394
  async lockPartitionForPush() {
383
395
  this.#assertOpen();
384
- await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
385
- ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
386
- await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
396
+ await lockPartitionOn(this.#client, this.#partition);
387
397
  await this.#client.query('SAVEPOINT syncular_push_candidate');
388
398
  this.#pushApplySavepoint = true;
389
399
  }
@@ -433,26 +443,37 @@ class PostgresTransaction {
433
443
  // Allocate the next dense commitSeq under a per-partition row lock: the
434
444
  // UPDATE … RETURNING serializes concurrent pushes to this partition and
435
445
  // never leaves a gap on rollback (see the file header).
436
- const { rows } = await q.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 1)
437
- ON CONFLICT (partition) DO UPDATE
438
- SET max_commit_seq = sync_partitions.max_commit_seq + 1
439
- RETURNING max_commit_seq`, [p]);
440
- const commitSeq = asNumber(rows[0]?.max_commit_seq);
441
- await q.query(`INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms)
442
- 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`, [
443
455
  p,
444
- commitSeq,
445
456
  commit.clientId,
446
457
  commit.clientCommitId,
447
458
  commit.actorId,
448
459
  commit.createdAtMs,
449
460
  ]);
461
+ const commitSeq = asNumber(rows[0]?.commit_seq);
450
462
  for (let idx = 0; idx < commit.changes.length; idx++) {
451
463
  const change = commit.changes[idx];
452
464
  if (change === undefined)
453
465
  continue;
454
- await q.query(`INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload)
455
- 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`, [
456
477
  p,
457
478
  commitSeq,
458
479
  idx,
@@ -463,10 +484,6 @@ class PostgresTransaction {
463
484
  JSON.stringify(change.scopes),
464
485
  change.payload ?? null,
465
486
  ]);
466
- for (const [variable, value] of Object.entries(change.scopes)) {
467
- await q.query(`INSERT INTO sync_change_scopes(partition, tbl, var, value, commit_seq)
468
- VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, [p, change.table, variable, value, commitSeq]);
469
- }
470
487
  }
471
488
  return commitSeq;
472
489
  }
@@ -617,6 +634,9 @@ export class PostgresServerStorage {
617
634
  await client.query('DELETE FROM sync_row_scopes WHERE tbl=$1', [
618
635
  tableName,
619
636
  ]);
637
+ await client.query('DELETE FROM sync_blob_refs WHERE tbl=$1', [
638
+ tableName,
639
+ ]);
620
640
  await client.query(dropTableDdl(tableName));
621
641
  }
622
642
  for (const statement of schemaDdl(schema, existing, 'postgres', existingIndexes)) {
@@ -661,6 +681,7 @@ export class PostgresServerStorage {
661
681
  if (logEpoch.length === 0)
662
682
  throw new Error('log epoch must be non-empty');
663
683
  await this.#exec.transaction(async (client) => {
684
+ await lockPartitionOn(client, partition);
664
685
  await client.query(`INSERT INTO sync_partition_registry(
665
686
  partition, log_epoch, epoch_required, last_authenticated_at_ms
666
687
  ) VALUES ($1,$2,TRUE,$3)
@@ -732,7 +753,7 @@ export class PostgresServerStorage {
732
753
  if (this.#tables === undefined) {
733
754
  throw new Error('ensureSchema(schema) must run before registered queries');
734
755
  }
735
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
756
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
736
757
  return this.#exec.transaction(async (client) => {
737
758
  await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
738
759
  const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
@@ -745,19 +766,38 @@ export class PostgresServerStorage {
745
766
  };
746
767
  });
747
768
  }
769
+ async getPartitionLogEpoch(partition) {
770
+ const { rows } = await this.#exec.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
771
+ return rows[0]?.log_epoch;
772
+ }
748
773
  async getHorizonSeq(partition) {
749
774
  const { rows } = await this.#exec.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
750
775
  return rows[0] === undefined ? 0 : asNumber(rows[0].horizon_seq);
751
776
  }
752
777
  async setHorizonSeq(partition, seq) {
753
778
  await this.#exec.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES ($1,$2)
754
- ON CONFLICT (partition) DO UPDATE SET horizon_seq=EXCLUDED.horizon_seq`, [partition, seq]);
779
+ ON CONFLICT (partition) DO UPDATE SET horizon_seq=GREATEST(sync_partitions.horizon_seq,EXCLUDED.horizon_seq)`, [partition, seq]);
755
780
  }
756
- async pruneCommitsThrough(partition, seq) {
757
- const removed = await this.#exec.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, seq]);
758
- await this.#exec.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, seq]);
759
- await this.#exec.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, seq]);
760
- return removed.rowCount;
781
+ async pruneCommitsThrough(partition, query) {
782
+ validateCommitPruneQuery(query);
783
+ return this.#exec.transaction(async (client) => {
784
+ await lockPartitionOn(client, partition);
785
+ const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1 FOR UPDATE', [partition]);
786
+ if (epoch.rows[0]?.log_epoch !== query.logEpoch)
787
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
788
+ const previous = await client.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
789
+ const previousHorizonSeq = asNumber(previous.rows[0]?.horizon_seq);
790
+ const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
791
+ await client.query('UPDATE sync_partitions SET horizon_seq=$2 WHERE partition=$1', [partition, horizonSeq]);
792
+ const removed = await client.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
793
+ await client.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
794
+ await client.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
795
+ return {
796
+ previousHorizonSeq,
797
+ horizonSeq,
798
+ removedCommits: removed.rowCount,
799
+ };
800
+ });
761
801
  }
762
802
  async getCommitSeqBefore(partition, createdBeforeMs) {
763
803
  const { rows } = await this.#exec.query('SELECT max(commit_seq) AS seq FROM sync_commits WHERE partition=$1 AND created_at_ms<$2', [partition, createdBeforeMs]);
@@ -1069,6 +1109,22 @@ export class PostgresServerStorage {
1069
1109
  record.updatedAtMs,
1070
1110
  ]);
1071
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
+ }
1124
+ async getActiveClientCursorFloor(partition, cutoffMs) {
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]);
1126
+ return rows[0].cursor === null ? null : asNumber(rows[0].cursor);
1127
+ }
1072
1128
  async listClientCursors(partition) {
1073
1129
  const { rows } = await this.#exec.query('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=$1', [partition]);
1074
1130
  return rows.map((r) => ({
package/dist/prune.d.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  * least the newest `minRetainedCommits` commits are always retained.
8
8
  */
9
9
  import { type SyncularServerEvents } from './events.js';
10
- import type { ServerStorage } from './storage.js';
10
+ import type { CommitPruneQuery, ServerStorage } from './storage.js';
11
+ /** Shared validation for the built-in atomic pruning adapters. */
12
+ export declare function validateCommitPruneQuery(query: CommitPruneQuery): void;
11
13
  export interface RetentionPolicy {
12
14
  /** Active window for laggard cursors (default 14 days). */
13
15
  readonly activeWindowMs: number;
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) {
@@ -330,6 +361,22 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
330
361
  return { nextCursor: sub.cursor, active: false };
331
362
  }
332
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
+ }
333
380
  // §4.6: a cursor behind the horizon (and not resuming a bootstrap)
334
381
  // cannot compute deltas — answer `reset` and echo the cursor.
335
382
  if (token === undefined && sub.cursor >= 0 && sub.cursor < horizonSeq) {
@@ -390,13 +437,6 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
390
437
  effectiveScopes: plan.effective,
391
438
  bootstrap: false,
392
439
  };
393
- const commits = await ctx.storage.readCommitWindow(ctx.partition, {
394
- table: sub.table,
395
- scopeFilter: plan.effective,
396
- afterSeq: sub.cursor,
397
- throughSeq: maxSeq,
398
- limitChanges: limits.limitCommits + 1,
399
- });
400
440
  let delivered = 0;
401
441
  let deliveredCommits = 0;
402
442
  let lastDeliveredSeq = sub.cursor;
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
  }
@@ -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