@syncular/server 0.32.0 → 0.32.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.
@@ -144,6 +144,7 @@ class D1Transaction {
144
144
  * `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
145
145
  */
146
146
  #pending = new Map();
147
+ #clearedTombstones = new Set();
147
148
  constructor(db, partition, resolveTable, prepareQuery, pushApplySerialized) {
148
149
  this.#db = db;
149
150
  this.#partition = partition;
@@ -175,6 +176,9 @@ class D1Transaction {
175
176
  }
176
177
  async getTombstoneSeq(table, rowId) {
177
178
  this.#assertOpen();
179
+ if (this.#clearedTombstones.has(_a.#key(table, rowId))) {
180
+ return undefined;
181
+ }
178
182
  // Persisted read, no #pending overlay: a delete buffered by this commit
179
183
  // records its tombstone at appendCommit, matching the SQLite/Postgres
180
184
  // adapters where the tombstone insert also runs at commit append.
@@ -186,6 +190,7 @@ class D1Transaction {
186
190
  }
187
191
  async clearTombstone(table, rowId) {
188
192
  this.#assertOpen();
193
+ this.#clearedTombstones.add(_a.#key(table, rowId));
189
194
  this.#buffer_('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?', [this.#partition, table, rowId]);
190
195
  }
191
196
  async getPushResult(clientId, clientCommitId) {
@@ -250,7 +255,7 @@ class D1Transaction {
250
255
  }
251
256
  }
252
257
  return [...rows.values()]
253
- .sort((left, right) => left.rowId.localeCompare(right.rowId))
258
+ .sort((left, right) => left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0)
254
259
  .slice(0, query.limit);
255
260
  }
256
261
  async scanRowsByIndex(query) {
@@ -287,7 +292,7 @@ class D1Transaction {
287
292
  rows.set(rowId, pending.row);
288
293
  }
289
294
  return [...rows.values()]
290
- .sort((left, right) => left.rowId.localeCompare(right.rowId))
295
+ .sort((left, right) => left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0)
291
296
  .slice(0, query.limit);
292
297
  }
293
298
  async queryAuthoritative(query) {
@@ -334,6 +339,7 @@ class D1Transaction {
334
339
  }
335
340
  this.#buffer.length = checkpoint;
336
341
  this.#pending.clear();
342
+ this.#clearedTombstones.clear();
337
343
  this.#maxCommitSeq = undefined;
338
344
  this.#applicationOpIndexes.clear();
339
345
  await this.putPushResult(clientId, clientCommitId, result);
package/dist/handler.js CHANGED
@@ -341,25 +341,35 @@ async function* streamResponse(plan, ctx, schema, report) {
341
341
  }
342
342
  try {
343
343
  // Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
344
+ const currentSchema = compileSchema(ctx.schema);
344
345
  let acceptedThrough = 0;
345
346
  for (const push of plan.pushes) {
346
- const processed = await processPushCommitWithTrace(ctx, compileSchema(ctx.schema), plan.resolved, plan.header.clientId, push, schema);
347
+ const processed = await processPushCommitWithTrace(ctx, currentSchema, plan.resolved, plan.header.clientId, push, schema);
347
348
  acceptedThrough = Math.max(acceptedThrough, processed.frame.commitSeq ?? 0);
348
349
  const frame = {
349
350
  ...processed.frame,
350
351
  results: processed.frame.results.map((result) => {
351
- if (result.status !== 'conflict')
352
+ if (result.status !== 'conflict' || schema === currentSchema) {
352
353
  return result;
354
+ }
353
355
  const operation = push.operations[result.opIndex];
356
+ // §2.3: a replay returns the persisted result without rebuilding
357
+ // operations, so a reused clientCommitId with changed operations
358
+ // leaves no table to project the stored conflict row through.
359
+ if (operation === undefined) {
360
+ throw syncError('sync.invalid_request', 'replayed clientCommitId carries changed operations', JSON.stringify({
361
+ clientCommitId: push.clientCommitId,
362
+ opIndex: result.opIndex,
363
+ }));
364
+ }
354
365
  const target = schema.tables.get(operation.table);
355
- const current = compileSchema(ctx.schema).tables.get(operation.table);
366
+ const bitmapBytes = Math.ceil(target.columns.length / 8);
356
367
  return {
357
368
  ...result,
358
- serverRow: projectRowPayload(current, target, result.serverRow),
369
+ serverRow: projectRowPayload(currentSchema.tables.get(operation.table), target, result.serverRow),
359
370
  conflictColumns: result.conflictColumns
360
- .slice(0, Math.ceil(target.columns.length / 8))
361
- .map((byte, i) => i === Math.ceil(target.columns.length / 8) - 1 &&
362
- target.columns.length % 8 !== 0
371
+ .slice(0, bitmapBytes)
372
+ .map((byte, i) => i === bitmapBytes - 1 && target.columns.length % 8 !== 0
363
373
  ? byte & ((1 << (target.columns.length % 8)) - 1)
364
374
  : byte),
365
375
  };
@@ -4,7 +4,7 @@ import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPar
4
4
  import { reportError, SyncError, syncError } from './errors.js';
5
5
  import { processPushOperationsWithTrace } from './push.js';
6
6
  import { compileSchema } from './schema.js';
7
- import { authorizeWrite } from './scopes.js';
7
+ import { authorizeWrite, renderScopeValue, } from './scopes.js';
8
8
  import { transactionQuery } from './storage-query.js';
9
9
  import { toValidateRow } from './validate.js';
10
10
  function scopeAllowed(allowed, variable, value) {
@@ -265,9 +265,9 @@ function commandOperations(mutations, schema) {
265
265
  }
266
266
  return mutation.values[column.name] ?? null;
267
267
  });
268
- const rowId = values[table.primaryKeyIndex];
269
- if (typeof rowId !== 'string' || rowId.length === 0) {
270
- throw syncError('operation.invalid_request', 'command upsert requires a non-empty string primary key');
268
+ const rowId = renderScopeValue(values[table.primaryKeyIndex]);
269
+ if (rowId === undefined) {
270
+ throw syncError('operation.invalid_request', 'command upsert requires a non-empty primary key');
271
271
  }
272
272
  return {
273
273
  table: table.name,
package/dist/push.js CHANGED
@@ -298,6 +298,7 @@ async function applyOperation(tx, schema, wireSchema, resolved, op, opIndex, blo
298
298
  for (let i = 0; i < table.columns.length; i++) {
299
299
  if (present[i] &&
300
300
  !crdtIndexes.has(i) &&
301
+ i !== table.primaryKeyIndex &&
301
302
  effectiveVersion(i) > op.baseVersion) {
302
303
  marked.push(i);
303
304
  }
@@ -602,7 +603,16 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
602
603
  const changes = [];
603
604
  const operations = [];
604
605
  const originByIndex = [];
606
+ const finalByRow = new Map();
605
607
  for (const write of staged) {
608
+ const rowKey = `${write.table.name}\u0000${write.rowId}`;
609
+ const existing = finalByRow.get(rowKey);
610
+ finalByRow.set(rowKey, {
611
+ first: existing?.first ?? write,
612
+ last: write,
613
+ });
614
+ }
615
+ for (const { last: write } of finalByRow.values()) {
606
616
  if (write.op !== 'upsert' || write.values === undefined)
607
617
  continue;
608
618
  for (const reference of write.table.references) {
@@ -636,9 +646,9 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
636
646
  const scheduled = new Set();
637
647
  const processed = new Set();
638
648
  const queue = [];
639
- for (const write of staged) {
640
- if (write.op === 'delete' && write.stored !== undefined) {
641
- schedule(write.table, write.rowId, write.opIndex, scheduled, queue);
649
+ for (const { first, last } of finalByRow.values()) {
650
+ if (last.op === 'delete' && first.stored !== undefined) {
651
+ schedule(last.table, last.rowId, last.opIndex, scheduled, queue);
642
652
  }
643
653
  }
644
654
  for (let cursor = 0; cursor < queue.length; cursor++) {
@@ -649,6 +659,12 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
649
659
  if (processed.has(key))
650
660
  continue;
651
661
  processed.add(key);
662
+ const parentPkColumn = item.table.columns[item.table.primaryKeyIndex];
663
+ const parentKeyValue = parentPkColumn?.type === 'integer'
664
+ ? Number(item.rowId)
665
+ : parentPkColumn?.type === 'boolean'
666
+ ? item.rowId === 'true'
667
+ : item.rowId;
652
668
  for (const reference of item.table.referencedBy) {
653
669
  if (tx.scanRowsByIndex === undefined) {
654
670
  throw new Error('storage transaction does not support declared-reference enforcement (scanRowsByIndex)');
@@ -659,7 +675,7 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
659
675
  const page = await tx.scanRowsByIndex({
660
676
  table: reference.table,
661
677
  index: reference.index,
662
- values: [item.rowId],
678
+ values: [parentKeyValue],
663
679
  afterRowId,
664
680
  limit: 1_000,
665
681
  });
@@ -684,15 +700,20 @@ async function enforceReferences(tx, schema, staged, clientOpCount, cascadeLimit
684
700
  throw new Error(`unreachable: reference child table ${reference.table} is not compiled`);
685
701
  }
686
702
  for (const childRow of children) {
703
+ if (reference.onDelete === 'CASCADE') {
704
+ if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
705
+ continue;
706
+ }
707
+ }
708
+ else if (scheduled.has(`${child.name}\u0000${childRow.rowId}`)) {
709
+ continue;
710
+ }
687
711
  if (originByIndex.length >= cascadeLimit) {
688
712
  return {
689
713
  kind: 'terminate',
690
714
  record: referenceRejection(item.originOpIndex, 'declared-reference cascade exceeded the per-commit operation cap (§6.11)', { reason: 'cascade_limit' }),
691
715
  };
692
716
  }
693
- if (!schedule(child, childRow.rowId, item.originOpIndex, scheduled, queue)) {
694
- continue;
695
- }
696
717
  const syntheticIndex = clientOpCount + originByIndex.length;
697
718
  const storedValues = decodeRow(child.columns, childRow.payload);
698
719
  if (reference.onDelete === 'CASCADE') {
package/dist/realtime.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * one handler, two framings); the only JSON data-plane server event is
17
17
  * the `sync` wake-up (§8.3).
18
18
  */
19
- import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
19
+ import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, isSupportedProtocolWireVersion, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
20
20
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
21
21
  import { adapterSyncError, reportError, SyncError, syncError, } from './errors.js';
22
22
  import { emitEvent } from './events.js';
@@ -160,6 +160,8 @@ export class RealtimeSession {
160
160
  #registrationGeneration = 0;
161
161
  /** Generation of the most recent refresh whose result was applied. */
162
162
  #registrationApplied = 0;
163
+ /** Highest sequence that required a catch-up wake for this session. */
164
+ #wakeRequiredThroughSeq;
163
165
  constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
164
166
  this.sessionId = crypto.randomUUID();
165
167
  this.partition = options.partition;
@@ -170,6 +172,7 @@ export class RealtimeSession {
170
172
  this.cursor = cursor;
171
173
  this.lastKnownSeq = latestSeq;
172
174
  this.wakePending = cursor < latestSeq;
175
+ this.#wakeRequiredThroughSeq = cursor < latestSeq ? latestSeq : 0;
173
176
  this.registrations = registrations;
174
177
  this.openedAtMs = clock();
175
178
  this.#send = options.send;
@@ -208,10 +211,10 @@ export class RealtimeSession {
208
211
  // proves those commits no longer need socket notifications, so the next
209
212
  // notification must be adjacent to the acknowledged cursor.
210
213
  this.lastKnownSeq = Math.max(this.lastKnownSeq, this.cursor);
211
- if (this.cursor >= this.lastKnownSeq)
214
+ if (this.cursor >= this.#wakeRequiredThroughSeq)
212
215
  this.wakePending = false;
213
216
  // §8.2: acks update the client cursor record without an HTTP pull.
214
- this.#persistCursor();
217
+ this.#persistCursor(cursor);
215
218
  }
216
219
  /**
217
220
  * Resolve once every queued control-plane write has settled (§8.2). A host
@@ -413,7 +416,7 @@ export class RealtimeSession {
413
416
  };
414
417
  try {
415
418
  const requestedWireVersion = (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
416
- if (requestedWireVersion === 1 || requestedWireVersion === 2) {
419
+ if (isSupportedProtocolWireVersion(requestedWireVersion)) {
417
420
  this.wireVersion = requestedWireVersion;
418
421
  }
419
422
  let stream;
@@ -537,12 +540,12 @@ export class RealtimeSession {
537
540
  this.#closeSocket?.();
538
541
  this.#hub.disconnect(this);
539
542
  }
540
- #persistCursor() {
543
+ #persistCursor(cursor) {
541
544
  // §8.2: start the write now and keep it on the control tail so `drain()`
542
545
  // can await it. Two acks may write concurrently: storage folds them with
543
546
  // `MAX(cursor, ?)`, so neither order loses the later cursor.
544
547
  const write = (async () => {
545
- await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, this.cursor, this.#clock());
548
+ await this.#storage.advanceClientCursor(this.partition, this.clientId, this.actorId, this.logEpoch, cursor, this.#clock());
546
549
  })();
547
550
  write.catch((error) => {
548
551
  // Best-effort for the connection (the next pull repairs the cursor);
@@ -571,6 +574,7 @@ export class RealtimeSession {
571
574
  }
572
575
  sendWake(reason) {
573
576
  this.wakePending = true;
577
+ this.#wakeRequiredThroughSeq = Math.max(this.#wakeRequiredThroughSeq, this.lastKnownSeq);
574
578
  this.#sendSafe(JSON.stringify({
575
579
  event: 'sync',
576
580
  data: {
@@ -866,6 +870,12 @@ export class RealtimeHub {
866
870
  ...(this.#config.commitValidator !== undefined
867
871
  ? { commitValidator: this.#config.commitValidator }
868
872
  : {}),
873
+ ...(this.#config.reactionPlanner !== undefined
874
+ ? { reactionPlanner: this.#config.reactionPlanner }
875
+ : {}),
876
+ ...(this.#config.checkpoints !== undefined
877
+ ? { checkpoints: this.#config.checkpoints }
878
+ : {}),
869
879
  ...(this.#config.clock !== undefined
870
880
  ? { clock: this.#config.clock }
871
881
  : {}),
@@ -893,6 +903,9 @@ export class RealtimeHub {
893
903
  ...(this.#config.onError !== undefined
894
904
  ? { onError: this.#config.onError }
895
905
  : {}),
906
+ ...(this.#config.mapError !== undefined
907
+ ? { mapError: this.#config.mapError }
908
+ : {}),
896
909
  realtime: this,
897
910
  };
898
911
  }
@@ -929,7 +942,9 @@ export class RealtimeHub {
929
942
  const registrations = await this.loadRegistrations(options.partition, options.actorId, options.clientId);
930
943
  const latestSeq = await storage.getMaxCommitSeq(options.partition);
931
944
  const cursor = record?.cursor ?? -1;
932
- const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record?.wireVersion ?? PROTOCOL_WIRE_VERSION, this.#config.events);
945
+ const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
946
+ ? record.wireVersion
947
+ : PROTOCOL_WIRE_VERSION, this.#config.events);
933
948
  this.#sessions.add(session);
934
949
  const helloResult = options.send(JSON.stringify({
935
950
  event: 'hello',
package/dist/seed.js CHANGED
@@ -13,6 +13,7 @@ import { decodeMessage, encodeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, }
13
13
  import { SyncError } from './errors.js';
14
14
  import { composeEvents } from './events-ring.js';
15
15
  import { handleSyncRequest } from './handler.js';
16
+ import { renderScopeValue } from './scopes.js';
16
17
  /** Structured terminal failure from the real push path used by a seed. */
17
18
  export class SeedMutationError extends Error {
18
19
  name = 'SeedMutationError';
@@ -94,9 +95,9 @@ export async function seedMutations(config, target, mutations) {
94
95
  }
95
96
  const values = table.columns.map((column) => (byColumn.get(column.name) ?? null));
96
97
  const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
97
- const rowId = values[pkIndex];
98
- if (typeof rowId !== 'string' || rowId.length === 0) {
99
- throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty string primary key`);
98
+ const rowId = renderScopeValue(values[pkIndex]);
99
+ if (rowId === undefined) {
100
+ throw new SyncError('sync.invalid_request', `seedMutations: table ${table.name}: upsert requires a non-empty primary key`);
100
101
  }
101
102
  return {
102
103
  table: mutation.table,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -68,7 +68,7 @@
68
68
  "verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
69
69
  },
70
70
  "dependencies": {
71
- "@syncular/core": "0.32.0"
71
+ "@syncular/core": "0.32.1"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/d1-storage.ts CHANGED
@@ -302,6 +302,7 @@ class D1Transaction implements StorageTransaction {
302
302
  * `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
303
303
  */
304
304
  readonly #pending = new Map<string, PendingRow>();
305
+ readonly #clearedTombstones = new Set<string>();
305
306
 
306
307
  constructor(
307
308
  db: Pick<D1Database, 'prepare' | 'batch'>,
@@ -347,6 +348,9 @@ class D1Transaction implements StorageTransaction {
347
348
  rowId: string,
348
349
  ): Promise<number | undefined> {
349
350
  this.#assertOpen();
351
+ if (this.#clearedTombstones.has(D1Transaction.#key(table, rowId))) {
352
+ return undefined;
353
+ }
350
354
  // Persisted read, no #pending overlay: a delete buffered by this commit
351
355
  // records its tombstone at appendCommit, matching the SQLite/Postgres
352
356
  // adapters where the tombstone insert also runs at commit append.
@@ -361,6 +365,7 @@ class D1Transaction implements StorageTransaction {
361
365
 
362
366
  async clearTombstone(table: string, rowId: string): Promise<void> {
363
367
  this.#assertOpen();
368
+ this.#clearedTombstones.add(D1Transaction.#key(table, rowId));
364
369
  this.#buffer_(
365
370
  'DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
366
371
  [this.#partition, table, rowId],
@@ -448,7 +453,9 @@ class D1Transaction implements StorageTransaction {
448
453
  }
449
454
  }
450
455
  return [...rows.values()]
451
- .sort((left, right) => left.rowId.localeCompare(right.rowId))
456
+ .sort((left, right) =>
457
+ left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0,
458
+ )
452
459
  .slice(0, query.limit);
453
460
  }
454
461
 
@@ -502,7 +509,9 @@ class D1Transaction implements StorageTransaction {
502
509
  if (matches) rows.set(rowId, pending.row);
503
510
  }
504
511
  return [...rows.values()]
505
- .sort((left, right) => left.rowId.localeCompare(right.rowId))
512
+ .sort((left, right) =>
513
+ left.rowId < right.rowId ? -1 : left.rowId > right.rowId ? 1 : 0,
514
+ )
506
515
  .slice(0, query.limit);
507
516
  }
508
517
 
@@ -574,6 +583,7 @@ class D1Transaction implements StorageTransaction {
574
583
  }
575
584
  this.#buffer.length = checkpoint;
576
585
  this.#pending.clear();
586
+ this.#clearedTombstones.clear();
577
587
  this.#maxCommitSeq = undefined;
578
588
  this.#applicationOpIndexes.clear();
579
589
  await this.putPushResult(clientId, clientCommitId, result);
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
  ),
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;
@@ -721,7 +725,7 @@ export class RealtimeSession {
721
725
  this.#hub.disconnect(this);
722
726
  }
723
727
 
724
- #persistCursor(): void {
728
+ #persistCursor(cursor: number): void {
725
729
  // §8.2: start the write now and keep it on the control tail so `drain()`
726
730
  // can await it. Two acks may write concurrently: storage folds them with
727
731
  // `MAX(cursor, ?)`, so neither order loses the later cursor.
@@ -731,7 +735,7 @@ export class RealtimeSession {
731
735
  this.clientId,
732
736
  this.actorId,
733
737
  this.logEpoch,
734
- this.cursor,
738
+ cursor,
735
739
  this.#clock(),
736
740
  );
737
741
  })();
@@ -767,6 +771,10 @@ export class RealtimeSession {
767
771
 
768
772
  sendWake(reason: WakeReason): void {
769
773
  this.wakePending = true;
774
+ this.#wakeRequiredThroughSeq = Math.max(
775
+ this.#wakeRequiredThroughSeq,
776
+ this.lastKnownSeq,
777
+ );
770
778
  this.#sendSafe(
771
779
  JSON.stringify({
772
780
  event: 'sync',
@@ -1121,6 +1129,12 @@ export class RealtimeHub {
1121
1129
  ...(this.#config.commitValidator !== undefined
1122
1130
  ? { commitValidator: this.#config.commitValidator }
1123
1131
  : {}),
1132
+ ...(this.#config.reactionPlanner !== undefined
1133
+ ? { reactionPlanner: this.#config.reactionPlanner }
1134
+ : {}),
1135
+ ...(this.#config.checkpoints !== undefined
1136
+ ? { checkpoints: this.#config.checkpoints }
1137
+ : {}),
1124
1138
  ...(this.#config.clock !== undefined
1125
1139
  ? { clock: this.#config.clock }
1126
1140
  : {}),
@@ -1148,6 +1162,9 @@ export class RealtimeHub {
1148
1162
  ...(this.#config.onError !== undefined
1149
1163
  ? { onError: this.#config.onError }
1150
1164
  : {}),
1165
+ ...(this.#config.mapError !== undefined
1166
+ ? { mapError: this.#config.mapError }
1167
+ : {}),
1151
1168
  realtime: this,
1152
1169
  };
1153
1170
  }
@@ -1211,7 +1228,9 @@ export class RealtimeHub {
1211
1228
  this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES,
1212
1229
  storage,
1213
1230
  registry.logEpoch,
1214
- record?.wireVersion ?? PROTOCOL_WIRE_VERSION,
1231
+ record !== undefined && isSupportedProtocolWireVersion(record.wireVersion)
1232
+ ? record.wireVersion
1233
+ : PROTOCOL_WIRE_VERSION,
1215
1234
  this.#config.events,
1216
1235
  );
1217
1236
  this.#sessions.add(session);
package/src/seed.ts CHANGED
@@ -24,6 +24,7 @@ import { SyncError } from './errors';
24
24
  import type { SyncularServerEvent, SyncularServerEvents } from './events';
25
25
  import { composeEvents } from './events-ring';
26
26
  import { handleSyncRequest } from './handler';
27
+ import { renderScopeValue } from './scopes';
27
28
 
28
29
  /** One app-shaped seed mutation — the same vocabulary as client mutations. */
29
30
  export type SeedMutation =
@@ -171,11 +172,11 @@ export async function seedMutations(
171
172
  (column) => (byColumn.get(column.name) ?? null) as RowValue,
172
173
  );
173
174
  const pkIndex = table.columns.findIndex((c) => c.name === table.primaryKey);
174
- const rowId = values[pkIndex];
175
- if (typeof rowId !== 'string' || rowId.length === 0) {
175
+ const rowId = renderScopeValue(values[pkIndex]);
176
+ if (rowId === undefined) {
176
177
  throw new SyncError(
177
178
  'sync.invalid_request',
178
- `seedMutations: table ${table.name}: upsert requires a non-empty string primary key`,
179
+ `seedMutations: table ${table.name}: upsert requires a non-empty primary key`,
179
180
  );
180
181
  }
181
182
  return {