@syncular/server 0.30.16 → 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 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 a static import-graph scan,
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, injected so the pull path never
161
- * statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
162
- * (bit-2 clients are served the rows lane) — the Workers/edge posture. A
163
- * Bun or Node host wires `buildSqliteImage` from
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 {
@@ -410,13 +410,16 @@ class D1Transaction {
410
410
  row,
411
411
  });
412
412
  const p = this.#partition;
413
- this.#buffer_(deleteSqliteRowScopesSql(compiled), [
414
- p,
415
- table,
416
- row.rowId,
417
- p,
418
- row.rowId,
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('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
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 becomes
50
- * `sync.internal_error` (§10.2), whose message never carries the exception
51
- * text.
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
- const entry = ERROR_CATALOG[code];
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 becomes
308
- * `sync.internal_error` (§10.2), whose message never carries the exception
309
- * text.
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: building an image needs a SQLite engine. The host injects the
180
- // builder through `sqliteImageBuilder`; when omitted we default to the
181
- // in-tree `buildSqliteImage` ONLY on a Bun runtime, reached by a *dynamic*
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);
@@ -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
  }
@@ -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.30.16",
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.30.16"
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, injected so the pull path never
186
- * statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
187
- * (bit-2 clients are served the rows lane) — the Workers/edge posture. A
188
- * Bun or Node host wires `buildSqliteImage` from
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
- this.#buffer_(deleteSqliteRowScopesSql(compiled), [
664
- p,
665
- table,
666
- row.rowId,
667
- p,
668
- row.rowId,
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
- 'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
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
- const entry = ERROR_CATALOG[code];
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 becomes
350
- * `sync.internal_error` (§10.2), whose message never carries the exception
351
- * text.
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: building an image needs a SQLite engine. The host injects the
284
- // builder through `sqliteImageBuilder`; when omitted we default to the
285
- // in-tree `buildSqliteImage` ONLY on a Bun runtime, reached by a *dynamic*
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(error, this.#hub.onError, 'realtime');
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(error, this.#hub.onError, 'realtime');
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(error, this.#hub.onError, 'realtime');
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
  }
@@ -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,