@syncular/server 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/handler.ts CHANGED
@@ -526,11 +526,12 @@ async function* streamResponse(
526
526
  }
527
527
  try {
528
528
  // Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
529
+ const currentSchema = compileSchema(ctx.schema);
529
530
  let acceptedThrough = 0;
530
531
  for (const push of plan.pushes) {
531
532
  const processed = await processPushCommitWithTrace(
532
533
  ctx,
533
- compileSchema(ctx.schema),
534
+ currentSchema,
534
535
  plan.resolved,
535
536
  plan.header.clientId,
536
537
  push,
@@ -543,20 +544,36 @@ async function* streamResponse(
543
544
  const frame = {
544
545
  ...processed.frame,
545
546
  results: processed.frame.results.map((result) => {
546
- if (result.status !== 'conflict') return result;
547
- const operation = push.operations[result.opIndex]!;
547
+ if (result.status !== 'conflict' || schema === currentSchema) {
548
+ return result;
549
+ }
550
+ const operation = push.operations[result.opIndex];
551
+ // §2.3: a replay returns the persisted result without rebuilding
552
+ // operations, so a reused clientCommitId with changed operations
553
+ // leaves no table to project the stored conflict row through.
554
+ if (operation === undefined) {
555
+ throw syncError(
556
+ 'sync.invalid_request',
557
+ 'replayed clientCommitId carries changed operations',
558
+ JSON.stringify({
559
+ clientCommitId: push.clientCommitId,
560
+ opIndex: result.opIndex,
561
+ }),
562
+ );
563
+ }
548
564
  const target = schema.tables.get(operation.table)!;
549
- const current = compileSchema(ctx.schema).tables.get(
550
- operation.table,
551
- )!;
565
+ const bitmapBytes = Math.ceil(target.columns.length / 8);
552
566
  return {
553
567
  ...result,
554
- serverRow: projectRowPayload(current, target, result.serverRow),
568
+ serverRow: projectRowPayload(
569
+ currentSchema.tables.get(operation.table)!,
570
+ target,
571
+ result.serverRow,
572
+ ),
555
573
  conflictColumns: result.conflictColumns
556
- .slice(0, Math.ceil(target.columns.length / 8))
574
+ .slice(0, bitmapBytes)
557
575
  .map((byte, i) =>
558
- i === Math.ceil(target.columns.length / 8) - 1 &&
559
- target.columns.length % 8 !== 0
576
+ i === bitmapBytes - 1 && target.columns.length % 8 !== 0
560
577
  ? byte & ((1 << (target.columns.length % 8)) - 1)
561
578
  : byte,
562
579
  ),
@@ -700,6 +717,17 @@ async function* streamResponse(
700
717
  updatedAtMs: clockOf(ctx)(),
701
718
  subscriptions,
702
719
  });
720
+ if (
721
+ ctx.realtime?.notifySubscriptions !== undefined &&
722
+ JSON.stringify(
723
+ (previous?.subscriptions ?? []).map((s) => [s.id, s.table, s.scopes]),
724
+ ) !== JSON.stringify(subscriptions.map((s) => [s.id, s.table, s.scopes]))
725
+ ) {
726
+ await ctx.realtime.notifySubscriptions(
727
+ ctx.partition,
728
+ plan.header.clientId,
729
+ );
730
+ }
703
731
  } catch (error) {
704
732
  if (error instanceof SyncError) {
705
733
  if (report !== undefined) {
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export * from './admin';
11
11
  export * from './authoritative-query';
12
12
  export * from './blob-handlers';
13
13
  export * from './blob-store';
14
+ export * from './bucket-blob-store';
15
+ export * from './bucket-segment-store';
14
16
  export * from './content-encoding';
15
17
  export * from './context';
16
18
  export * from './crdt-merger';
@@ -65,8 +67,10 @@ export {
65
67
  } from './reactions';
66
68
  export * from './realtime';
67
69
  export * from './restore';
70
+ export * from './object-bucket';
68
71
  export * from './relational-rows';
69
72
  export * from './s3-blob-store';
73
+ export * from './s3-object-bucket';
70
74
  export * from './s3-segment-store';
71
75
  export * from './schema';
72
76
  export * from './scopes';
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The object-bucket seam: the handful of object-store operations the durable
3
+ * segment store (§5.1) and blob store (§5.9.2) actually perform, expressed
4
+ * without an HTTP transport.
5
+ *
6
+ * Two adapters implement it — `S3ObjectBucket` (`s3-object-bucket.ts`,
7
+ * hand-rolled SigV4 over `fetch`) and the R2 binding adapter in
8
+ * `@syncular/server-workers` (`r2-stores.ts`, the Worker's `env.BUCKET`) — so
9
+ * one implementation of the store logic (`bucket-segment-store.ts`,
10
+ * `bucket-blob-store.ts`) runs over both. The key layouts are the same on
11
+ * either transport, so a bucket can be served through the S3 API for one host
12
+ * and the binding for another.
13
+ *
14
+ * Everything here is structural: an adapter may be any object with these
15
+ * methods, and Cloudflare's `R2Bucket` is assignable to the declaration in
16
+ * `r2-stores.ts` without a dependency on `@cloudflare/workers-types`.
17
+ */
18
+
19
+ /** One stored object, readable once. Mirrors `R2ObjectBody`. */
20
+ export interface BucketObject {
21
+ /** The body, streamed; `null` only if the backend served none. */
22
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>> | null;
23
+ /** Backend ETag of the stored object (opaque, used as a CAS token). */
24
+ readonly etag: string | undefined;
25
+ /** Stored size in bytes, when the backend reports one. */
26
+ readonly size: number | undefined;
27
+ /** User metadata: the `x-amz-meta-*` headers S3-side, `customMetadata` on R2. */
28
+ readonly metadata: Readonly<Record<string, string>>;
29
+ arrayBuffer(): Promise<ArrayBuffer>;
30
+ text(): Promise<string>;
31
+ }
32
+
33
+ /** One stored object's description without its body. Mirrors `R2Object`. */
34
+ export interface BucketObjectHead {
35
+ readonly etag: string | undefined;
36
+ readonly size: number | undefined;
37
+ readonly metadata: Readonly<Record<string, string>>;
38
+ }
39
+
40
+ export interface BucketWriteOptions {
41
+ readonly body: Uint8Array;
42
+ readonly contentType: string;
43
+ /** User metadata; values must be ASCII (both backends require it). */
44
+ readonly metadata?: Readonly<Record<string, string>> | undefined;
45
+ /**
46
+ * Create-only write (`If-None-Match: *`, R2 `etagDoesNotMatch: '*'`). The
47
+ * content-addressed bytes object is written this way so a second publisher
48
+ * of identical bytes is a no-op and a hash collision cannot overwrite.
49
+ */
50
+ readonly createOnly?: boolean | undefined;
51
+ /**
52
+ * Compare-and-swap write (`If-Match`, R2 `etagMatches`): the write lands
53
+ * only if the stored ETag still equals this. The read-modify-write of the
54
+ * segment record and of the stats accumulator depend on it.
55
+ */
56
+ readonly ifMatch?: string | undefined;
57
+ }
58
+
59
+ /** One page of keys under a prefix. `cursor` is set when more remain. */
60
+ export interface BucketListPage {
61
+ readonly keys: readonly string[];
62
+ readonly cursor: string | undefined;
63
+ }
64
+
65
+ export interface ObjectBucket {
66
+ /** The object plus its bytes, or `undefined` when the key is absent. */
67
+ get(key: string): Promise<BucketObject | undefined>;
68
+ head(key: string): Promise<BucketObjectHead | undefined>;
69
+ /**
70
+ * Write one object. Returns `false` when the `createOnly` / `ifMatch`
71
+ * precondition failed, so the caller can re-read and retry; a backend that
72
+ * ignores conditional writes would instead silently lose a concurrent
73
+ * update, which is why the return value is not optional.
74
+ */
75
+ put(key: string, options: BucketWriteOptions): Promise<boolean>;
76
+ /** Delete one object. Deleting an absent key succeeds (idempotent). */
77
+ delete(key: string): Promise<void>;
78
+ /**
79
+ * List keys under a prefix, one page at a time. The blob orphan sweep
80
+ * (§5.9.2) is the only caller: everything on the hot path is a GET/HEAD.
81
+ */
82
+ list(prefix: string, cursor?: string): Promise<BucketListPage>;
83
+ }
@@ -271,5 +271,12 @@ export function composeRealtimeNotifiers(
271
271
  notifiers.map((notifier) => notifier.notifyCommit(partition, commit)),
272
272
  );
273
273
  },
274
+ notifySubscriptions: async (partition, clientId) => {
275
+ await Promise.all(
276
+ notifiers.map((notifier) =>
277
+ notifier.notifySubscriptions?.(partition, clientId),
278
+ ),
279
+ );
280
+ },
274
281
  };
275
282
  }
package/src/operations.ts CHANGED
@@ -21,7 +21,11 @@ import {
21
21
  import { reportError, SyncError, syncError } from './errors';
22
22
  import { processPushOperationsWithTrace } from './push';
23
23
  import { compileSchema } from './schema';
24
- import { authorizeWrite, type ResolvedScopes } from './scopes';
24
+ import {
25
+ authorizeWrite,
26
+ renderScopeValue,
27
+ type ResolvedScopes,
28
+ } from './scopes';
25
29
  import type {
26
30
  AuthoritativeQueryRequest,
27
31
  AuthoritativeQueryResult,
@@ -534,11 +538,11 @@ function commandOperations(
534
538
  }
535
539
  return mutation.values[column.name] ?? null;
536
540
  });
537
- const rowId = values[table.primaryKeyIndex];
538
- if (typeof rowId !== 'string' || rowId.length === 0) {
541
+ const rowId = renderScopeValue(values[table.primaryKeyIndex]);
542
+ if (rowId === undefined) {
539
543
  throw syncError(
540
544
  'operation.invalid_request',
541
- 'command upsert requires a non-empty string primary key',
545
+ 'command upsert requires a non-empty primary key',
542
546
  );
543
547
  }
544
548
  return {
package/src/push.ts CHANGED
@@ -503,6 +503,7 @@ async function applyOperation(
503
503
  if (
504
504
  present[i] &&
505
505
  !crdtIndexes.has(i) &&
506
+ i !== table.primaryKeyIndex &&
506
507
  effectiveVersion(i) > op.baseVersion
507
508
  ) {
508
509
  marked.push(i);
@@ -978,7 +979,20 @@ async function enforceReferences(
978
979
  const operations: ValidateCommitOperation[] = [];
979
980
  const originByIndex: number[] = [];
980
981
 
982
+ const finalByRow = new Map<
983
+ string,
984
+ { readonly first: StagedWrite; readonly last: StagedWrite }
985
+ >();
981
986
  for (const write of staged) {
987
+ const rowKey = `${write.table.name}\u0000${write.rowId}`;
988
+ const existing = finalByRow.get(rowKey);
989
+ finalByRow.set(rowKey, {
990
+ first: existing?.first ?? write,
991
+ last: write,
992
+ });
993
+ }
994
+
995
+ for (const { last: write } of finalByRow.values()) {
982
996
  if (write.op !== 'upsert' || write.values === undefined) continue;
983
997
  for (const reference of write.table.references) {
984
998
  const value = write.values[reference.columnIndex];
@@ -1028,9 +1042,9 @@ async function enforceReferences(
1028
1042
  readonly rowId: string;
1029
1043
  readonly originOpIndex: number;
1030
1044
  }> = [];
1031
- for (const write of staged) {
1032
- if (write.op === 'delete' && write.stored !== undefined) {
1033
- schedule(write.table, write.rowId, write.opIndex, scheduled, queue);
1045
+ for (const { first, last } of finalByRow.values()) {
1046
+ if (last.op === 'delete' && first.stored !== undefined) {
1047
+ schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
1034
1048
  }
1035
1049
  }
1036
1050
 
@@ -1040,6 +1054,13 @@ async function enforceReferences(
1040
1054
  const key = `${item.table.name}\u0000${item.rowId}`;
1041
1055
  if (processed.has(key)) continue;
1042
1056
  processed.add(key);
1057
+ const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
1058
+ const parentKeyValue: RowValue =
1059
+ parentPkColumn?.type === 'integer'
1060
+ ? Number(item.rowId)
1061
+ : parentPkColumn?.type === 'boolean'
1062
+ ? item.rowId === 'true'
1063
+ : item.rowId;
1043
1064
  for (const reference of item.table.referencedBy) {
1044
1065
  if (tx.scanRowsByIndex === undefined) {
1045
1066
  throw new Error(
@@ -1052,7 +1073,7 @@ async function enforceReferences(
1052
1073
  const page = await tx.scanRowsByIndex({
1053
1074
  table: reference.table,
1054
1075
  index: reference.index,
1055
- values: [item.rowId],
1076
+ values: [parentKeyValue],
1056
1077
  afterRowId,
1057
1078
  limit: 1_000,
1058
1079
  });
@@ -1081,6 +1102,21 @@ async function enforceReferences(
1081
1102
  );
1082
1103
  }
1083
1104
  for (const childRow of children) {
1105
+ if (reference.onDelete === 'CASCADE') {
1106
+ if (
1107
+ !schedule(
1108
+ child,
1109
+ childRow.rowId,
1110
+ item.originOpIndex,
1111
+ scheduled,
1112
+ queue,
1113
+ )
1114
+ ) {
1115
+ continue;
1116
+ }
1117
+ } else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
1118
+ continue;
1119
+ }
1084
1120
  if (originByIndex.length >= cascadeLimit) {
1085
1121
  return {
1086
1122
  kind: 'terminate',
@@ -1091,11 +1127,6 @@ async function enforceReferences(
1091
1127
  ),
1092
1128
  };
1093
1129
  }
1094
- if (
1095
- !schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)
1096
- ) {
1097
- continue;
1098
- }
1099
1130
  const syntheticIndex = clientOpCount + originByIndex.length;
1100
1131
  const storedValues = decodeRow(child.columns, childRow.payload);
1101
1132
  if (reference.onDelete === 'CASCADE') {
package/src/realtime.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  encodeMessage,
24
24
  encodePresenceError,
25
25
  encodePresenceFanout,
26
+ isSupportedProtocolWireVersion,
26
27
  MessageStreamScanner,
27
28
  PROTOCOL_WIRE_VERSION,
28
29
  type PresenceKind,
@@ -278,6 +279,8 @@ export class RealtimeSession {
278
279
  #registrationGeneration = 0;
279
280
  /** Generation of the most recent refresh whose result was applied. */
280
281
  #registrationApplied = 0;
282
+ /** Highest sequence that required a catch-up wake for this session. */
283
+ #wakeRequiredThroughSeq: number;
281
284
 
282
285
  constructor(
283
286
  hub: RealtimeHub,
@@ -301,6 +304,7 @@ export class RealtimeSession {
301
304
  this.cursor = cursor;
302
305
  this.lastKnownSeq = latestSeq;
303
306
  this.wakePending = cursor < latestSeq;
307
+ this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
304
308
  this.registrations = registrations;
305
309
  this.openedAtMs = clock();
306
310
  this.#send = options.send;
@@ -336,9 +340,9 @@ export class RealtimeSession {
336
340
  // proves those commits no longer need socket notifications, so the next
337
341
  // notification must be adjacent to the acknowledged cursor.
338
342
  this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
339
- if (this.cursor >= this.lastKnownSeq) this.wakePending = false;
343
+ if (this.cursor >= this.#wakeRequiredThroughSeq) this.wakePending = false;
340
344
  // §8.2: acks update the client cursor record without an HTTP pull.
341
- this.#persistCursor();
345
+ this.#persistCursor(cursor);
342
346
  }
343
347
 
344
348
  /**
@@ -571,7 +575,7 @@ export class RealtimeSession {
571
575
  try {
572
576
  const requestedWireVersion =
573
577
  (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
574
- if (requestedWireVersion === 1 || requestedWireVersion === 2) {
578
+ if (isSupportedProtocolWireVersion(requestedWireVersion)) {
575
579
  this.wireVersion = requestedWireVersion;
576
580
  }
577
581
  let stream: AsyncIterable<Uint8Array> | undefined;
@@ -688,6 +692,15 @@ export class RealtimeSession {
688
692
  * deltas immediately; it also survives a newer round-end refresh that
689
693
  * failed, because that round applied nothing.
690
694
  */
695
+ /**
696
+ * A pull outside this connection changed the client's subscriptions. A
697
+ * round in flight on the connection reloads them at its end anyway.
698
+ */
699
+ async refreshAfterPull(): Promise<void> {
700
+ if (this.#activeRound !== undefined) return;
701
+ await this.refreshRegistrations(false);
702
+ }
703
+
691
704
  async refreshRegistrations(failClosed: boolean): Promise<void> {
692
705
  const generation = ++this.#registrationGeneration;
693
706
  let registrations: Registration[];
@@ -721,7 +734,7 @@ export class RealtimeSession {
721
734
  this.#hub.disconnect(this);
722
735
  }
723
736
 
724
- #persistCursor(): void {
737
+ #persistCursor(cursor: number): void {
725
738
  // §8.2: start the write now and keep it on the control tail so `drain()`
726
739
  // can await it. Two acks may write concurrently: storage folds them with
727
740
  // `MAX(cursor, ?)`, so neither order loses the later cursor.
@@ -731,7 +744,7 @@ export class RealtimeSession {
731
744
  this.clientId,
732
745
  this.actorId,
733
746
  this.logEpoch,
734
- this.cursor,
747
+ cursor,
735
748
  this.#clock(),
736
749
  );
737
750
  })();
@@ -767,6 +780,10 @@ export class RealtimeSession {
767
780
 
768
781
  sendWake(reason: WakeReason): void {
769
782
  this.wakePending = true;
783
+ this.#wakeRequiredThroughSeq = Math.max(
784
+ this.#wakeRequiredThroughSeq,
785
+ this.lastKnownSeq,
786
+ );
770
787
  this.#sendSafe(
771
788
  JSON.stringify({
772
789
  event: 'sync',
@@ -1096,58 +1113,13 @@ export class RealtimeHub {
1096
1113
  'socket sync rounds require a segment store on the realtime hub (§8.7)',
1097
1114
  );
1098
1115
  }
1116
+ // The whole config, so a new `SyncServerConfig` field reaches socket and
1117
+ // hub-served HTTP rounds without being listed here.
1099
1118
  return {
1119
+ ...this.#config,
1120
+ segments,
1100
1121
  partition: identity.partition,
1101
1122
  actorId: identity.actorId,
1102
- schema: this.#config.schema,
1103
- ...(this.#config.schemaWindow !== undefined
1104
- ? { schemaWindow: this.#config.schemaWindow }
1105
- : {}),
1106
- storage: this.#config.storage,
1107
- segments,
1108
- resolveScopes: this.#config.resolveScopes,
1109
- ...(this.#config.blobs !== undefined
1110
- ? { blobs: this.#config.blobs }
1111
- : {}),
1112
- ...(this.#config.maxBlobBytes !== undefined
1113
- ? { maxBlobBytes: this.#config.maxBlobBytes }
1114
- : {}),
1115
- ...(this.#config.crdtMergers !== undefined
1116
- ? { crdtMergers: this.#config.crdtMergers }
1117
- : {}),
1118
- ...(this.#config.validators !== undefined
1119
- ? { validators: this.#config.validators }
1120
- : {}),
1121
- ...(this.#config.commitValidator !== undefined
1122
- ? { commitValidator: this.#config.commitValidator }
1123
- : {}),
1124
- ...(this.#config.clock !== undefined
1125
- ? { clock: this.#config.clock }
1126
- : {}),
1127
- ...(this.#config.limits !== undefined
1128
- ? { limits: this.#config.limits }
1129
- : {}),
1130
- ...(this.#config.signedUrls !== undefined
1131
- ? { signedUrls: this.#config.signedUrls }
1132
- : {}),
1133
- ...(this.#config.blobSignedUrls !== undefined
1134
- ? { blobSignedUrls: this.#config.blobSignedUrls }
1135
- : {}),
1136
- ...(this.#config.blobUploadUrls !== undefined
1137
- ? { blobUploadUrls: this.#config.blobUploadUrls }
1138
- : {}),
1139
- ...(this.#config.sqliteImageBuilder !== undefined
1140
- ? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
1141
- : {}),
1142
- ...(this.#config.leases !== undefined
1143
- ? { leases: this.#config.leases }
1144
- : {}),
1145
- ...(this.#config.events !== undefined
1146
- ? { events: this.#config.events }
1147
- : {}),
1148
- ...(this.#config.onError !== undefined
1149
- ? { onError: this.#config.onError }
1150
- : {}),
1151
1123
  realtime: this,
1152
1124
  };
1153
1125
  }
@@ -1211,7 +1183,9 @@ export class RealtimeHub {
1211
1183
  this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
1212
1184
  storage,
1213
1185
  registry.logEpoch,
1214
- record?.wireVersion ?? PROTOCOL_WIRE_VERSION,
1186
+ record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
1187
+ ? record.wireVersion
1188
+ : PROTOCOL_WIRE_VERSION,
1215
1189
  this.#config.events,
1216
1190
  );
1217
1191
  this.#sessions.add(session);
@@ -1292,6 +1266,22 @@ export class RealtimeHub {
1292
1266
  }
1293
1267
  }
1294
1268
 
1269
+ /**
1270
+ * RealtimeNotifier: a pull changed a client's subscription list (§8.7).
1271
+ * Its sockets reload their registrations with the round-end semantics, so
1272
+ * an unreadable record keeps the previous registrations.
1273
+ */
1274
+ async notifySubscriptions(
1275
+ partition: string,
1276
+ clientId: string,
1277
+ ): Promise<void> {
1278
+ for (const session of [...this.#sessions]) {
1279
+ if (session.partition !== partition || session.clientId !== clientId)
1280
+ continue;
1281
+ await session.refreshAfterPull();
1282
+ }
1283
+ }
1284
+
1295
1285
  /**
1296
1286
  * Host-initiated, fail-closed registration refresh (§8.7). A host calls this
1297
1287
  * after it changes an actor's membership or connection state, so the