@syncular/server 0.15.47 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +48 -5
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +3 -12
  4. package/dist/authoritative-query.d.ts +14 -6
  5. package/dist/authoritative-query.js +60 -82
  6. package/dist/blob-handlers.js +4 -1
  7. package/dist/context.d.ts +3 -1
  8. package/dist/context.js +4 -0
  9. package/dist/d1-storage.d.ts +8 -2
  10. package/dist/d1-storage.js +134 -26
  11. package/dist/errors.js +6 -0
  12. package/dist/events.d.ts +2 -1
  13. package/dist/frame-bytes.d.ts +2 -2
  14. package/dist/frame-bytes.js +33 -14
  15. package/dist/handler.js +58 -14
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/operations.d.ts +2 -0
  19. package/dist/operations.js +25 -5
  20. package/dist/postgres-storage.d.ts +9 -3
  21. package/dist/postgres-storage.js +110 -20
  22. package/dist/prune.d.ts +3 -1
  23. package/dist/prune.js +18 -14
  24. package/dist/pull.d.ts +1 -1
  25. package/dist/pull.js +74 -37
  26. package/dist/push.js +5 -5
  27. package/dist/realtime.d.ts +4 -1
  28. package/dist/realtime.js +33 -9
  29. package/dist/restore.d.ts +13 -0
  30. package/dist/restore.js +13 -0
  31. package/dist/s3-segment-store.js +10 -1
  32. package/dist/seed.js +36 -4
  33. package/dist/segment-download.js +5 -2
  34. package/dist/segment-store.d.ts +3 -0
  35. package/dist/segment-store.js +1 -0
  36. package/dist/sqlite-bun-driver.d.ts +2 -1
  37. package/dist/sqlite-bun-driver.js +5 -2
  38. package/dist/sqlite-bun.js +2 -2
  39. package/dist/sqlite-dialect.d.ts +1 -1
  40. package/dist/sqlite-dialect.js +7 -0
  41. package/dist/sqlite-image.d.ts +3 -3
  42. package/dist/sqlite-image.js +10 -6
  43. package/dist/sqlite-node.js +2 -2
  44. package/dist/sqlite-segment-store.js +14 -5
  45. package/dist/sqlite-storage.d.ts +8 -2
  46. package/dist/sqlite-storage.js +147 -36
  47. package/dist/storage-errors.d.ts +1 -1
  48. package/dist/storage-errors.js +3 -0
  49. package/dist/storage.d.ts +38 -10
  50. package/package.json +2 -2
  51. package/src/admin.ts +7 -15
  52. package/src/authoritative-query.ts +89 -94
  53. package/src/blob-handlers.ts +8 -1
  54. package/src/context.ts +16 -1
  55. package/src/d1-storage.ts +193 -29
  56. package/src/errors.ts +6 -0
  57. package/src/events.ts +2 -1
  58. package/src/frame-bytes.ts +40 -14
  59. package/src/handler.ts +102 -29
  60. package/src/index.ts +1 -0
  61. package/src/operations.ts +37 -4
  62. package/src/postgres-storage.ts +184 -38
  63. package/src/prune.ts +29 -15
  64. package/src/pull.ts +90 -41
  65. package/src/push.ts +5 -5
  66. package/src/realtime.ts +46 -7
  67. package/src/restore.ts +28 -0
  68. package/src/s3-segment-store.ts +10 -1
  69. package/src/seed.ts +46 -4
  70. package/src/segment-download.ts +11 -2
  71. package/src/segment-store.ts +4 -0
  72. package/src/sqlite-bun-driver.ts +6 -2
  73. package/src/sqlite-bun.ts +2 -2
  74. package/src/sqlite-dialect.ts +7 -0
  75. package/src/sqlite-image.ts +26 -15
  76. package/src/sqlite-node.ts +2 -2
  77. package/src/sqlite-segment-store.ts +18 -4
  78. package/src/sqlite-storage.ts +203 -39
  79. package/src/storage-errors.ts +10 -1
  80. package/src/storage.ts +57 -10
@@ -1,4 +1,6 @@
1
1
  var _a;
2
+ import { validateCommitPruneQuery } from './prune.js';
3
+ import { StorageQueryError } from './storage-errors.js';
2
4
  /**
3
5
  * Cloudflare D1 server storage for Workers deployments.
4
6
  *
@@ -479,6 +481,12 @@ export class D1ServerStorage {
479
481
  for (const statement of sqliteDdlStatements()) {
480
482
  await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
481
483
  }
484
+ const { results } = await this.#db
485
+ .prepare('PRAGMA table_info("sync_clients")')
486
+ .all();
487
+ if (!results.some((column) => column.name === 'wire_version')) {
488
+ await this.#db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;');
489
+ }
482
490
  }
483
491
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
484
492
  table(name) {
@@ -563,6 +571,68 @@ export class D1ServerStorage {
563
571
  this.#tables = schema.tables;
564
572
  this.#schemaVersion = schema.version;
565
573
  }
574
+ async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
575
+ if (initialLogEpoch.length === 0) {
576
+ throw new Error('initial log epoch must be non-empty');
577
+ }
578
+ await this.#db
579
+ .prepare(`INSERT INTO sync_partition_registry(
580
+ partition, log_epoch, last_authenticated_at_ms
581
+ ) VALUES (?,?,?)
582
+ ON CONFLICT(partition) DO UPDATE SET
583
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
584
+ .bind(partition, initialLogEpoch, authenticatedAtMs)
585
+ .run();
586
+ const row = await this.#db
587
+ .prepare(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
588
+ FROM sync_partition_registry WHERE partition=?`)
589
+ .bind(partition)
590
+ .first();
591
+ if (row === null)
592
+ throw new Error('partition registry write did not persist');
593
+ return {
594
+ partition,
595
+ logEpoch: row.log_epoch,
596
+ epochRequired: row.epoch_required === 1,
597
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
598
+ };
599
+ }
600
+ async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
601
+ if (logEpoch.length === 0)
602
+ throw new Error('log epoch must be non-empty');
603
+ await this.#db.batch([
604
+ this.#db
605
+ .prepare(`INSERT INTO sync_partition_registry(
606
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
607
+ ) VALUES (?,?,1,?)
608
+ ON CONFLICT(partition) DO UPDATE SET
609
+ log_epoch=excluded.log_epoch,
610
+ epoch_required=1,
611
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
612
+ .bind(partition, logEpoch, authenticatedAtMs),
613
+ this.#db
614
+ .prepare('DELETE FROM sync_clients WHERE partition=?')
615
+ .bind(partition),
616
+ ]);
617
+ return {
618
+ partition,
619
+ logEpoch,
620
+ epochRequired: true,
621
+ lastAuthenticatedAtMs: authenticatedAtMs,
622
+ };
623
+ }
624
+ async listPartitionRegistry() {
625
+ const { results } = await this.#db
626
+ .prepare(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
627
+ FROM sync_partition_registry ORDER BY partition`)
628
+ .all();
629
+ return results.map((row) => ({
630
+ partition: row.partition,
631
+ logEpoch: row.log_epoch,
632
+ epochRequired: row.epoch_required === 1,
633
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
634
+ }));
635
+ }
566
636
  /** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
567
637
  async #rewriteRows(table, oldLayout) {
568
638
  const select = selectRowsForRewriteSql(table, 'sqlite');
@@ -608,7 +678,7 @@ export class D1ServerStorage {
608
678
  if (this.#tables === undefined) {
609
679
  throw new Error('ensureSchema(schema) must run before registered queries');
610
680
  }
611
- const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.sql, query.params, query.tables, this.#tables), partition);
681
+ const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
612
682
  const results = await this.#db.batch([
613
683
  this.#db.prepare(prepared.sql).bind(...prepared.params),
614
684
  this.#db
@@ -639,6 +709,13 @@ export class D1ServerStorage {
639
709
  maxCommitSeq,
640
710
  };
641
711
  }
712
+ async getPartitionLogEpoch(partition) {
713
+ const row = await this.#db
714
+ .prepare('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
715
+ .bind(partition)
716
+ .first();
717
+ return row?.log_epoch;
718
+ }
642
719
  async getHorizonSeq(partition) {
643
720
  const row = await this.#db
644
721
  .prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
@@ -648,27 +725,55 @@ export class D1ServerStorage {
648
725
  }
649
726
  async setHorizonSeq(partition, seq) {
650
727
  await this.#db
651
- .prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq')
728
+ .prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)')
652
729
  .bind(partition, seq)
653
730
  .run();
654
731
  }
655
- async pruneCommitsThrough(partition, seq) {
656
- const before = await this.#db
657
- .prepare('SELECT count(*) AS n FROM sync_commits WHERE partition=? AND commit_seq<=?')
658
- .bind(partition, seq)
659
- .first();
660
- await this.#db.batch([
732
+ async pruneCommitsThrough(partition, query) {
733
+ validateCommitPruneQuery(query);
734
+ if (!this.#pushApplySerialized)
735
+ throw new Error('D1 pruning requires externally serialized partition writes');
736
+ const epochGuard = 'EXISTS (SELECT 1 FROM sync_partition_registry WHERE partition=? AND log_epoch=?)';
737
+ const horizon = '(SELECT horizon_seq FROM sync_partitions WHERE partition=?)';
738
+ const results = await this.#db.batch([
661
739
  this.#db
662
- .prepare('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
663
- .bind(partition, seq),
740
+ .prepare(`SELECT log_epoch, coalesce(${horizon},0) AS previous_horizon_seq FROM sync_partition_registry WHERE partition=?`)
741
+ .bind(partition, partition),
664
742
  this.#db
665
- .prepare('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
666
- .bind(partition, seq),
743
+ .prepare(`INSERT INTO sync_partitions(partition,horizon_seq) SELECT ?,? WHERE ${epochGuard}
744
+ ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
745
+ .bind(partition, query.throughSeq, partition, query.logEpoch),
667
746
  this.#db
668
- .prepare('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
669
- .bind(partition, seq),
747
+ .prepare(`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`)
748
+ .bind(partition, partition, partition),
749
+ ...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) => this.#db
750
+ .prepare(`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`)
751
+ .bind(partition, partition, partition, query.logEpoch)),
670
752
  ]);
671
- return before?.n ?? 0;
753
+ const [before, after] = [results[0], results[2]].map((result) => {
754
+ if (typeof result !== 'object' ||
755
+ result === null ||
756
+ !('results' in result) ||
757
+ !Array.isArray(result.results)) {
758
+ throw new Error('D1 pruning returned an invalid batch result');
759
+ }
760
+ const row = result.results[0];
761
+ return typeof row === 'object' && row !== null
762
+ ? row
763
+ : undefined;
764
+ });
765
+ if (before?.log_epoch !== query.logEpoch)
766
+ throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
767
+ if (typeof before.previous_horizon_seq !== 'number' ||
768
+ typeof after?.horizon_seq !== 'number' ||
769
+ typeof after.removed_commits !== 'number') {
770
+ throw new Error('D1 pruning returned invalid horizon metadata');
771
+ }
772
+ return {
773
+ previousHorizonSeq: before.previous_horizon_seq,
774
+ horizonSeq: after.horizon_seq,
775
+ removedCommits: after.removed_commits,
776
+ };
672
777
  }
673
778
  async getCommitSeqBefore(partition, createdBeforeMs) {
674
779
  const row = await this.#db
@@ -912,7 +1017,7 @@ export class D1ServerStorage {
912
1017
  }
913
1018
  async getClientRecord(partition, clientId) {
914
1019
  const record = await this.#db
915
- .prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
1020
+ .prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
916
1021
  .bind(partition, clientId)
917
1022
  .first();
918
1023
  if (record === null)
@@ -920,6 +1025,7 @@ export class D1ServerStorage {
920
1025
  return {
921
1026
  clientId: record.client_id,
922
1027
  actorId: record.actor_id,
1028
+ wireVersion: record.wire_version,
923
1029
  cursor: record.cursor,
924
1030
  updatedAtMs: record.updated_at_ms,
925
1031
  subscriptions: JSON.parse(record.subscriptions),
@@ -927,10 +1033,17 @@ export class D1ServerStorage {
927
1033
  }
928
1034
  async putClientRecord(partition, record) {
929
1035
  await this.#db
930
- .prepare('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?)')
931
- .bind(partition, record.clientId, record.actorId, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
1036
+ .prepare('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
1037
+ .bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
932
1038
  .run();
933
1039
  }
1040
+ async getActiveClientCursorFloor(partition, cutoffMs) {
1041
+ const row = await this.#db
1042
+ .prepare('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
1043
+ .bind(partition, cutoffMs)
1044
+ .first();
1045
+ return row.cursor;
1046
+ }
934
1047
  async listClientCursors(partition) {
935
1048
  const { results } = await this.#db
936
1049
  .prepare('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=?')
@@ -976,12 +1089,13 @@ export class D1ServerStorage {
976
1089
  // -- admin/console read surface --------------------------------------------
977
1090
  async listClientRecords(partition) {
978
1091
  const { results } = await this.#db
979
- .prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
1092
+ .prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
980
1093
  .bind(partition)
981
1094
  .all();
982
1095
  return results.map((record) => ({
983
1096
  clientId: record.client_id,
984
1097
  actorId: record.actor_id,
1098
+ wireVersion: record.wire_version,
985
1099
  cursor: record.cursor,
986
1100
  updatedAtMs: record.updated_at_ms,
987
1101
  subscriptions: JSON.parse(record.subscriptions),
@@ -1065,12 +1179,6 @@ export class D1ServerStorage {
1065
1179
  };
1066
1180
  }
1067
1181
  async listPartitions() {
1068
- // Union: the registry row appears on first commit, the client row on
1069
- // first pull — a partition with only one of the two still shows up.
1070
- const { results } = await this.#db
1071
- .prepare(`SELECT partition FROM sync_partitions
1072
- UNION SELECT partition FROM sync_clients ORDER BY partition`)
1073
- .all();
1074
- return results.map((r) => r.partition);
1182
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1075
1183
  }
1076
1184
  }
package/dist/errors.js CHANGED
@@ -191,6 +191,12 @@ export const ERROR_CATALOG = {
191
191
  recommendedAction: 'upgradeClient',
192
192
  httpStatus: 400,
193
193
  },
194
+ 'sync.client_wire_unsupported': {
195
+ category: 'schema-mismatch',
196
+ retryable: false,
197
+ recommendedAction: 'upgradeClient',
198
+ httpStatus: 400,
199
+ },
194
200
  'sync.websocket_connection_limit': {
195
201
  category: 'rate-limited',
196
202
  retryable: true,
package/dist/events.d.ts CHANGED
@@ -28,10 +28,11 @@ export interface RequestHandledEvent {
28
28
  /**
29
29
  * `ok` — response streamed to END;
30
30
  * `schema_floor` — §2.4 required-schema answer;
31
+ * `reset` — §2.1 log-epoch reset answer;
31
32
  * `rejected` — request validation failed before any bytes (§1.7);
32
33
  * `error` — in-band ERROR frame (§1.6) or a thrown host failure.
33
34
  */
34
- readonly outcome: 'ok' | 'schema_floor' | 'rejected' | 'error';
35
+ readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
35
36
  /** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
36
37
  readonly errorCode?: string;
37
38
  readonly pushCommits: number;
@@ -10,11 +10,11 @@
10
10
  */
11
11
  import { type ResponseFrame } from '@syncular/core';
12
12
  /** The 8-byte SSP2 response envelope header (§1.2). */
13
- export declare const RESPONSE_ENVELOPE_HEADER: Uint8Array;
13
+ export declare function responseEnvelopeHeader(wireVersion: number): Uint8Array;
14
14
  /** The terminating END frame (§1.2 rule 1). */
15
15
  export declare const END_FRAME_BYTES: Uint8Array;
16
16
  /**
17
17
  * Encode one response frame (5-byte frame header + payload) using the
18
18
  * reference codec.
19
19
  */
20
- export declare function encodeResponseFrame(frame: ResponseFrame): Uint8Array;
20
+ export declare function encodeResponseFrame(frame: ResponseFrame, wireVersion?: number): Uint8Array;
@@ -9,7 +9,13 @@
9
9
  * bytes; the reference codec remains the single source of wire bytes.
10
10
  */
11
11
  import { encodeMessage, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
12
- const STUB_HEADER = { type: 'RESP_HEADER' };
12
+ const stubHeader = (wireVersion) => wireVersion >= 2
13
+ ? {
14
+ type: 'RESP_HEADER',
15
+ logEpoch: 'frame-probe',
16
+ resetRequired: false,
17
+ }
18
+ : { type: 'RESP_HEADER' };
13
19
  const STUB_SUB_START = {
14
20
  type: 'SUB_START',
15
21
  id: '',
@@ -20,25 +26,32 @@ const STUB_SUB_START = {
20
26
  };
21
27
  const STUB_SUB_END = { type: 'SUB_END', nextCursor: 0 };
22
28
  const probe = encodeMessage({
23
- wireVersion: PROTOCOL_WIRE_VERSION,
29
+ wireVersion: 1,
24
30
  msgKind: 'response',
25
- frames: [STUB_HEADER],
31
+ frames: [stubHeader(1)],
26
32
  });
27
33
  /** The 8-byte SSP2 response envelope header (§1.2). */
28
- export const RESPONSE_ENVELOPE_HEADER = probe.slice(0, 8);
34
+ export function responseEnvelopeHeader(wireVersion) {
35
+ const encoded = encodeMessage({
36
+ wireVersion,
37
+ msgKind: 'response',
38
+ frames: [stubHeader(wireVersion)],
39
+ });
40
+ return encoded.slice(0, 8);
41
+ }
29
42
  /** The terminating END frame (§1.2 rule 1). */
30
43
  export const END_FRAME_BYTES = probe.slice(probe.length - 5);
31
- function wrapperFor(frame) {
44
+ function wrapperFor(frame, wireVersion) {
32
45
  switch (frame.type) {
33
46
  case 'RESP_HEADER':
34
47
  return { frames: [frame], index: 0 };
35
48
  case 'LEASE':
36
49
  // §7.3.2: LEASE rides immediately after RESP_HEADER.
37
- return { frames: [STUB_HEADER, frame], index: 1 };
50
+ return { frames: [stubHeader(wireVersion), frame], index: 1 };
38
51
  case 'PUSH_RESULT':
39
52
  case 'ERROR':
40
53
  case 'UNKNOWN':
41
- return { frames: [STUB_HEADER, frame], index: 1 };
54
+ return { frames: [stubHeader(wireVersion), frame], index: 1 };
42
55
  case 'PUSH_RESULT_DETAILS': {
43
56
  const result = {
44
57
  type: 'PUSH_RESULT',
@@ -52,17 +65,23 @@ function wrapperFor(frame) {
52
65
  retryable: false,
53
66
  })),
54
67
  };
55
- return { frames: [STUB_HEADER, result, frame], index: 2 };
68
+ return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
56
69
  }
57
70
  case 'SUB_START':
58
- return { frames: [STUB_HEADER, frame, STUB_SUB_END], index: 1 };
71
+ return {
72
+ frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
73
+ index: 1,
74
+ };
59
75
  case 'SUB_END':
60
- return { frames: [STUB_HEADER, STUB_SUB_START, frame], index: 2 };
76
+ return {
77
+ frames: [stubHeader(wireVersion), STUB_SUB_START, frame],
78
+ index: 2,
79
+ };
61
80
  case 'COMMIT':
62
81
  case 'SEGMENT_REF':
63
82
  case 'SEGMENT_INLINE':
64
83
  return {
65
- frames: [STUB_HEADER, STUB_SUB_START, frame, STUB_SUB_END],
84
+ frames: [stubHeader(wireVersion), STUB_SUB_START, frame, STUB_SUB_END],
66
85
  index: 2,
67
86
  };
68
87
  }
@@ -71,10 +90,10 @@ function wrapperFor(frame) {
71
90
  * Encode one response frame (5-byte frame header + payload) using the
72
91
  * reference codec.
73
92
  */
74
- export function encodeResponseFrame(frame) {
75
- const { frames, index } = wrapperFor(frame);
93
+ export function encodeResponseFrame(frame, wireVersion = PROTOCOL_WIRE_VERSION) {
94
+ const { frames, index } = wrapperFor(frame, wireVersion);
76
95
  const encoded = encodeMessage({
77
- wireVersion: PROTOCOL_WIRE_VERSION,
96
+ wireVersion,
78
97
  msgKind: 'response',
79
98
  frames,
80
99
  });
package/dist/handler.js CHANGED
@@ -10,10 +10,10 @@
10
10
  * frame (§1.6).
11
11
  */
12
12
  import { DecodeError, decodeMessage, } from '@syncular/core';
13
- import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, } from './context.js';
13
+ import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
14
14
  import { SyncError, syncError } from './errors.js';
15
15
  import { emitEvent, } from './events.js';
16
- import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
16
+ import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
17
17
  import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
18
18
  import { processPushCommitWithTrace } from './push.js';
19
19
  import { compileSchema } from './schema.js';
@@ -96,7 +96,7 @@ async function resolveOnce(ctx, clientId, schema) {
96
96
  }
97
97
  return { resolved: { ok: true, allowed }, leaseToEmit };
98
98
  }
99
- async function planRequest(request, ctx, schema) {
99
+ async function planRequest(request, ctx, schema, registry) {
100
100
  const header = request.frames[0];
101
101
  if (header === undefined || header.type !== 'REQ_HEADER') {
102
102
  throw syncError('sync.invalid_request', 'missing REQ_HEADER');
@@ -112,10 +112,22 @@ async function planRequest(request, ctx, schema) {
112
112
  else if (frame.type === 'SUBSCRIPTION')
113
113
  subFrames.push(frame);
114
114
  }
115
+ if (request.wireVersion === 1 && registry.epochRequired) {
116
+ throw syncError('sync.client_wire_unsupported', 'this partition requires a client that validates log epochs (§2.1)');
117
+ }
118
+ if (request.wireVersion >= 2 &&
119
+ header.logEpoch === undefined &&
120
+ pushes.length > 0) {
121
+ throw syncError('sync.invalid_request', 'epoch acquisition requests must not carry push commits (§2.1)');
122
+ }
123
+ const epochReset = request.wireVersion >= 2 && header.logEpoch !== registry.logEpoch;
115
124
  if (header.schemaVersion !== schema.version) {
116
125
  // §2.4: no degraded encoding — answer with the schema floor (§1.6).
117
126
  // No lease is issued on a floor round (§7.3.3).
118
127
  return {
128
+ wireVersion: request.wireVersion,
129
+ logEpoch: registry.logEpoch,
130
+ epochReset,
119
131
  header,
120
132
  pushes,
121
133
  pull,
@@ -125,6 +137,20 @@ async function planRequest(request, ctx, schema) {
125
137
  leaseToEmit: undefined,
126
138
  };
127
139
  }
140
+ if (epochReset) {
141
+ return {
142
+ wireVersion: request.wireVersion,
143
+ logEpoch: registry.logEpoch,
144
+ epochReset: true,
145
+ header,
146
+ pushes: [],
147
+ pull: undefined,
148
+ subscriptions: [],
149
+ resolved: { ok: false },
150
+ schemaFloor: false,
151
+ leaseToEmit: undefined,
152
+ };
153
+ }
128
154
  if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
129
155
  throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
130
156
  }
@@ -181,6 +207,9 @@ async function planRequest(request, ctx, schema) {
181
207
  return { frame, table, status: 'revoked', effective: {} };
182
208
  });
183
209
  return {
210
+ wireVersion: request.wireVersion,
211
+ logEpoch: registry.logEpoch,
212
+ epochReset: false,
184
213
  header,
185
214
  pushes,
186
215
  pull,
@@ -256,7 +285,7 @@ function emitPushEvent(events, ctx, clientId, push, processed) {
256
285
  }
257
286
  async function* streamResponse(plan, ctx, schema, report) {
258
287
  const events = ctx.events;
259
- yield RESPONSE_ENVELOPE_HEADER;
288
+ yield responseEnvelopeHeader(plan.wireVersion);
260
289
  if (plan.schemaFloor) {
261
290
  if (report !== undefined)
262
291
  report.outcome = 'schema_floor';
@@ -264,21 +293,33 @@ async function* streamResponse(plan, ctx, schema, report) {
264
293
  type: 'RESP_HEADER',
265
294
  requiredSchemaVersion: schema.version,
266
295
  latestSchemaVersion: schema.version,
267
- });
296
+ ...(plan.wireVersion >= 2
297
+ ? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
298
+ : {}),
299
+ }, plan.wireVersion);
268
300
  yield END_FRAME_BYTES;
269
301
  return;
270
302
  }
271
303
  yield encodeResponseFrame({
272
304
  type: 'RESP_HEADER',
273
305
  latestSchemaVersion: schema.version,
274
- });
306
+ ...(plan.wireVersion >= 2
307
+ ? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
308
+ : {}),
309
+ }, plan.wireVersion);
310
+ if (plan.epochReset) {
311
+ if (report !== undefined)
312
+ report.outcome = 'reset';
313
+ yield END_FRAME_BYTES;
314
+ return;
315
+ }
275
316
  // §7.3.2: the LEASE frame rides immediately after RESP_HEADER.
276
317
  if (plan.leaseToEmit !== undefined) {
277
318
  yield encodeResponseFrame({
278
319
  type: 'LEASE',
279
320
  leaseId: plan.leaseToEmit.leaseId,
280
321
  expiresAtMs: plan.leaseToEmit.expiresAtMs,
281
- });
322
+ }, plan.wireVersion);
282
323
  if (events !== undefined) {
283
324
  emitEvent(events, {
284
325
  type: 'lease.issued',
@@ -299,10 +340,11 @@ async function* streamResponse(plan, ctx, schema, report) {
299
340
  if (events !== undefined) {
300
341
  emitPushEvent(events, ctx, plan.header.clientId, push, processed);
301
342
  }
302
- yield encodeResponseFrame(frame);
343
+ yield encodeResponseFrame(frame, plan.wireVersion);
303
344
  const details = pushResultDetailsFrame(frame);
304
- if (details !== undefined)
305
- yield encodeResponseFrame(details);
345
+ if (details !== undefined) {
346
+ yield encodeResponseFrame(details, plan.wireVersion);
347
+ }
306
348
  }
307
349
  // Pull half (§4): subscriptions echoed in request order.
308
350
  const cursors = [];
@@ -313,7 +355,7 @@ async function* streamResponse(plan, ctx, schema, report) {
313
355
  const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
314
356
  for (const subscription of plan.subscriptions) {
315
357
  const trace = summaries !== undefined ? { segments: [] } : undefined;
316
- const section = subscriptionSection(ctx, schema, limits, subscription, maxSeq, horizonSeq, trace);
358
+ const section = subscriptionSection(ctx, schema, limits, subscription, maxSeq, horizonSeq, trace, plan.logEpoch);
317
359
  let status = 'active';
318
360
  let bootstrap = false;
319
361
  let commits = 0;
@@ -331,7 +373,7 @@ async function* streamResponse(plan, ctx, schema, report) {
331
373
  changes += frame.changes.length;
332
374
  }
333
375
  }
334
- yield encodeResponseFrame(frame);
376
+ yield encodeResponseFrame(frame, plan.wireVersion);
335
377
  step = await section.next();
336
378
  }
337
379
  if (step.value.active)
@@ -379,6 +421,7 @@ async function* streamResponse(plan, ctx, schema, report) {
379
421
  await ctx.storage.putClientRecord(ctx.partition, {
380
422
  clientId: plan.header.clientId,
381
423
  actorId: ctx.actorId,
424
+ wireVersion: plan.wireVersion,
382
425
  cursor,
383
426
  updatedAtMs: clockOf(ctx)(),
384
427
  subscriptions,
@@ -399,7 +442,7 @@ async function* streamResponse(plan, ctx, schema, report) {
399
442
  retryable: error.retryable,
400
443
  recommendedAction: error.recommendedAction,
401
444
  ...(error.details !== undefined ? { details: error.details } : {}),
402
- });
445
+ }, plan.wireVersion);
403
446
  yield END_FRAME_BYTES;
404
447
  return;
405
448
  }
@@ -461,7 +504,8 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
461
504
  // Relational row tables: create/
462
505
  // migrate on first contact; memoized per storage instance thereafter.
463
506
  await ctx.storage.ensureSchema(schema);
464
- const plan = await planRequest(request, ctx, schema);
507
+ const registry = await touchAuthenticatedPartition(ctx);
508
+ const plan = await planRequest(request, ctx, schema, registry);
465
509
  if (events === undefined)
466
510
  return streamResponse(plan, ctx, schema);
467
511
  const report = { outcome: 'ok' };
package/dist/index.d.ts CHANGED
@@ -32,6 +32,7 @@ export * from './push.js';
32
32
  export * from './readiness.js';
33
33
  export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, type PlannedReaction, type PruneReactionsOptions, type ReactionHandler, type ReactionHandlerInput, type ReactionHandlers, type ReactionPlan, type ReactionPlanner, type ReactionPlannerInput, type ReactionPruneResult, type ReactionRetentionPolicy, type ReactionRunnerOptions, type ReactionRunResult, type ReactionTypeMap, } from './reactions.js';
34
34
  export * from './realtime.js';
35
+ export * from './restore.js';
35
36
  export * from './relational-rows.js';
36
37
  export * from './s3-blob-store.js';
37
38
  export * from './s3-segment-store.js';
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ export * from './push.js';
36
36
  export * from './readiness.js';
37
37
  export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, } from './reactions.js';
38
38
  export * from './realtime.js';
39
+ export * from './restore.js';
39
40
  export * from './relational-rows.js';
40
41
  export * from './s3-blob-store.js';
41
42
  export * from './s3-segment-store.js';
@@ -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,5 +1,6 @@
1
+ import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
1
2
  import { decodeRow, decodeRemoteOperationRequest, encodeRow, encodeRemoteOperationResponse, } from '@syncular/core';
2
- import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
3
+ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
3
4
  import { SyncError, syncError } from './errors.js';
4
5
  import { processPushOperationsWithTrace } from './push.js';
5
6
  import { compileSchema } from './schema.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
  });
@@ -362,6 +381,7 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
362
381
  return encodeRemoteOperationError(error, 'operation.invalid_request');
363
382
  }
364
383
  try {
384
+ await touchAuthenticatedPartition(ctx);
365
385
  if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
366
386
  throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
367
387
  }