@syncular/server 0.30.15 → 0.31.0
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 +73 -3
- package/dist/context.d.ts +16 -6
- package/dist/d1-storage.js +20 -8
- package/dist/errors.d.ts +17 -4
- package/dist/errors.js +31 -6
- package/dist/pull.js +3 -26
- package/dist/realtime.d.ts +3 -1
- package/dist/realtime.js +7 -3
- package/dist/sqlite-image.d.ts +1 -1
- package/package.json +2 -2
- package/src/context.ts +16 -6
- package/src/d1-storage.ts +23 -8
- package/src/errors.ts +45 -5
- package/src/pull.ts +3 -29
- package/src/realtime.ts +23 -3
- package/src/sqlite-image.ts +1 -1
package/README.md
CHANGED
|
@@ -28,8 +28,8 @@ telemetry. See the [domain event guide](https://syncular.dev/guide-domain-events
|
|
|
28
28
|
|
|
29
29
|
The server core is **runtime-neutral TypeScript** — `handleSyncRequest` and
|
|
30
30
|
the realtime session speak only Web `Request`/`Response`/`fetch`/Web-Crypto,
|
|
31
|
-
no Bun- or Node-only builtin (enforced by
|
|
32
|
-
`test/runtime-neutrality.test.ts`). Adapters wire that core to a runtime.
|
|
31
|
+
no Bun- or Node-only builtin (enforced by static and dynamic import scans
|
|
32
|
+
and an unminified browser bundle in `test/runtime-neutrality.test.ts`). Adapters wire that core to a runtime.
|
|
33
33
|
The supported set, and what deliberately does **not** get an adapter:
|
|
34
34
|
|
|
35
35
|
| Runtime | Adapter | Transport | Storage | Status |
|
|
@@ -83,11 +83,12 @@ import {
|
|
|
83
83
|
ensureSyncServerReady,
|
|
84
84
|
type SyncServerConfig,
|
|
85
85
|
} from '@syncular/server';
|
|
86
|
-
import { SqliteServerStorage } from '@syncular/server/sqlite';
|
|
86
|
+
import { buildSqliteImage, SqliteServerStorage } from '@syncular/server/sqlite';
|
|
87
87
|
|
|
88
88
|
const config: SyncServerConfig = {
|
|
89
89
|
schema,
|
|
90
90
|
storage: new SqliteServerStorage('./sync.db'),
|
|
91
|
+
sqliteImageBuilder: buildSqliteImage,
|
|
91
92
|
segments,
|
|
92
93
|
resolveScopes,
|
|
93
94
|
};
|
|
@@ -102,6 +103,14 @@ storage, leases, and SQLite-image generation without an external SQLite
|
|
|
102
103
|
package. The runtime-specific database wrappers are `BunSqliteDatabase` and
|
|
103
104
|
`NodeSqliteDatabase` when a host needs direct access to the native handle.
|
|
104
105
|
|
|
106
|
+
Hosts opt into building SQLite bootstrap images by supplying
|
|
107
|
+
`sqliteImageBuilder: buildSqliteImage` in the server config (and the realtime
|
|
108
|
+
hub config when it serves sync rounds). The neutral `@syncular/server` entry
|
|
109
|
+
never detects the runtime or loads a SQLite builder. Import the builder from
|
|
110
|
+
`@syncular/server/sqlite` on Bun or Node. Without a builder, the server serves
|
|
111
|
+
matching stored images and negotiates inline or external rows, including
|
|
112
|
+
signed URLs when configured, according to the client's accepted formats.
|
|
113
|
+
|
|
105
114
|
The helper accepts the generated `ServerSchema`, compiles it, and calls the
|
|
106
115
|
storage backend's low-level `ensureSchema(CompiledSchema)`. A failure is a
|
|
107
116
|
`SyncServerReadinessError` with stable code `sync.schema_not_ready`, a `phase`
|
|
@@ -121,6 +130,22 @@ partition. The rotation clears stale client cursors and requires version 2
|
|
|
121
130
|
clients to reset their server-derived rows while preserving the outbox. Follow
|
|
122
131
|
the complete [backup and restore runbook](https://syncular.dev/server-backup-restore/).
|
|
123
132
|
|
|
133
|
+
## Adapter error hooks
|
|
134
|
+
|
|
135
|
+
A `SyncError` answers with its catalog code and status. By default, any other exception an
|
|
136
|
+
adapter catches answers `sync.internal_error` and is handed to
|
|
137
|
+
`SyncServerConfig.onError` exactly once; a throwing observer does not change
|
|
138
|
+
the response. The same config accepts an optional synchronous `mapError` hook:
|
|
139
|
+
after `onError` observes the original, `mapError` may return a catalog
|
|
140
|
+
`SyncError` (with structured `details`, for example `retryAfterMs`) that the
|
|
141
|
+
adapter answers instead. Return `undefined` to keep `sync.internal_error`. A
|
|
142
|
+
throw, a non-`SyncError` return, a code that is not an own catalog entry, or
|
|
143
|
+
`details` that is not a JSON document is contained as `sync.internal_error`.
|
|
144
|
+
The hook covers the sync, operations, segments, blobs, admin, and realtime
|
|
145
|
+
surfaces; `createSyncularAdminRoutes` takes its own `mapError`. It does not
|
|
146
|
+
change a remote operation's `operation.*` envelope, and a mapped `retryAfterMs`
|
|
147
|
+
is delivery metadata. Client retry scheduling remains unchanged.
|
|
148
|
+
|
|
124
149
|
## Write validators and recovery metadata
|
|
125
150
|
|
|
126
151
|
`validators` is the server-authoritative seam for row business rules that
|
|
@@ -243,10 +268,55 @@ Do not set that assertion on a stateless Worker. Custom storages must implement
|
|
|
243
268
|
the pre-operation partition lock, locked idempotency re-check, atomic rejection
|
|
244
269
|
finalization, and—when `commitValidator` is used—candidate scans.
|
|
245
270
|
|
|
271
|
+
`D1ServerStorage` updates the client record in place instead of deleting and
|
|
272
|
+
reinserting it, and a row update whose scope map is unchanged leaves the
|
|
273
|
+
scope-index entries alone. Both liveness timestamps keep their cadence: the
|
|
274
|
+
partition's `last_authenticated_at_ms` and the client's `updated_at_ms`
|
|
275
|
+
retain their existing refresh cadence. A logical same-value commit still
|
|
276
|
+
applies in full: it advances `server_version`, records the change, and stores
|
|
277
|
+
the submitted payload. See the measured local baseline in the
|
|
278
|
+
[Cloudflare Workers guide](https://syncular.dev/server-workers/#routine-write-cost).
|
|
279
|
+
|
|
246
280
|
Whole-commit validation checks a client-proposed commit; it does not grant
|
|
247
281
|
authority. Privileged operations such as connecting facilities still belong in
|
|
248
282
|
explicit server-authoritative commands.
|
|
249
283
|
|
|
284
|
+
## Push idempotency and retry contract
|
|
285
|
+
|
|
286
|
+
The idempotency key for a push commit is the triple
|
|
287
|
+
**(`partition`, `clientId`, `clientCommitId`)**. The server persists the full
|
|
288
|
+
commit result in the same transaction as the writes, and a replay of the same
|
|
289
|
+
key returns that result byte-equivalent: an applied commit replays as `cached`,
|
|
290
|
+
a rejected commit replays as `rejected` (§2.3, §6.3).
|
|
291
|
+
|
|
292
|
+
The key contains the commit ID and nothing about the operations. Host
|
|
293
|
+
authentication, request-envelope validation, and the §1.5 clientId-actor
|
|
294
|
+
binding run before the lookup.
|
|
295
|
+
`clientId` is a client-supplied namespace inside the authenticated partition,
|
|
296
|
+
not proof of an authenticated device. When a retained result exists for the
|
|
297
|
+
key, the server skips `buildOperations`, the commit and write validators, and
|
|
298
|
+
the apply transaction: a retry that reuses the key with different operations
|
|
299
|
+
returns the persisted result, and those operations are never built, validated,
|
|
300
|
+
or applied. `packages/server/test/push.test.ts` pins this.
|
|
301
|
+
|
|
302
|
+
A client MUST NOT reuse a `clientCommitId` for changed intent, including after
|
|
303
|
+
the persisted result has been pruned. One ID identifies one logical commit
|
|
304
|
+
permanently, and a later edit is a later commit with a new ID.
|
|
305
|
+
|
|
306
|
+
Do not key idempotency on the request payload bytes. A §5.11 client re-encrypts
|
|
307
|
+
an encrypted column at every send with a fresh nonce, and a schema upgrade
|
|
308
|
+
re-encodes pending commits, so the wire payload of an unchanged commit differs
|
|
309
|
+
between a lost-ACK retry and the original send. A payload fingerprint rejects
|
|
310
|
+
those retries. The `encryption/lost-ack-retry-dedupes-changed-ciphertext`
|
|
311
|
+
conformance scenario exercises the changed-ciphertext retry against both cores.
|
|
312
|
+
|
|
313
|
+
Per-device namespacing and content binding are not implemented. Each needs an
|
|
314
|
+
input the ID does not carry: an authenticated device identity from the host, or
|
|
315
|
+
a client-side format that binds the commit content. Neither is specified here,
|
|
316
|
+
and the ID check does not authenticate the client. Within one partition the ID
|
|
317
|
+
contract is a correctness property: it stops a replayed ID from applying
|
|
318
|
+
different operations.
|
|
319
|
+
|
|
250
320
|
## Seed idempotency and safe revisioning
|
|
251
321
|
|
|
252
322
|
`seedMutations` uses the real push path and a stable `clientId`/`commitId`.
|
package/dist/context.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { BlobStore } from './blob-store.js';
|
|
9
9
|
import type { CrdtMergerRegistry } from './crdt-merger.js';
|
|
10
|
-
import type { SyncularErrorHandler } from './errors.js';
|
|
10
|
+
import type { SyncularErrorHandler, SyncularErrorMapper } from './errors.js';
|
|
11
11
|
import type { SyncularServerEvents } from './events.js';
|
|
12
12
|
import type { LeaseStore } from './lease-store.js';
|
|
13
13
|
import type { AnyReactionPlanner } from './reactions.js';
|
|
@@ -157,11 +157,10 @@ export interface SyncServerConfig {
|
|
|
157
157
|
*/
|
|
158
158
|
readonly blobUploadUrls?: BlobUploadPresignConfig;
|
|
159
159
|
/**
|
|
160
|
-
* §5.3 sqlite-image builder
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* `@syncular/server/sqlite`.
|
|
160
|
+
* §5.3 sqlite-image builder. The host explicitly imports and supplies
|
|
161
|
+
* `buildSqliteImage` from `@syncular/server/sqlite` on Bun or Node.
|
|
162
|
+
* Absent ⇒ serve matching stored images or negotiate the rows lane.
|
|
163
|
+
* The neutral core never imports a runtime-specific builder.
|
|
165
164
|
*/
|
|
166
165
|
readonly sqliteImageBuilder?: SqliteImageBuilder;
|
|
167
166
|
readonly realtime?: RealtimeNotifier;
|
|
@@ -178,6 +177,17 @@ export interface SyncServerConfig {
|
|
|
178
177
|
* handler never changes the response.
|
|
179
178
|
*/
|
|
180
179
|
readonly onError?: SyncularErrorHandler;
|
|
180
|
+
/**
|
|
181
|
+
* Optional synchronous adapter error mapper (§10.2). After `onError`
|
|
182
|
+
* observes an adapter exception, `mapError` may return a catalog
|
|
183
|
+
* `SyncError` (including structured `details`, e.g. `retryAfterMs`) that
|
|
184
|
+
* the adapter answers instead of `sync.internal_error`; `undefined` keeps
|
|
185
|
+
* the default. A throw, a non-`SyncError` return, or malformed `details`
|
|
186
|
+
* JSON is contained as `sync.internal_error`. Unlike `onError`, this hook
|
|
187
|
+
* may affect the response. It does not change the remote-operation
|
|
188
|
+
* `operation.*` envelope.
|
|
189
|
+
*/
|
|
190
|
+
readonly mapError?: SyncularErrorMapper;
|
|
181
191
|
}
|
|
182
192
|
/** Per-request context: the config plus host-authenticated identity. */
|
|
183
193
|
export interface SyncRequestContext extends SyncServerConfig {
|
package/dist/d1-storage.js
CHANGED
|
@@ -410,13 +410,16 @@ class D1Transaction {
|
|
|
410
410
|
row,
|
|
411
411
|
});
|
|
412
412
|
const p = this.#partition;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
413
|
+
// Scope index maintenance: delete only the old keys the new scope map no
|
|
414
|
+
// longer carries, then insert the new keys. Keys present in both are left
|
|
415
|
+
// alone, so the `INSERT OR IGNORE` below finds them and writes nothing.
|
|
416
|
+
// This DELETE precedes the row upsert in the same atomic batch, and its
|
|
417
|
+
// subquery reads the row's pre-upsert scope map.
|
|
418
|
+
const scopesJson = JSON.stringify(row.scopes);
|
|
419
|
+
this.#buffer_(`${deleteSqliteRowScopesSql(compiled)}
|
|
420
|
+
AND (var, value) NOT IN (
|
|
421
|
+
SELECT key, value FROM json_each(?)
|
|
422
|
+
)`, [p, table, row.rowId, p, row.rowId, scopesJson]);
|
|
420
423
|
this.#buffer_(upsertSql(compiled, 'sqlite'), upsertValues(compiled, p, row, 'sqlite'));
|
|
421
424
|
for (const [variable, value] of Object.entries(row.scopes)) {
|
|
422
425
|
this.#buffer_('INSERT OR IGNORE INTO sync_row_scopes(partition, tbl, var, value, row_id) VALUES (?,?,?,?,?)', [p, table, variable, value, row.rowId]);
|
|
@@ -1563,8 +1566,17 @@ export class D1ServerStorage {
|
|
|
1563
1566
|
};
|
|
1564
1567
|
}
|
|
1565
1568
|
async putClientRecord(partition, record) {
|
|
1569
|
+
// Update the existing record in place, so the table keeps one row per
|
|
1570
|
+
// (partition, client_id). Every non-key field is written from the new
|
|
1571
|
+
// record; a delete-and-insert would drop and recreate the row instead.
|
|
1566
1572
|
await this.#db
|
|
1567
|
-
.prepare(
|
|
1573
|
+
.prepare(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
|
|
1574
|
+
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
1575
|
+
actor_id=excluded.actor_id,
|
|
1576
|
+
wire_version=excluded.wire_version,
|
|
1577
|
+
cursor=excluded.cursor,
|
|
1578
|
+
subscriptions=excluded.subscriptions,
|
|
1579
|
+
updated_at_ms=excluded.updated_at_ms`)
|
|
1568
1580
|
.bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
|
|
1569
1581
|
.run();
|
|
1570
1582
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -44,12 +44,25 @@ export type SyncularErrorRoute = 'sync' | 'operations' | 'segments' | 'blobs' |
|
|
|
44
44
|
export type SyncularErrorHandler = (error: unknown, context: {
|
|
45
45
|
readonly route: SyncularErrorRoute;
|
|
46
46
|
}) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Optional synchronous error mapper. After `onError` has observed the
|
|
49
|
+
* original, `mapError` may translate an adapter exception to a catalog
|
|
50
|
+
* `SyncError` (with `details` metadata such as `retryAfterMs`) that the
|
|
51
|
+
* adapter answers instead of `sync.internal_error`. Return `undefined` to
|
|
52
|
+
* keep the default. A throw, a non-`SyncError` return, or `details` that is
|
|
53
|
+
* not a JSON document is contained as `sync.internal_error`.
|
|
54
|
+
*/
|
|
55
|
+
export type SyncularErrorMapper = (error: unknown, context: {
|
|
56
|
+
readonly route: SyncularErrorRoute;
|
|
57
|
+
}) => SyncError | undefined;
|
|
47
58
|
/**
|
|
48
59
|
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
49
|
-
* passes through unchanged. Anything else goes to `onError` and
|
|
50
|
-
* `sync.internal_error` (§10.2)
|
|
51
|
-
*
|
|
60
|
+
* passes through unchanged. Anything else goes to `onError` and answers
|
|
61
|
+
* `sync.internal_error` (§10.2) unless the optional `mapError` hook returns a
|
|
62
|
+
* catalog `SyncError` to answer instead. The default `sync.internal_error`
|
|
63
|
+
* message never carries the exception text; a mapped error's message is the
|
|
64
|
+
* host's choice.
|
|
52
65
|
*/
|
|
53
|
-
export declare function adapterSyncError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): SyncError;
|
|
66
|
+
export declare function adapterSyncError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute, mapError?: SyncularErrorMapper): SyncError;
|
|
54
67
|
/** Hand `error` to the host's `onError`; a throwing handler is contained. */
|
|
55
68
|
export declare function reportError(error: unknown, onError: SyncularErrorHandler | undefined, route: SyncularErrorRoute): void;
|
package/dist/errors.js
CHANGED
|
@@ -272,10 +272,10 @@ export class SyncError extends Error {
|
|
|
272
272
|
httpStatus;
|
|
273
273
|
details;
|
|
274
274
|
constructor(code, message, details) {
|
|
275
|
-
|
|
276
|
-
if (entry === undefined) {
|
|
275
|
+
if (!Object.hasOwn(ERROR_CATALOG, code)) {
|
|
277
276
|
throw new Error(`unknown error code ${code} (not in the §10.2 catalog)`);
|
|
278
277
|
}
|
|
278
|
+
const entry = ERROR_CATALOG[code];
|
|
279
279
|
super(message ?? code);
|
|
280
280
|
this.code = code;
|
|
281
281
|
this.category = entry.category;
|
|
@@ -304,14 +304,39 @@ export function errorBody(error) {
|
|
|
304
304
|
}
|
|
305
305
|
/**
|
|
306
306
|
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
307
|
-
* passes through unchanged. Anything else goes to `onError` and
|
|
308
|
-
* `sync.internal_error` (§10.2)
|
|
309
|
-
*
|
|
307
|
+
* passes through unchanged. Anything else goes to `onError` and answers
|
|
308
|
+
* `sync.internal_error` (§10.2) unless the optional `mapError` hook returns a
|
|
309
|
+
* catalog `SyncError` to answer instead. The default `sync.internal_error`
|
|
310
|
+
* message never carries the exception text; a mapped error's message is the
|
|
311
|
+
* host's choice.
|
|
310
312
|
*/
|
|
311
|
-
export function adapterSyncError(error, onError, route) {
|
|
313
|
+
export function adapterSyncError(error, onError, route, mapError) {
|
|
312
314
|
if (error instanceof SyncError)
|
|
313
315
|
return error;
|
|
314
316
|
reportError(error, onError, route);
|
|
317
|
+
try {
|
|
318
|
+
const mapped = mapError?.(error, { route });
|
|
319
|
+
if (mapped instanceof SyncError) {
|
|
320
|
+
const { code, message, details } = mapped;
|
|
321
|
+
let detailsValid = details === undefined;
|
|
322
|
+
if (!detailsValid && typeof details === 'string') {
|
|
323
|
+
JSON.parse(details);
|
|
324
|
+
detailsValid = true;
|
|
325
|
+
}
|
|
326
|
+
if (typeof code === 'string' &&
|
|
327
|
+
typeof message === 'string' &&
|
|
328
|
+
Object.hasOwn(ERROR_CATALOG, code) &&
|
|
329
|
+
detailsValid) {
|
|
330
|
+
// Reconstruct from the validated fields: the catalog owns
|
|
331
|
+
// retryability and status, so a mutated `SyncError` cannot smuggle a
|
|
332
|
+
// different entry onto the wire.
|
|
333
|
+
return new SyncError(code, message, details);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// A throwing mapper or getter, or malformed details, keeps the default.
|
|
339
|
+
}
|
|
315
340
|
return new SyncError('sync.internal_error', 'internal server error');
|
|
316
341
|
}
|
|
317
342
|
/** Hand `error` to the host's `onError`; a throwing handler is contained. */
|
package/dist/pull.js
CHANGED
|
@@ -10,24 +10,6 @@ import { issueSegmentUrl } from './signed-url.js';
|
|
|
10
10
|
import { projectRowPayload, projectRowValues } from './schema-window.js';
|
|
11
11
|
// One artifact build per owning storage pair and complete immutable identity.
|
|
12
12
|
const imageBuilds = new WeakMap();
|
|
13
|
-
/**
|
|
14
|
-
* Resolve the §5.3 image builder: the host-injected one if present, else the
|
|
15
|
-
* in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
|
|
16
|
-
* is never a static dep of the neutral core), else `undefined` (rows lane).
|
|
17
|
-
* Memoized so the dynamic import happens at most once per process.
|
|
18
|
-
*/
|
|
19
|
-
let cachedDefaultBuilder;
|
|
20
|
-
async function resolveImageBuilder(ctx) {
|
|
21
|
-
if (ctx.sqliteImageBuilder !== undefined)
|
|
22
|
-
return ctx.sqliteImageBuilder;
|
|
23
|
-
if (cachedDefaultBuilder === undefined) {
|
|
24
|
-
const hasBun = globalThis.Bun !== undefined;
|
|
25
|
-
cachedDefaultBuilder = hasBun
|
|
26
|
-
? (await import('./sqlite-bun.js')).buildSqliteImage
|
|
27
|
-
: null;
|
|
28
|
-
}
|
|
29
|
-
return cachedDefaultBuilder ?? undefined;
|
|
30
|
-
}
|
|
31
13
|
/** §4.2 accept bitmask. */
|
|
32
14
|
export const ACCEPT_INLINE_ROWS = 1 << 0;
|
|
33
15
|
export const ACCEPT_EXTERNAL_ROWS = 1 << 1;
|
|
@@ -176,14 +158,9 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
176
158
|
// The probe is the rows lane's first page, read by `prepareSections`.
|
|
177
159
|
if (probe.length <= limits.limitSnapshotRows)
|
|
178
160
|
return false;
|
|
179
|
-
// §5.3:
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
// import so `bun:sqlite` is never a static dependency of the pull path
|
|
183
|
-
// (runtime neutrality is enforced by test/runtime-neutrality.test.ts). On
|
|
184
|
-
// Workers/edge (no `Bun`) this yields the rows lane for a bit-2 client — a
|
|
185
|
-
// support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
|
|
186
|
-
const buildImage = await resolveImageBuilder(ctx);
|
|
161
|
+
// §5.3: hosts opt into image construction with a runtime-specific builder.
|
|
162
|
+
// Without one, matching stored images remain usable and cold pulls use rows.
|
|
163
|
+
const buildImage = ctx.sqliteImageBuilder;
|
|
187
164
|
if (buildImage === undefined)
|
|
188
165
|
return false;
|
|
189
166
|
let stores = imageBuilds.get(storage);
|
package/dist/realtime.d.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { type PresenceKind, type ScopeMap, type WakeReason } from '@syncular/core';
|
|
20
20
|
import type { SyncRequestContext, SyncServerConfig } from './context.js';
|
|
21
|
-
import { type SyncularErrorHandler } from './errors.js';
|
|
21
|
+
import { type SyncularErrorHandler, type SyncularErrorMapper } from './errors.js';
|
|
22
22
|
import { type SyncularServerEvents } from './events.js';
|
|
23
23
|
import { type CompiledTable } from './schema.js';
|
|
24
24
|
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
@@ -204,6 +204,8 @@ export declare class RealtimeHub {
|
|
|
204
204
|
}): SyncRequestContext;
|
|
205
205
|
/** The host's error reporter (`SyncServerConfig.onError`), if any. */
|
|
206
206
|
get onError(): SyncularErrorHandler | undefined;
|
|
207
|
+
/** The host's adapter error mapper (`SyncServerConfig.mapError`), if any. */
|
|
208
|
+
get mapError(): SyncularErrorMapper | undefined;
|
|
207
209
|
requestContext(session: RealtimeSession): SyncRequestContext;
|
|
208
210
|
/**
|
|
209
211
|
* Register a connected socket (§8.1): load the client's last pull's
|
package/dist/realtime.js
CHANGED
|
@@ -436,7 +436,7 @@ export class RealtimeSession {
|
|
|
436
436
|
// `sync.internal_error`, exactly like the HTTP adapter's 500.
|
|
437
437
|
const sync = error instanceof DecodeError
|
|
438
438
|
? syncError(error.code, error.message)
|
|
439
|
-
: adapterSyncError(error, this.#hub.onError, 'realtime');
|
|
439
|
+
: adapterSyncError(error, (error, context) => this.#hub.onError?.(error, context), 'realtime', (error, context) => this.#hub.mapError?.(error, context));
|
|
440
440
|
finishRound(); // END is in this one chunk
|
|
441
441
|
await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
|
|
442
442
|
return;
|
|
@@ -460,7 +460,7 @@ export class RealtimeSession {
|
|
|
460
460
|
// the §8.7 violation path.
|
|
461
461
|
const sync = error instanceof DecodeError
|
|
462
462
|
? syncError(error.code, error.message)
|
|
463
|
-
: adapterSyncError(error, this.#hub.onError, 'realtime');
|
|
463
|
+
: adapterSyncError(error, (error, context) => this.#hub.onError?.(error, context), 'realtime', (error, context) => this.#hub.mapError?.(error, context));
|
|
464
464
|
finishRound();
|
|
465
465
|
await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
|
|
466
466
|
return;
|
|
@@ -476,7 +476,7 @@ export class RealtimeSession {
|
|
|
476
476
|
catch (error) {
|
|
477
477
|
// A host failure mid-stream leaves the byte stream unfinishable —
|
|
478
478
|
// fail loud, drop the connection (§8.7 / §1.4 abort rule).
|
|
479
|
-
reportError(error, this.#hub.onError, 'realtime');
|
|
479
|
+
reportError(error, (error, context) => this.#hub.onError?.(error, context), 'realtime');
|
|
480
480
|
this.#violation('sync round failed mid-stream');
|
|
481
481
|
}
|
|
482
482
|
finally {
|
|
@@ -876,6 +876,10 @@ export class RealtimeHub {
|
|
|
876
876
|
get onError() {
|
|
877
877
|
return this.#config.onError;
|
|
878
878
|
}
|
|
879
|
+
/** The host's adapter error mapper (`SyncServerConfig.mapError`), if any. */
|
|
880
|
+
get mapError() {
|
|
881
|
+
return this.#config.mapError;
|
|
882
|
+
}
|
|
879
883
|
requestContext(session) {
|
|
880
884
|
return this.requestContextFor(session);
|
|
881
885
|
}
|
package/dist/sqlite-image.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface SqliteImageInput {
|
|
|
18
18
|
* a real SQLite engine, which is not available on every runtime. The core
|
|
19
19
|
* takes the builder as an optional capability rather than importing a driver
|
|
20
20
|
* on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
|
|
21
|
-
* host omits it and serves the rows lane.
|
|
21
|
+
* host omits it and serves matching stored images or the rows lane.
|
|
22
22
|
*/
|
|
23
23
|
export type SqliteImageBuilder = (input: SqliteImageInput) => Promise<Uint8Array>;
|
|
24
24
|
/** Populate a §5.3 image database for a whole-table snapshot. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Syncular server: handleSyncRequest + storage/auth interfaces for the sync protocol",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"verify:node": "cd ../.. && bun build ./packages/server/test/sqlite-runtime/verify-node.mjs --target=node --conditions=bun --outfile=./packages/server/.verify-node.built.mjs && node ./packages/server/.verify-node.built.mjs"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@syncular/core": "0.
|
|
71
|
+
"@syncular/core": "0.31.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/context.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { BlobStore } from './blob-store';
|
|
9
9
|
import type { CrdtMergerRegistry } from './crdt-merger';
|
|
10
|
-
import type { SyncularErrorHandler } from './errors';
|
|
10
|
+
import type { SyncularErrorHandler, SyncularErrorMapper } from './errors';
|
|
11
11
|
import type { SyncularServerEvents } from './events';
|
|
12
12
|
import type { LeaseStore } from './lease-store';
|
|
13
13
|
import type { AnyReactionPlanner } from './reactions';
|
|
@@ -182,11 +182,10 @@ export interface SyncServerConfig {
|
|
|
182
182
|
*/
|
|
183
183
|
readonly blobUploadUrls?: BlobUploadPresignConfig;
|
|
184
184
|
/**
|
|
185
|
-
* §5.3 sqlite-image builder
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* `@syncular/server/sqlite`.
|
|
185
|
+
* §5.3 sqlite-image builder. The host explicitly imports and supplies
|
|
186
|
+
* `buildSqliteImage` from `@syncular/server/sqlite` on Bun or Node.
|
|
187
|
+
* Absent ⇒ serve matching stored images or negotiate the rows lane.
|
|
188
|
+
* The neutral core never imports a runtime-specific builder.
|
|
190
189
|
*/
|
|
191
190
|
readonly sqliteImageBuilder?: SqliteImageBuilder;
|
|
192
191
|
readonly realtime?: RealtimeNotifier;
|
|
@@ -203,6 +202,17 @@ export interface SyncServerConfig {
|
|
|
203
202
|
* handler never changes the response.
|
|
204
203
|
*/
|
|
205
204
|
readonly onError?: SyncularErrorHandler;
|
|
205
|
+
/**
|
|
206
|
+
* Optional synchronous adapter error mapper (§10.2). After `onError`
|
|
207
|
+
* observes an adapter exception, `mapError` may return a catalog
|
|
208
|
+
* `SyncError` (including structured `details`, e.g. `retryAfterMs`) that
|
|
209
|
+
* the adapter answers instead of `sync.internal_error`; `undefined` keeps
|
|
210
|
+
* the default. A throw, a non-`SyncError` return, or malformed `details`
|
|
211
|
+
* JSON is contained as `sync.internal_error`. Unlike `onError`, this hook
|
|
212
|
+
* may affect the response. It does not change the remote-operation
|
|
213
|
+
* `operation.*` envelope.
|
|
214
|
+
*/
|
|
215
|
+
readonly mapError?: SyncularErrorMapper;
|
|
206
216
|
}
|
|
207
217
|
|
|
208
218
|
/** Per-request context: the config plus host-authenticated identity. */
|
package/src/d1-storage.ts
CHANGED
|
@@ -660,13 +660,19 @@ class D1Transaction implements StorageTransaction {
|
|
|
660
660
|
row,
|
|
661
661
|
});
|
|
662
662
|
const p = this.#partition;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
663
|
+
// Scope index maintenance: delete only the old keys the new scope map no
|
|
664
|
+
// longer carries, then insert the new keys. Keys present in both are left
|
|
665
|
+
// alone, so the `INSERT OR IGNORE` below finds them and writes nothing.
|
|
666
|
+
// This DELETE precedes the row upsert in the same atomic batch, and its
|
|
667
|
+
// subquery reads the row's pre-upsert scope map.
|
|
668
|
+
const scopesJson = JSON.stringify(row.scopes);
|
|
669
|
+
this.#buffer_(
|
|
670
|
+
`${deleteSqliteRowScopesSql(compiled)}
|
|
671
|
+
AND (var, value) NOT IN (
|
|
672
|
+
SELECT key, value FROM json_each(?)
|
|
673
|
+
)`,
|
|
674
|
+
[p, table, row.rowId, p, row.rowId, scopesJson],
|
|
675
|
+
);
|
|
670
676
|
this.#buffer_(
|
|
671
677
|
upsertSql(compiled, 'sqlite'),
|
|
672
678
|
upsertValues(compiled, p, row, 'sqlite'),
|
|
@@ -2310,9 +2316,18 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2310
2316
|
partition: string,
|
|
2311
2317
|
record: ClientRecord,
|
|
2312
2318
|
): Promise<void> {
|
|
2319
|
+
// Update the existing record in place, so the table keeps one row per
|
|
2320
|
+
// (partition, client_id). Every non-key field is written from the new
|
|
2321
|
+
// record; a delete-and-insert would drop and recreate the row instead.
|
|
2313
2322
|
await this.#db
|
|
2314
2323
|
.prepare(
|
|
2315
|
-
|
|
2324
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)
|
|
2325
|
+
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
2326
|
+
actor_id=excluded.actor_id,
|
|
2327
|
+
wire_version=excluded.wire_version,
|
|
2328
|
+
cursor=excluded.cursor,
|
|
2329
|
+
subscriptions=excluded.subscriptions,
|
|
2330
|
+
updated_at_ms=excluded.updated_at_ms`,
|
|
2316
2331
|
)
|
|
2317
2332
|
.bind(
|
|
2318
2333
|
partition,
|
package/src/errors.ts
CHANGED
|
@@ -282,10 +282,10 @@ export class SyncError extends Error {
|
|
|
282
282
|
readonly details?: string;
|
|
283
283
|
|
|
284
284
|
constructor(code: string, message?: string, details?: string) {
|
|
285
|
-
|
|
286
|
-
if (entry === undefined) {
|
|
285
|
+
if (!Object.hasOwn(ERROR_CATALOG, code)) {
|
|
287
286
|
throw new Error(`unknown error code ${code} (not in the §10.2 catalog)`);
|
|
288
287
|
}
|
|
288
|
+
const entry = ERROR_CATALOG[code]!;
|
|
289
289
|
super(message ?? code);
|
|
290
290
|
this.code = code;
|
|
291
291
|
this.category = entry.category;
|
|
@@ -344,19 +344,59 @@ export type SyncularErrorHandler = (
|
|
|
344
344
|
context: { readonly route: SyncularErrorRoute },
|
|
345
345
|
) => void;
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Optional synchronous error mapper. After `onError` has observed the
|
|
349
|
+
* original, `mapError` may translate an adapter exception to a catalog
|
|
350
|
+
* `SyncError` (with `details` metadata such as `retryAfterMs`) that the
|
|
351
|
+
* adapter answers instead of `sync.internal_error`. Return `undefined` to
|
|
352
|
+
* keep the default. A throw, a non-`SyncError` return, or `details` that is
|
|
353
|
+
* not a JSON document is contained as `sync.internal_error`.
|
|
354
|
+
*/
|
|
355
|
+
export type SyncularErrorMapper = (
|
|
356
|
+
error: unknown,
|
|
357
|
+
context: { readonly route: SyncularErrorRoute },
|
|
358
|
+
) => SyncError | undefined;
|
|
359
|
+
|
|
347
360
|
/**
|
|
348
361
|
* The protocol error for an exception an adapter caught. A `SyncError`
|
|
349
|
-
* passes through unchanged. Anything else goes to `onError` and
|
|
350
|
-
* `sync.internal_error` (§10.2)
|
|
351
|
-
*
|
|
362
|
+
* passes through unchanged. Anything else goes to `onError` and answers
|
|
363
|
+
* `sync.internal_error` (§10.2) unless the optional `mapError` hook returns a
|
|
364
|
+
* catalog `SyncError` to answer instead. The default `sync.internal_error`
|
|
365
|
+
* message never carries the exception text; a mapped error's message is the
|
|
366
|
+
* host's choice.
|
|
352
367
|
*/
|
|
353
368
|
export function adapterSyncError(
|
|
354
369
|
error: unknown,
|
|
355
370
|
onError: SyncularErrorHandler | undefined,
|
|
356
371
|
route: SyncularErrorRoute,
|
|
372
|
+
mapError?: SyncularErrorMapper,
|
|
357
373
|
): SyncError {
|
|
358
374
|
if (error instanceof SyncError) return error;
|
|
359
375
|
reportError(error, onError, route);
|
|
376
|
+
try {
|
|
377
|
+
const mapped = mapError?.(error, { route });
|
|
378
|
+
if (mapped instanceof SyncError) {
|
|
379
|
+
const { code, message, details } = mapped;
|
|
380
|
+
let detailsValid = details === undefined;
|
|
381
|
+
if (!detailsValid && typeof details === 'string') {
|
|
382
|
+
JSON.parse(details);
|
|
383
|
+
detailsValid = true;
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
typeof code === 'string' &&
|
|
387
|
+
typeof message === 'string' &&
|
|
388
|
+
Object.hasOwn(ERROR_CATALOG, code) &&
|
|
389
|
+
detailsValid
|
|
390
|
+
) {
|
|
391
|
+
// Reconstruct from the validated fields: the catalog owns
|
|
392
|
+
// retryability and status, so a mutated `SyncError` cannot smuggle a
|
|
393
|
+
// different entry onto the wire.
|
|
394
|
+
return new SyncError(code, message, details);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// A throwing mapper or getter, or malformed details, keeps the default.
|
|
399
|
+
}
|
|
360
400
|
return new SyncError('sync.internal_error', 'internal server error');
|
|
361
401
|
}
|
|
362
402
|
|
package/src/pull.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
import { scopeDigest } from './scopes';
|
|
24
24
|
import type { SegmentRecord, SegmentStore } from './segment-store';
|
|
25
25
|
import { issueSegmentUrl } from './signed-url';
|
|
26
|
-
import type { SqliteImageBuilder } from './sqlite-image';
|
|
27
26
|
import type { ServerStorage, StoredCommit, StoredRow } from './storage';
|
|
28
27
|
import { projectRowPayload, projectRowValues } from './schema-window';
|
|
29
28
|
|
|
@@ -33,26 +32,6 @@ const imageBuilds = new WeakMap<
|
|
|
33
32
|
WeakMap<SegmentStore, Map<string, Promise<SegmentRecord>>>
|
|
34
33
|
>();
|
|
35
34
|
|
|
36
|
-
/**
|
|
37
|
-
* Resolve the §5.3 image builder: the host-injected one if present, else the
|
|
38
|
-
* in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
|
|
39
|
-
* is never a static dep of the neutral core), else `undefined` (rows lane).
|
|
40
|
-
* Memoized so the dynamic import happens at most once per process.
|
|
41
|
-
*/
|
|
42
|
-
let cachedDefaultBuilder: SqliteImageBuilder | null | undefined;
|
|
43
|
-
async function resolveImageBuilder(
|
|
44
|
-
ctx: SyncRequestContext,
|
|
45
|
-
): Promise<SqliteImageBuilder | undefined> {
|
|
46
|
-
if (ctx.sqliteImageBuilder !== undefined) return ctx.sqliteImageBuilder;
|
|
47
|
-
if (cachedDefaultBuilder === undefined) {
|
|
48
|
-
const hasBun = (globalThis as { Bun?: unknown }).Bun !== undefined;
|
|
49
|
-
cachedDefaultBuilder = hasBun
|
|
50
|
-
? (await import('./sqlite-bun')).buildSqliteImage
|
|
51
|
-
: null;
|
|
52
|
-
}
|
|
53
|
-
return cachedDefaultBuilder ?? undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
35
|
/** §4.2 accept bitmask. */
|
|
57
36
|
export const ACCEPT_INLINE_ROWS = 1 << 0;
|
|
58
37
|
export const ACCEPT_EXTERNAL_ROWS = 1 << 1;
|
|
@@ -280,14 +259,9 @@ async function* sqliteImageSegment(
|
|
|
280
259
|
// rows page — smaller tables stay on the (typically inline) rows lane.
|
|
281
260
|
// The probe is the rows lane's first page, read by `prepareSections`.
|
|
282
261
|
if (probe.length <= limits.limitSnapshotRows) return false;
|
|
283
|
-
// §5.3:
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
// import so `bun:sqlite` is never a static dependency of the pull path
|
|
287
|
-
// (runtime neutrality is enforced by test/runtime-neutrality.test.ts). On
|
|
288
|
-
// Workers/edge (no `Bun`) this yields the rows lane for a bit-2 client — a
|
|
289
|
-
// support floor, not a fallback (§5.3: sqlite is an *accept*, not a demand).
|
|
290
|
-
const buildImage = await resolveImageBuilder(ctx);
|
|
262
|
+
// §5.3: hosts opt into image construction with a runtime-specific builder.
|
|
263
|
+
// Without one, matching stored images remain usable and cold pulls use rows.
|
|
264
|
+
const buildImage = ctx.sqliteImageBuilder;
|
|
291
265
|
if (buildImage === undefined) return false;
|
|
292
266
|
let stores = imageBuilds.get(storage);
|
|
293
267
|
if (stores === undefined) {
|
package/src/realtime.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
reportError,
|
|
45
45
|
SyncError,
|
|
46
46
|
type SyncularErrorHandler,
|
|
47
|
+
type SyncularErrorMapper,
|
|
47
48
|
syncError,
|
|
48
49
|
} from './errors';
|
|
49
50
|
import { emitEvent, type SyncularServerEvents } from './events';
|
|
@@ -599,7 +600,12 @@ export class RealtimeSession {
|
|
|
599
600
|
const sync =
|
|
600
601
|
error instanceof DecodeError
|
|
601
602
|
? syncError(error.code, error.message)
|
|
602
|
-
: adapterSyncError(
|
|
603
|
+
: adapterSyncError(
|
|
604
|
+
error,
|
|
605
|
+
(error, context) => this.#hub.onError?.(error, context),
|
|
606
|
+
'realtime',
|
|
607
|
+
(error, context) => this.#hub.mapError?.(error, context),
|
|
608
|
+
);
|
|
603
609
|
finishRound(); // END is in this one chunk
|
|
604
610
|
await this.#sendRoundChunk(
|
|
605
611
|
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
@@ -625,7 +631,12 @@ export class RealtimeSession {
|
|
|
625
631
|
const sync =
|
|
626
632
|
error instanceof DecodeError
|
|
627
633
|
? syncError(error.code, error.message)
|
|
628
|
-
: adapterSyncError(
|
|
634
|
+
: adapterSyncError(
|
|
635
|
+
error,
|
|
636
|
+
(error, context) => this.#hub.onError?.(error, context),
|
|
637
|
+
'realtime',
|
|
638
|
+
(error, context) => this.#hub.mapError?.(error, context),
|
|
639
|
+
);
|
|
629
640
|
finishRound();
|
|
630
641
|
await this.#sendRoundChunk(
|
|
631
642
|
errorResponseBytes(sync, this.wireVersion, this.logEpoch),
|
|
@@ -641,7 +652,11 @@ export class RealtimeSession {
|
|
|
641
652
|
} catch (error) {
|
|
642
653
|
// A host failure mid-stream leaves the byte stream unfinishable —
|
|
643
654
|
// fail loud, drop the connection (§8.7 / §1.4 abort rule).
|
|
644
|
-
reportError(
|
|
655
|
+
reportError(
|
|
656
|
+
error,
|
|
657
|
+
(error, context) => this.#hub.onError?.(error, context),
|
|
658
|
+
'realtime',
|
|
659
|
+
);
|
|
645
660
|
this.#violation('sync round failed mid-stream');
|
|
646
661
|
} finally {
|
|
647
662
|
finishRound();
|
|
@@ -1108,6 +1123,11 @@ export class RealtimeHub {
|
|
|
1108
1123
|
return this.#config.onError;
|
|
1109
1124
|
}
|
|
1110
1125
|
|
|
1126
|
+
/** The host's adapter error mapper (`SyncServerConfig.mapError`), if any. */
|
|
1127
|
+
get mapError(): SyncularErrorMapper | undefined {
|
|
1128
|
+
return this.#config.mapError;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1111
1131
|
requestContext(session: RealtimeSession): SyncRequestContext {
|
|
1112
1132
|
return this.requestContextFor(session);
|
|
1113
1133
|
}
|
package/src/sqlite-image.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface SqliteImageInput {
|
|
|
69
69
|
* a real SQLite engine, which is not available on every runtime. The core
|
|
70
70
|
* takes the builder as an optional capability rather than importing a driver
|
|
71
71
|
* on the pull path. A Bun or Node host passes `buildSqliteImage`; a Workers
|
|
72
|
-
* host omits it and serves the rows lane.
|
|
72
|
+
* host omits it and serves matching stored images or the rows lane.
|
|
73
73
|
*/
|
|
74
74
|
export type SqliteImageBuilder = (
|
|
75
75
|
input: SqliteImageInput,
|