@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/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/seed.d.ts
CHANGED
|
@@ -26,9 +26,34 @@ export interface SeedTarget {
|
|
|
26
26
|
/** The client commit id (default `'seed-commit-1'`). */
|
|
27
27
|
readonly commitId?: string;
|
|
28
28
|
}
|
|
29
|
+
export interface SeedMutationErrorOptions {
|
|
30
|
+
readonly clientId: string;
|
|
31
|
+
readonly clientCommitId: string;
|
|
32
|
+
readonly opIndex: number;
|
|
33
|
+
readonly code: string;
|
|
34
|
+
readonly replayed: boolean;
|
|
35
|
+
readonly retryable: boolean;
|
|
36
|
+
readonly message: string;
|
|
37
|
+
readonly recordedAtMs?: number;
|
|
38
|
+
readonly cacheIdentity?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
41
|
+
export declare class SeedMutationError extends Error {
|
|
42
|
+
readonly name = "SeedMutationError";
|
|
43
|
+
readonly clientId: string;
|
|
44
|
+
readonly clientCommitId: string;
|
|
45
|
+
readonly opIndex: number;
|
|
46
|
+
/** Exact protocol or host-validator rejection code. */
|
|
47
|
+
readonly code: string;
|
|
48
|
+
readonly replayed: boolean;
|
|
49
|
+
readonly retryable: boolean;
|
|
50
|
+
readonly recordedAtMs?: number;
|
|
51
|
+
readonly cacheIdentity?: string;
|
|
52
|
+
constructor(options: SeedMutationErrorOptions);
|
|
53
|
+
}
|
|
29
54
|
/**
|
|
30
55
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
31
|
-
* `
|
|
32
|
-
* seed fails loud
|
|
56
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
57
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
33
58
|
*/
|
|
34
59
|
export declare function seedMutations(config: SyncServerConfig, target: SeedTarget, mutations: readonly SeedMutation[]): Promise<void>;
|
package/dist/seed.js
CHANGED
|
@@ -11,7 +11,36 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
13
13
|
import { SyncError } from './errors.js';
|
|
14
|
+
import { composeEvents } from './events-ring.js';
|
|
14
15
|
import { handleSyncRequest } from './handler.js';
|
|
16
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
17
|
+
export class SeedMutationError extends Error {
|
|
18
|
+
name = 'SeedMutationError';
|
|
19
|
+
clientId;
|
|
20
|
+
clientCommitId;
|
|
21
|
+
opIndex;
|
|
22
|
+
/** Exact protocol or host-validator rejection code. */
|
|
23
|
+
code;
|
|
24
|
+
replayed;
|
|
25
|
+
retryable;
|
|
26
|
+
recordedAtMs;
|
|
27
|
+
cacheIdentity;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
super(options.message);
|
|
30
|
+
this.clientId = options.clientId;
|
|
31
|
+
this.clientCommitId = options.clientCommitId;
|
|
32
|
+
this.opIndex = options.opIndex;
|
|
33
|
+
this.code = options.code;
|
|
34
|
+
this.replayed = options.replayed;
|
|
35
|
+
this.retryable = options.retryable;
|
|
36
|
+
if (options.recordedAtMs !== undefined) {
|
|
37
|
+
this.recordedAtMs = options.recordedAtMs;
|
|
38
|
+
}
|
|
39
|
+
if (options.cacheIdentity !== undefined) {
|
|
40
|
+
this.cacheIdentity = options.cacheIdentity;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
15
44
|
const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
|
|
16
45
|
/** Pinned §12 schema alias used by generated row types and every client host. */
|
|
17
46
|
function snakeToCamel(name) {
|
|
@@ -35,8 +64,8 @@ function snakeToCamel(name) {
|
|
|
35
64
|
}
|
|
36
65
|
/**
|
|
37
66
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
38
|
-
* `
|
|
39
|
-
* seed fails loud
|
|
67
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
68
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
40
69
|
*/
|
|
41
70
|
export async function seedMutations(config, target, mutations) {
|
|
42
71
|
const clientId = target.clientId ?? 'seed';
|
|
@@ -88,11 +117,28 @@ export async function seedMutations(config, target, mutations) {
|
|
|
88
117
|
accept: 0b0011,
|
|
89
118
|
},
|
|
90
119
|
];
|
|
120
|
+
let terminalEvent;
|
|
121
|
+
const capture = {
|
|
122
|
+
emit(event) {
|
|
123
|
+
if ((event.type === 'push.rejected' || event.type === 'push.conflicted') &&
|
|
124
|
+
event.clientId === clientId &&
|
|
125
|
+
event.clientCommitId === clientCommitId) {
|
|
126
|
+
terminalEvent = event;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
91
130
|
const response = await handleSyncRequest(encodeMessage({
|
|
92
131
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
93
132
|
msgKind: 'request',
|
|
94
133
|
frames,
|
|
95
|
-
}), {
|
|
134
|
+
}), {
|
|
135
|
+
...config,
|
|
136
|
+
partition: target.partition,
|
|
137
|
+
actorId: target.actorId,
|
|
138
|
+
events: config.events === undefined
|
|
139
|
+
? capture
|
|
140
|
+
: composeEvents(config.events, capture),
|
|
141
|
+
});
|
|
96
142
|
// Fail loud: surface the first rejected/failed operation.
|
|
97
143
|
const message = decodeMessage(response);
|
|
98
144
|
const result = message.frames.find((frame) => frame.type === 'PUSH_RESULT' && frame.clientCommitId === clientCommitId);
|
|
@@ -101,9 +147,27 @@ export async function seedMutations(config, target, mutations) {
|
|
|
101
147
|
}
|
|
102
148
|
if (result.status === 'rejected') {
|
|
103
149
|
const failed = result.results.find((r) => r.status !== 'applied');
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
const code = failed?.code ?? 'sync.invalid_request';
|
|
151
|
+
const opIndex = failed?.opIndex ?? 0;
|
|
152
|
+
const retryable = failed?.status === 'error' ? failed.retryable : false;
|
|
153
|
+
const detail = failed === undefined
|
|
154
|
+
? ''
|
|
155
|
+
: ` (op ${opIndex}: ${code} — ${failed.message})`;
|
|
156
|
+
const replayed = terminalEvent?.replay ?? false;
|
|
157
|
+
throw new SeedMutationError({
|
|
158
|
+
clientId,
|
|
159
|
+
clientCommitId,
|
|
160
|
+
opIndex,
|
|
161
|
+
code,
|
|
162
|
+
replayed,
|
|
163
|
+
retryable,
|
|
164
|
+
message: `seedMutations: the seed commit was rejected${replayed ? ' (cached replay)' : ''}${detail}`,
|
|
165
|
+
...(terminalEvent?.recordedAtMs !== undefined
|
|
166
|
+
? { recordedAtMs: terminalEvent.recordedAtMs }
|
|
167
|
+
: {}),
|
|
168
|
+
...(terminalEvent?.cacheIdentity !== undefined
|
|
169
|
+
? { cacheIdentity: terminalEvent.cacheIdentity }
|
|
170
|
+
: {}),
|
|
171
|
+
});
|
|
108
172
|
}
|
|
109
173
|
}
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -93,6 +93,12 @@ export function serializePushResult(result) {
|
|
|
93
93
|
return JSON.stringify({
|
|
94
94
|
status: result.status,
|
|
95
95
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
96
|
+
...(result.recordedAtMs !== undefined
|
|
97
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
98
|
+
: {}),
|
|
99
|
+
...(result.cacheIdentity !== undefined
|
|
100
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
101
|
+
: {}),
|
|
96
102
|
results: result.results.map((record) => {
|
|
97
103
|
if (record.status === 'conflict') {
|
|
98
104
|
return {
|
|
@@ -146,6 +152,12 @@ export function deserializePushResult(text) {
|
|
|
146
152
|
return {
|
|
147
153
|
status: parsed.status,
|
|
148
154
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
155
|
+
...(parsed.recordedAtMs !== undefined
|
|
156
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
157
|
+
: {}),
|
|
158
|
+
...(parsed.cacheIdentity !== undefined
|
|
159
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
160
|
+
: {}),
|
|
149
161
|
results,
|
|
150
162
|
};
|
|
151
163
|
}
|
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) {
|
package/dist/storage.d.ts
CHANGED
|
@@ -64,6 +64,10 @@ export interface StoredPushResult {
|
|
|
64
64
|
readonly status: 'applied' | 'rejected';
|
|
65
65
|
/** Present iff `status` is `applied`. */
|
|
66
66
|
readonly commitSeq?: number;
|
|
67
|
+
/** Host clock when this terminal idempotency outcome was first recorded. */
|
|
68
|
+
readonly recordedAtMs?: number;
|
|
69
|
+
/** Privacy-safe identity used to distinguish this stored outcome from a race. */
|
|
70
|
+
readonly cacheIdentity?: string;
|
|
67
71
|
readonly results: readonly PushOperationResult[];
|
|
68
72
|
}
|
|
69
73
|
export interface ClientSubscription {
|
|
@@ -182,15 +186,23 @@ export interface StorageTransaction {
|
|
|
182
186
|
*/
|
|
183
187
|
scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
184
188
|
/**
|
|
185
|
-
* Serialize
|
|
186
|
-
*
|
|
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.
|
|
187
199
|
*/
|
|
188
200
|
lockPartitionForCommitValidation?(): Promise<void>;
|
|
189
201
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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.
|
|
194
206
|
*/
|
|
195
207
|
commitRejectedPushResult?(clientId: string, clientCommitId: string, result: StoredPushResult): Promise<void>;
|
|
196
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/events.ts
CHANGED
|
@@ -63,12 +63,22 @@ export interface PushRejectedEvent extends PushEventBase {
|
|
|
63
63
|
readonly type: 'push.rejected';
|
|
64
64
|
readonly code: string;
|
|
65
65
|
readonly opIndex: number;
|
|
66
|
+
/** True when the rejection was replayed from the idempotency cache. */
|
|
67
|
+
readonly replay: boolean;
|
|
68
|
+
/** Original host time for outcomes recorded by a metadata-aware server. */
|
|
69
|
+
readonly recordedAtMs?: number;
|
|
70
|
+
/** Privacy-safe identity of the stored outcome, when available. */
|
|
71
|
+
readonly cacheIdentity?: string;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/** A push commit terminated by a version conflict (§6.2). */
|
|
69
75
|
export interface PushConflictedEvent extends PushEventBase {
|
|
70
76
|
readonly type: 'push.conflicted';
|
|
71
77
|
readonly opIndex: number;
|
|
78
|
+
/** True when the conflict was replayed from the idempotency cache. */
|
|
79
|
+
readonly replay: boolean;
|
|
80
|
+
readonly recordedAtMs?: number;
|
|
81
|
+
readonly cacheIdentity?: string;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
/** One emitted segment within a pull subscription section. */
|