@syncular/server 0.15.23 → 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 +18 -20
- package/dist/d1-storage.d.ts +9 -3
- package/dist/d1-storage.js +27 -21
- package/dist/postgres-storage.js +9 -9
- package/dist/push.js +43 -61
- package/dist/realtime.d.ts +14 -30
- package/dist/realtime.js +28 -5
- package/dist/sqlite-storage.js +39 -13
- package/dist/storage.d.ts +14 -6
- package/package.json +2 -2
- package/src/d1-storage.ts +36 -25
- package/src/postgres-storage.ts +9 -15
- package/src/push.ts +70 -96
- package/src/realtime.ts +39 -39
- package/src/sqlite-storage.ts +40 -19
- package/src/storage.ts +14 -6
package/dist/storage.d.ts
CHANGED
|
@@ -186,15 +186,23 @@ export interface StorageTransaction {
|
|
|
186
186
|
*/
|
|
187
187
|
scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
188
188
|
/**
|
|
189
|
-
* Serialize
|
|
190
|
-
*
|
|
189
|
+
* Serialize every push apply for this partition before any operation read,
|
|
190
|
+
* validation, merge, or write. The push layer re-checks idempotency only
|
|
191
|
+
* after this resolves and retains the lock through terminal-result commit.
|
|
192
|
+
* Missing support fails closed before an app-row mutation.
|
|
193
|
+
*/
|
|
194
|
+
lockPartitionForPush?(): Promise<void>;
|
|
195
|
+
/**
|
|
196
|
+
* @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
|
|
197
|
+
* bridge for custom adapters whose existing implementation already locks
|
|
198
|
+
* the complete partition from before candidate reads through commit.
|
|
191
199
|
*/
|
|
192
200
|
lockPartitionForCommitValidation?(): Promise<void>;
|
|
193
201
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
202
|
+
* Rejection finalization while the push-apply serialization lock is still
|
|
203
|
+
* held: discard every candidate write, persist the rejected idempotency
|
|
204
|
+
* result, and finish the transaction atomically. Required for every push so
|
|
205
|
+
* a concurrent duplicate cannot rerun operations, validators, or merges.
|
|
198
206
|
*/
|
|
199
207
|
commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
|
|
200
208
|
upsertRow(table: string, row: StoredRow, context?: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.24",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"!dist/**/*.test.d.ts"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@syncular/core": "0.15.
|
|
56
|
+
"@syncular/core": "0.15.24"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/d1-storage.ts
CHANGED
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
* reading `max_commit_seq` live and buffering the `+1` write. Under a single
|
|
32
32
|
* Worker request this is exact. Two concurrent pushes to one partition need an
|
|
33
33
|
* external serialization point (normally a per-partition Durable Object); a
|
|
34
|
-
* realtime notifier alone does not serialize HTTP writes.
|
|
35
|
-
*
|
|
36
|
-
* primitive (a DO or a Queue).
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* realtime notifier alone does not serialize HTTP writes. Every deployment
|
|
35
|
+
* that accepts D1 pushes MUST front same-partition sync rounds with a
|
|
36
|
+
* coordinating primitive (a DO or a Queue). The adapter fails closed unless
|
|
37
|
+
* that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
|
|
38
|
+
* provide the required pre-operation lock.
|
|
39
39
|
* This mirrors PostgreSQL's per-partition row lock, achieved by placement
|
|
40
40
|
* rather than a lock D1 does not expose.
|
|
41
41
|
*/
|
|
@@ -143,12 +143,12 @@ class D1Transaction implements StorageTransaction {
|
|
|
143
143
|
readonly #db: D1Database;
|
|
144
144
|
readonly #partition: string;
|
|
145
145
|
readonly #resolveTable: (name: string) => CompiledTable;
|
|
146
|
-
readonly #
|
|
146
|
+
readonly #pushApplySerialized: boolean;
|
|
147
147
|
readonly #buffer: BufferedStatement[] = [];
|
|
148
148
|
#open = true;
|
|
149
149
|
/** Live snapshot of `max_commit_seq`, advanced within this transaction. */
|
|
150
150
|
#maxCommitSeq: number | undefined;
|
|
151
|
-
#
|
|
151
|
+
#pushApplyCheckpoint: number | undefined;
|
|
152
152
|
#lastApplicationOpIndex: number | undefined;
|
|
153
153
|
/**
|
|
154
154
|
* Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
|
|
@@ -161,12 +161,12 @@ class D1Transaction implements StorageTransaction {
|
|
|
161
161
|
db: D1Database,
|
|
162
162
|
partition: string,
|
|
163
163
|
resolveTable: (name: string) => CompiledTable,
|
|
164
|
-
|
|
164
|
+
pushApplySerialized: boolean,
|
|
165
165
|
) {
|
|
166
166
|
this.#db = db;
|
|
167
167
|
this.#partition = partition;
|
|
168
168
|
this.#resolveTable = resolveTable;
|
|
169
|
-
this.#
|
|
169
|
+
this.#pushApplySerialized = pushApplySerialized;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
#assertOpen(): void {
|
|
@@ -312,16 +312,16 @@ class D1Transaction implements StorageTransaction {
|
|
|
312
312
|
.slice(0, query.limit);
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
async
|
|
315
|
+
async lockPartitionForPush(): Promise<void> {
|
|
316
316
|
this.#assertOpen();
|
|
317
|
-
if (!this.#
|
|
317
|
+
if (!this.#pushApplySerialized) {
|
|
318
318
|
throw new Error(
|
|
319
|
-
'D1
|
|
319
|
+
'D1 push apply requires externally serialized partition writes',
|
|
320
320
|
);
|
|
321
321
|
}
|
|
322
322
|
// D1 has no interactive lock. The caller explicitly asserted that every
|
|
323
323
|
// write for this partition is already serialized (normally by its DO).
|
|
324
|
-
this.#
|
|
324
|
+
this.#pushApplyCheckpoint = this.#buffer.length;
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
async commitRejectedPushResult(
|
|
@@ -330,11 +330,9 @@ class D1Transaction implements StorageTransaction {
|
|
|
330
330
|
result: StoredPushResult,
|
|
331
331
|
): Promise<void> {
|
|
332
332
|
this.#assertOpen();
|
|
333
|
-
const checkpoint = this.#
|
|
333
|
+
const checkpoint = this.#pushApplyCheckpoint;
|
|
334
334
|
if (checkpoint === undefined) {
|
|
335
|
-
throw new Error(
|
|
336
|
-
'whole-commit rejection requires its validation checkpoint',
|
|
337
|
-
);
|
|
335
|
+
throw new Error('push rejection requires its apply checkpoint');
|
|
338
336
|
}
|
|
339
337
|
this.#buffer.length = checkpoint;
|
|
340
338
|
this.#pending.clear();
|
|
@@ -558,16 +556,22 @@ class D1Transaction implements StorageTransaction {
|
|
|
558
556
|
|
|
559
557
|
async commit(): Promise<void> {
|
|
560
558
|
this.#assertOpen();
|
|
561
|
-
this.#
|
|
562
|
-
|
|
559
|
+
if (this.#buffer.length === 0) {
|
|
560
|
+
this.#open = false;
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
563
|
const statements = this.#buffer.map((entry) =>
|
|
564
564
|
this.#db.prepare(entry.sql).bind(...entry.params),
|
|
565
565
|
);
|
|
566
566
|
// One atomic D1 batch — the §6.4 all-or-nothing commit.
|
|
567
567
|
try {
|
|
568
568
|
await this.#db.batch(statements);
|
|
569
|
+
this.#open = false;
|
|
569
570
|
} catch (error) {
|
|
570
571
|
if (isD1ConstraintError(error)) {
|
|
572
|
+
// D1 batches are atomic. Keep this logical transaction open so the
|
|
573
|
+
// push layer can discard its buffered candidates and persist the
|
|
574
|
+
// terminal rejection while the external partition queue is retained.
|
|
571
575
|
throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
|
|
572
576
|
}
|
|
573
577
|
throw error;
|
|
@@ -590,23 +594,30 @@ const D1_MAX_BIND_PARAMS = 100;
|
|
|
590
594
|
export interface D1ServerStorageOptions {
|
|
591
595
|
/**
|
|
592
596
|
* Assert that all writes for a partition reach this storage serially.
|
|
593
|
-
* Required for
|
|
594
|
-
* Set this only inside
|
|
595
|
-
* coordinator; the default fails closed
|
|
597
|
+
* Required for every push because D1 exposes no interactive transaction
|
|
598
|
+
* lock. Set this only inside an explicit per-partition request queue,
|
|
599
|
+
* Durable Object, or equivalent coordinator; the default fails closed.
|
|
600
|
+
*/
|
|
601
|
+
readonly pushApplySerialized?: boolean;
|
|
602
|
+
/**
|
|
603
|
+
* @deprecated Use `pushApplySerialized`. This alias remains valid only
|
|
604
|
+
* because the old assertion already promised that every partition write,
|
|
605
|
+
* not merely validator callbacks, was externally serialized.
|
|
596
606
|
*/
|
|
597
607
|
readonly commitValidationSerialized?: boolean;
|
|
598
608
|
}
|
|
599
609
|
|
|
600
610
|
export class D1ServerStorage implements ServerStorage {
|
|
601
611
|
readonly #db: D1Database;
|
|
602
|
-
readonly #
|
|
612
|
+
readonly #pushApplySerialized: boolean;
|
|
603
613
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
604
614
|
#tables: ReadonlyMap<string, CompiledTable> | undefined;
|
|
605
615
|
#schemaVersion: number | undefined;
|
|
606
616
|
|
|
607
617
|
constructor(db: D1Database, options: D1ServerStorageOptions = {}) {
|
|
608
618
|
this.#db = db;
|
|
609
|
-
this.#
|
|
619
|
+
this.#pushApplySerialized =
|
|
620
|
+
options.pushApplySerialized === true ||
|
|
610
621
|
options.commitValidationSerialized === true;
|
|
611
622
|
}
|
|
612
623
|
|
|
@@ -769,7 +780,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
769
780
|
this.#db,
|
|
770
781
|
partition,
|
|
771
782
|
(name) => this.table(name),
|
|
772
|
-
this.#
|
|
783
|
+
this.#pushApplySerialized,
|
|
773
784
|
);
|
|
774
785
|
}
|
|
775
786
|
|
package/src/postgres-storage.ts
CHANGED
|
@@ -439,7 +439,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
439
439
|
#partition: string;
|
|
440
440
|
#resolveTable: (name: string) => CompiledTable;
|
|
441
441
|
#open = true;
|
|
442
|
-
#
|
|
442
|
+
#pushApplySavepoint = false;
|
|
443
443
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
444
444
|
#resolve: () => void;
|
|
445
445
|
#reject: (error: unknown) => void;
|
|
@@ -521,7 +521,7 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
521
521
|
);
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
-
async
|
|
524
|
+
async lockPartitionForPush(): Promise<void> {
|
|
525
525
|
this.#assertOpen();
|
|
526
526
|
await this.#client.query(
|
|
527
527
|
`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
@@ -532,8 +532,8 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
532
532
|
'SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE',
|
|
533
533
|
[this.#partition],
|
|
534
534
|
);
|
|
535
|
-
await this.#client.query('SAVEPOINT
|
|
536
|
-
this.#
|
|
535
|
+
await this.#client.query('SAVEPOINT syncular_push_candidate');
|
|
536
|
+
this.#pushApplySavepoint = true;
|
|
537
537
|
}
|
|
538
538
|
|
|
539
539
|
async commitRejectedPushResult(
|
|
@@ -542,18 +542,12 @@ class PostgresTransaction implements StorageTransaction {
|
|
|
542
542
|
result: StoredPushResult,
|
|
543
543
|
): Promise<void> {
|
|
544
544
|
this.#assertOpen();
|
|
545
|
-
if (!this.#
|
|
546
|
-
throw new Error(
|
|
547
|
-
'whole-commit rejection requires its validation savepoint',
|
|
548
|
-
);
|
|
545
|
+
if (!this.#pushApplySavepoint) {
|
|
546
|
+
throw new Error('push rejection requires its apply savepoint');
|
|
549
547
|
}
|
|
550
|
-
await this.#client.query(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
await this.#client.query(
|
|
554
|
-
'RELEASE SAVEPOINT syncular_commit_validation_candidate',
|
|
555
|
-
);
|
|
556
|
-
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;
|
|
557
551
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
558
552
|
await this.commit();
|
|
559
553
|
}
|
package/src/push.ts
CHANGED
|
@@ -806,35 +806,6 @@ function idempotencyCacheMissFrame(
|
|
|
806
806
|
};
|
|
807
807
|
}
|
|
808
808
|
|
|
809
|
-
async function persistRejectedPushResult(
|
|
810
|
-
storage: SyncRequestContext['storage'],
|
|
811
|
-
partition: string,
|
|
812
|
-
clientId: string,
|
|
813
|
-
clientCommitId: string,
|
|
814
|
-
stored: StoredPushResult,
|
|
815
|
-
): Promise<{ readonly stored: StoredPushResult; readonly replayed: boolean }> {
|
|
816
|
-
const rejectionTx = await storage.begin(partition);
|
|
817
|
-
try {
|
|
818
|
-
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
819
|
-
await rejectionTx.commit();
|
|
820
|
-
} catch (error) {
|
|
821
|
-
await rejectionTx.rollback();
|
|
822
|
-
throw error;
|
|
823
|
-
}
|
|
824
|
-
const canonical = await storage.getPushResult(
|
|
825
|
-
partition,
|
|
826
|
-
clientId,
|
|
827
|
-
clientCommitId,
|
|
828
|
-
);
|
|
829
|
-
if (canonical === undefined) {
|
|
830
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
831
|
-
}
|
|
832
|
-
return {
|
|
833
|
-
stored: canonical,
|
|
834
|
-
replayed: canonical.cacheIdentity !== stored.cacheIdentity,
|
|
835
|
-
};
|
|
836
|
-
}
|
|
837
|
-
|
|
838
809
|
export interface AppliedCommitEvent {
|
|
839
810
|
readonly commit: StoredCommit;
|
|
840
811
|
}
|
|
@@ -899,48 +870,49 @@ export async function processPushCommitWithTrace(
|
|
|
899
870
|
const validators = ctx.validators;
|
|
900
871
|
const commitValidator = ctx.commitValidator;
|
|
901
872
|
const tx = await storage.begin(partition);
|
|
873
|
+
const lockPartitionForPush =
|
|
874
|
+
tx.lockPartitionForPush?.bind(tx) ??
|
|
875
|
+
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
902
876
|
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
903
877
|
try {
|
|
904
|
-
if (
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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(
|
|
921
899
|
frame.clientCommitId,
|
|
900
|
+
serializedPersisted,
|
|
901
|
+
true,
|
|
922
902
|
);
|
|
923
|
-
if (serializedPersisted !== undefined) {
|
|
924
|
-
await tx.rollback();
|
|
925
|
-
return processedPushCommit(
|
|
926
|
-
frame.clientCommitId,
|
|
927
|
-
serializedPersisted,
|
|
928
|
-
true,
|
|
929
|
-
);
|
|
930
|
-
}
|
|
931
|
-
} catch (error) {
|
|
932
|
-
if (
|
|
933
|
-
error instanceof SyncError &&
|
|
934
|
-
error.code === 'sync.idempotency_cache_miss'
|
|
935
|
-
) {
|
|
936
|
-
await tx.rollback();
|
|
937
|
-
return {
|
|
938
|
-
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
939
|
-
replayed: false,
|
|
940
|
-
};
|
|
941
|
-
}
|
|
942
|
-
throw error;
|
|
943
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;
|
|
944
916
|
}
|
|
945
917
|
const results: PushOperationResult[] = [];
|
|
946
918
|
const changes: NewChange[] = [];
|
|
@@ -993,32 +965,24 @@ export async function processPushCommitWithTrace(
|
|
|
993
965
|
status: 'rejected',
|
|
994
966
|
results: [terminated],
|
|
995
967
|
});
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
await tx.rollback();
|
|
1008
|
-
const canonical = await persistRejectedPushResult(
|
|
1009
|
-
storage,
|
|
1010
|
-
partition,
|
|
1011
|
-
clientId,
|
|
1012
|
-
frame.clientCommitId,
|
|
1013
|
-
stored,
|
|
1014
|
-
);
|
|
1015
|
-
return processedPushCommit(
|
|
1016
|
-
frame.clientCommitId,
|
|
1017
|
-
canonical.stored,
|
|
1018
|
-
canonical.replayed,
|
|
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',
|
|
1019
979
|
);
|
|
1020
980
|
}
|
|
1021
|
-
return processedPushCommit(
|
|
981
|
+
return processedPushCommit(
|
|
982
|
+
frame.clientCommitId,
|
|
983
|
+
canonical,
|
|
984
|
+
canonical.cacheIdentity !== stored.cacheIdentity,
|
|
985
|
+
);
|
|
1022
986
|
}
|
|
1023
987
|
|
|
1024
988
|
const commitSeq = await tx.appendCommit({
|
|
@@ -1045,7 +1009,6 @@ export async function processPushCommitWithTrace(
|
|
|
1045
1009
|
}
|
|
1046
1010
|
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
1047
1011
|
} catch (error) {
|
|
1048
|
-
await tx.rollback();
|
|
1049
1012
|
if (error instanceof StorageConstraintError) {
|
|
1050
1013
|
const stored = newStoredPushResult(createdAtMs, {
|
|
1051
1014
|
status: 'rejected',
|
|
@@ -1059,19 +1022,30 @@ export async function processPushCommitWithTrace(
|
|
|
1059
1022
|
},
|
|
1060
1023
|
],
|
|
1061
1024
|
});
|
|
1062
|
-
|
|
1063
|
-
|
|
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(
|
|
1064
1033
|
partition,
|
|
1065
1034
|
clientId,
|
|
1066
1035
|
frame.clientCommitId,
|
|
1067
|
-
stored,
|
|
1068
1036
|
);
|
|
1037
|
+
if (canonical === undefined) {
|
|
1038
|
+
throw new Error(
|
|
1039
|
+
'push rejection finalization did not persist an outcome',
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1069
1042
|
return processedPushCommit(
|
|
1070
1043
|
frame.clientCommitId,
|
|
1071
|
-
canonical
|
|
1072
|
-
canonical.
|
|
1044
|
+
canonical,
|
|
1045
|
+
canonical.cacheIdentity !== stored.cacheIdentity,
|
|
1073
1046
|
);
|
|
1074
1047
|
}
|
|
1048
|
+
await tx.rollback();
|
|
1075
1049
|
throw error;
|
|
1076
1050
|
}
|
|
1077
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`.
|