@syncular/server 0.15.22 → 0.15.24
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/README.md +33 -20
- package/dist/d1-storage.d.ts +9 -3
- package/dist/d1-storage.js +27 -21
- package/dist/events.d.ts +10 -0
- package/dist/handler.js +21 -5
- package/dist/postgres-storage.js +21 -9
- package/dist/push.d.ts +13 -0
- package/dist/push.js +85 -63
- package/dist/realtime.d.ts +14 -30
- package/dist/realtime.js +28 -5
- package/dist/seed.d.ts +27 -2
- package/dist/seed.js +71 -7
- package/dist/sqlite-dialect.js +12 -0
- package/dist/sqlite-storage.js +39 -13
- package/dist/storage.d.ts +18 -6
- package/package.json +2 -2
- package/src/d1-storage.ts +36 -25
- package/src/events.ts +10 -0
- package/src/handler.ts +21 -5
- package/src/postgres-storage.ts +23 -15
- package/src/push.ts +141 -93
- package/src/realtime.ts +39 -39
- package/src/seed.ts +95 -10
- package/src/sqlite-dialect.ts +14 -0
- package/src/sqlite-storage.ts +40 -19
- package/src/storage.ts +18 -6
package/src/handler.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type SubscriptionPlan,
|
|
45
45
|
subscriptionSection,
|
|
46
46
|
} from './pull';
|
|
47
|
-
import {
|
|
47
|
+
import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
|
|
48
48
|
import type { CompiledSchema } from './schema';
|
|
49
49
|
import { compileSchema } from './schema';
|
|
50
50
|
import { computeEffective, type ResolvedScopes } from './scopes';
|
|
@@ -339,8 +339,9 @@ function emitPushEvent(
|
|
|
339
339
|
ctx: SyncRequestContext,
|
|
340
340
|
clientId: string,
|
|
341
341
|
push: PushCommitFrame,
|
|
342
|
-
|
|
342
|
+
processed: ProcessedPushCommit,
|
|
343
343
|
): void {
|
|
344
|
+
const { frame } = processed;
|
|
344
345
|
const base = {
|
|
345
346
|
atMs: clockOf(ctx)(),
|
|
346
347
|
partition: ctx.partition,
|
|
@@ -354,7 +355,7 @@ function emitPushEvent(
|
|
|
354
355
|
type: 'push.applied',
|
|
355
356
|
...base,
|
|
356
357
|
...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
|
|
357
|
-
replay:
|
|
358
|
+
replay: processed.replayed,
|
|
358
359
|
});
|
|
359
360
|
return;
|
|
360
361
|
}
|
|
@@ -365,6 +366,13 @@ function emitPushEvent(
|
|
|
365
366
|
type: 'push.conflicted',
|
|
366
367
|
...base,
|
|
367
368
|
opIndex: record.opIndex,
|
|
369
|
+
replay: processed.replayed,
|
|
370
|
+
...(processed.recordedAtMs !== undefined
|
|
371
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
372
|
+
: {}),
|
|
373
|
+
...(processed.cacheIdentity !== undefined
|
|
374
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
375
|
+
: {}),
|
|
368
376
|
});
|
|
369
377
|
return;
|
|
370
378
|
}
|
|
@@ -376,6 +384,13 @@ function emitPushEvent(
|
|
|
376
384
|
? record.code
|
|
377
385
|
: 'sync.invalid_request',
|
|
378
386
|
opIndex: record?.opIndex ?? 0,
|
|
387
|
+
replay: processed.replayed,
|
|
388
|
+
...(processed.recordedAtMs !== undefined
|
|
389
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
390
|
+
: {}),
|
|
391
|
+
...(processed.cacheIdentity !== undefined
|
|
392
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
393
|
+
: {}),
|
|
379
394
|
});
|
|
380
395
|
}
|
|
381
396
|
|
|
@@ -423,15 +438,16 @@ async function* streamResponse(
|
|
|
423
438
|
try {
|
|
424
439
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
425
440
|
for (const push of plan.pushes) {
|
|
426
|
-
const
|
|
441
|
+
const processed = await processPushCommitWithTrace(
|
|
427
442
|
ctx,
|
|
428
443
|
schema,
|
|
429
444
|
plan.resolved,
|
|
430
445
|
plan.header.clientId,
|
|
431
446
|
push,
|
|
432
447
|
);
|
|
448
|
+
const { frame } = processed;
|
|
433
449
|
if (events !== undefined) {
|
|
434
|
-
emitPushEvent(events, ctx, plan.header.clientId, push,
|
|
450
|
+
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
435
451
|
}
|
|
436
452
|
yield encodeResponseFrame(frame);
|
|
437
453
|
const details = pushResultDetailsFrame(frame);
|
package/src/postgres-storage.ts
CHANGED
|
@@ -193,6 +193,12 @@ function serializePushResult(result: StoredPushResult): unknown {
|
|
|
193
193
|
return {
|
|
194
194
|
status: result.status,
|
|
195
195
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
196
|
+
...(result.recordedAtMs !== undefined
|
|
197
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
198
|
+
: {}),
|
|
199
|
+
...(result.cacheIdentity !== undefined
|
|
200
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
201
|
+
: {}),
|
|
196
202
|
results: result.results.map((record) => {
|
|
197
203
|
if (record.status === 'conflict') {
|
|
198
204
|
return {
|
|
@@ -223,6 +229,8 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
223
229
|
const parsed = value as {
|
|
224
230
|
status: 'applied' | 'rejected';
|
|
225
231
|
commitSeq?: number;
|
|
232
|
+
recordedAtMs?: number;
|
|
233
|
+
cacheIdentity?: string;
|
|
226
234
|
results: SerializedResult[];
|
|
227
235
|
};
|
|
228
236
|
const results: PushOperationResult[] = parsed.results.map((record) => {
|
|
@@ -251,6 +259,12 @@ function deserializePushResult(value: unknown): StoredPushResult {
|
|
|
251
259
|
return {
|
|
252
260
|
status: parsed.status,
|
|
253
261
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
262
|
+
...(parsed.recordedAtMs !== undefined
|
|
263
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
264
|
+
: {}),
|
|
265
|
+
...(parsed.cacheIdentity !== undefined
|
|
266
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
267
|
+
: {}),
|
|
254
268
|
results,
|
|
255
269
|
};
|
|
256
270
|
}
|
|
@@ -425,7 +439,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
425
439
|
#partition: string;
|
|
426
440
|
#resolveTable: (name: string) => CompiledTable;
|
|
427
441
|
#open = true;
|
|
428
|
-
#
|
|
442
|
+
#pushApplySavepoint = false;
|
|
429
443
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
430
444
|
#resolve: () => void;
|
|
431
445
|
#reject: (error: unknown) => void;
|
|
@@ -507,7 +521,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
507
521
|
);
|
|
508
522
|
}
|
|
509
523
|
|
|
510
|
-
async
|
|
524
|
+
async lockPartitionForPush(): Promise<void> {
|
|
511
525
|
this.#assertOpen();
|
|
512
526
|
await this.#client.query(
|
|
513
527
|
`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
@@ -518,8 +532,8 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
518
532
|
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
|
|
519
533
|
[this.#partition],
|
|
520
534
|
);
|
|
521
|
-
await this.#client.query('SAVEPOINT
|
|
522
|
-
this.#
|
|
535
|
+
await this.#client.query('SAVEPOINT syncular_push_candidate');
|
|
536
|
+
this.#pushApplySavepoint = true;
|
|
523
537
|
}
|
|
524
538
|
|
|
525
539
|
async commitRejectedPushResult(
|
|
@@ -528,18 +542,12 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
528
542
|
result: StoredPushResult,
|
|
529
543
|
): Promise<void> {
|
|
530
544
|
this.#assertOpen();
|
|
531
|
-
if (!this.#
|
|
532
|
-
throw new Error(
|
|
533
|
-
'whole-commit rejection requires its validation savepoint',
|
|
534
|
-
);
|
|
545
|
+
if (!this.#pushApplySavepoint) {
|
|
546
|
+
throw new Error('push rejection requires its apply savepoint');
|
|
535
547
|
}
|
|
536
|
-
await this.#client.query(
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
await this.#client.query(
|
|
540
|
-
'RELEASE SAVEPOINT syncular_commit_validation_candidate',
|
|
541
|
-
);
|
|
542
|
-
this.#commitValidationSavepoint = false;
|
|
548
|
+
await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
|
|
549
|
+
await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
|
|
550
|
+
this.#pushApplySavepoint = false;
|
|
543
551
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
544
552
|
await this.commit();
|
|
545
553
|
}
|
package/src/push.ts
CHANGED
|
@@ -750,6 +750,42 @@ function resultFrame(
|
|
|
750
750
|
};
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
export interface ProcessedPushCommit {
|
|
754
|
+
readonly frame: PushResultFrame;
|
|
755
|
+
/** True when this request observed an already-recorded idempotency outcome. */
|
|
756
|
+
readonly replayed: boolean;
|
|
757
|
+
readonly recordedAtMs?: number;
|
|
758
|
+
readonly cacheIdentity?: string;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function processedPushCommit(
|
|
762
|
+
clientCommitId: string,
|
|
763
|
+
stored: StoredPushResult,
|
|
764
|
+
replayed: boolean,
|
|
765
|
+
): ProcessedPushCommit {
|
|
766
|
+
return {
|
|
767
|
+
frame: resultFrame(clientCommitId, stored, replayed),
|
|
768
|
+
replayed,
|
|
769
|
+
...(stored.recordedAtMs !== undefined
|
|
770
|
+
? { recordedAtMs: stored.recordedAtMs }
|
|
771
|
+
: {}),
|
|
772
|
+
...(stored.cacheIdentity !== undefined
|
|
773
|
+
? { cacheIdentity: stored.cacheIdentity }
|
|
774
|
+
: {}),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function newStoredPushResult(
|
|
779
|
+
recordedAtMs: number,
|
|
780
|
+
result: Omit<StoredPushResult, 'recordedAtMs' | 'cacheIdentity'>,
|
|
781
|
+
): StoredPushResult {
|
|
782
|
+
return {
|
|
783
|
+
...result,
|
|
784
|
+
recordedAtMs,
|
|
785
|
+
cacheIdentity: crypto.randomUUID(),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
753
789
|
function idempotencyCacheMissFrame(
|
|
754
790
|
clientCommitId: string,
|
|
755
791
|
error: SyncError,
|
|
@@ -770,32 +806,6 @@ function idempotencyCacheMissFrame(
|
|
|
770
806
|
};
|
|
771
807
|
}
|
|
772
808
|
|
|
773
|
-
async function persistRejectedPushResult(
|
|
774
|
-
storage: SyncRequestContext['storage'],
|
|
775
|
-
partition: string,
|
|
776
|
-
clientId: string,
|
|
777
|
-
clientCommitId: string,
|
|
778
|
-
stored: StoredPushResult,
|
|
779
|
-
): Promise<StoredPushResult> {
|
|
780
|
-
const rejectionTx = await storage.begin(partition);
|
|
781
|
-
try {
|
|
782
|
-
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
783
|
-
await rejectionTx.commit();
|
|
784
|
-
} catch (error) {
|
|
785
|
-
await rejectionTx.rollback();
|
|
786
|
-
throw error;
|
|
787
|
-
}
|
|
788
|
-
const canonical = await storage.getPushResult(
|
|
789
|
-
partition,
|
|
790
|
-
clientId,
|
|
791
|
-
clientCommitId,
|
|
792
|
-
);
|
|
793
|
-
if (canonical === undefined) {
|
|
794
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
795
|
-
}
|
|
796
|
-
return canonical;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
809
|
export interface AppliedCommitEvent {
|
|
800
810
|
readonly commit: StoredCommit;
|
|
801
811
|
}
|
|
@@ -811,6 +821,23 @@ export async function processPushCommit(
|
|
|
811
821
|
clientId: string,
|
|
812
822
|
frame: PushCommitFrame,
|
|
813
823
|
): Promise<PushResultFrame> {
|
|
824
|
+
return (
|
|
825
|
+
await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)
|
|
826
|
+
).frame;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
831
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
832
|
+
* cache provenance needed by structured events and server helpers.
|
|
833
|
+
*/
|
|
834
|
+
export async function processPushCommitWithTrace(
|
|
835
|
+
ctx: SyncRequestContext,
|
|
836
|
+
schema: CompiledSchema,
|
|
837
|
+
resolved: ResolvedScopes,
|
|
838
|
+
clientId: string,
|
|
839
|
+
frame: PushCommitFrame,
|
|
840
|
+
): Promise<ProcessedPushCommit> {
|
|
814
841
|
const { storage, partition } = ctx;
|
|
815
842
|
let persisted: StoredPushResult | undefined;
|
|
816
843
|
try {
|
|
@@ -826,12 +853,15 @@ export async function processPushCommit(
|
|
|
826
853
|
) {
|
|
827
854
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
828
855
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
829
|
-
return
|
|
856
|
+
return {
|
|
857
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
858
|
+
replayed: false,
|
|
859
|
+
};
|
|
830
860
|
}
|
|
831
861
|
throw error;
|
|
832
862
|
}
|
|
833
863
|
if (persisted !== undefined) {
|
|
834
|
-
return
|
|
864
|
+
return processedPushCommit(frame.clientCommitId, persisted, true);
|
|
835
865
|
}
|
|
836
866
|
|
|
837
867
|
const createdAtMs = clockOf(ctx)();
|
|
@@ -840,41 +870,49 @@ export async function processPushCommit(
|
|
|
840
870
|
const validators = ctx.validators;
|
|
841
871
|
const commitValidator = ctx.commitValidator;
|
|
842
872
|
const tx = await storage.begin(partition);
|
|
873
|
+
const lockPartitionForPush =
|
|
874
|
+
tx.lockPartitionForPush?.bind(tx) ??
|
|
875
|
+
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
843
876
|
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
844
877
|
try {
|
|
845
|
-
if (
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
878
|
+
if (
|
|
879
|
+
lockPartitionForPush === undefined ||
|
|
880
|
+
commitRejectedPushResult === undefined
|
|
881
|
+
) {
|
|
882
|
+
throw new Error(
|
|
883
|
+
'storage transaction does not support serialized push apply and atomic rejection finalization',
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
await lockPartitionForPush();
|
|
887
|
+
// The optimistic lookup above may have raced another delivery. Re-check
|
|
888
|
+
// only after acquiring partition serialization and before any operation
|
|
889
|
+
// read, validation, merge, or staged write.
|
|
890
|
+
try {
|
|
891
|
+
const serializedPersisted = await storage.getPushResult(
|
|
892
|
+
partition,
|
|
893
|
+
clientId,
|
|
894
|
+
frame.clientCommitId,
|
|
895
|
+
);
|
|
896
|
+
if (serializedPersisted !== undefined) {
|
|
897
|
+
await tx.rollback();
|
|
898
|
+
return processedPushCommit(
|
|
862
899
|
frame.clientCommitId,
|
|
900
|
+
serializedPersisted,
|
|
901
|
+
true,
|
|
863
902
|
);
|
|
864
|
-
if (serializedPersisted !== undefined) {
|
|
865
|
-
await tx.rollback();
|
|
866
|
-
return resultFrame(frame.clientCommitId, serializedPersisted, true);
|
|
867
|
-
}
|
|
868
|
-
} catch (error) {
|
|
869
|
-
if (
|
|
870
|
-
error instanceof SyncError &&
|
|
871
|
-
error.code === 'sync.idempotency_cache_miss'
|
|
872
|
-
) {
|
|
873
|
-
await tx.rollback();
|
|
874
|
-
return idempotencyCacheMissFrame(frame.clientCommitId, error);
|
|
875
|
-
}
|
|
876
|
-
throw error;
|
|
877
903
|
}
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if (
|
|
906
|
+
error instanceof SyncError &&
|
|
907
|
+
error.code === 'sync.idempotency_cache_miss'
|
|
908
|
+
) {
|
|
909
|
+
await tx.rollback();
|
|
910
|
+
return {
|
|
911
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
912
|
+
replayed: false,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
throw error;
|
|
878
916
|
}
|
|
879
917
|
const results: PushOperationResult[] = [];
|
|
880
918
|
const changes: NewChange[] = [];
|
|
@@ -923,36 +961,28 @@ export async function processPushCommit(
|
|
|
923
961
|
if (terminated !== undefined) {
|
|
924
962
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
925
963
|
// every write of the commit rolls back.
|
|
926
|
-
const stored
|
|
964
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
927
965
|
status: 'rejected',
|
|
928
966
|
results: [terminated],
|
|
929
|
-
};
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
await tx.rollback();
|
|
942
|
-
const canonical = await persistRejectedPushResult(
|
|
943
|
-
storage,
|
|
944
|
-
partition,
|
|
945
|
-
clientId,
|
|
946
|
-
frame.clientCommitId,
|
|
947
|
-
stored,
|
|
948
|
-
);
|
|
949
|
-
return resultFrame(
|
|
950
|
-
frame.clientCommitId,
|
|
951
|
-
canonical,
|
|
952
|
-
canonical !== stored,
|
|
967
|
+
});
|
|
968
|
+
// Discard candidates and persist the rejection while retaining the same
|
|
969
|
+
// partition lock. There is no unlock gap in which a duplicate can rerun.
|
|
970
|
+
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
971
|
+
const canonical = await storage.getPushResult(
|
|
972
|
+
partition,
|
|
973
|
+
clientId,
|
|
974
|
+
frame.clientCommitId,
|
|
975
|
+
);
|
|
976
|
+
if (canonical === undefined) {
|
|
977
|
+
throw new Error(
|
|
978
|
+
'push rejection finalization did not persist an outcome',
|
|
953
979
|
);
|
|
954
980
|
}
|
|
955
|
-
return
|
|
981
|
+
return processedPushCommit(
|
|
982
|
+
frame.clientCommitId,
|
|
983
|
+
canonical,
|
|
984
|
+
canonical.cacheIdentity !== stored.cacheIdentity,
|
|
985
|
+
);
|
|
956
986
|
}
|
|
957
987
|
|
|
958
988
|
const commitSeq = await tx.appendCommit({
|
|
@@ -962,7 +992,11 @@ export async function processPushCommit(
|
|
|
962
992
|
createdAtMs,
|
|
963
993
|
changes,
|
|
964
994
|
});
|
|
965
|
-
const stored
|
|
995
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
996
|
+
status: 'applied',
|
|
997
|
+
commitSeq,
|
|
998
|
+
results,
|
|
999
|
+
});
|
|
966
1000
|
await tx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
967
1001
|
await tx.commit();
|
|
968
1002
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
@@ -973,11 +1007,10 @@ export async function processPushCommit(
|
|
|
973
1007
|
changes,
|
|
974
1008
|
});
|
|
975
1009
|
}
|
|
976
|
-
return
|
|
1010
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
977
1011
|
} catch (error) {
|
|
978
|
-
await tx.rollback();
|
|
979
1012
|
if (error instanceof StorageConstraintError) {
|
|
980
|
-
const stored
|
|
1013
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
981
1014
|
status: 'rejected',
|
|
982
1015
|
results: [
|
|
983
1016
|
{
|
|
@@ -988,16 +1021,31 @@ export async function processPushCommit(
|
|
|
988
1021
|
retryable: false,
|
|
989
1022
|
},
|
|
990
1023
|
],
|
|
991
|
-
};
|
|
992
|
-
|
|
993
|
-
|
|
1024
|
+
});
|
|
1025
|
+
if (commitRejectedPushResult === undefined) {
|
|
1026
|
+
await tx.rollback();
|
|
1027
|
+
throw new Error(
|
|
1028
|
+
'storage transaction lost atomic push rejection finalization support',
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
1032
|
+
const canonical = await storage.getPushResult(
|
|
994
1033
|
partition,
|
|
995
1034
|
clientId,
|
|
996
1035
|
frame.clientCommitId,
|
|
997
|
-
stored,
|
|
998
1036
|
);
|
|
999
|
-
|
|
1037
|
+
if (canonical === undefined) {
|
|
1038
|
+
throw new Error(
|
|
1039
|
+
'push rejection finalization did not persist an outcome',
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
return processedPushCommit(
|
|
1043
|
+
frame.clientCommitId,
|
|
1044
|
+
canonical,
|
|
1045
|
+
canonical.cacheIdentity !== stored.cacheIdentity,
|
|
1046
|
+
);
|
|
1000
1047
|
}
|
|
1048
|
+
await tx.rollback();
|
|
1001
1049
|
throw error;
|
|
1002
1050
|
}
|
|
1003
1051
|
}
|
package/src/realtime.ts
CHANGED
|
@@ -30,53 +30,26 @@ import {
|
|
|
30
30
|
type ScopeMap,
|
|
31
31
|
type WakeReason,
|
|
32
32
|
} from '@syncular/core';
|
|
33
|
-
import type {
|
|
34
|
-
LeaseConfig,
|
|
35
|
-
ResolveScopes,
|
|
36
|
-
ServerLimits,
|
|
37
|
-
SyncRequestContext,
|
|
38
|
-
} from './context';
|
|
33
|
+
import type { SyncRequestContext, SyncServerConfig } from './context';
|
|
39
34
|
import { RESOLVER_OUTAGE } from './context';
|
|
40
35
|
import { SyncError, syncError } from './errors';
|
|
41
36
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
42
37
|
import { createSyncResponseStream } from './handler';
|
|
43
|
-
import type { ServerSchema } from './schema';
|
|
44
38
|
import { type CompiledSchema, compileSchema } from './schema';
|
|
45
39
|
import {
|
|
46
40
|
computeEffective,
|
|
47
41
|
matchesEffective,
|
|
48
42
|
type ResolvedScopes,
|
|
49
43
|
} from './scopes';
|
|
50
|
-
import type { SegmentStore } from './segment-store';
|
|
51
|
-
import type { SegmentUrlConfig } from './signed-url';
|
|
52
44
|
import type { ServerStorage, StoredCommit } from './storage';
|
|
53
|
-
import type { CommitValidator, ValidatorRegistry } from './validate';
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
readonly validators?: ValidatorRegistry;
|
|
61
|
-
/** §6.8 whole-commit validator shared with HTTP sync rounds. */
|
|
62
|
-
readonly commitValidator?: CommitValidator;
|
|
63
|
-
readonly clock?: () => number;
|
|
46
|
+
/**
|
|
47
|
+
* Realtime adds fanout/presence tuning to the canonical sync-server config;
|
|
48
|
+
* socket rounds must never have a narrower push/pull capability set than HTTP.
|
|
49
|
+
*/
|
|
50
|
+
export interface RealtimeHubConfig extends Omit<SyncServerConfig, 'realtime'> {
|
|
64
51
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
65
52
|
readonly maxDeltaBytes?: number;
|
|
66
|
-
/** Optional structured-events sink (`realtime.*` events). */
|
|
67
|
-
readonly events?: SyncularServerEvents;
|
|
68
|
-
/**
|
|
69
|
-
* Segment store for sync rounds over the socket (§8.7). Without it a
|
|
70
|
-
* socket round fails loudly with an in-band ERROR — provide the same
|
|
71
|
-
* store the HTTP binding uses (one handler, two framings).
|
|
72
|
-
*/
|
|
73
|
-
readonly segments?: SegmentStore;
|
|
74
|
-
/** Request limits for socket rounds; defaults match the HTTP binding. */
|
|
75
|
-
readonly limits?: Partial<ServerLimits>;
|
|
76
|
-
readonly signedUrls?: SegmentUrlConfig;
|
|
77
|
-
/** §7.3 auth leases for socket sync rounds (§8.7) — same config the
|
|
78
|
-
* HTTP binding uses, so rounds over the socket are lease-aware too. */
|
|
79
|
-
readonly leases?: LeaseConfig;
|
|
80
53
|
/**
|
|
81
54
|
* §8.6 presence: cap on the serialized size (bytes) of a published
|
|
82
55
|
* presence document. An over-cap publish is rejected loudly to the
|
|
@@ -494,9 +467,11 @@ export class RealtimeSession {
|
|
|
494
467
|
* round's request byte stream (§8.7). Synchronous entry — assembly and
|
|
495
468
|
* violation detection happen inline so a pipelined chunk arriving
|
|
496
469
|
* while a response streams is caught deterministically; the round
|
|
497
|
-
* itself runs async once the request is complete.
|
|
470
|
+
* itself runs async once the request is complete. The returned promise, when
|
|
471
|
+
* present, resolves only after response streaming and registration refresh;
|
|
472
|
+
* coordinated hosts await it to retain their partition FIFO through commit.
|
|
498
473
|
*/
|
|
499
|
-
handleBinary(bytes: Uint8Array): void {
|
|
474
|
+
handleBinary(bytes: Uint8Array): Promise<void> | undefined {
|
|
500
475
|
if (bytes.length === 0) return;
|
|
501
476
|
if (bytes[0] !== REALTIME_TAG_ROUND) {
|
|
502
477
|
// §8.7: client→server tags other than 0x01 — a broken client.
|
|
@@ -531,7 +506,7 @@ export class RealtimeSession {
|
|
|
531
506
|
}
|
|
532
507
|
const token = Symbol('round');
|
|
533
508
|
this.#activeRound = token;
|
|
534
|
-
|
|
509
|
+
return this.#runRound(done.message.slice(), token);
|
|
535
510
|
}
|
|
536
511
|
|
|
537
512
|
/** Drive the shared handler and stream the response back (§8.7). */
|
|
@@ -914,7 +889,10 @@ export class RealtimeHub {
|
|
|
914
889
|
* the same shape the HTTP adapter builds, so the round drives the
|
|
915
890
|
* SAME handler with zero semantic divergence.
|
|
916
891
|
*/
|
|
917
|
-
|
|
892
|
+
requestContextFor(identity: {
|
|
893
|
+
readonly partition: string;
|
|
894
|
+
readonly actorId: string;
|
|
895
|
+
}): SyncRequestContext {
|
|
918
896
|
const segments = this.#config.segments;
|
|
919
897
|
if (segments === undefined) {
|
|
920
898
|
// Fail loud (§8.7): a hub serving socket rounds needs the same
|
|
@@ -925,12 +903,21 @@ export class RealtimeHub {
|
|
|
925
903
|
);
|
|
926
904
|
}
|
|
927
905
|
return {
|
|
928
|
-
partition:
|
|
929
|
-
actorId:
|
|
906
|
+
partition: identity.partition,
|
|
907
|
+
actorId: identity.actorId,
|
|
930
908
|
schema: this.#config.schema,
|
|
931
909
|
storage: this.#config.storage,
|
|
932
910
|
segments,
|
|
933
911
|
resolveScopes: this.#config.resolveScopes,
|
|
912
|
+
...(this.#config.blobs !== undefined
|
|
913
|
+
? { blobs: this.#config.blobs }
|
|
914
|
+
: {}),
|
|
915
|
+
...(this.#config.maxBlobBytes !== undefined
|
|
916
|
+
? { maxBlobBytes: this.#config.maxBlobBytes }
|
|
917
|
+
: {}),
|
|
918
|
+
...(this.#config.crdtMergers !== undefined
|
|
919
|
+
? { crdtMergers: this.#config.crdtMergers }
|
|
920
|
+
: {}),
|
|
934
921
|
...(this.#config.validators !== undefined
|
|
935
922
|
? { validators: this.#config.validators }
|
|
936
923
|
: {}),
|
|
@@ -946,6 +933,15 @@ export class RealtimeHub {
|
|
|
946
933
|
...(this.#config.signedUrls !== undefined
|
|
947
934
|
? { signedUrls: this.#config.signedUrls }
|
|
948
935
|
: {}),
|
|
936
|
+
...(this.#config.blobSignedUrls !== undefined
|
|
937
|
+
? { blobSignedUrls: this.#config.blobSignedUrls }
|
|
938
|
+
: {}),
|
|
939
|
+
...(this.#config.blobUploadUrls !== undefined
|
|
940
|
+
? { blobUploadUrls: this.#config.blobUploadUrls }
|
|
941
|
+
: {}),
|
|
942
|
+
...(this.#config.sqliteImageBuilder !== undefined
|
|
943
|
+
? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
|
|
944
|
+
: {}),
|
|
949
945
|
...(this.#config.leases !== undefined
|
|
950
946
|
? { leases: this.#config.leases }
|
|
951
947
|
: {}),
|
|
@@ -956,6 +952,10 @@ export class RealtimeHub {
|
|
|
956
952
|
};
|
|
957
953
|
}
|
|
958
954
|
|
|
955
|
+
requestContext(session: RealtimeSession): SyncRequestContext {
|
|
956
|
+
return this.requestContextFor(session);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
959
|
/**
|
|
960
960
|
* Register a connected socket (§8.1): load the client's last pull's
|
|
961
961
|
* subscription list, resolve + intersect scopes, send `hello`.
|