@syncular/server 0.0.6-90 → 0.0.6-93

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.
@@ -31,6 +31,7 @@ import type {
31
31
  UpdateQueryBuilder,
32
32
  UpdateResult,
33
33
  } from 'kysely';
34
+ import { sql } from 'kysely';
34
35
  import type { SyncCoreDb } from '../schema';
35
36
  import type {
36
37
  ApplyOperationResult,
@@ -67,6 +68,14 @@ function isConstraintViolationError(message: string): boolean {
67
68
  );
68
69
  }
69
70
 
71
+ function isMissingColumnReferenceError(message: string): boolean {
72
+ const normalized = message.toLowerCase();
73
+ return (
74
+ normalized.includes('no such column') ||
75
+ (normalized.includes('column') && normalized.includes('does not exist'))
76
+ );
77
+ }
78
+
70
79
  /**
71
80
  * Scope definition for a column - maps scope variable to column name.
72
81
  */
@@ -505,96 +514,19 @@ export function createServerHandler<
505
514
  : {};
506
515
  const payload = applyInboundTransform(payloadRecord, ctx.schemaVersion);
507
516
 
508
- // Check whether the row exists and fetch only version metadata for hot path.
509
- const existingRow = await (
510
- trx.selectFrom(table) as SelectQueryBuilder<
511
- ServerDB,
512
- keyof ServerDB & string,
513
- Record<string, unknown>
514
- >
515
- )
516
- .select(ref<string>(versionColumn))
517
- .where(ref<string>(primaryKey), '=', op.row_id)
518
- .executeTakeFirst();
519
-
520
- const hasExistingRow = existingRow !== undefined;
521
- const existingVersion =
522
- (existingRow?.[versionColumn] as number | undefined) ?? 0;
523
-
524
- // Check version conflict
525
- if (
526
- hasExistingRow &&
527
- op.base_version != null &&
528
- existingVersion !== op.base_version
529
- ) {
530
- const conflictRow = await (
531
- trx.selectFrom(table).selectAll() as SelectQueryBuilder<
532
- ServerDB,
533
- keyof ServerDB & string,
534
- Record<string, unknown>
535
- >
536
- )
537
- .where(ref<string>(primaryKey), '=', op.row_id)
538
- .executeTakeFirst();
539
-
540
- if (!conflictRow) {
541
- return {
542
- result: {
543
- opIndex,
544
- status: 'error',
545
- error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
546
- code: 'ROW_MISSING',
547
- retriable: false,
548
- },
549
- emittedChanges: [],
550
- };
551
- }
552
-
553
- return {
554
- result: {
555
- opIndex,
556
- status: 'conflict',
557
- message: `Version conflict: server=${existingVersion}, base=${op.base_version}`,
558
- server_version: existingVersion,
559
- server_row: applyOutboundTransform(
560
- conflictRow as Selectable<ServerDB[TableName]>
561
- ),
562
- },
563
- emittedChanges: [],
564
- };
565
- }
566
-
567
- // If the client provided a base version, they expected this row to exist.
568
- // A missing row usually indicates stale local state after a server reset.
569
- if (!hasExistingRow && op.base_version != null) {
570
- return {
571
- result: {
572
- opIndex,
573
- status: 'error',
574
- error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
575
- code: 'ROW_MISSING',
576
- retriable: false,
577
- },
578
- emittedChanges: [],
579
- };
580
- }
581
-
582
- const nextVersion = existingVersion + 1;
583
-
584
517
  let updated: Record<string, unknown> | undefined;
585
518
  let constraintError: { message: string; code: string } | null = null;
586
519
 
587
520
  try {
588
- if (hasExistingRow) {
589
- // Update - merge payload with existing
590
- const updateSet: Record<string, unknown> = {
521
+ if (op.base_version != null) {
522
+ const expectedVersion = op.base_version;
523
+ const conditionalUpdateSet: Record<string, unknown> = {
591
524
  ...payload,
592
- [versionColumn]: nextVersion,
525
+ [versionColumn]: expectedVersion + 1,
593
526
  };
594
- // Don't update primary key or scope columns
595
- delete updateSet[primaryKey];
527
+ delete conditionalUpdateSet[primaryKey];
596
528
  for (const col of Object.values(scopeColumns)) {
597
- delete updateSet[col];
529
+ delete conditionalUpdateSet[col];
598
530
  }
599
531
 
600
532
  updated = (await (
@@ -605,31 +537,144 @@ export function createServerHandler<
605
537
  UpdateResult
606
538
  >
607
539
  )
608
- .set(updateSet as UpdateSetObject)
540
+ .set(conditionalUpdateSet as UpdateSetObject)
609
541
  .where(ref<string>(primaryKey), '=', op.row_id)
542
+ .where(ref<string>(versionColumn), '=', expectedVersion)
610
543
  .returningAll()
611
544
  .executeTakeFirst()) as Record<string, unknown> | undefined;
545
+
546
+ if (!updated) {
547
+ const conflictRow = await (
548
+ trx.selectFrom(table).selectAll() as SelectQueryBuilder<
549
+ ServerDB,
550
+ keyof ServerDB & string,
551
+ Record<string, unknown>
552
+ >
553
+ )
554
+ .where(ref<string>(primaryKey), '=', op.row_id)
555
+ .executeTakeFirst();
556
+
557
+ if (!conflictRow) {
558
+ return {
559
+ result: {
560
+ opIndex,
561
+ status: 'error',
562
+ error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
563
+ code: 'ROW_MISSING',
564
+ retriable: false,
565
+ },
566
+ emittedChanges: [],
567
+ };
568
+ }
569
+
570
+ const existingVersion =
571
+ (conflictRow[versionColumn] as number | undefined) ?? 0;
572
+ return {
573
+ result: {
574
+ opIndex,
575
+ status: 'conflict',
576
+ message: `Version conflict: server=${existingVersion}, base=${expectedVersion}`,
577
+ server_version: existingVersion,
578
+ server_row: applyOutboundTransform(
579
+ conflictRow as Selectable<ServerDB[TableName]>
580
+ ),
581
+ },
582
+ emittedChanges: [],
583
+ };
584
+ }
612
585
  } else {
613
- // Insert
614
- const insertValues: Record<string, unknown> = {
586
+ const updateSet: Record<string, unknown> = {
615
587
  ...payload,
616
- [primaryKey]: op.row_id,
617
- [versionColumn]: 1,
588
+ [versionColumn]: sql`${sql.ref(versionColumn)} + 1`,
618
589
  };
590
+ delete updateSet[primaryKey];
591
+ for (const col of Object.values(scopeColumns)) {
592
+ delete updateSet[col];
593
+ }
619
594
 
620
595
  updated = (await (
621
- trx.insertInto(table) as InsertQueryBuilder<
596
+ trx.updateTable(table) as UpdateQueryBuilder<
622
597
  ServerDB,
623
598
  TableName,
624
- InsertResult
599
+ TableName,
600
+ UpdateResult
625
601
  >
626
602
  )
627
- .values(insertValues as Insertable<ServerDB[TableName]>)
603
+ .set(updateSet as UpdateSetObject)
604
+ .where(ref<string>(primaryKey), '=', op.row_id)
628
605
  .returningAll()
629
606
  .executeTakeFirst()) as Record<string, unknown> | undefined;
607
+
608
+ if (!updated) {
609
+ const insertValues: Record<string, unknown> = {
610
+ ...payload,
611
+ [primaryKey]: op.row_id,
612
+ [versionColumn]: 1,
613
+ };
614
+
615
+ try {
616
+ updated = (await (
617
+ trx.insertInto(table) as InsertQueryBuilder<
618
+ ServerDB,
619
+ TableName,
620
+ InsertResult
621
+ >
622
+ )
623
+ .values(insertValues as Insertable<ServerDB[TableName]>)
624
+ .returningAll()
625
+ .executeTakeFirst()) as Record<string, unknown> | undefined;
626
+ } catch (err) {
627
+ const message = err instanceof Error ? err.message : String(err);
628
+ if (!isConstraintViolationError(message)) {
629
+ throw err;
630
+ }
631
+ updated = (await (
632
+ trx.updateTable(table) as UpdateQueryBuilder<
633
+ ServerDB,
634
+ TableName,
635
+ TableName,
636
+ UpdateResult
637
+ >
638
+ )
639
+ .set(updateSet as UpdateSetObject)
640
+ .where(ref<string>(primaryKey), '=', op.row_id)
641
+ .returningAll()
642
+ .executeTakeFirst()) as Record<string, unknown> | undefined;
643
+ if (!updated) {
644
+ constraintError = {
645
+ message,
646
+ code: classifyConstraintViolationCode(message),
647
+ };
648
+ }
649
+ }
650
+ }
630
651
  }
631
652
  } catch (err) {
632
653
  const message = err instanceof Error ? err.message : String(err);
654
+ if (op.base_version != null && isMissingColumnReferenceError(message)) {
655
+ const row = await (
656
+ trx.selectFrom(table).selectAll() as SelectQueryBuilder<
657
+ ServerDB,
658
+ keyof ServerDB & string,
659
+ Record<string, unknown>
660
+ >
661
+ )
662
+ .where(ref<string>(primaryKey), '=', op.row_id)
663
+ .executeTakeFirst();
664
+ if (!row) {
665
+ return {
666
+ result: {
667
+ opIndex,
668
+ status: 'error',
669
+ error: 'ROW_NOT_FOUND_FOR_BASE_VERSION',
670
+ code: 'ROW_MISSING',
671
+ retriable: false,
672
+ },
673
+ emittedChanges: [],
674
+ };
675
+ }
676
+ }
677
+
633
678
  if (!isConstraintViolationError(message)) {
634
679
  throw err;
635
680
  }
package/src/pull.ts CHANGED
@@ -35,8 +35,14 @@ import {
35
35
  readSnapshotChunkRefByPageKey,
36
36
  } from './snapshot-chunks';
37
37
  import type { SnapshotChunkStorage } from './snapshot-chunks/types';
38
+ import {
39
+ createMemoryScopeCache,
40
+ type ScopeCacheBackend,
41
+ } from './subscriptions/cache';
38
42
  import { resolveEffectiveScopesForSubscriptions } from './subscriptions/resolve';
39
43
 
44
+ const defaultScopeCache = createMemoryScopeCache();
45
+
40
46
  function concatByteChunks(chunks: readonly Uint8Array[]): Uint8Array {
41
47
  if (chunks.length === 1) {
42
48
  return chunks[0] ?? new Uint8Array();
@@ -67,6 +73,20 @@ export interface PullResult {
67
73
  clientCursor: number;
68
74
  }
69
75
 
76
+ interface PendingExternalChunkWrite {
77
+ snapshot: SyncSnapshot;
78
+ cacheLookup: {
79
+ partitionId: string;
80
+ scopeKey: string;
81
+ scope: string;
82
+ asOfCommitSeq: number;
83
+ rowCursor: string | null;
84
+ rowLimit: number;
85
+ };
86
+ rowFramePayload: Uint8Array;
87
+ expiresAt: string;
88
+ }
89
+
70
90
  /**
71
91
  * Generate a stable cache key for snapshot chunks.
72
92
  */
@@ -261,6 +281,12 @@ export async function pull<
261
281
  * instead of inline in the database.
262
282
  */
263
283
  chunkStorage?: SnapshotChunkStorage;
284
+ /**
285
+ * Optional shared scope cache backend.
286
+ * Request-local memoization is always applied, even with custom backends.
287
+ * Defaults to process-local memory cache.
288
+ */
289
+ scopeCache?: ScopeCacheBackend;
264
290
  }): Promise<PullResult> {
265
291
  const { request, dialect } = args;
266
292
  const db = args.db;
@@ -296,6 +322,7 @@ export async function pull<
296
322
  50
297
323
  );
298
324
  const dedupeRows = request.dedupeRows === true;
325
+ const pendingExternalChunkWrites: PendingExternalChunkWrite[] = [];
299
326
 
300
327
  // Resolve effective scopes for each subscription
301
328
  const resolved = await resolveEffectiveScopesForSubscriptions({
@@ -303,6 +330,7 @@ export async function pull<
303
330
  auth: args.auth,
304
331
  subscriptions: request.subscriptions ?? [],
305
332
  handlers: args.handlers,
333
+ scopeCache: args.scopeCache ?? defaultScopeCache,
306
334
  });
307
335
 
308
336
  const result = await dialect.executeInTransaction(db, async (trx) => {
@@ -470,63 +498,51 @@ export async function pull<
470
498
  const rowFramePayload = concatByteChunks(
471
499
  bundle.rowFrameParts
472
500
  );
473
- const sha256 = await sha256Hex(rowFramePayload);
474
501
  const expiresAt = new Date(
475
502
  Date.now() + Math.max(1000, bundle.ttlMs)
476
503
  ).toISOString();
477
504
 
478
505
  if (args.chunkStorage) {
479
- if (args.chunkStorage.storeChunkStream) {
480
- const { stream: bodyStream, byteLength } =
481
- await gzipBytesToStream(rowFramePayload);
482
- chunkRef = await args.chunkStorage.storeChunkStream({
506
+ const snapshot: SyncSnapshot = {
507
+ table: bundle.table,
508
+ rows: [],
509
+ chunks: [],
510
+ isFirstPage: bundle.isFirstPage,
511
+ isLastPage: bundle.isLastPage,
512
+ };
513
+ snapshots.push(snapshot);
514
+ pendingExternalChunkWrites.push({
515
+ snapshot,
516
+ cacheLookup: {
483
517
  partitionId,
484
518
  scopeKey: cacheKey,
485
519
  scope: bundle.table,
486
520
  asOfCommitSeq: effectiveState.asOfCommitSeq,
487
521
  rowCursor: bundle.startCursor,
488
522
  rowLimit: bundleRowLimit,
489
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
490
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
491
- sha256,
492
- byteLength,
493
- bodyStream,
494
- expiresAt,
495
- });
496
- } else {
497
- const compressedBody = await gzipBytes(rowFramePayload);
498
- chunkRef = await args.chunkStorage.storeChunk({
499
- partitionId,
500
- scopeKey: cacheKey,
501
- scope: bundle.table,
502
- asOfCommitSeq: effectiveState.asOfCommitSeq,
503
- rowCursor: bundle.startCursor,
504
- rowLimit: bundleRowLimit,
505
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
506
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
507
- sha256,
508
- body: compressedBody,
509
- expiresAt,
510
- });
511
- }
512
- } else {
513
- const compressedBody = await gzipBytes(rowFramePayload);
514
- const chunkId = randomId();
515
- chunkRef = await insertSnapshotChunk(trx, {
516
- chunkId,
517
- partitionId,
518
- scopeKey: cacheKey,
519
- scope: bundle.table,
520
- asOfCommitSeq: effectiveState.asOfCommitSeq,
521
- rowCursor: bundle.startCursor,
522
- rowLimit: bundleRowLimit,
523
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
524
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
525
- sha256,
526
- body: compressedBody,
523
+ },
524
+ rowFramePayload,
527
525
  expiresAt,
528
526
  });
527
+ return;
529
528
  }
529
+ const sha256 = await sha256Hex(rowFramePayload);
530
+ const compressedBody = await gzipBytes(rowFramePayload);
531
+ const chunkId = randomId();
532
+ chunkRef = await insertSnapshotChunk(trx, {
533
+ chunkId,
534
+ partitionId,
535
+ scopeKey: cacheKey,
536
+ scope: bundle.table,
537
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
538
+ rowCursor: bundle.startCursor,
539
+ rowLimit: bundleRowLimit,
540
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
541
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
542
+ sha256,
543
+ body: compressedBody,
544
+ expiresAt,
545
+ });
530
546
  }
531
547
 
532
548
  snapshots.push({
@@ -671,7 +687,6 @@ export async function pull<
671
687
  commitSeq: number;
672
688
  createdAt: string;
673
689
  actorId: string;
674
- changeId: number;
675
690
  change: SyncChange;
676
691
  }
677
692
  >();
@@ -694,11 +709,15 @@ export async function pull<
694
709
  scopes: r.scopes,
695
710
  };
696
711
 
712
+ // Move row keys to insertion tail so Map iteration yields
713
+ // "latest change wins" order without a full array sort.
714
+ if (latestByRowKey.has(rowKey)) {
715
+ latestByRowKey.delete(rowKey);
716
+ }
697
717
  latestByRowKey.set(rowKey, {
698
718
  commitSeq: r.commit_seq,
699
719
  createdAt: r.created_at,
700
720
  actorId: r.actor_id,
701
- changeId: r.change_id,
702
721
  change,
703
722
  });
704
723
  }
@@ -718,12 +737,8 @@ export async function pull<
718
737
  continue;
719
738
  }
720
739
 
721
- const latest = Array.from(latestByRowKey.values()).sort(
722
- (a, b) => a.commitSeq - b.commitSeq || a.changeId - b.changeId
723
- );
724
-
725
740
  const commits: SyncCommit[] = [];
726
- for (const item of latest) {
741
+ for (const item of latestByRowKey.values()) {
727
742
  const lastCommit = commits[commits.length - 1];
728
743
  if (!lastCommit || lastCommit.commitSeq !== item.commitSeq) {
729
744
  commits.push({
@@ -829,6 +844,61 @@ export async function pull<
829
844
  };
830
845
  });
831
846
 
847
+ const chunkStorage = args.chunkStorage;
848
+ if (chunkStorage && pendingExternalChunkWrites.length > 0) {
849
+ for (const pending of pendingExternalChunkWrites) {
850
+ let chunkRef = await readSnapshotChunkRefByPageKey(db, {
851
+ partitionId: pending.cacheLookup.partitionId,
852
+ scopeKey: pending.cacheLookup.scopeKey,
853
+ scope: pending.cacheLookup.scope,
854
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
855
+ rowCursor: pending.cacheLookup.rowCursor,
856
+ rowLimit: pending.cacheLookup.rowLimit,
857
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
858
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
859
+ });
860
+
861
+ if (!chunkRef) {
862
+ const sha256 = await sha256Hex(pending.rowFramePayload);
863
+ if (chunkStorage.storeChunkStream) {
864
+ const { stream: bodyStream, byteLength } =
865
+ await gzipBytesToStream(pending.rowFramePayload);
866
+ chunkRef = await chunkStorage.storeChunkStream({
867
+ partitionId: pending.cacheLookup.partitionId,
868
+ scopeKey: pending.cacheLookup.scopeKey,
869
+ scope: pending.cacheLookup.scope,
870
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
871
+ rowCursor: pending.cacheLookup.rowCursor,
872
+ rowLimit: pending.cacheLookup.rowLimit,
873
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
874
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
875
+ sha256,
876
+ byteLength,
877
+ bodyStream,
878
+ expiresAt: pending.expiresAt,
879
+ });
880
+ } else {
881
+ const compressedBody = await gzipBytes(pending.rowFramePayload);
882
+ chunkRef = await chunkStorage.storeChunk({
883
+ partitionId: pending.cacheLookup.partitionId,
884
+ scopeKey: pending.cacheLookup.scopeKey,
885
+ scope: pending.cacheLookup.scope,
886
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
887
+ rowCursor: pending.cacheLookup.rowCursor,
888
+ rowLimit: pending.cacheLookup.rowLimit,
889
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
890
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
891
+ sha256,
892
+ body: compressedBody,
893
+ expiresAt: pending.expiresAt,
894
+ });
895
+ }
896
+ }
897
+
898
+ pending.snapshot.chunks = [chunkRef];
899
+ }
900
+ }
901
+
832
902
  const durationMs = Math.max(0, Date.now() - startedAtMs);
833
903
  const stats = summarizePullResponse(result.response);
834
904