@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
@@ -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(
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';
package/src/operations.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ type AuthoritativeRelationPlan,
3
+ validateAuthoritativeRelationPlan,
4
+ } from './authoritative-query';
1
5
  import {
2
6
  decodeRow,
3
7
  decodeRemoteOperationRequest,
@@ -9,7 +13,11 @@ import {
9
13
  type ScopeMap,
10
14
  } from '@syncular/core';
11
15
  import type { SyncRequestContext } from './context';
12
- import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context';
16
+ import {
17
+ REMOTE_COMMAND_CLIENT_ID_PREFIX,
18
+ RESOLVER_OUTAGE,
19
+ touchAuthenticatedPartition,
20
+ } from './context';
13
21
  import { SyncError, syncError } from './errors';
14
22
  import { processPushOperationsWithTrace } from './push';
15
23
  import { compileSchema } from './schema';
@@ -38,6 +46,7 @@ export interface AuthoritativeQueryDescriptor<Params = undefined> {
38
46
  readonly hasParams: boolean;
39
47
  readonly sql: string;
40
48
  readonly tables: readonly string[];
49
+ readonly relationPlans: readonly AuthoritativeRelationPlan[];
41
50
  readonly resultColumns: readonly {
42
51
  readonly name: string;
43
52
  readonly type:
@@ -276,6 +285,9 @@ export function registerRemoteQuery<Params>(
276
285
  options: RemoteQueryOptions<Params>,
277
286
  ): RegisteredRemoteQuery {
278
287
  if (
288
+ !Array.isArray(descriptor.relationPlans) ||
289
+ !descriptor.relationPlans.some((plan) => plan.sql === descriptor.sql) ||
290
+ descriptor.relationPlans.some((plan) => !Array.isArray(plan.relations)) ||
279
291
  descriptor.id.length === 0 ||
280
292
  new Set(descriptor.tables).size !== descriptor.tables.length ||
281
293
  !Array.isArray(descriptor.resultColumns) ||
@@ -284,9 +296,12 @@ export function registerRemoteQuery<Params>(
284
296
  descriptor.resultColumns.length
285
297
  ) {
286
298
  throw new Error(
287
- 'remote query requires a non-empty id and unique tables and result columns',
299
+ 'remote query requires generated relation plans, a non-empty id, and unique tables and result columns; regenerate queries',
288
300
  );
289
301
  }
302
+ for (const plan of descriptor.relationPlans) {
303
+ validateAuthoritativeRelationPlan(plan, descriptor.tables);
304
+ }
290
305
  if (
291
306
  !Number.isSafeInteger(options.maxRows) ||
292
307
  options.maxRows < 1 ||
@@ -390,12 +405,29 @@ export function registerRemoteQuery<Params>(
390
405
  'configured storage does not implement authoritative queries',
391
406
  );
392
407
  }
393
- await ctx.storage.ensureSchema(schema);
394
408
  const selectedSql = descriptor.sqlFor?.(params) ?? descriptor.sql;
409
+ const plan = descriptor.relationPlans.find(
410
+ (candidate) => candidate.sql === selectedSql,
411
+ );
412
+ if (plan === undefined) {
413
+ throw syncError(
414
+ 'operation.invalid_request',
415
+ 'selected SQL has no generated relation plan; regenerate queries',
416
+ );
417
+ }
418
+ await ctx.storage.ensureSchema(schema);
419
+ const prefix = 'SELECT * FROM (';
395
420
  let result;
396
421
  try {
397
422
  result = await ctx.storage.queryAuthoritative(ctx.partition, {
398
- sql: `SELECT * FROM (${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
423
+ plan: {
424
+ sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
425
+ relations: plan.relations.map((relation) => ({
426
+ ...relation,
427
+ start: relation.start + prefix.length,
428
+ end: relation.end + prefix.length,
429
+ })),
430
+ },
399
431
  params: [...descriptor.bind(params), options.maxRows + 1],
400
432
  tables: descriptor.tables,
401
433
  });
@@ -673,6 +705,7 @@ export async function handleRemoteOperation(
673
705
  return encodeRemoteOperationError(error, 'operation.invalid_request');
674
706
  }
675
707
  try {
708
+ await touchAuthenticatedPartition(ctx);
676
709
  if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
677
710
  throw syncError(
678
711
  'sync.invalid_client_id',