@syncular/server 0.15.46 → 0.15.48

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 (91) hide show
  1. package/README.md +17 -4
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +2 -7
  4. package/dist/blob-handlers.js +4 -1
  5. package/dist/context.d.ts +5 -2
  6. package/dist/context.js +4 -0
  7. package/dist/d1-storage.d.ts +4 -1
  8. package/dist/d1-storage.js +75 -11
  9. package/dist/errors.js +6 -0
  10. package/dist/events.d.ts +2 -1
  11. package/dist/frame-bytes.d.ts +2 -2
  12. package/dist/frame-bytes.js +33 -14
  13. package/dist/handler.js +58 -14
  14. package/dist/index-bun.d.ts +2 -0
  15. package/dist/index-bun.js +2 -0
  16. package/dist/index-node.d.ts +2 -0
  17. package/dist/index-node.js +2 -0
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +4 -6
  20. package/dist/operations.js +2 -1
  21. package/dist/postgres-storage.d.ts +5 -2
  22. package/dist/postgres-storage.js +71 -10
  23. package/dist/pull.d.ts +1 -1
  24. package/dist/pull.js +12 -6
  25. package/dist/realtime.d.ts +4 -1
  26. package/dist/realtime.js +33 -9
  27. package/dist/restore.d.ts +13 -0
  28. package/dist/restore.js +13 -0
  29. package/dist/s3-segment-store.js +10 -1
  30. package/dist/seed.js +36 -4
  31. package/dist/segment-download.js +5 -2
  32. package/dist/segment-store.d.ts +3 -0
  33. package/dist/segment-store.js +1 -0
  34. package/dist/sqlite-blob-store.d.ts +4 -9
  35. package/dist/sqlite-blob-store.js +5 -10
  36. package/dist/sqlite-bun-driver.d.ts +12 -0
  37. package/dist/sqlite-bun-driver.js +30 -0
  38. package/dist/sqlite-bun.d.ts +24 -0
  39. package/dist/sqlite-bun.js +40 -0
  40. package/dist/sqlite-dialect.d.ts +8 -8
  41. package/dist/sqlite-dialect.js +9 -2
  42. package/dist/sqlite-driver.d.ts +26 -0
  43. package/dist/sqlite-driver.js +8 -0
  44. package/dist/sqlite-image.d.ts +7 -9
  45. package/dist/sqlite-image.js +26 -28
  46. package/dist/sqlite-lease-store.d.ts +4 -9
  47. package/dist/sqlite-lease-store.js +5 -10
  48. package/dist/sqlite-node-driver.d.ts +10 -0
  49. package/dist/sqlite-node-driver.js +30 -0
  50. package/dist/sqlite-node.d.ts +24 -0
  51. package/dist/sqlite-node.js +50 -0
  52. package/dist/sqlite-segment-store.d.ts +4 -10
  53. package/dist/sqlite-segment-store.js +20 -14
  54. package/dist/sqlite-storage.d.ts +7 -12
  55. package/dist/sqlite-storage.js +89 -16
  56. package/dist/storage-errors.js +4 -1
  57. package/dist/storage.d.ts +18 -5
  58. package/package.json +18 -3
  59. package/src/admin.ts +3 -9
  60. package/src/blob-handlers.ts +8 -1
  61. package/src/context.ts +18 -2
  62. package/src/d1-storage.ts +107 -12
  63. package/src/errors.ts +6 -0
  64. package/src/events.ts +2 -1
  65. package/src/frame-bytes.ts +40 -14
  66. package/src/handler.ts +102 -29
  67. package/src/index-bun.ts +9 -0
  68. package/src/index-node.ts +9 -0
  69. package/src/index.ts +9 -6
  70. package/src/operations.ts +6 -1
  71. package/src/postgres-storage.ts +102 -13
  72. package/src/pull.ts +12 -1
  73. package/src/realtime.ts +46 -7
  74. package/src/restore.ts +28 -0
  75. package/src/s3-segment-store.ts +10 -1
  76. package/src/seed.ts +46 -4
  77. package/src/segment-download.ts +11 -2
  78. package/src/segment-store.ts +4 -0
  79. package/src/sqlite-blob-store.ts +11 -10
  80. package/src/sqlite-bun-driver.ts +46 -0
  81. package/src/sqlite-bun.ts +53 -0
  82. package/src/sqlite-dialect.ts +14 -7
  83. package/src/sqlite-driver.ts +44 -0
  84. package/src/sqlite-image.ts +44 -49
  85. package/src/sqlite-lease-store.ts +11 -10
  86. package/src/sqlite-node-driver.ts +46 -0
  87. package/src/sqlite-node.ts +62 -0
  88. package/src/sqlite-segment-store.ts +29 -15
  89. package/src/sqlite-storage.ts +131 -19
  90. package/src/storage-errors.ts +4 -1
  91. package/src/storage.ts +28 -5
package/src/d1-storage.ts CHANGED
@@ -95,6 +95,7 @@ import type {
95
95
  IndexRowScanQuery,
96
96
  NewCommit,
97
97
  NewReaction,
98
+ PartitionRegistryEntry,
98
99
  PrunedReactionCounts,
99
100
  ReactionClaimQuery,
100
101
  ReactionFailure,
@@ -752,6 +753,14 @@ export class D1ServerStorage implements ServerStorage {
752
753
  for (const statement of sqliteDdlStatements()) {
753
754
  await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
754
755
  }
756
+ const { results } = await this.#db
757
+ .prepare('PRAGMA table_info("sync_clients")')
758
+ .all<{ name: string }>();
759
+ if (!results.some((column) => column.name === 'wire_version')) {
760
+ await this.#db.exec(
761
+ 'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
762
+ );
763
+ }
755
764
  }
756
765
 
757
766
  /** Resolve a table's compiled schema; row operations require `ensureSchema`. */
@@ -859,6 +868,95 @@ export class D1ServerStorage implements ServerStorage {
859
868
  this.#schemaVersion = schema.version;
860
869
  }
861
870
 
871
+ async touchPartition(
872
+ partition: string,
873
+ authenticatedAtMs: number,
874
+ initialLogEpoch: string,
875
+ ): Promise<PartitionRegistryEntry> {
876
+ if (initialLogEpoch.length === 0) {
877
+ throw new Error('initial log epoch must be non-empty');
878
+ }
879
+ await this.#db
880
+ .prepare(
881
+ `INSERT INTO sync_partition_registry(
882
+ partition, log_epoch, last_authenticated_at_ms
883
+ ) VALUES (?,?,?)
884
+ ON CONFLICT(partition) DO UPDATE SET
885
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
886
+ )
887
+ .bind(partition, initialLogEpoch, authenticatedAtMs)
888
+ .run();
889
+ const row = await this.#db
890
+ .prepare(
891
+ `SELECT log_epoch, epoch_required, last_authenticated_at_ms
892
+ FROM sync_partition_registry WHERE partition=?`,
893
+ )
894
+ .bind(partition)
895
+ .first<{
896
+ log_epoch: string;
897
+ epoch_required: number;
898
+ last_authenticated_at_ms: number;
899
+ }>();
900
+ if (row === null)
901
+ throw new Error('partition registry write did not persist');
902
+ return {
903
+ partition,
904
+ logEpoch: row.log_epoch,
905
+ epochRequired: row.epoch_required === 1,
906
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
907
+ };
908
+ }
909
+
910
+ async rotatePartitionLogEpoch(
911
+ partition: string,
912
+ logEpoch: string,
913
+ authenticatedAtMs: number,
914
+ ): Promise<PartitionRegistryEntry> {
915
+ if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
916
+ await this.#db.batch([
917
+ this.#db
918
+ .prepare(
919
+ `INSERT INTO sync_partition_registry(
920
+ partition, log_epoch, epoch_required, last_authenticated_at_ms
921
+ ) VALUES (?,?,1,?)
922
+ ON CONFLICT(partition) DO UPDATE SET
923
+ log_epoch=excluded.log_epoch,
924
+ epoch_required=1,
925
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
926
+ )
927
+ .bind(partition, logEpoch, authenticatedAtMs),
928
+ this.#db
929
+ .prepare('DELETE FROM sync_clients WHERE partition=?')
930
+ .bind(partition),
931
+ ]);
932
+ return {
933
+ partition,
934
+ logEpoch,
935
+ epochRequired: true,
936
+ lastAuthenticatedAtMs: authenticatedAtMs,
937
+ };
938
+ }
939
+
940
+ async listPartitionRegistry(): Promise<PartitionRegistryEntry[]> {
941
+ const { results } = await this.#db
942
+ .prepare(
943
+ `SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
944
+ FROM sync_partition_registry ORDER BY partition`,
945
+ )
946
+ .all<{
947
+ partition: string;
948
+ log_epoch: string;
949
+ epoch_required: number;
950
+ last_authenticated_at_ms: number;
951
+ }>();
952
+ return results.map((row) => ({
953
+ partition: row.partition,
954
+ logEpoch: row.log_epoch,
955
+ epochRequired: row.epoch_required === 1,
956
+ lastAuthenticatedAtMs: row.last_authenticated_at_ms,
957
+ }));
958
+ }
959
+
862
960
  /** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
863
961
  async #rewriteRows(
864
962
  table: CompiledTable,
@@ -1390,12 +1488,13 @@ export class D1ServerStorage implements ServerStorage {
1390
1488
  ): Promise<ClientRecord | undefined> {
1391
1489
  const record = await this.#db
1392
1490
  .prepare(
1393
- 'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1491
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
1394
1492
  )
1395
1493
  .bind(partition, clientId)
1396
1494
  .first<{
1397
1495
  client_id: string;
1398
1496
  actor_id: string;
1497
+ wire_version: number;
1399
1498
  cursor: number;
1400
1499
  subscriptions: string;
1401
1500
  updated_at_ms: number;
@@ -1404,6 +1503,7 @@ export class D1ServerStorage implements ServerStorage {
1404
1503
  return {
1405
1504
  clientId: record.client_id,
1406
1505
  actorId: record.actor_id,
1506
+ wireVersion: record.wire_version,
1407
1507
  cursor: record.cursor,
1408
1508
  updatedAtMs: record.updated_at_ms,
1409
1509
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
@@ -1416,12 +1516,13 @@ export class D1ServerStorage implements ServerStorage {
1416
1516
  ): Promise<void> {
1417
1517
  await this.#db
1418
1518
  .prepare(
1419
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?)',
1519
+ 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
1420
1520
  )
1421
1521
  .bind(
1422
1522
  partition,
1423
1523
  record.clientId,
1424
1524
  record.actorId,
1525
+ record.wireVersion,
1425
1526
  record.cursor,
1426
1527
  JSON.stringify(record.subscriptions),
1427
1528
  record.updatedAtMs,
@@ -1494,12 +1595,13 @@ export class D1ServerStorage implements ServerStorage {
1494
1595
  async listClientRecords(partition: string): Promise<ClientRecord[]> {
1495
1596
  const { results } = await this.#db
1496
1597
  .prepare(
1497
- 'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
1598
+ 'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
1498
1599
  )
1499
1600
  .bind(partition)
1500
1601
  .all<{
1501
1602
  client_id: string;
1502
1603
  actor_id: string;
1604
+ wire_version: number;
1503
1605
  cursor: number;
1504
1606
  subscriptions: string;
1505
1607
  updated_at_ms: number;
@@ -1507,6 +1609,7 @@ export class D1ServerStorage implements ServerStorage {
1507
1609
  return results.map((record) => ({
1508
1610
  clientId: record.client_id,
1509
1611
  actorId: record.actor_id,
1612
+ wireVersion: record.wire_version,
1510
1613
  cursor: record.cursor,
1511
1614
  updatedAtMs: record.updated_at_ms,
1512
1615
  subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
@@ -1628,14 +1731,6 @@ export class D1ServerStorage implements ServerStorage {
1628
1731
  }
1629
1732
 
1630
1733
  async listPartitions(): Promise<string[]> {
1631
- // Union: the registry row appears on first commit, the client row on
1632
- // first pull — a partition with only one of the two still shows up.
1633
- const { results } = await this.#db
1634
- .prepare(
1635
- `SELECT partition FROM sync_partitions
1636
- UNION SELECT partition FROM sync_clients ORDER BY partition`,
1637
- )
1638
- .all<{ partition: string }>();
1639
- return results.map((r) => r.partition);
1734
+ return (await this.listPartitionRegistry()).map((entry) => entry.partition);
1640
1735
  }
1641
1736
  }
package/src/errors.ts CHANGED
@@ -199,6 +199,12 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
199
199
  recommendedAction: 'upgradeClient',
200
200
  httpStatus: 400,
201
201
  },
202
+ 'sync.client_wire_unsupported': {
203
+ category: 'schema-mismatch',
204
+ retryable: false,
205
+ recommendedAction: 'upgradeClient',
206
+ httpStatus: 400,
207
+ },
202
208
  'sync.websocket_connection_limit': {
203
209
  category: 'rate-limited',
204
210
  retryable: true,
package/src/events.ts CHANGED
@@ -29,10 +29,11 @@ export interface RequestHandledEvent {
29
29
  /**
30
30
  * `ok` — response streamed to END;
31
31
  * `schema_floor` — §2.4 required-schema answer;
32
+ * `reset` — §2.1 log-epoch reset answer;
32
33
  * `rejected` — request validation failed before any bytes (§1.7);
33
34
  * `error` — in-band ERROR frame (§1.6) or a thrown host failure.
34
35
  */
35
- readonly outcome: 'ok' | 'schema_floor' | 'rejected' | 'error';
36
+ readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
36
37
  /** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
37
38
  readonly errorCode?: string;
38
39
  readonly pushCommits: number;
@@ -18,7 +18,14 @@ import {
18
18
  type SubStartFrame,
19
19
  } from '@syncular/core';
20
20
 
21
- const STUB_HEADER: RespHeaderFrame = { type: 'RESP_HEADER' };
21
+ const stubHeader = (wireVersion: number): RespHeaderFrame =>
22
+ wireVersion >= 2
23
+ ? {
24
+ type: 'RESP_HEADER',
25
+ logEpoch: 'frame-probe',
26
+ resetRequired: false,
27
+ }
28
+ : { type: 'RESP_HEADER' };
22
29
  const STUB_SUB_START: SubStartFrame = {
23
30
  type: 'SUB_START',
24
31
  id: '',
@@ -30,18 +37,28 @@ const STUB_SUB_START: SubStartFrame = {
30
37
  const STUB_SUB_END: SubEndFrame = { type: 'SUB_END', nextCursor: 0 };
31
38
 
32
39
  const probe = encodeMessage({
33
- wireVersion: PROTOCOL_WIRE_VERSION,
40
+ wireVersion: 1,
34
41
  msgKind: 'response',
35
- frames: [STUB_HEADER],
42
+ frames: [stubHeader(1)],
36
43
  });
37
44
 
38
45
  /** The 8-byte SSP2 response envelope header (§1.2). */
39
- export const RESPONSE_ENVELOPE_HEADER: Uint8Array = probe.slice(0, 8);
46
+ export function responseEnvelopeHeader(wireVersion: number): Uint8Array {
47
+ const encoded = encodeMessage({
48
+ wireVersion,
49
+ msgKind: 'response',
50
+ frames: [stubHeader(wireVersion)],
51
+ });
52
+ return encoded.slice(0, 8);
53
+ }
40
54
 
41
55
  /** The terminating END frame (§1.2 rule 1). */
42
56
  export const END_FRAME_BYTES: Uint8Array = probe.slice(probe.length - 5);
43
57
 
44
- function wrapperFor(frame: ResponseFrame): {
58
+ function wrapperFor(
59
+ frame: ResponseFrame,
60
+ wireVersion: number,
61
+ ): {
45
62
  frames: ResponseFrame[];
46
63
  index: number;
47
64
  } {
@@ -50,11 +67,11 @@ function wrapperFor(frame: ResponseFrame): {
50
67
  return { frames: [frame], index: 0 };
51
68
  case 'LEASE':
52
69
  // §7.3.2: LEASE rides immediately after RESP_HEADER.
53
- return { frames: [STUB_HEADER, frame], index: 1 };
70
+ return { frames: [stubHeader(wireVersion), frame], index: 1 };
54
71
  case 'PUSH_RESULT':
55
72
  case 'ERROR':
56
73
  case 'UNKNOWN':
57
- return { frames: [STUB_HEADER, frame], index: 1 };
74
+ return { frames: [stubHeader(wireVersion), frame], index: 1 };
58
75
  case 'PUSH_RESULT_DETAILS': {
59
76
  const result: PushResultFrame = {
60
77
  type: 'PUSH_RESULT',
@@ -68,17 +85,23 @@ function wrapperFor(frame: ResponseFrame): {
68
85
  retryable: false,
69
86
  })),
70
87
  };
71
- return { frames: [STUB_HEADER, result, frame], index: 2 };
88
+ return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
72
89
  }
73
90
  case 'SUB_START':
74
- return { frames: [STUB_HEADER, frame, STUB_SUB_END], index: 1 };
91
+ return {
92
+ frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
93
+ index: 1,
94
+ };
75
95
  case 'SUB_END':
76
- return { frames: [STUB_HEADER, STUB_SUB_START, frame], index: 2 };
96
+ return {
97
+ frames: [stubHeader(wireVersion), STUB_SUB_START, frame],
98
+ index: 2,
99
+ };
77
100
  case 'COMMIT':
78
101
  case 'SEGMENT_REF':
79
102
  case 'SEGMENT_INLINE':
80
103
  return {
81
- frames: [STUB_HEADER, STUB_SUB_START, frame, STUB_SUB_END],
104
+ frames: [stubHeader(wireVersion), STUB_SUB_START, frame, STUB_SUB_END],
82
105
  index: 2,
83
106
  };
84
107
  }
@@ -88,10 +111,13 @@ function wrapperFor(frame: ResponseFrame): {
88
111
  * Encode one response frame (5-byte frame header + payload) using the
89
112
  * reference codec.
90
113
  */
91
- export function encodeResponseFrame(frame: ResponseFrame): Uint8Array {
92
- const { frames, index } = wrapperFor(frame);
114
+ export function encodeResponseFrame(
115
+ frame: ResponseFrame,
116
+ wireVersion = PROTOCOL_WIRE_VERSION,
117
+ ): Uint8Array {
118
+ const { frames, index } = wrapperFor(frame, wireVersion);
93
119
  const encoded = encodeMessage({
94
- wireVersion: PROTOCOL_WIRE_VERSION,
120
+ wireVersion,
95
121
  msgKind: 'response',
96
122
  frames,
97
123
  });
package/src/handler.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  limitsOf,
29
29
  REMOTE_COMMAND_CLIENT_ID_PREFIX,
30
30
  RESOLVER_OUTAGE,
31
+ touchAuthenticatedPartition,
31
32
  } from './context';
32
33
  import { SyncError, syncError } from './errors';
33
34
  import {
@@ -38,7 +39,7 @@ import {
38
39
  import {
39
40
  END_FRAME_BYTES,
40
41
  encodeResponseFrame,
41
- RESPONSE_ENVELOPE_HEADER,
42
+ responseEnvelopeHeader,
42
43
  } from './frame-bytes';
43
44
  import type { LeaseRecord } from './lease-store';
44
45
  import {
@@ -53,9 +54,12 @@ import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
53
54
  import type { CompiledSchema } from './schema';
54
55
  import { compileSchema } from './schema';
55
56
  import { computeEffective, type ResolvedScopes } from './scopes';
56
- import type { ClientSubscription } from './storage';
57
+ import type { ClientSubscription, PartitionRegistryEntry } from './storage';
57
58
 
58
59
  interface RequestPlan {
60
+ readonly wireVersion: number;
61
+ readonly logEpoch: string;
62
+ readonly epochReset: boolean;
59
63
  readonly header: ReqHeaderFrame;
60
64
  readonly pushes: readonly PushCommitFrame[];
61
65
  readonly pull: PullHeaderFrame | undefined;
@@ -192,6 +196,7 @@ async function planRequest(
192
196
  request: RequestMessage,
193
197
  ctx: SyncRequestContext,
194
198
  schema: CompiledSchema,
199
+ registry: PartitionRegistryEntry,
195
200
  ): Promise<RequestPlan> {
196
201
  const header = request.frames[0];
197
202
  if (header === undefined || header.type !== 'REQ_HEADER') {
@@ -206,10 +211,32 @@ async function planRequest(
206
211
  else if (frame.type === 'SUBSCRIPTION') subFrames.push(frame);
207
212
  }
208
213
 
214
+ if (request.wireVersion === 1 && registry.epochRequired) {
215
+ throw syncError(
216
+ 'sync.client_wire_unsupported',
217
+ 'this partition requires a client that validates log epochs (§2.1)',
218
+ );
219
+ }
220
+ if (
221
+ request.wireVersion >= 2 &&
222
+ header.logEpoch === undefined &&
223
+ pushes.length > 0
224
+ ) {
225
+ throw syncError(
226
+ 'sync.invalid_request',
227
+ 'epoch acquisition requests must not carry push commits (§2.1)',
228
+ );
229
+ }
230
+ const epochReset =
231
+ request.wireVersion >= 2 && header.logEpoch !== registry.logEpoch;
232
+
209
233
  if (header.schemaVersion !== schema.version) {
210
234
  // §2.4: no degraded encoding — answer with the schema floor (§1.6).
211
235
  // No lease is issued on a floor round (§7.3.3).
212
236
  return {
237
+ wireVersion: request.wireVersion,
238
+ logEpoch: registry.logEpoch,
239
+ epochReset,
213
240
  header,
214
241
  pushes,
215
242
  pull,
@@ -220,6 +247,21 @@ async function planRequest(
220
247
  };
221
248
  }
222
249
 
250
+ if (epochReset) {
251
+ return {
252
+ wireVersion: request.wireVersion,
253
+ logEpoch: registry.logEpoch,
254
+ epochReset: true,
255
+ header,
256
+ pushes: [],
257
+ pull: undefined,
258
+ subscriptions: [],
259
+ resolved: { ok: false },
260
+ schemaFloor: false,
261
+ leaseToEmit: undefined,
262
+ };
263
+ }
264
+
223
265
  if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
224
266
  throw syncError(
225
267
  'sync.invalid_client_id',
@@ -313,6 +355,9 @@ async function planRequest(
313
355
  });
314
356
 
315
357
  return {
358
+ wireVersion: request.wireVersion,
359
+ logEpoch: registry.logEpoch,
360
+ epochReset: false,
316
361
  header,
317
362
  pushes,
318
363
  pull,
@@ -325,7 +370,7 @@ async function planRequest(
325
370
 
326
371
  /** Mutable outcome box shared with the instrumented stream wrapper. */
327
372
  interface RequestReport {
328
- outcome: 'ok' | 'schema_floor' | 'error';
373
+ outcome: 'ok' | 'schema_floor' | 'reset' | 'error';
329
374
  errorCode?: string;
330
375
  }
331
376
 
@@ -413,28 +458,48 @@ async function* streamResponse(
413
458
  report?: RequestReport,
414
459
  ): AsyncGenerator<Uint8Array> {
415
460
  const events = ctx.events;
416
- yield RESPONSE_ENVELOPE_HEADER;
461
+ yield responseEnvelopeHeader(plan.wireVersion);
417
462
  if (plan.schemaFloor) {
418
463
  if (report !== undefined) report.outcome = 'schema_floor';
419
- yield encodeResponseFrame({
464
+ yield encodeResponseFrame(
465
+ {
466
+ type: 'RESP_HEADER',
467
+ requiredSchemaVersion: schema.version,
468
+ latestSchemaVersion: schema.version,
469
+ ...(plan.wireVersion >= 2
470
+ ? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
471
+ : {}),
472
+ },
473
+ plan.wireVersion,
474
+ );
475
+ yield END_FRAME_BYTES;
476
+ return;
477
+ }
478
+ yield encodeResponseFrame(
479
+ {
420
480
  type: 'RESP_HEADER',
421
- requiredSchemaVersion: schema.version,
422
481
  latestSchemaVersion: schema.version,
423
- });
482
+ ...(plan.wireVersion >= 2
483
+ ? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
484
+ : {}),
485
+ },
486
+ plan.wireVersion,
487
+ );
488
+ if (plan.epochReset) {
489
+ if (report !== undefined) report.outcome = 'reset';
424
490
  yield END_FRAME_BYTES;
425
491
  return;
426
492
  }
427
- yield encodeResponseFrame({
428
- type: 'RESP_HEADER',
429
- latestSchemaVersion: schema.version,
430
- });
431
493
  // §7.3.2: the LEASE frame rides immediately after RESP_HEADER.
432
494
  if (plan.leaseToEmit !== undefined) {
433
- yield encodeResponseFrame({
434
- type: 'LEASE',
435
- leaseId: plan.leaseToEmit.leaseId,
436
- expiresAtMs: plan.leaseToEmit.expiresAtMs,
437
- });
495
+ yield encodeResponseFrame(
496
+ {
497
+ type: 'LEASE',
498
+ leaseId: plan.leaseToEmit.leaseId,
499
+ expiresAtMs: plan.leaseToEmit.expiresAtMs,
500
+ },
501
+ plan.wireVersion,
502
+ );
438
503
  if (events !== undefined) {
439
504
  emitEvent(events, {
440
505
  type: 'lease.issued',
@@ -461,9 +526,11 @@ async function* streamResponse(
461
526
  if (events !== undefined) {
462
527
  emitPushEvent(events, ctx, plan.header.clientId, push, processed);
463
528
  }
464
- yield encodeResponseFrame(frame);
529
+ yield encodeResponseFrame(frame, plan.wireVersion);
465
530
  const details = pushResultDetailsFrame(frame);
466
- if (details !== undefined) yield encodeResponseFrame(details);
531
+ if (details !== undefined) {
532
+ yield encodeResponseFrame(details, plan.wireVersion);
533
+ }
467
534
  }
468
535
 
469
536
  // Pull half (§4): subscriptions echoed in request order.
@@ -485,6 +552,7 @@ async function* streamResponse(
485
552
  maxSeq,
486
553
  horizonSeq,
487
554
  trace,
555
+ plan.logEpoch,
488
556
  );
489
557
  let status: 'active' | 'revoked' | 'reset' = 'active';
490
558
  let bootstrap = false;
@@ -502,7 +570,7 @@ async function* streamResponse(
502
570
  changes += frame.changes.length;
503
571
  }
504
572
  }
505
- yield encodeResponseFrame(frame);
573
+ yield encodeResponseFrame(frame, plan.wireVersion);
506
574
  step = await section.next();
507
575
  }
508
576
  if (step.value.active) cursors.push(step.value.nextCursor);
@@ -556,6 +624,7 @@ async function* streamResponse(
556
624
  await ctx.storage.putClientRecord(ctx.partition, {
557
625
  clientId: plan.header.clientId,
558
626
  actorId: ctx.actorId,
627
+ wireVersion: plan.wireVersion,
559
628
  cursor,
560
629
  updatedAtMs: clockOf(ctx)(),
561
630
  subscriptions,
@@ -567,15 +636,18 @@ async function* streamResponse(
567
636
  report.errorCode = error.code;
568
637
  }
569
638
  // §1.6: in-band ERROR, then END, nothing else.
570
- yield encodeResponseFrame({
571
- type: 'ERROR',
572
- code: error.code,
573
- message: error.message,
574
- category: error.category,
575
- retryable: error.retryable,
576
- recommendedAction: error.recommendedAction,
577
- ...(error.details !== undefined ? { details: error.details } : {}),
578
- });
639
+ yield encodeResponseFrame(
640
+ {
641
+ type: 'ERROR',
642
+ code: error.code,
643
+ message: error.message,
644
+ category: error.category,
645
+ retryable: error.retryable,
646
+ recommendedAction: error.recommendedAction,
647
+ ...(error.details !== undefined ? { details: error.details } : {}),
648
+ },
649
+ plan.wireVersion,
650
+ );
579
651
  yield END_FRAME_BYTES;
580
652
  return;
581
653
  }
@@ -644,7 +716,8 @@ async function createStreamCore(
644
716
  // Relational row tables: create/
645
717
  // migrate on first contact; memoized per storage instance thereafter.
646
718
  await ctx.storage.ensureSchema(schema);
647
- const plan = await planRequest(request, ctx, schema);
719
+ const registry = await touchAuthenticatedPartition(ctx);
720
+ const plan = await planRequest(request, ctx, schema, registry);
648
721
  if (events === undefined) return streamResponse(plan, ctx, schema);
649
722
  const report: RequestReport = { outcome: 'ok' };
650
723
  return instrumentedStream(
@@ -0,0 +1,9 @@
1
+ export * from './index';
2
+ export {
3
+ buildSqliteImage,
4
+ BunSqliteDatabase,
5
+ SqliteBlobStore,
6
+ SqliteLeaseStore,
7
+ SqliteSegmentStore,
8
+ SqliteServerStorage,
9
+ } from './sqlite-bun';
@@ -0,0 +1,9 @@
1
+ export * from './index';
2
+ export {
3
+ buildSqliteImage,
4
+ NodeSqliteDatabase,
5
+ SqliteBlobStore,
6
+ SqliteLeaseStore,
7
+ SqliteSegmentStore,
8
+ SqliteServerStorage,
9
+ } from './sqlite-node';
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export {
64
64
  type ReactionTypeMap,
65
65
  } from './reactions';
66
66
  export * from './realtime';
67
+ export * from './restore';
67
68
  export * from './relational-rows';
68
69
  export * from './s3-blob-store';
69
70
  export * from './s3-segment-store';
@@ -73,13 +74,15 @@ export * from './seed';
73
74
  export * from './segment-download';
74
75
  export * from './segment-store';
75
76
  export * from './signed-url';
76
- // Bun-specific storages (top-level `bun:sqlite`): re-exported for Bun/Node
77
- // hosts. Workers/edge builds tree-shake them (and their `bun:sqlite` import)
78
- // away — the runtime-neutral core closure is enforced by
79
- // `test/runtime-neutrality.test.ts`.
80
- export * from './sqlite-blob-store';
81
77
  export * from './sqlite-dialect';
82
- export * from './sqlite-image';
78
+ export * from './sqlite-driver';
79
+ export {
80
+ IMAGE_METADATA_TABLE,
81
+ IMAGE_VERSION_COLUMN,
82
+ type SqliteImageBuilder,
83
+ type SqliteImageInput,
84
+ } from './sqlite-image';
85
+ export * from './sqlite-blob-store';
83
86
  export * from './sqlite-lease-store';
84
87
  export * from './sqlite-segment-store';
85
88
  export * from './sqlite-storage';
package/src/operations.ts CHANGED
@@ -9,7 +9,11 @@ import {
9
9
  type ScopeMap,
10
10
  } from '@syncular/core';
11
11
  import type { SyncRequestContext } from './context';
12
- import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context';
12
+ import {
13
+ REMOTE_COMMAND_CLIENT_ID_PREFIX,
14
+ RESOLVER_OUTAGE,
15
+ touchAuthenticatedPartition,
16
+ } from './context';
13
17
  import { SyncError, syncError } from './errors';
14
18
  import { processPushOperationsWithTrace } from './push';
15
19
  import { compileSchema } from './schema';
@@ -673,6 +677,7 @@ export async function handleRemoteOperation(
673
677
  return encodeRemoteOperationError(error, 'operation.invalid_request');
674
678
  }
675
679
  try {
680
+ await touchAuthenticatedPartition(ctx);
676
681
  if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
677
682
  throw syncError(
678
683
  'sync.invalid_client_id',