@syncular/server 0.15.22 → 0.15.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -20
- package/dist/d1-storage.d.ts +9 -3
- package/dist/d1-storage.js +27 -21
- package/dist/events.d.ts +10 -0
- package/dist/handler.js +21 -5
- package/dist/postgres-storage.js +21 -9
- package/dist/push.d.ts +13 -0
- package/dist/push.js +85 -63
- package/dist/realtime.d.ts +14 -30
- package/dist/realtime.js +28 -5
- package/dist/seed.d.ts +27 -2
- package/dist/seed.js +71 -7
- package/dist/sqlite-dialect.js +12 -0
- package/dist/sqlite-storage.js +39 -13
- package/dist/storage.d.ts +18 -6
- package/package.json +2 -2
- package/src/d1-storage.ts +36 -25
- package/src/events.ts +10 -0
- package/src/handler.ts +21 -5
- package/src/postgres-storage.ts +23 -15
- package/src/push.ts +141 -93
- package/src/realtime.ts +39 -39
- package/src/seed.ts +95 -10
- package/src/sqlite-dialect.ts +14 -0
- package/src/sqlite-storage.ts +40 -19
- package/src/storage.ts +18 -6
package/src/seed.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
} from '@syncular/core';
|
|
22
22
|
import type { SyncServerConfig } from './context';
|
|
23
23
|
import { SyncError } from './errors';
|
|
24
|
+
import type { SyncularServerEvent, SyncularServerEvents } from './events';
|
|
25
|
+
import { composeEvents } from './events-ring';
|
|
24
26
|
import { handleSyncRequest } from './handler';
|
|
25
27
|
|
|
26
28
|
/** One app-shaped seed mutation — the same vocabulary as client mutations. */
|
|
@@ -54,6 +56,48 @@ export interface SeedTarget {
|
|
|
54
56
|
readonly commitId?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
export interface SeedMutationErrorOptions {
|
|
60
|
+
readonly clientId: string;
|
|
61
|
+
readonly clientCommitId: string;
|
|
62
|
+
readonly opIndex: number;
|
|
63
|
+
readonly code: string;
|
|
64
|
+
readonly replayed: boolean;
|
|
65
|
+
readonly retryable: boolean;
|
|
66
|
+
readonly message: string;
|
|
67
|
+
readonly recordedAtMs?: number;
|
|
68
|
+
readonly cacheIdentity?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Structured terminal failure from the real push path used by a seed. */
|
|
72
|
+
export class SeedMutationError extends Error {
|
|
73
|
+
override readonly name = 'SeedMutationError';
|
|
74
|
+
readonly clientId: string;
|
|
75
|
+
readonly clientCommitId: string;
|
|
76
|
+
readonly opIndex: number;
|
|
77
|
+
/** Exact protocol or host-validator rejection code. */
|
|
78
|
+
readonly code: string;
|
|
79
|
+
readonly replayed: boolean;
|
|
80
|
+
readonly retryable: boolean;
|
|
81
|
+
readonly recordedAtMs?: number;
|
|
82
|
+
readonly cacheIdentity?: string;
|
|
83
|
+
|
|
84
|
+
constructor(options: SeedMutationErrorOptions) {
|
|
85
|
+
super(options.message);
|
|
86
|
+
this.clientId = options.clientId;
|
|
87
|
+
this.clientCommitId = options.clientCommitId;
|
|
88
|
+
this.opIndex = options.opIndex;
|
|
89
|
+
this.code = options.code;
|
|
90
|
+
this.replayed = options.replayed;
|
|
91
|
+
this.retryable = options.retryable;
|
|
92
|
+
if (options.recordedAtMs !== undefined) {
|
|
93
|
+
this.recordedAtMs = options.recordedAtMs;
|
|
94
|
+
}
|
|
95
|
+
if (options.cacheIdentity !== undefined) {
|
|
96
|
+
this.cacheIdentity = options.cacheIdentity;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
57
101
|
const MAPPABLE_RE = /^_*[A-Za-z][A-Za-z0-9_]*$/;
|
|
58
102
|
|
|
59
103
|
/** Pinned §12 schema alias used by generated row types and every client host. */
|
|
@@ -79,8 +123,8 @@ function snakeToCamel(name: string): string {
|
|
|
79
123
|
|
|
80
124
|
/**
|
|
81
125
|
* Seed `mutations` into a partition through the real push path. Throws a
|
|
82
|
-
* `
|
|
83
|
-
* seed fails loud
|
|
126
|
+
* `SeedMutationError` when the push is rejected and `SyncError` for malformed
|
|
127
|
+
* helper input, so a broken seed fails loud instead of serving an empty store.
|
|
84
128
|
*/
|
|
85
129
|
export async function seedMutations(
|
|
86
130
|
config: SyncServerConfig,
|
|
@@ -154,13 +198,37 @@ export async function seedMutations(
|
|
|
154
198
|
accept: 0b0011,
|
|
155
199
|
},
|
|
156
200
|
];
|
|
201
|
+
type SeedTerminalEvent = Extract<
|
|
202
|
+
SyncularServerEvent,
|
|
203
|
+
{ type: 'push.rejected' | 'push.conflicted' }
|
|
204
|
+
>;
|
|
205
|
+
let terminalEvent: SeedTerminalEvent | undefined;
|
|
206
|
+
const capture: SyncularServerEvents = {
|
|
207
|
+
emit(event) {
|
|
208
|
+
if (
|
|
209
|
+
(event.type === 'push.rejected' || event.type === 'push.conflicted') &&
|
|
210
|
+
event.clientId === clientId &&
|
|
211
|
+
event.clientCommitId === clientCommitId
|
|
212
|
+
) {
|
|
213
|
+
terminalEvent = event;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
157
217
|
const response = await handleSyncRequest(
|
|
158
218
|
encodeMessage({
|
|
159
219
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
160
220
|
msgKind: 'request',
|
|
161
221
|
frames,
|
|
162
222
|
}),
|
|
163
|
-
{
|
|
223
|
+
{
|
|
224
|
+
...config,
|
|
225
|
+
partition: target.partition,
|
|
226
|
+
actorId: target.actorId,
|
|
227
|
+
events:
|
|
228
|
+
config.events === undefined
|
|
229
|
+
? capture
|
|
230
|
+
: composeEvents(config.events, capture),
|
|
231
|
+
},
|
|
164
232
|
);
|
|
165
233
|
|
|
166
234
|
// Fail loud: surface the first rejected/failed operation.
|
|
@@ -177,13 +245,30 @@ export async function seedMutations(
|
|
|
177
245
|
}
|
|
178
246
|
if (result.status === 'rejected') {
|
|
179
247
|
const failed = result.results.find((r) => r.status !== 'applied');
|
|
248
|
+
const code = failed?.code ?? 'sync.invalid_request';
|
|
249
|
+
const opIndex = failed?.opIndex ?? 0;
|
|
250
|
+
const retryable = failed?.status === 'error' ? failed.retryable : false;
|
|
180
251
|
const detail =
|
|
181
|
-
failed
|
|
182
|
-
?
|
|
183
|
-
:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
252
|
+
failed === undefined
|
|
253
|
+
? ''
|
|
254
|
+
: ` (op ${opIndex}: ${code} — ${failed.message})`;
|
|
255
|
+
const replayed = terminalEvent?.replay ?? false;
|
|
256
|
+
throw new SeedMutationError({
|
|
257
|
+
clientId,
|
|
258
|
+
clientCommitId,
|
|
259
|
+
opIndex,
|
|
260
|
+
code,
|
|
261
|
+
replayed,
|
|
262
|
+
retryable,
|
|
263
|
+
message: `seedMutations: the seed commit was rejected${
|
|
264
|
+
replayed ? ' (cached replay)' : ''
|
|
265
|
+
}${detail}`,
|
|
266
|
+
...(terminalEvent?.recordedAtMs !== undefined
|
|
267
|
+
? { recordedAtMs: terminalEvent.recordedAtMs }
|
|
268
|
+
: {}),
|
|
269
|
+
...(terminalEvent?.cacheIdentity !== undefined
|
|
270
|
+
? { cacheIdentity: terminalEvent.cacheIdentity }
|
|
271
|
+
: {}),
|
|
272
|
+
});
|
|
188
273
|
}
|
|
189
274
|
}
|
package/src/sqlite-dialect.ts
CHANGED
|
@@ -134,6 +134,12 @@ export function serializePushResult(result: StoredPushResult): string {
|
|
|
134
134
|
return JSON.stringify({
|
|
135
135
|
status: result.status,
|
|
136
136
|
...(result.commitSeq !== undefined ? { commitSeq: result.commitSeq } : {}),
|
|
137
|
+
...(result.recordedAtMs !== undefined
|
|
138
|
+
? { recordedAtMs: result.recordedAtMs }
|
|
139
|
+
: {}),
|
|
140
|
+
...(result.cacheIdentity !== undefined
|
|
141
|
+
? { cacheIdentity: result.cacheIdentity }
|
|
142
|
+
: {}),
|
|
137
143
|
results: result.results.map((record) => {
|
|
138
144
|
if (record.status === 'conflict') {
|
|
139
145
|
return {
|
|
@@ -164,6 +170,8 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
164
170
|
const parsed = JSON.parse(text) as {
|
|
165
171
|
status: 'applied' | 'rejected';
|
|
166
172
|
commitSeq?: number;
|
|
173
|
+
recordedAtMs?: number;
|
|
174
|
+
cacheIdentity?: string;
|
|
167
175
|
results: SerializedResult[];
|
|
168
176
|
};
|
|
169
177
|
const results: PushOperationResult[] = parsed.results.map((record) => {
|
|
@@ -192,6 +200,12 @@ export function deserializePushResult(text: string): StoredPushResult {
|
|
|
192
200
|
return {
|
|
193
201
|
status: parsed.status,
|
|
194
202
|
...(parsed.commitSeq !== undefined ? { commitSeq: parsed.commitSeq } : {}),
|
|
203
|
+
...(parsed.recordedAtMs !== undefined
|
|
204
|
+
? { recordedAtMs: parsed.recordedAtMs }
|
|
205
|
+
: {}),
|
|
206
|
+
...(parsed.cacheIdentity !== undefined
|
|
207
|
+
? { cacheIdentity: parsed.cacheIdentity }
|
|
208
|
+
: {}),
|
|
195
209
|
results,
|
|
196
210
|
};
|
|
197
211
|
}
|
package/src/sqlite-storage.ts
CHANGED
|
@@ -69,11 +69,17 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
69
69
|
#storage: SqliteServerStorage;
|
|
70
70
|
#partition: string;
|
|
71
71
|
#open = true;
|
|
72
|
-
#
|
|
72
|
+
#pushApplySavepoint = false;
|
|
73
|
+
readonly #release: () => void;
|
|
73
74
|
|
|
74
|
-
constructor(
|
|
75
|
+
constructor(
|
|
76
|
+
storage: SqliteServerStorage,
|
|
77
|
+
partition: string,
|
|
78
|
+
release: () => void,
|
|
79
|
+
) {
|
|
75
80
|
this.#storage = storage;
|
|
76
81
|
this.#partition = partition;
|
|
82
|
+
this.#release = release;
|
|
77
83
|
storage.db.exec('BEGIN IMMEDIATE');
|
|
78
84
|
}
|
|
79
85
|
|
|
@@ -96,11 +102,11 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
96
102
|
return this.#storage.scanRowsByIndex(this.#partition, query);
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
async
|
|
105
|
+
async lockPartitionForPush(): Promise<void> {
|
|
100
106
|
this.#assertOpen();
|
|
101
107
|
// BEGIN IMMEDIATE in the constructor already owns SQLite's writer lock.
|
|
102
|
-
this.#storage.db.exec('SAVEPOINT
|
|
103
|
-
this.#
|
|
108
|
+
this.#storage.db.exec('SAVEPOINT syncular_push_candidate');
|
|
109
|
+
this.#pushApplySavepoint = true;
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
async commitRejectedPushResult(
|
|
@@ -109,18 +115,12 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
109
115
|
result: StoredPushResult,
|
|
110
116
|
): Promise<void> {
|
|
111
117
|
this.#assertOpen();
|
|
112
|
-
if (!this.#
|
|
113
|
-
throw new Error(
|
|
114
|
-
'whole-commit rejection requires its validation savepoint',
|
|
115
|
-
);
|
|
118
|
+
if (!this.#pushApplySavepoint) {
|
|
119
|
+
throw new Error('push rejection requires its apply savepoint');
|
|
116
120
|
}
|
|
117
|
-
this.#storage.db.exec(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.#storage.db.exec(
|
|
121
|
-
'RELEASE SAVEPOINT syncular_commit_validation_candidate',
|
|
122
|
-
);
|
|
123
|
-
this.#commitValidationSavepoint = false;
|
|
121
|
+
this.#storage.db.exec('ROLLBACK TO SAVEPOINT syncular_push_candidate');
|
|
122
|
+
this.#storage.db.exec('RELEASE SAVEPOINT syncular_push_candidate');
|
|
123
|
+
this.#pushApplySavepoint = false;
|
|
124
124
|
await this.putPushResult(clientId, clientCommitId, result);
|
|
125
125
|
await this.commit();
|
|
126
126
|
}
|
|
@@ -245,18 +245,28 @@ class SqliteTransaction implements StorageTransaction {
|
|
|
245
245
|
async commit(): Promise<void> {
|
|
246
246
|
this.#assertOpen();
|
|
247
247
|
this.#open = false;
|
|
248
|
-
|
|
248
|
+
try {
|
|
249
|
+
this.#storage.db.exec('COMMIT');
|
|
250
|
+
} finally {
|
|
251
|
+
this.#release();
|
|
252
|
+
}
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
async rollback(): Promise<void> {
|
|
252
256
|
if (!this.#open) return;
|
|
253
257
|
this.#open = false;
|
|
254
|
-
|
|
258
|
+
try {
|
|
259
|
+
this.#storage.db.exec('ROLLBACK');
|
|
260
|
+
} finally {
|
|
261
|
+
this.#release();
|
|
262
|
+
}
|
|
255
263
|
}
|
|
256
264
|
}
|
|
257
265
|
|
|
258
266
|
export class SqliteServerStorage implements ServerStorage {
|
|
259
267
|
readonly db: Database;
|
|
268
|
+
/** One bun:sqlite connection can own only one transaction at a time. */
|
|
269
|
+
#transactionTail: Promise<void> = Promise.resolve();
|
|
260
270
|
/** Set by `ensureSchema`: app-table lookup for the relational row store. */
|
|
261
271
|
#tables: ReadonlyMap<string, CompiledTable> | undefined;
|
|
262
272
|
#schemaVersion: number | undefined;
|
|
@@ -405,7 +415,18 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
405
415
|
}
|
|
406
416
|
|
|
407
417
|
async begin(partition: string): Promise<StorageTransaction> {
|
|
408
|
-
|
|
418
|
+
const previous = this.#transactionTail;
|
|
419
|
+
let release!: () => void;
|
|
420
|
+
this.#transactionTail = new Promise<void>((resolve) => {
|
|
421
|
+
release = resolve;
|
|
422
|
+
});
|
|
423
|
+
await previous;
|
|
424
|
+
try {
|
|
425
|
+
return new SqliteTransaction(this, partition, release);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
release();
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
409
430
|
}
|
|
410
431
|
|
|
411
432
|
/** Internal: write a row + refresh its scope-index entries. */
|
package/src/storage.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface StoredPushResult {
|
|
|
70
70
|
readonly status: 'applied' | 'rejected';
|
|
71
71
|
/** Present iff `status` is `applied`. */
|
|
72
72
|
readonly commitSeq?: number;
|
|
73
|
+
/** Host clock when this terminal idempotency outcome was first recorded. */
|
|
74
|
+
readonly recordedAtMs?: number;
|
|
75
|
+
/** Privacy-safe identity used to distinguish this stored outcome from a race. */
|
|
76
|
+
readonly cacheIdentity?: string;
|
|
73
77
|
readonly results: readonly PushOperationResult[];
|
|
74
78
|
}
|
|
75
79
|
|
|
@@ -199,15 +203,23 @@ export interface StorageTransaction {
|
|
|
199
203
|
*/
|
|
200
204
|
scanRowsByIndex?(query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
201
205
|
/**
|
|
202
|
-
* Serialize
|
|
203
|
-
*
|
|
206
|
+
* Serialize every push apply for this partition before any operation read,
|
|
207
|
+
* validation, merge, or write. The push layer re-checks idempotency only
|
|
208
|
+
* after this resolves and retains the lock through terminal-result commit.
|
|
209
|
+
* Missing support fails closed before an app-row mutation.
|
|
210
|
+
*/
|
|
211
|
+
lockPartitionForPush?(): Promise<void>;
|
|
212
|
+
/**
|
|
213
|
+
* @deprecated Implement `lockPartitionForPush`. Kept as a compatibility
|
|
214
|
+
* bridge for custom adapters whose existing implementation already locks
|
|
215
|
+
* the complete partition from before candidate reads through commit.
|
|
204
216
|
*/
|
|
205
217
|
lockPartitionForCommitValidation?(): Promise<void>;
|
|
206
218
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
219
|
+
* Rejection finalization while the push-apply serialization lock is still
|
|
220
|
+
* held: discard every candidate write, persist the rejected idempotency
|
|
221
|
+
* result, and finish the transaction atomically. Required for every push so
|
|
222
|
+
* a concurrent duplicate cannot rerun operations, validators, or merges.
|
|
211
223
|
*/
|
|
212
224
|
commitRejectedPushResult?(
|
|
213
225
|
clientId: string,
|