@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/README.md
CHANGED
|
@@ -19,7 +19,7 @@ The supported set, and what deliberately does **not** get an adapter:
|
|
|
19
19
|
| Runtime | Adapter | Transport | Storage | Status |
|
|
20
20
|
| --- | --- | --- | --- | --- |
|
|
21
21
|
| **Bun / Node** | `@syncular/server-hono` | HTTP (`POST /sync`, segments, blobs) **+ WS realtime** (§8, host-driven upgrade) | any: `SqliteServerStorage`, `PostgresServerStorage`, memory | **Supported now** — the reference deployment; runs the full conformance catalog on both bindings. |
|
|
22
|
-
| **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ WS realtime** (§8
|
|
22
|
+
| **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ optional WS realtime** (§8) | `D1ServerStorage` behind one per-partition Durable Object queue; R2-as-S3 for segments/blobs | **Supported now** — D1 sync writes always traverse the DO; WebSocket upgrades remain optional. |
|
|
23
23
|
| Raw Deno / edge-misc | — | — | — | **Not adapted** (policy below). |
|
|
24
24
|
|
|
25
25
|
**The policy for "not adapted".** Untested ≠ unsupported forever. The core
|
|
@@ -30,23 +30,20 @@ HTTP via the fetch-handler round-trip tests), and we do not claim runtimes we
|
|
|
30
30
|
do not test. Deno is a plausible future adapter the day someone runs the
|
|
31
31
|
catalog on it; until then it is neutral-core-friendly, not supported.
|
|
32
32
|
|
|
33
|
-
**Workers realtime — the Durable Object.**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
serialization point the D1 storage wants); WebSocket **hibernation** so idle
|
|
42
|
-
sockets don't bill wall time (the existing `RealtimeSession` is the
|
|
33
|
+
**Workers coordination and realtime — the Durable Object.** HTTP-only is fully
|
|
34
|
+
conformant at the protocol level, but D1 still requires a per-partition Durable
|
|
35
|
+
Object queue for every `/sync` round that may push. WebSocket upgrades are
|
|
36
|
+
optional; the write coordinator is not. `SyncularRealtimeDO` hosts that FIFO
|
|
37
|
+
and, when realtime is enabled, the `RealtimeHub`. The DO id derives from the
|
|
38
|
+
partition, so unrelated partitions remain concurrent. WebSocket
|
|
39
|
+
**hibernation** keeps idle sockets from billing wall time (the existing
|
|
40
|
+
`RealtimeSession` is the
|
|
43
41
|
per-connection state machine, driven from the hibernation callbacks and
|
|
44
42
|
rehydrated from a minimal socket attachment + the D1 client record on wake);
|
|
45
43
|
storage via the same **D1** binding so realtime rounds and `POST /sync` rounds
|
|
46
44
|
share one commit log and one segment store; commit fan-out (§8.2) runs in-DO
|
|
47
|
-
(no LISTEN/NOTIFY needed — writes and sockets are co-located)
|
|
48
|
-
|
|
49
|
-
LISTEN/NOTIFY analogue). Full shape, wiring, hibernation semantics, and the
|
|
45
|
+
(no LISTEN/NOTIFY needed — writes and sockets are co-located). Full shape,
|
|
46
|
+
wiring, hibernation semantics, and the
|
|
50
47
|
manual real-workerd smoke recipe in `@syncular/server-workers/README.md`.
|
|
51
48
|
|
|
52
49
|
**No relay (decision).** There is deliberately no *relay* — no bridge that
|
|
@@ -162,12 +159,13 @@ re-checks idempotency after taking that lock and persists a rejected outcome
|
|
|
162
159
|
while the lock is retained, so overlapping duplicate deliveries cannot rerun
|
|
163
160
|
the callback.
|
|
164
161
|
|
|
165
|
-
SQLite and PostgreSQL provide that serialization directly
|
|
166
|
-
an interactive lock: `D1ServerStorage` fails closed unless it
|
|
167
|
-
inside an
|
|
168
|
-
`{
|
|
169
|
-
|
|
170
|
-
|
|
162
|
+
SQLite and PostgreSQL provide that serialization directly for every push. D1
|
|
163
|
+
does not expose an interactive lock: `D1ServerStorage` fails closed unless it
|
|
164
|
+
is constructed inside an explicit per-partition coordinator with
|
|
165
|
+
`{ pushApplySerialized: true }` (normally the packaged Durable Object FIFO).
|
|
166
|
+
Do not set that assertion on a stateless Worker. Custom storages must implement
|
|
167
|
+
the pre-operation partition lock, locked idempotency re-check, atomic rejection
|
|
168
|
+
finalization, and—when `commitValidator` is used—candidate scans.
|
|
171
169
|
|
|
172
170
|
Whole-commit validation checks a client-proposed commit; it does not grant
|
|
173
171
|
authority. Privileged operations such as connecting facilities still belong in
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -16,9 +16,15 @@ export interface D1Database {
|
|
|
16
16
|
export interface D1ServerStorageOptions {
|
|
17
17
|
/**
|
|
18
18
|
* Assert that all writes for a partition reach this storage serially.
|
|
19
|
-
* Required for
|
|
20
|
-
* Set this only inside
|
|
21
|
-
* coordinator; the default fails closed
|
|
19
|
+
* Required for every push because D1 exposes no interactive transaction
|
|
20
|
+
* lock. Set this only inside an explicit per-partition request queue,
|
|
21
|
+
* Durable Object, or equivalent coordinator; the default fails closed.
|
|
22
|
+
*/
|
|
23
|
+
readonly pushApplySerialized?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated Use `pushApplySerialized`. This alias remains valid only
|
|
26
|
+
* because the old assertion already promised that every partition write,
|
|
27
|
+
* not merely validator callbacks, was externally serialized.
|
|
22
28
|
*/
|
|
23
29
|
readonly commitValidationSerialized?: boolean;
|
|
24
30
|
}
|
package/dist/d1-storage.js
CHANGED
|
@@ -32,11 +32,11 @@ var _a;
|
|
|
32
32
|
* reading `max_commit_seq` live and buffering the `+1` write. Under a single
|
|
33
33
|
* Worker request this is exact. Two concurrent pushes to one partition need an
|
|
34
34
|
* external serialization point (normally a per-partition Durable Object); a
|
|
35
|
-
* realtime notifier alone does not serialize HTTP writes.
|
|
36
|
-
*
|
|
37
|
-
* primitive (a DO or a Queue).
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* realtime notifier alone does not serialize HTTP writes. Every deployment
|
|
36
|
+
* that accepts D1 pushes MUST front same-partition sync rounds with a
|
|
37
|
+
* coordinating primitive (a DO or a Queue). The adapter fails closed unless
|
|
38
|
+
* that coordinator explicitly sets `pushApplySerialized`, because D1 cannot
|
|
39
|
+
* provide the required pre-operation lock.
|
|
40
40
|
* This mirrors PostgreSQL's per-partition row lock, achieved by placement
|
|
41
41
|
* rather than a lock D1 does not expose.
|
|
42
42
|
*/
|
|
@@ -58,12 +58,12 @@ class D1Transaction {
|
|
|
58
58
|
#db;
|
|
59
59
|
#partition;
|
|
60
60
|
#resolveTable;
|
|
61
|
-
#
|
|
61
|
+
#pushApplySerialized;
|
|
62
62
|
#buffer = [];
|
|
63
63
|
#open = true;
|
|
64
64
|
/** Live snapshot of `max_commit_seq`, advanced within this transaction. */
|
|
65
65
|
#maxCommitSeq;
|
|
66
|
-
#
|
|
66
|
+
#pushApplyCheckpoint;
|
|
67
67
|
#lastApplicationOpIndex;
|
|
68
68
|
/**
|
|
69
69
|
* Read-your-own-writes overlay (§6.2 needs `getRow` to see buffered writes
|
|
@@ -71,11 +71,11 @@ class D1Transaction {
|
|
|
71
71
|
* `tbl\u0000rowId` → the pending state, consulted by `getRow` before D1.
|
|
72
72
|
*/
|
|
73
73
|
#pending = new Map();
|
|
74
|
-
constructor(db, partition, resolveTable,
|
|
74
|
+
constructor(db, partition, resolveTable, pushApplySerialized) {
|
|
75
75
|
this.#db = db;
|
|
76
76
|
this.#partition = partition;
|
|
77
77
|
this.#resolveTable = resolveTable;
|
|
78
|
-
this.#
|
|
78
|
+
this.#pushApplySerialized = pushApplySerialized;
|
|
79
79
|
}
|
|
80
80
|
#assertOpen() {
|
|
81
81
|
if (!this.#open)
|
|
@@ -188,20 +188,20 @@ class D1Transaction {
|
|
|
188
188
|
.sort((left, right) => left.rowId.localeCompare(right.rowId))
|
|
189
189
|
.slice(0, query.limit);
|
|
190
190
|
}
|
|
191
|
-
async
|
|
191
|
+
async lockPartitionForPush() {
|
|
192
192
|
this.#assertOpen();
|
|
193
|
-
if (!this.#
|
|
194
|
-
throw new Error('D1
|
|
193
|
+
if (!this.#pushApplySerialized) {
|
|
194
|
+
throw new Error('D1 push apply requires externally serialized partition writes');
|
|
195
195
|
}
|
|
196
196
|
// D1 has no interactive lock. The caller explicitly asserted that every
|
|
197
197
|
// write for this partition is already serialized (normally by its DO).
|
|
198
|
-
this.#
|
|
198
|
+
this.#pushApplyCheckpoint = this.#buffer.length;
|
|
199
199
|
}
|
|
200
200
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
201
201
|
this.#assertOpen();
|
|
202
|
-
const checkpoint = this.#
|
|
202
|
+
const checkpoint = this.#pushApplyCheckpoint;
|
|
203
203
|
if (checkpoint === undefined) {
|
|
204
|
-
throw new Error('
|
|
204
|
+
throw new Error('push rejection requires its apply checkpoint');
|
|
205
205
|
}
|
|
206
206
|
this.#buffer.length = checkpoint;
|
|
207
207
|
this.#pending.clear();
|
|
@@ -352,16 +352,21 @@ class D1Transaction {
|
|
|
352
352
|
}
|
|
353
353
|
async commit() {
|
|
354
354
|
this.#assertOpen();
|
|
355
|
-
this.#
|
|
356
|
-
|
|
355
|
+
if (this.#buffer.length === 0) {
|
|
356
|
+
this.#open = false;
|
|
357
357
|
return;
|
|
358
|
+
}
|
|
358
359
|
const statements = this.#buffer.map((entry) => this.#db.prepare(entry.sql).bind(...entry.params));
|
|
359
360
|
// One atomic D1 batch — the §6.4 all-or-nothing commit.
|
|
360
361
|
try {
|
|
361
362
|
await this.#db.batch(statements);
|
|
363
|
+
this.#open = false;
|
|
362
364
|
}
|
|
363
365
|
catch (error) {
|
|
364
366
|
if (isD1ConstraintError(error)) {
|
|
367
|
+
// D1 batches are atomic. Keep this logical transaction open so the
|
|
368
|
+
// push layer can discard its buffered candidates and persist the
|
|
369
|
+
// terminal rejection while the external partition queue is retained.
|
|
365
370
|
throw new StorageConstraintError(error, this.#lastApplicationOpIndex);
|
|
366
371
|
}
|
|
367
372
|
throw error;
|
|
@@ -382,14 +387,15 @@ _a = D1Transaction;
|
|
|
382
387
|
const D1_MAX_BIND_PARAMS = 100;
|
|
383
388
|
export class D1ServerStorage {
|
|
384
389
|
#db;
|
|
385
|
-
#
|
|
390
|
+
#pushApplySerialized;
|
|
386
391
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
387
392
|
#tables;
|
|
388
393
|
#schemaVersion;
|
|
389
394
|
constructor(db, options = {}) {
|
|
390
395
|
this.#db = db;
|
|
391
|
-
this.#
|
|
392
|
-
options.
|
|
396
|
+
this.#pushApplySerialized =
|
|
397
|
+
options.pushApplySerialized === true ||
|
|
398
|
+
options.commitValidationSerialized === true;
|
|
393
399
|
}
|
|
394
400
|
/** Apply the schema DDL (idempotent). Call once before use. */
|
|
395
401
|
async migrate() {
|
|
@@ -512,7 +518,7 @@ export class D1ServerStorage {
|
|
|
512
518
|
}
|
|
513
519
|
}
|
|
514
520
|
async begin(partition) {
|
|
515
|
-
return new D1Transaction(this.#db, partition, (name) => this.table(name), this.#
|
|
521
|
+
return new D1Transaction(this.#db, partition, (name) => this.table(name), this.#pushApplySerialized);
|
|
516
522
|
}
|
|
517
523
|
async getMaxCommitSeq(partition) {
|
|
518
524
|
const row = await this.#db
|
package/dist/postgres-storage.js
CHANGED
|
@@ -245,7 +245,7 @@ class PostgresTransaction {
|
|
|
245
245
|
#partition;
|
|
246
246
|
#resolveTable;
|
|
247
247
|
#open = true;
|
|
248
|
-
#
|
|
248
|
+
#pushApplySavepoint = false;
|
|
249
249
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
250
250
|
#resolve;
|
|
251
251
|
#reject;
|
|
@@ -309,22 +309,22 @@ class PostgresTransaction {
|
|
|
309
309
|
this.#assertOpen();
|
|
310
310
|
return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
|
|
311
311
|
}
|
|
312
|
-
async
|
|
312
|
+
async lockPartitionForPush() {
|
|
313
313
|
this.#assertOpen();
|
|
314
314
|
await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
315
315
|
ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
|
|
316
316
|
await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
|
|
317
|
-
await this.#client.query('SAVEPOINT
|
|
318
|
-
this.#
|
|
317
|
+
await this.#client.query('SAVEPOINT syncular_push_candidate');
|
|
318
|
+
this.#pushApplySavepoint = true;
|
|
319
319
|
}
|
|
320
320
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
321
321
|
this.#assertOpen();
|
|
322
|
-
if (!this.#
|
|
323
|
-
throw new Error('
|
|
322
|
+
if (!this.#pushApplySavepoint) {
|
|
323
|
+
throw new Error('push rejection requires its apply savepoint');
|
|
324
324
|
}
|
|
325
|
-
await this.#client.query('ROLLBACK TO SAVEPOINT
|
|
326
|
-
await this.#client.query('RELEASE SAVEPOINT
|
|
327
|
-
this.#
|
|
325
|
+
await this.#client.query('ROLLBACK TO SAVEPOINT syncular_push_candidate');
|
|
326
|
+
await this.#client.query('RELEASE SAVEPOINT syncular_push_candidate');
|
|
327
|
+
this.#pushApplySavepoint = false;
|
|
328
328
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
329
329
|
await this.commit();
|
|
330
330
|
}
|
package/dist/push.js
CHANGED
|
@@ -501,25 +501,6 @@ function idempotencyCacheMissFrame(clientCommitId, error) {
|
|
|
501
501
|
],
|
|
502
502
|
};
|
|
503
503
|
}
|
|
504
|
-
async function persistRejectedPushResult(storage, partition, clientId, clientCommitId, stored) {
|
|
505
|
-
const rejectionTx = await storage.begin(partition);
|
|
506
|
-
try {
|
|
507
|
-
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
508
|
-
await rejectionTx.commit();
|
|
509
|
-
}
|
|
510
|
-
catch (error) {
|
|
511
|
-
await rejectionTx.rollback();
|
|
512
|
-
throw error;
|
|
513
|
-
}
|
|
514
|
-
const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
515
|
-
if (canonical === undefined) {
|
|
516
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
517
|
-
}
|
|
518
|
-
return {
|
|
519
|
-
stored: canonical,
|
|
520
|
-
replayed: canonical.cacheIdentity !== stored.cacheIdentity,
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
504
|
/**
|
|
524
505
|
* Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
|
|
525
506
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
@@ -559,35 +540,35 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
559
540
|
const validators = ctx.validators;
|
|
560
541
|
const commitValidator = ctx.commitValidator;
|
|
561
542
|
const tx = await storage.begin(partition);
|
|
543
|
+
const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
|
|
544
|
+
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
562
545
|
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
563
546
|
try {
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
|
|
578
|
-
}
|
|
547
|
+
if (lockPartitionForPush === undefined ||
|
|
548
|
+
commitRejectedPushResult === undefined) {
|
|
549
|
+
throw new Error('storage transaction does not support serialized push apply and atomic rejection finalization');
|
|
550
|
+
}
|
|
551
|
+
await lockPartitionForPush();
|
|
552
|
+
// The optimistic lookup above may have raced another delivery. Re-check
|
|
553
|
+
// only after acquiring partition serialization and before any operation
|
|
554
|
+
// read, validation, merge, or staged write.
|
|
555
|
+
try {
|
|
556
|
+
const serializedPersisted = await storage.getPushResult(partition, clientId, frame.clientCommitId);
|
|
557
|
+
if (serializedPersisted !== undefined) {
|
|
558
|
+
await tx.rollback();
|
|
559
|
+
return processedPushCommit(frame.clientCommitId, serializedPersisted, true);
|
|
579
560
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
throw error;
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
if (error instanceof SyncError &&
|
|
564
|
+
error.code === 'sync.idempotency_cache_miss') {
|
|
565
|
+
await tx.rollback();
|
|
566
|
+
return {
|
|
567
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
568
|
+
replayed: false,
|
|
569
|
+
};
|
|
590
570
|
}
|
|
571
|
+
throw error;
|
|
591
572
|
}
|
|
592
573
|
const results = [];
|
|
593
574
|
const changes = [];
|
|
@@ -620,21 +601,14 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
620
601
|
status: 'rejected',
|
|
621
602
|
results: [terminated],
|
|
622
603
|
});
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
631
|
-
}
|
|
632
|
-
else {
|
|
633
|
-
await tx.rollback();
|
|
634
|
-
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
635
|
-
return processedPushCommit(frame.clientCommitId, canonical.stored, canonical.replayed);
|
|
604
|
+
// Discard candidates and persist the rejection while retaining the same
|
|
605
|
+
// partition lock. There is no unlock gap in which a duplicate can rerun.
|
|
606
|
+
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
607
|
+
const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
|
|
608
|
+
if (canonical === undefined) {
|
|
609
|
+
throw new Error('push rejection finalization did not persist an outcome');
|
|
636
610
|
}
|
|
637
|
-
return processedPushCommit(frame.clientCommitId,
|
|
611
|
+
return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
638
612
|
}
|
|
639
613
|
const commitSeq = await tx.appendCommit({
|
|
640
614
|
clientId,
|
|
@@ -661,7 +635,6 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
661
635
|
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
662
636
|
}
|
|
663
637
|
catch (error) {
|
|
664
|
-
await tx.rollback();
|
|
665
638
|
if (error instanceof StorageConstraintError) {
|
|
666
639
|
const stored = newStoredPushResult(createdAtMs, {
|
|
667
640
|
status: 'rejected',
|
|
@@ -675,9 +648,18 @@ export async function processPushCommitWithTrace(ctx, schema, resolved, clientId
|
|
|
675
648
|
},
|
|
676
649
|
],
|
|
677
650
|
});
|
|
678
|
-
|
|
679
|
-
|
|
651
|
+
if (commitRejectedPushResult === undefined) {
|
|
652
|
+
await tx.rollback();
|
|
653
|
+
throw new Error('storage transaction lost atomic push rejection finalization support');
|
|
654
|
+
}
|
|
655
|
+
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
656
|
+
const canonical = await storage.getPushResult(partition, clientId, frame.clientCommitId);
|
|
657
|
+
if (canonical === undefined) {
|
|
658
|
+
throw new Error('push rejection finalization did not persist an outcome');
|
|
659
|
+
}
|
|
660
|
+
return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
680
661
|
}
|
|
662
|
+
await tx.rollback();
|
|
681
663
|
throw error;
|
|
682
664
|
}
|
|
683
665
|
}
|
package/dist/realtime.d.ts
CHANGED
|
@@ -14,38 +14,16 @@
|
|
|
14
14
|
* the `sync` wake-up (§8.3).
|
|
15
15
|
*/
|
|
16
16
|
import { type PresenceKind, type ScopeMap, type WakeReason } from '@syncular/core';
|
|
17
|
-
import type {
|
|
17
|
+
import type { SyncRequestContext, SyncServerConfig } from './context.js';
|
|
18
18
|
import { type SyncularServerEvents } from './events.js';
|
|
19
|
-
import type { ServerSchema } from './schema.js';
|
|
20
|
-
import type { SegmentStore } from './segment-store.js';
|
|
21
|
-
import type { SegmentUrlConfig } from './signed-url.js';
|
|
22
19
|
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
/** §6.7 validators used by sync rounds carried over this socket. */
|
|
29
|
-
readonly validators?: ValidatorRegistry;
|
|
30
|
-
/** §6.8 whole-commit validator shared with HTTP sync rounds. */
|
|
31
|
-
readonly commitValidator?: CommitValidator;
|
|
32
|
-
readonly clock?: () => number;
|
|
20
|
+
/**
|
|
21
|
+
* Realtime adds fanout/presence tuning to the canonical sync-server config;
|
|
22
|
+
* socket rounds must never have a narrower push/pull capability set than HTTP.
|
|
23
|
+
*/
|
|
24
|
+
export interface RealtimeHubConfig extends Omit<SyncServerConfig, 'realtime'> {
|
|
33
25
|
/** Deltas larger than this become `delta-too-large` wake-ups (§8.2). */
|
|
34
26
|
readonly maxDeltaBytes?: number;
|
|
35
|
-
/** Optional structured-events sink (`realtime.*` events). */
|
|
36
|
-
readonly events?: SyncularServerEvents;
|
|
37
|
-
/**
|
|
38
|
-
* Segment store for sync rounds over the socket (§8.7). Without it a
|
|
39
|
-
* socket round fails loudly with an in-band ERROR — provide the same
|
|
40
|
-
* store the HTTP binding uses (one handler, two framings).
|
|
41
|
-
*/
|
|
42
|
-
readonly segments?: SegmentStore;
|
|
43
|
-
/** Request limits for socket rounds; defaults match the HTTP binding. */
|
|
44
|
-
readonly limits?: Partial<ServerLimits>;
|
|
45
|
-
readonly signedUrls?: SegmentUrlConfig;
|
|
46
|
-
/** §7.3 auth leases for socket sync rounds (§8.7) — same config the
|
|
47
|
-
* HTTP binding uses, so rounds over the socket are lease-aware too. */
|
|
48
|
-
readonly leases?: LeaseConfig;
|
|
49
27
|
/**
|
|
50
28
|
* §8.6 presence: cap on the serialized size (bytes) of a published
|
|
51
29
|
* presence document. An over-cap publish is rejected loudly to the
|
|
@@ -149,9 +127,11 @@ export declare class RealtimeSession {
|
|
|
149
127
|
* round's request byte stream (§8.7). Synchronous entry — assembly and
|
|
150
128
|
* violation detection happen inline so a pipelined chunk arriving
|
|
151
129
|
* while a response streams is caught deterministically; the round
|
|
152
|
-
* itself runs async once the request is complete.
|
|
130
|
+
* itself runs async once the request is complete. The returned promise, when
|
|
131
|
+
* present, resolves only after response streaming and registration refresh;
|
|
132
|
+
* coordinated hosts await it to retain their partition FIFO through commit.
|
|
153
133
|
*/
|
|
154
|
-
handleBinary(bytes: Uint8Array): void;
|
|
134
|
+
handleBinary(bytes: Uint8Array): Promise<void> | undefined;
|
|
155
135
|
sendHeartbeat(): void;
|
|
156
136
|
sendWake(reason: WakeReason): void;
|
|
157
137
|
/** Called by the hub for every applied commit, in commitSeq order. */
|
|
@@ -186,6 +166,10 @@ export declare class RealtimeHub {
|
|
|
186
166
|
* the same shape the HTTP adapter builds, so the round drives the
|
|
187
167
|
* SAME handler with zero semantic divergence.
|
|
188
168
|
*/
|
|
169
|
+
requestContextFor(identity: {
|
|
170
|
+
readonly partition: string;
|
|
171
|
+
readonly actorId: string;
|
|
172
|
+
}): SyncRequestContext;
|
|
189
173
|
requestContext(session: RealtimeSession): SyncRequestContext;
|
|
190
174
|
/**
|
|
191
175
|
* Register a connected socket (§8.1): load the client's last pull's
|
package/dist/realtime.js
CHANGED
|
@@ -327,7 +327,9 @@ export class RealtimeSession {
|
|
|
327
327
|
* round's request byte stream (§8.7). Synchronous entry — assembly and
|
|
328
328
|
* violation detection happen inline so a pipelined chunk arriving
|
|
329
329
|
* while a response streams is caught deterministically; the round
|
|
330
|
-
* itself runs async once the request is complete.
|
|
330
|
+
* itself runs async once the request is complete. The returned promise, when
|
|
331
|
+
* present, resolves only after response streaming and registration refresh;
|
|
332
|
+
* coordinated hosts await it to retain their partition FIFO through commit.
|
|
331
333
|
*/
|
|
332
334
|
handleBinary(bytes) {
|
|
333
335
|
if (bytes.length === 0)
|
|
@@ -365,7 +367,7 @@ export class RealtimeSession {
|
|
|
365
367
|
}
|
|
366
368
|
const token = Symbol('round');
|
|
367
369
|
this.#activeRound = token;
|
|
368
|
-
|
|
370
|
+
return this.#runRound(done.message.slice(), token);
|
|
369
371
|
}
|
|
370
372
|
/** Drive the shared handler and stream the response back (§8.7). */
|
|
371
373
|
async #runRound(requestBytes, token) {
|
|
@@ -702,7 +704,7 @@ export class RealtimeHub {
|
|
|
702
704
|
* the same shape the HTTP adapter builds, so the round drives the
|
|
703
705
|
* SAME handler with zero semantic divergence.
|
|
704
706
|
*/
|
|
705
|
-
|
|
707
|
+
requestContextFor(identity) {
|
|
706
708
|
const segments = this.#config.segments;
|
|
707
709
|
if (segments === undefined) {
|
|
708
710
|
// Fail loud (§8.7): a hub serving socket rounds needs the same
|
|
@@ -710,12 +712,21 @@ export class RealtimeHub {
|
|
|
710
712
|
throw syncError('sync.invalid_request', 'socket sync rounds require a segment store on the realtime hub (§8.7)');
|
|
711
713
|
}
|
|
712
714
|
return {
|
|
713
|
-
partition:
|
|
714
|
-
actorId:
|
|
715
|
+
partition: identity.partition,
|
|
716
|
+
actorId: identity.actorId,
|
|
715
717
|
schema: this.#config.schema,
|
|
716
718
|
storage: this.#config.storage,
|
|
717
719
|
segments,
|
|
718
720
|
resolveScopes: this.#config.resolveScopes,
|
|
721
|
+
...(this.#config.blobs !== undefined
|
|
722
|
+
? { blobs: this.#config.blobs }
|
|
723
|
+
: {}),
|
|
724
|
+
...(this.#config.maxBlobBytes !== undefined
|
|
725
|
+
? { maxBlobBytes: this.#config.maxBlobBytes }
|
|
726
|
+
: {}),
|
|
727
|
+
...(this.#config.crdtMergers !== undefined
|
|
728
|
+
? { crdtMergers: this.#config.crdtMergers }
|
|
729
|
+
: {}),
|
|
719
730
|
...(this.#config.validators !== undefined
|
|
720
731
|
? { validators: this.#config.validators }
|
|
721
732
|
: {}),
|
|
@@ -731,6 +742,15 @@ export class RealtimeHub {
|
|
|
731
742
|
...(this.#config.signedUrls !== undefined
|
|
732
743
|
? { signedUrls: this.#config.signedUrls }
|
|
733
744
|
: {}),
|
|
745
|
+
...(this.#config.blobSignedUrls !== undefined
|
|
746
|
+
? { blobSignedUrls: this.#config.blobSignedUrls }
|
|
747
|
+
: {}),
|
|
748
|
+
...(this.#config.blobUploadUrls !== undefined
|
|
749
|
+
? { blobUploadUrls: this.#config.blobUploadUrls }
|
|
750
|
+
: {}),
|
|
751
|
+
...(this.#config.sqliteImageBuilder !== undefined
|
|
752
|
+
? { sqliteImageBuilder: this.#config.sqliteImageBuilder }
|
|
753
|
+
: {}),
|
|
734
754
|
...(this.#config.leases !== undefined
|
|
735
755
|
? { leases: this.#config.leases }
|
|
736
756
|
: {}),
|
|
@@ -740,6 +760,9 @@ export class RealtimeHub {
|
|
|
740
760
|
realtime: this,
|
|
741
761
|
};
|
|
742
762
|
}
|
|
763
|
+
requestContext(session) {
|
|
764
|
+
return this.requestContextFor(session);
|
|
765
|
+
}
|
|
743
766
|
/**
|
|
744
767
|
* Register a connected socket (§8.1): load the client's last pull's
|
|
745
768
|
* subscription list, resolve + intersect scopes, send `hello`.
|
package/dist/sqlite-storage.js
CHANGED
|
@@ -17,10 +17,12 @@ class SqliteTransaction {
|
|
|
17
17
|
#storage;
|
|
18
18
|
#partition;
|
|
19
19
|
#open = true;
|
|
20
|
-
#
|
|
21
|
-
|
|
20
|
+
#pushApplySavepoint = false;
|
|
21
|
+
#release;
|
|
22
|
+
constructor(storage, partition, release) {
|
|
22
23
|
this.#storage = storage;
|
|
23
24
|
this.#partition = partition;
|
|
25
|
+
this.#release = release;
|
|
24
26
|
storage.db.exec('BEGIN IMMEDIATE');
|
|
25
27
|
}
|
|
26
28
|
#assertOpen() {
|
|
@@ -39,20 +41,20 @@ class SqliteTransaction {
|
|
|
39
41
|
this.#assertOpen();
|
|
40
42
|
return this.#storage.scanRowsByIndex(this.#partition, query);
|
|
41
43
|
}
|
|
42
|
-
async
|
|
44
|
+
async lockPartitionForPush() {
|
|
43
45
|
this.#assertOpen();
|
|
44
46
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
45
|
-
this.#storage.db.exec('SAVEPOINT
|
|
46
|
-
this.#
|
|
47
|
+
this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
|
|
48
|
+
this.#pushApplySavepoint = true;
|
|
47
49
|
}
|
|
48
50
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
49
51
|
this.#assertOpen();
|
|
50
|
-
if (!this.#
|
|
51
|
-
throw new Error('
|
|
52
|
+
if (!this.#pushApplySavepoint) {
|
|
53
|
+
throw new Error('push rejection requires its apply savepoint');
|
|
52
54
|
}
|
|
53
|
-
this.#storage.db.exec('ROLLBACK TO SAVEPOINT
|
|
54
|
-
this.#storage.db.exec('RELEASE SAVEPOINT
|
|
55
|
-
this.#
|
|
55
|
+
this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_push_candidate');
|
|
56
|
+
this.#storage.db.exec('RELEASE SAVEPOINT syncular_push_candidate');
|
|
57
|
+
this.#pushApplySavepoint = false;
|
|
56
58
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
57
59
|
await this.commit();
|
|
58
60
|
}
|
|
@@ -113,17 +115,29 @@ class SqliteTransaction {
|
|
|
113
115
|
async commit() {
|
|
114
116
|
this.#assertOpen();
|
|
115
117
|
this.#open = false;
|
|
116
|
-
|
|
118
|
+
try {
|
|
119
|
+
this.#storage.db.exec('COMMIT');
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
this.#release();
|
|
123
|
+
}
|
|
117
124
|
}
|
|
118
125
|
async rollback() {
|
|
119
126
|
if (!this.#open)
|
|
120
127
|
return;
|
|
121
128
|
this.#open = false;
|
|
122
|
-
|
|
129
|
+
try {
|
|
130
|
+
this.#storage.db.exec('ROLLBACK');
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
this.#release();
|
|
134
|
+
}
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
export class SqliteServerStorage {
|
|
126
138
|
db;
|
|
139
|
+
/** One bun:sqlite connection can own only one transaction at a time. */
|
|
140
|
+
#transactionTail = Promise.resolve();
|
|
127
141
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
128
142
|
#tables;
|
|
129
143
|
#schemaVersion;
|
|
@@ -237,7 +251,19 @@ export class SqliteServerStorage {
|
|
|
237
251
|
}
|
|
238
252
|
}
|
|
239
253
|
async begin(partition) {
|
|
240
|
-
|
|
254
|
+
const previous = this.#transactionTail;
|
|
255
|
+
let release;
|
|
256
|
+
this.#transactionTail = new Promise((resolve) => {
|
|
257
|
+
release = resolve;
|
|
258
|
+
});
|
|
259
|
+
await previous;
|
|
260
|
+
try {
|
|
261
|
+
return new SqliteTransaction(this, partition, release);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
release();
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
241
267
|
}
|
|
242
268
|
/** Internal: write a row + refresh its scope-index entries. */
|
|
243
269
|
writeRow(partition, table, row) {
|