@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/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,17 +159,33 @@ 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
|
|
174
172
|
explicit server-authoritative commands.
|
|
175
173
|
|
|
174
|
+
## Seed idempotency and safe revisioning
|
|
175
|
+
|
|
176
|
+
`seedMutations` uses the real push path and a stable `clientId`/`commitId`.
|
|
177
|
+
Both applied and rejected outcomes are terminal for that key. A rejected call
|
|
178
|
+
throws `SeedMutationError`, whose structured `code`, `opIndex`, `replayed`,
|
|
179
|
+
`recordedAtMs`, and `cacheIdentity` fields distinguish a fresh policy failure
|
|
180
|
+
from replay of an older cached rejection.
|
|
181
|
+
|
|
182
|
+
After correcting a development seed definition, advance an explicit seed
|
|
183
|
+
revision (`catalog-v1` to `catalog-v2`) and rerun it; do not delete the database
|
|
184
|
+
or unrelated rows. This does not apply to application commands. After an
|
|
185
|
+
unknown command outcome, reuse the original idempotency key because changing it
|
|
186
|
+
can execute the operation twice. The full inspection and recovery recipe is in
|
|
187
|
+
the public [server guide](https://syncular.dev/guide-server/#seeding-data).
|
|
188
|
+
|
|
176
189
|
The task-oriented [concurrency and conflict-correction guide](https://syncular.dev/guide-concurrency-correction/)
|
|
177
190
|
shows version projection, aggregate rollback, corrected replacement commits,
|
|
178
191
|
explicit acknowledgement, and restart-safe recovery UI together.
|
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/events.d.ts
CHANGED
|
@@ -59,11 +59,21 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
59
59
|
readonly type: 'push.rejected';
|
|
60
60
|
readonly code: string;
|
|
61
61
|
readonly opIndex: number;
|
|
62
|
+
/** True when the rejection was replayed from the idempotency cache. */
|
|
63
|
+
readonly replay: boolean;
|
|
64
|
+
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
65
|
+
readonly recordedAtMs?: number;
|
|
66
|
+
/** Privacy-safe identity of the stored outcome, when available. */
|
|
67
|
+
readonly cacheIdentity?: string;
|
|
62
68
|
}
|
|
63
69
|
/** A push commit terminated by a version conflict (§6.2). */
|
|
64
70
|
export interface PushConflictedEvent extends PushEventBase {
|
|
65
71
|
readonly type: 'push.conflicted';
|
|
66
72
|
readonly opIndex: number;
|
|
73
|
+
/** True when the conflict was replayed from the idempotency cache. */
|
|
74
|
+
readonly replay: boolean;
|
|
75
|
+
readonly recordedAtMs?: number;
|
|
76
|
+
readonly cacheIdentity?: string;
|
|
67
77
|
}
|
|
68
78
|
/** One emitted segment within a pull subscription section. */
|
|
69
79
|
export interface PullSegmentSummary {
|
package/dist/handler.js
CHANGED
|
@@ -15,7 +15,7 @@ import { SyncError, syncError } from './errors.js';
|
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
16
|
import { END_FRAME_BYTES, encodeResponseFrame, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
|
|
17
17
|
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
|
|
18
|
-
import {
|
|
18
|
+
import { processPushCommitWithTrace } from './push.js';
|
|
19
19
|
import { compileSchema } from './schema.js';
|
|
20
20
|
import { computeEffective } from './scopes.js';
|
|
21
21
|
function validateResolvedKeys(allowed, schema) {
|
|
@@ -199,7 +199,8 @@ function pushResultDetailsFrame(frame) {
|
|
|
199
199
|
entries,
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
|
-
function emitPushEvent(events, ctx, clientId, push,
|
|
202
|
+
function emitPushEvent(events, ctx, clientId, push, processed) {
|
|
203
|
+
const { frame } = processed;
|
|
203
204
|
const base = {
|
|
204
205
|
atMs: clockOf(ctx)(),
|
|
205
206
|
partition: ctx.partition,
|
|
@@ -213,7 +214,7 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
213
214
|
type: 'push.applied',
|
|
214
215
|
...base,
|
|
215
216
|
...(frame.commitSeq !== undefined ? { commitSeq: frame.commitSeq } : {}),
|
|
216
|
-
replay:
|
|
217
|
+
replay: processed.replayed,
|
|
217
218
|
});
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
@@ -224,6 +225,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
224
225
|
type: 'push.conflicted',
|
|
225
226
|
...base,
|
|
226
227
|
opIndex: record.opIndex,
|
|
228
|
+
replay: processed.replayed,
|
|
229
|
+
...(processed.recordedAtMs !== undefined
|
|
230
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
231
|
+
: {}),
|
|
232
|
+
...(processed.cacheIdentity !== undefined
|
|
233
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
234
|
+
: {}),
|
|
227
235
|
});
|
|
228
236
|
return;
|
|
229
237
|
}
|
|
@@ -234,6 +242,13 @@ function emitPushEvent(events, ctx, clientId, push, frame) {
|
|
|
234
242
|
? record.code
|
|
235
243
|
: 'sync.invalid_request',
|
|
236
244
|
opIndex: record?.opIndex ?? 0,
|
|
245
|
+
replay: processed.replayed,
|
|
246
|
+
...(processed.recordedAtMs !== undefined
|
|
247
|
+
? { recordedAtMs: processed.recordedAtMs }
|
|
248
|
+
: {}),
|
|
249
|
+
...(processed.cacheIdentity !== undefined
|
|
250
|
+
? { cacheIdentity: processed.cacheIdentity }
|
|
251
|
+
: {}),
|
|
237
252
|
});
|
|
238
253
|
}
|
|
239
254
|
async function* streamResponse(plan, ctx, schema, report) {
|
|
@@ -276,9 +291,10 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
276
291
|
try {
|
|
277
292
|
// Push half (§6): one PUSH_RESULT per PUSH_COMMIT, in request order.
|
|
278
293
|
for (const push of plan.pushes) {
|
|
279
|
-
const
|
|
294
|
+
const processed = await processPushCommitWithTrace(ctx, schema, plan.resolved, plan.header.clientId, push);
|
|
295
|
+
const { frame } = processed;
|
|
280
296
|
if (events !== undefined) {
|
|
281
|
-
emitPushEvent(events, ctx, plan.header.clientId, push,
|
|
297
|
+
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
282
298
|
}
|
|
283
299
|
yield encodeResponseFrame(frame);
|
|
284
300
|
const details = pushResultDetailsFrame(frame);
|
package/dist/postgres-storage.js
CHANGED
|
@@ -91,6 +91,12 @@ function serializePushResult(result) {
|
|
|
91
91
|
return {
|
|
92
92
|
status: result.status,
|
|
93
93
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
94
|
+
...(result.recordedAtMs !== undefined
|
|
95
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
96
|
+
: {}),
|
|
97
|
+
...(result.cacheIdentity !== undefined
|
|
98
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
99
|
+
: {}),
|
|
94
100
|
results: result.results.map((record) => {
|
|
95
101
|
if (record.status === 'conflict') {
|
|
96
102
|
return {
|
|
@@ -144,6 +150,12 @@ function deserializePushResult(value) {
|
|
|
144
150
|
return {
|
|
145
151
|
status: parsed.status,
|
|
146
152
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
153
|
+
...(parsed.recordedAtMs !== undefined
|
|
154
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
155
|
+
: {}),
|
|
156
|
+
...(parsed.cacheIdentity !== undefined
|
|
157
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
158
|
+
: {}),
|
|
147
159
|
results,
|
|
148
160
|
};
|
|
149
161
|
}
|
|
@@ -233,7 +245,7 @@ class PostgresTransaction {
|
|
|
233
245
|
#partition;
|
|
234
246
|
#resolveTable;
|
|
235
247
|
#open = true;
|
|
236
|
-
#
|
|
248
|
+
#pushApplySavepoint = false;
|
|
237
249
|
/** Resolves/rejects the `transaction(fn)` wrapper (see `begin`). */
|
|
238
250
|
#resolve;
|
|
239
251
|
#reject;
|
|
@@ -297,22 +309,22 @@ class PostgresTransaction {
|
|
|
297
309
|
this.#assertOpen();
|
|
298
310
|
return scanRowsByIndexOn(this.#client, this.#resolveTable(query.table), this.#partition, query);
|
|
299
311
|
}
|
|
300
|
-
async
|
|
312
|
+
async lockPartitionForPush() {
|
|
301
313
|
this.#assertOpen();
|
|
302
314
|
await this.#client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
303
315
|
ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
|
|
304
316
|
await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
|
|
305
|
-
await this.#client.query('SAVEPOINT
|
|
306
|
-
this.#
|
|
317
|
+
await this.#client.query('SAVEPOINT syncular_push_candidate');
|
|
318
|
+
this.#pushApplySavepoint = true;
|
|
307
319
|
}
|
|
308
320
|
async commitRejectedPushResult(clientId, clientCommitId, result) {
|
|
309
321
|
this.#assertOpen();
|
|
310
|
-
if (!this.#
|
|
311
|
-
throw new Error('
|
|
322
|
+
if (!this.#pushApplySavepoint) {
|
|
323
|
+
throw new Error('push rejection requires its apply savepoint');
|
|
312
324
|
}
|
|
313
|
-
await this.#client.query('ROLLBACK TO SAVEPOINT
|
|
314
|
-
await this.#client.query('RELEASE SAVEPOINT
|
|
315
|
-
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;
|
|
316
328
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
317
329
|
await this.commit();
|
|
318
330
|
}
|
package/dist/push.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ import type { SyncRequestContext } from './context.js';
|
|
|
22
22
|
import type { CompiledSchema } from './schema.js';
|
|
23
23
|
import type { ResolvedScopes } from './scopes.js';
|
|
24
24
|
import type { StoredCommit } from './storage.js';
|
|
25
|
+
export interface ProcessedPushCommit {
|
|
26
|
+
readonly frame: PushResultFrame;
|
|
27
|
+
/** True when this request observed an already-recorded idempotency outcome. */
|
|
28
|
+
readonly replayed: boolean;
|
|
29
|
+
readonly recordedAtMs?: number;
|
|
30
|
+
readonly cacheIdentity?: string;
|
|
31
|
+
}
|
|
25
32
|
export interface AppliedCommitEvent {
|
|
26
33
|
readonly commit: StoredCommit;
|
|
27
34
|
}
|
|
@@ -30,3 +37,9 @@ export interface AppliedCommitEvent {
|
|
|
30
37
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
31
38
|
*/
|
|
32
39
|
export declare function processPushCommit(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<PushResultFrame>;
|
|
40
|
+
/**
|
|
41
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
42
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
43
|
+
* cache provenance needed by structured events and server helpers.
|
|
44
|
+
*/
|
|
45
|
+
export declare function processPushCommitWithTrace(ctx: SyncRequestContext, schema: CompiledSchema, resolved: ResolvedScopes, clientId: string, frame: PushCommitFrame): Promise<ProcessedPushCommit>;
|
package/dist/push.js
CHANGED
|
@@ -466,6 +466,25 @@ function resultFrame(clientCommitId, stored, replay) {
|
|
|
466
466
|
results: [...stored.results],
|
|
467
467
|
};
|
|
468
468
|
}
|
|
469
|
+
function processedPushCommit(clientCommitId, stored, replayed) {
|
|
470
|
+
return {
|
|
471
|
+
frame: resultFrame(clientCommitId, stored, replayed),
|
|
472
|
+
replayed,
|
|
473
|
+
...(stored.recordedAtMs !== undefined
|
|
474
|
+
? { recordedAtMs: stored.recordedAtMs }
|
|
475
|
+
: {}),
|
|
476
|
+
...(stored.cacheIdentity !== undefined
|
|
477
|
+
? { cacheIdentity: stored.cacheIdentity }
|
|
478
|
+
: {}),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function newStoredPushResult(recordedAtMs, result) {
|
|
482
|
+
return {
|
|
483
|
+
...result,
|
|
484
|
+
recordedAtMs,
|
|
485
|
+
cacheIdentity: crypto.randomUUID(),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
469
488
|
function idempotencyCacheMissFrame(clientCommitId, error) {
|
|
470
489
|
return {
|
|
471
490
|
type: 'PUSH_RESULT',
|
|
@@ -482,27 +501,19 @@ function idempotencyCacheMissFrame(clientCommitId, error) {
|
|
|
482
501
|
],
|
|
483
502
|
};
|
|
484
503
|
}
|
|
485
|
-
async function persistRejectedPushResult(storage, partition, clientId, clientCommitId, stored) {
|
|
486
|
-
const rejectionTx = await storage.begin(partition);
|
|
487
|
-
try {
|
|
488
|
-
await rejectionTx.putPushResult(clientId, clientCommitId, stored);
|
|
489
|
-
await rejectionTx.commit();
|
|
490
|
-
}
|
|
491
|
-
catch (error) {
|
|
492
|
-
await rejectionTx.rollback();
|
|
493
|
-
throw error;
|
|
494
|
-
}
|
|
495
|
-
const canonical = await storage.getPushResult(partition, clientId, clientCommitId);
|
|
496
|
-
if (canonical === undefined) {
|
|
497
|
-
throw new Error('push rejection finalization did not persist an outcome');
|
|
498
|
-
}
|
|
499
|
-
return canonical;
|
|
500
|
-
}
|
|
501
504
|
/**
|
|
502
505
|
* Process one `PUSH_COMMIT` frame: idempotency replay (§2.3), sequential
|
|
503
506
|
* atomic apply (§6.4), realtime notification for applied commits.
|
|
504
507
|
*/
|
|
505
508
|
export async function processPushCommit(ctx, schema, resolved, clientId, frame) {
|
|
509
|
+
return (await processPushCommitWithTrace(ctx, schema, resolved, clientId, frame)).frame;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Host-observable variant of `processPushCommit`. The SSP2 wire frame keeps
|
|
513
|
+
* rejected replays as `status: rejected`; this companion result preserves the
|
|
514
|
+
* cache provenance needed by structured events and server helpers.
|
|
515
|
+
*/
|
|
516
|
+
export async function processPushCommitWithTrace(ctx, schema, resolved, clientId, frame) {
|
|
506
517
|
const { storage, partition } = ctx;
|
|
507
518
|
let persisted;
|
|
508
519
|
try {
|
|
@@ -513,12 +524,15 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
513
524
|
error.code === 'sync.idempotency_cache_miss') {
|
|
514
525
|
// §6.3: answer the retryable cache-miss for this commit rather than
|
|
515
526
|
// re-applying. Not persisted — a retry may find a readable record.
|
|
516
|
-
return
|
|
527
|
+
return {
|
|
528
|
+
frame: idempotencyCacheMissFrame(frame.clientCommitId, error),
|
|
529
|
+
replayed: false,
|
|
530
|
+
};
|
|
517
531
|
}
|
|
518
532
|
throw error;
|
|
519
533
|
}
|
|
520
534
|
if (persisted !== undefined) {
|
|
521
|
-
return
|
|
535
|
+
return processedPushCommit(frame.clientCommitId, persisted, true);
|
|
522
536
|
}
|
|
523
537
|
const createdAtMs = clockOf(ctx)();
|
|
524
538
|
const blobCtx = { store: ctx.blobs, partition };
|
|
@@ -526,32 +540,35 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
526
540
|
const validators = ctx.validators;
|
|
527
541
|
const commitValidator = ctx.commitValidator;
|
|
528
542
|
const tx = await storage.begin(partition);
|
|
543
|
+
const lockPartitionForPush = tx.lockPartitionForPush?.bind(tx) ??
|
|
544
|
+
tx.lockPartitionForCommitValidation?.bind(tx);
|
|
529
545
|
const commitRejectedPushResult = tx.commitRejectedPushResult?.bind(tx);
|
|
530
546
|
try {
|
|
531
|
-
if (
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
return resultFrame(frame.clientCommitId, serializedPersisted, true);
|
|
545
|
-
}
|
|
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);
|
|
546
560
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
+
};
|
|
554
570
|
}
|
|
571
|
+
throw error;
|
|
555
572
|
}
|
|
556
573
|
const results = [];
|
|
557
574
|
const changes = [];
|
|
@@ -580,25 +597,18 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
580
597
|
if (terminated !== undefined) {
|
|
581
598
|
// §6.3 rejected: only the terminating operation's record; §6.4:
|
|
582
599
|
// every write of the commit rolls back.
|
|
583
|
-
const stored = {
|
|
600
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
584
601
|
status: 'rejected',
|
|
585
602
|
results: [terminated],
|
|
586
|
-
};
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
await commitRejectedPushResult(clientId, frame.clientCommitId, stored);
|
|
595
|
-
}
|
|
596
|
-
else {
|
|
597
|
-
await tx.rollback();
|
|
598
|
-
const canonical = await persistRejectedPushResult(storage, partition, clientId, frame.clientCommitId, stored);
|
|
599
|
-
return resultFrame(frame.clientCommitId, canonical, canonical !== stored);
|
|
603
|
+
});
|
|
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');
|
|
600
610
|
}
|
|
601
|
-
return
|
|
611
|
+
return processedPushCommit(frame.clientCommitId, canonical, canonical.cacheIdentity !== stored.cacheIdentity);
|
|
602
612
|
}
|
|
603
613
|
const commitSeq = await tx.appendCommit({
|
|
604
614
|
clientId,
|
|
@@ -607,7 +617,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
607
617
|
createdAtMs,
|
|
608
618
|
changes,
|
|
609
619
|
});
|
|
610
|
-
const stored =
|
|
620
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
621
|
+
status: 'applied',
|
|
622
|
+
commitSeq,
|
|
623
|
+
results,
|
|
624
|
+
});
|
|
611
625
|
await tx.putPushResult(clientId, frame.clientCommitId, stored);
|
|
612
626
|
await tx.commit();
|
|
613
627
|
if (ctx.realtime !== undefined && changes.length > 0) {
|
|
@@ -618,12 +632,11 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
618
632
|
changes,
|
|
619
633
|
});
|
|
620
634
|
}
|
|
621
|
-
return
|
|
635
|
+
return processedPushCommit(frame.clientCommitId, stored, false);
|
|
622
636
|
}
|
|
623
637
|
catch (error) {
|
|
624
|
-
await tx.rollback();
|
|
625
638
|
if (error instanceof StorageConstraintError) {
|
|
626
|
-
const stored = {
|
|
639
|
+
const stored = newStoredPushResult(createdAtMs, {
|
|
627
640
|
status: 'rejected',
|
|
628
641
|
results: [
|
|
629
642
|
{
|
|
@@ -634,10 +647,19 @@ export async function processPushCommit(ctx, schema, resolved, clientId, frame)
|
|
|
634
647
|
retryable: false,
|
|
635
648
|
},
|
|
636
649
|
],
|
|
637
|
-
};
|
|
638
|
-
|
|
639
|
-
|
|
650
|
+
});
|
|
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);
|
|
640
661
|
}
|
|
662
|
+
await tx.rollback();
|
|
641
663
|
throw error;
|
|
642
664
|
}
|
|
643
665
|
}
|