@syncular/server 0.26.4 → 0.26.6

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.
Files changed (49) hide show
  1. package/dist/content-encoding.d.ts +17 -0
  2. package/dist/content-encoding.js +22 -0
  3. package/dist/context.d.ts +9 -0
  4. package/dist/d1-storage.d.ts +46 -0
  5. package/dist/d1-storage.js +12 -1
  6. package/dist/errors.d.ts +19 -0
  7. package/dist/errors.js +29 -0
  8. package/dist/handler.js +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/operations-realtime.js +4 -1
  12. package/dist/operations.js +6 -1
  13. package/dist/postgres-storage.d.ts +42 -0
  14. package/dist/postgres-storage.js +110 -45
  15. package/dist/pull.d.ts +46 -6
  16. package/dist/pull.js +129 -34
  17. package/dist/push.js +19 -2
  18. package/dist/realtime.d.ts +3 -0
  19. package/dist/realtime.js +32 -26
  20. package/dist/relational-rows.d.ts +19 -0
  21. package/dist/relational-rows.js +38 -0
  22. package/dist/s3-segment-store.d.ts +3 -1
  23. package/dist/s3-segment-store.js +16 -0
  24. package/dist/segment-download.d.ts +16 -0
  25. package/dist/segment-download.js +71 -21
  26. package/dist/segment-store.d.ts +16 -0
  27. package/dist/segment-store.js +19 -0
  28. package/dist/sqlite-storage.d.ts +5 -0
  29. package/dist/sqlite-storage.js +18 -1
  30. package/dist/storage.d.ts +20 -0
  31. package/package.json +2 -2
  32. package/src/content-encoding.ts +35 -0
  33. package/src/context.ts +9 -0
  34. package/src/d1-storage.ts +20 -1
  35. package/src/errors.ts +56 -0
  36. package/src/handler.ts +2 -0
  37. package/src/index.ts +1 -0
  38. package/src/operations-realtime.ts +4 -1
  39. package/src/operations.ts +6 -1
  40. package/src/postgres-storage.ts +145 -60
  41. package/src/pull.ts +212 -39
  42. package/src/push.ts +18 -2
  43. package/src/realtime.ts +45 -32
  44. package/src/relational-rows.ts +43 -0
  45. package/src/s3-segment-store.ts +15 -0
  46. package/src/segment-download.ts +129 -21
  47. package/src/segment-store.ts +36 -0
  48. package/src/sqlite-storage.ts +27 -0
  49. package/src/storage.ts +21 -0
@@ -3,7 +3,7 @@ import { SyncError, syncError } from './errors.js';
3
3
  import { emitEvent } from './events.js';
4
4
  import { compileSchema } from './schema.js';
5
5
  import { computeEffective, scopeDigest } from './scopes.js';
6
- import { publicationRecord } from './segment-store.js';
6
+ import { bytesStream, publicationRecord, } from './segment-store.js';
7
7
  function parseScopesHeader(header) {
8
8
  let parsed;
9
9
  try {
@@ -25,14 +25,28 @@ function parseScopesHeader(header) {
25
25
  }
26
26
  return scopes;
27
27
  }
28
- export async function handleSegmentDownload(ctx, request) {
28
+ export function handleSegmentDownload(ctx, request) {
29
+ return withDownloadEvent(ctx, request, () => downloadSegment(ctx, request), (result) => result.bytes.length);
30
+ }
31
+ /**
32
+ * The §5.5 download as a stream: the same authorization as
33
+ * `handleSegmentDownload`, with the bytes read through `SegmentStore.open`
34
+ * when the store provides it, so a host with bounded memory (a Worker)
35
+ * relays a large sqlite image without buffering it. A store without `open`
36
+ * serves its `get` bytes as a one-chunk stream.
37
+ */
38
+ export function openSegmentDownload(ctx, request) {
39
+ return withDownloadEvent(ctx, request, () => openSegment(ctx, request), (result) => result.byteLength);
40
+ }
41
+ /** Emit `segment.downloaded` around a download when an events sink exists. */
42
+ async function withDownloadEvent(ctx, request, run, bytesOf) {
29
43
  const events = ctx.events;
30
44
  if (events === undefined)
31
- return downloadSegment(ctx, request);
45
+ return run();
32
46
  const clock = clockOf(ctx);
33
47
  const startedAtMs = clock();
34
48
  try {
35
- const result = await downloadSegment(ctx, request);
49
+ const result = await run();
36
50
  emitEvent(events, {
37
51
  type: 'segment.downloaded',
38
52
  atMs: clock(),
@@ -41,7 +55,7 @@ export async function handleSegmentDownload(ctx, request) {
41
55
  segmentId: request.segmentId,
42
56
  outcome: 'ok',
43
57
  mediaType: result.record.mediaType,
44
- bytes: result.bytes.length,
58
+ bytes: bytesOf(result),
45
59
  durationMs: clock() - startedAtMs,
46
60
  });
47
61
  return result;
@@ -66,11 +80,57 @@ async function downloadSegment(ctx, request) {
66
80
  if (entry === undefined) {
67
81
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
68
82
  }
83
+ const record = await authorizePublication(ctx, request, registry.logEpoch, entry.record);
84
+ return { record, bytes: entry.bytes, headers: segmentHeaders(record) };
85
+ }
86
+ async function openSegment(ctx, request) {
87
+ const registry = await touchAuthenticatedPartition(ctx);
88
+ const opened = ctx.segments.open !== undefined
89
+ ? await ctx.segments.open(request.segmentId)
90
+ : await ctx.segments.get(request.segmentId).then((entry) => entry === undefined
91
+ ? undefined
92
+ : {
93
+ record: entry.record,
94
+ body: bytesStream(entry.bytes),
95
+ byteLength: entry.bytes.length,
96
+ });
97
+ if (opened === undefined) {
98
+ throw syncError('sync.not_found', 'unknown segment (§5.5)');
99
+ }
100
+ let record;
101
+ try {
102
+ record = await authorizePublication(ctx, request, registry.logEpoch, opened.record);
103
+ }
104
+ catch (error) {
105
+ await opened.body.cancel();
106
+ throw error;
107
+ }
108
+ return {
109
+ record,
110
+ body: opened.body,
111
+ byteLength: opened.byteLength,
112
+ headers: segmentHeaders(record),
113
+ };
114
+ }
115
+ function segmentHeaders(record) {
116
+ return {
117
+ 'Content-Type': 'application/octet-stream',
118
+ ETag: `"${record.segmentId}"`,
119
+ 'Cache-Control': 'private, max-age=0',
120
+ // Accept-Encoding: the body may be served compressed (§5.8).
121
+ Vary: 'Authorization, X-Syncular-Scopes, Accept-Encoding',
122
+ };
123
+ }
124
+ /**
125
+ * Select and authorize the caller's publication of a stored segment
126
+ * (§5.5), projected onto that publication.
127
+ */
128
+ async function authorizePublication(ctx, request, logEpoch, stored) {
69
129
  // Select the caller's own publication context. A publication under a
70
130
  // different partition or a stale log epoch is not this caller's grant, and
71
131
  // the entry's existence must not leak across that boundary (§5.5).
72
- const inContext = entry.record.publications.filter((publication) => publication.partition === ctx.partition &&
73
- publication.logEpoch === registry.logEpoch);
132
+ const inContext = stored.publications.filter((publication) => publication.partition === ctx.partition &&
133
+ publication.logEpoch === logEpoch);
74
134
  if (inContext.length === 0) {
75
135
  throw syncError('sync.not_found', 'unknown segment (§5.5)');
76
136
  }
@@ -144,18 +204,8 @@ async function downloadSegment(ctx, request) {
144
204
  if (checkpoints.some((checkpoint) => checkpoint.schemaVersion > publication.schemaVersion)) {
145
205
  throw syncError('sync.segment_expired', 'segment predates the partition backfill checkpoint — re-pull (§5.1)');
146
206
  }
147
- return {
148
- // The returned record is projected onto the selected publication, so its
149
- // `scopeDigests`/`publications` compatibility view cannot union a grant
150
- // from another partition (§5.1).
151
- record: publicationRecord(entry.record, publication),
152
- bytes: entry.bytes,
153
- headers: {
154
- 'Content-Type': 'application/octet-stream',
155
- ETag: `"${entry.record.segmentId}"`,
156
- 'Cache-Control': 'private, max-age=0',
157
- // Accept-Encoding: the body may be served compressed (§5.8).
158
- Vary: 'Authorization, X-Syncular-Scopes, Accept-Encoding',
159
- },
160
- };
207
+ // The returned record is projected onto the selected publication, so its
208
+ // `scopeDigests`/`publications` compatibility view cannot union a grant
209
+ // from another partition (§5.1).
210
+ return publicationRecord(stored, publication);
161
211
  }
@@ -130,6 +130,14 @@ export interface SegmentStoreStats {
130
130
  */
131
131
  readonly approximate?: boolean;
132
132
  }
133
+ /** `SegmentStore.open`: a stored segment with its bytes as a stream. */
134
+ export interface SegmentStream {
135
+ readonly record: SegmentRecord;
136
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
137
+ readonly byteLength: number;
138
+ }
139
+ /** One buffered segment as a single-chunk `SegmentStream` body. */
140
+ export declare function bytesStream(bytes: Uint8Array): ReadableStream<Uint8Array<ArrayBuffer>>;
133
141
  export interface SegmentStore {
134
142
  put(metadata: SegmentMetadata, bytes: Uint8Array, nowMs: number): Promise<SegmentRecord>;
135
143
  /** Returns expired records too — expiry is the caller's check (§5.5). */
@@ -151,6 +159,13 @@ export interface SegmentStore {
151
159
  * against the descriptor.
152
160
  */
153
161
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
162
+ /**
163
+ * Optional streaming read: the record plus the bytes as a stream, so the
164
+ * download route relays a segment larger than the host's memory (a
165
+ * sqlite image through a Worker). Returns expired records like `get`.
166
+ * Stores without it are served through `get`.
167
+ */
168
+ open?(segmentId: string): Promise<SegmentStream | undefined>;
154
169
  /** Admin/console counters: ADDITIVE, optional. */
155
170
  stats?(): Promise<SegmentStoreStats>;
156
171
  }
@@ -165,6 +180,7 @@ export declare class MemorySegmentStore implements SegmentStore {
165
180
  record: SegmentRecord;
166
181
  bytes: Uint8Array;
167
182
  } | undefined>;
183
+ open(segmentId: string): Promise<SegmentStream | undefined>;
168
184
  find(key: SegmentFindKey, nowMs: number): Promise<SegmentRecord | undefined>;
169
185
  stats(): Promise<SegmentStoreStats>;
170
186
  }
@@ -146,6 +146,15 @@ export function mergeSegmentRecord(existing, metadata, segmentId, byteLength, no
146
146
  expiresAtMs: Math.max(existing?.expiresAtMs ?? nowMs + ttlMs, nowMs + ttlMs),
147
147
  };
148
148
  }
149
+ /** One buffered segment as a single-chunk `SegmentStream` body. */
150
+ export function bytesStream(bytes) {
151
+ return new ReadableStream({
152
+ start(controller) {
153
+ controller.enqueue(new Uint8Array(bytes));
154
+ controller.close();
155
+ },
156
+ });
157
+ }
149
158
  export async function segmentIdFor(bytes) {
150
159
  return `sha256:${await sha256Hex(bytes)}`;
151
160
  }
@@ -168,6 +177,16 @@ export class MemorySegmentStore {
168
177
  async get(segmentId) {
169
178
  return this.#entries.get(segmentId);
170
179
  }
180
+ async open(segmentId) {
181
+ const entry = this.#entries.get(segmentId);
182
+ if (entry === undefined)
183
+ return undefined;
184
+ return {
185
+ record: entry.record,
186
+ body: bytesStream(entry.bytes),
187
+ byteLength: entry.bytes.length,
188
+ };
189
+ }
171
190
  async find(key, nowMs) {
172
191
  for (const { record } of this.#entries.values()) {
173
192
  const publication = selectSegmentPublication(record, {
@@ -1,4 +1,5 @@
1
1
  import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
2
+ import type { ScopeMap } from '@syncular/core';
2
3
  import type { CompiledSchema, CompiledTable } from './schema.js';
3
4
  import { type SqliteDatabase } from './sqlite-driver.js';
4
5
  import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDeclaration, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, ServeGate, StorageTransaction, StoredCommit, StoredCheckpoint, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
@@ -47,6 +48,10 @@ export declare class SqliteServerStorage implements ServerStorage {
47
48
  getReaction(partition: string, idempotencyKey: string): Promise<StoredReaction | undefined>;
48
49
  listReactions(partition: string, query: ReactionListQuery): Promise<StoredReaction[]>;
49
50
  pruneReactions(partition: string, query: ReactionPruneQuery): Promise<PrunedReactionCounts>;
51
+ latestScopedChangeSeq(partition: string, query: {
52
+ readonly table: string;
53
+ readonly scopeFilter: ScopeMap;
54
+ }): Promise<number>;
50
55
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
51
56
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
52
57
  scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
@@ -12,7 +12,7 @@ import { serveNotReadyError } from './readiness.js';
12
12
  */
13
13
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
14
14
  import { syncError } from './errors.js';
15
- import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
15
+ import { assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, latestScopedChangeSeqSql, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, upsertSql, upsertValues, } from './relational-rows.js';
16
16
  import { matchesEffective } from './scopes.js';
17
17
  import { collectCommitWindowPage, deserializePushResult, placeholders, SQLITE_DDL, serializePushResult, toStoredRow, } from './sqlite-dialect.js';
18
18
  import { SqliteAdapterRequiredError, } from './sqlite-driver.js';
@@ -1009,6 +1009,23 @@ END`);
1009
1009
  };
1010
1010
  });
1011
1011
  }
1012
+ async latestScopedChangeSeq(partition, query) {
1013
+ const firstVariable = Object.keys(query.scopeFilter).sort()[0];
1014
+ const values = firstVariable === undefined
1015
+ ? []
1016
+ : (query.scopeFilter[firstVariable] ?? []);
1017
+ if (firstVariable === undefined || values.length === 0)
1018
+ return 0;
1019
+ const row = this.db
1020
+ .query(latestScopedChangeSeqSql(values.length))
1021
+ .get(...values.flatMap((value) => [
1022
+ partition,
1023
+ query.table,
1024
+ firstVariable,
1025
+ value,
1026
+ ]));
1027
+ return row?.seq ?? 0;
1028
+ }
1012
1029
  async readCommitWindow(partition, query) {
1013
1030
  const variables = Object.keys(query.scopeFilter).sort();
1014
1031
  const firstVariable = variables[0];
package/dist/storage.d.ts CHANGED
@@ -344,6 +344,14 @@ export interface AuthoritativeQueryResult {
344
344
  */
345
345
  export interface StorageTransaction {
346
346
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
347
+ /**
348
+ * Optional read-ahead: the push layer names every row a commit's
349
+ * operations target, per table, before it applies them. A storage with
350
+ * per-statement network cost reads them in one statement and answers the
351
+ * following `getRow` calls from that snapshot until the row is written in
352
+ * this transaction.
353
+ */
354
+ prefetchRows?(table: string, rowIds: readonly string[]): Promise<void>;
347
355
  /**
348
356
  * §5 delete-precedence tombstone read: the `commit_seq` of the newest
349
357
  * applied delete of this row still within the pruning horizon, or
@@ -581,6 +589,18 @@ export interface ServerStorage {
581
589
  * reach `limitChanges` or the window is exhausted.
582
590
  */
583
591
  readCommitWindow(partition: string, query: CommitWindowQuery): Promise<StoredCommit[]>;
592
+ /**
593
+ * Newest commitSeq whose change to `query.table` carries a value of the
594
+ * filter's first variable (keys sorted) in the inverted change-scope index:
595
+ * the `readCommitWindow` candidate rule, so no change a window read would
596
+ * deliver for this filter sits above it. 0 when the index holds none (never
597
+ * written, or pruned). The §4.7 sqlite-lane bootstrap pin reads it; it MUST
598
+ * be an index lookup per value, independent of the table's history.
599
+ */
600
+ latestScopedChangeSeq(partition: string, query: {
601
+ readonly table: string;
602
+ readonly scopeFilter: ScopeMap;
603
+ }): Promise<number>;
584
604
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
585
605
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
586
606
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.26.4",
3
+ "version": "0.26.6",
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.26.4"
71
+ "@syncular/core": "0.26.6"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
@@ -92,3 +92,38 @@ export function encodeSegmentBody(
92
92
  }
93
93
  return { bytes };
94
94
  }
95
+
96
+ /**
97
+ * Segments above this size are relayed as a stream instead of being
98
+ * buffered and compressed in memory: a sqlite image can exceed the memory of
99
+ * a Workers isolate (128 MB).
100
+ */
101
+ export const SEGMENT_STREAM_THRESHOLD_BYTES = 16 * 1024 * 1024;
102
+
103
+ export interface EncodedSegmentStream {
104
+ readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
105
+ /** Absent ⇒ identity (no `Content-Encoding` header). */
106
+ readonly contentEncoding?: 'gzip';
107
+ }
108
+
109
+ /**
110
+ * Compress a streamed segment body per §5.8 with the runtime's
111
+ * `CompressionStream`: gzip when the client accepts it, identity otherwise
112
+ * (zstd has no standard streaming codec).
113
+ */
114
+ export function encodeSegmentStream(
115
+ body: ReadableStream<Uint8Array<ArrayBuffer>>,
116
+ acceptEncoding: string | undefined,
117
+ ): EncodedSegmentStream {
118
+ if (
119
+ acceptEncoding === undefined ||
120
+ !accepts(acceptEncoding, 'gzip') ||
121
+ typeof CompressionStream !== 'function'
122
+ ) {
123
+ return { body };
124
+ }
125
+ return {
126
+ body: body.pipeThrough(new CompressionStream('gzip')),
127
+ contentEncoding: 'gzip',
128
+ };
129
+ }
package/src/context.ts CHANGED
@@ -7,6 +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
11
  import type { SyncularServerEvents } from './events';
11
12
  import type { LeaseStore } from './lease-store';
12
13
  import type { AnyReactionPlanner } from './reactions';
@@ -192,6 +193,14 @@ export interface SyncServerConfig {
192
193
  * event objects are built. A throwing sink never affects processing.
193
194
  */
194
195
  readonly events?: SyncularServerEvents;
196
+ /**
197
+ * Host error reporting (§10.2 `sync.internal_error`). Receives the original
198
+ * exception whenever a request, socket round, segment or blob download,
199
+ * or remote operation fails with something other than a `SyncError`; the
200
+ * client sees only the catalog code. Like the events sink, a throwing
201
+ * handler never changes the response.
202
+ */
203
+ readonly onError?: SyncularErrorHandler;
195
204
  }
196
205
 
197
206
  /** Per-request context: the config plus host-authenticated identity. */
package/src/d1-storage.ts CHANGED
@@ -42,7 +42,7 @@ import type { CommitPruneQuery, CommitPruneResult } from './storage';
42
42
  * This mirrors PostgreSQL's per-partition row lock, achieved by placement
43
43
  * rather than a lock D1 does not expose.
44
44
  */
45
- import { decodeRow, type RowValue } from '@syncular/core';
45
+ import { decodeRow, type RowValue, type ScopeMap } from '@syncular/core';
46
46
  import {
47
47
  bindAuthoritativePartition,
48
48
  type BoundAuthoritativeQuery,
@@ -54,6 +54,7 @@ import {
54
54
  assertPhysicalColumns,
55
55
  assertStoredLayouts,
56
56
  commitWindowPageSql,
57
+ latestScopedChangeSeqSql,
57
58
  deleteRowSql,
58
59
  deleteSqliteRowScopesSql,
59
60
  dropTableDdl,
@@ -2146,6 +2147,24 @@ export class D1ServerStorage implements ServerStorage {
2146
2147
  };
2147
2148
  }
2148
2149
 
2150
+ async latestScopedChangeSeq(
2151
+ partition: string,
2152
+ query: { readonly table: string; readonly scopeFilter: ScopeMap },
2153
+ ): Promise<number> {
2154
+ const firstVariable = Object.keys(query.scopeFilter).sort()[0];
2155
+ const values =
2156
+ firstVariable === undefined
2157
+ ? []
2158
+ : (query.scopeFilter[firstVariable] ?? []);
2159
+ if (firstVariable === undefined || values.length === 0) return 0;
2160
+ // Concurrent reads of a pull share one `db.batch` round trip.
2161
+ const [row] = await this.#batchedAll<{ seq: number | null }>(
2162
+ latestScopedChangeSeqSql(values.length),
2163
+ values.flatMap((value) => [partition, query.table, firstVariable, value]),
2164
+ );
2165
+ return row?.seq ?? 0;
2166
+ }
2167
+
2149
2168
  async readCommitWindow(
2150
2169
  partition: string,
2151
2170
  query: CommitWindowQuery,
package/src/errors.ts CHANGED
@@ -171,6 +171,14 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
171
171
  // checkpoint is incomplete or the database is newer than the running build.
172
172
  // Formerly host-only; promoted to the §10.2 catalogue so an in-flight
173
173
  // request can be refused with a distinguishable identity.
174
+ // An exception outside this catalog (host code, storage, network) reached
175
+ // an adapter; the original goes to `onError`, never onto the wire.
176
+ 'sync.internal_error': {
177
+ category: 'internal',
178
+ retryable: true,
179
+ recommendedAction: 'retryLater',
180
+ httpStatus: 500,
181
+ },
174
182
  'sync.schema_not_ready': {
175
183
  category: 'internal',
176
184
  retryable: true,
@@ -310,3 +318,51 @@ export function errorBody(error: SyncError): {
310
318
  : {}),
311
319
  };
312
320
  }
321
+
322
+ /** The adapter surface that caught an exception reported through `onError`. */
323
+ export type SyncularErrorRoute =
324
+ | 'sync'
325
+ | 'operations'
326
+ | 'segments'
327
+ | 'blobs'
328
+ | 'admin'
329
+ | 'realtime';
330
+
331
+ /**
332
+ * Host error reporting: receives every exception that is not a `SyncError`
333
+ * before the adapter answers `sync.internal_error` (or the remote-operation
334
+ * `operation.*` failure code), with the surface that caught it.
335
+ */
336
+ export type SyncularErrorHandler = (
337
+ error: unknown,
338
+ context: { readonly route: SyncularErrorRoute },
339
+ ) => void;
340
+
341
+ /**
342
+ * The protocol error for an exception an adapter caught. A `SyncError`
343
+ * passes through unchanged. Anything else goes to `onError` and becomes
344
+ * `sync.internal_error` (§10.2), whose message never carries the exception
345
+ * text.
346
+ */
347
+ export function adapterSyncError(
348
+ error: unknown,
349
+ onError: SyncularErrorHandler | undefined,
350
+ route: SyncularErrorRoute,
351
+ ): SyncError {
352
+ if (error instanceof SyncError) return error;
353
+ reportError(error, onError, route);
354
+ return new SyncError('sync.internal_error', 'internal server error');
355
+ }
356
+
357
+ /** Hand `error` to the host's `onError`; a throwing handler is contained. */
358
+ export function reportError(
359
+ error: unknown,
360
+ onError: SyncularErrorHandler | undefined,
361
+ route: SyncularErrorRoute,
362
+ ): void {
363
+ try {
364
+ onError?.(error, { route });
365
+ } catch {
366
+ // The report is best effort; the response stays the catalog error.
367
+ }
368
+ }
package/src/handler.ts CHANGED
@@ -545,10 +545,12 @@ async function* streamResponse(
545
545
  const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
546
546
  const prepared = await prepareSections(
547
547
  ctx,
548
+ schema,
548
549
  limits,
549
550
  plan.subscriptions,
550
551
  maxSeq,
551
552
  horizonSeq,
553
+ plan.logEpoch,
552
554
  );
553
555
  for (const [index, subscription] of plan.subscriptions.entries()) {
554
556
  const trace: PullSectionTrace | undefined =
package/src/index.ts CHANGED
@@ -81,6 +81,7 @@ export {
81
81
  IMAGE_VERSION_COLUMN,
82
82
  type SqliteImageBuilder,
83
83
  type SqliteImageInput,
84
+ writeSqliteImage,
84
85
  } from './sqlite-image';
85
86
  export * from './sqlite-blob-store';
86
87
  export * from './sqlite-lease-store';
@@ -5,7 +5,7 @@ import {
5
5
  } from '@syncular/core';
6
6
  import type { RealtimeNotifier, SyncRequestContext } from './context';
7
7
  import { REMOTE_COMMAND_CLIENT_ID_PREFIX } from './context';
8
- import { SyncError, syncError } from './errors';
8
+ import { reportError, SyncError, syncError } from './errors';
9
9
  import type {
10
10
  RegisteredRemoteQuery,
11
11
  RemoteOperationRegistry,
@@ -181,6 +181,9 @@ class WatchSession implements RemoteOperationWatchSession {
181
181
  if (this.#isClosed || this.#watches.get(state.watchId) !== state) {
182
182
  return;
183
183
  }
184
+ if (!(error instanceof SyncError)) {
185
+ reportError(error, this.#ctx.onError, 'operations');
186
+ }
184
187
  this.#sendError(
185
188
  state.watchId,
186
189
  error instanceof SyncError
package/src/operations.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  RESOLVER_OUTAGE,
19
19
  touchAuthenticatedPartition,
20
20
  } from './context';
21
- import { SyncError, syncError } from './errors';
21
+ import { reportError, SyncError, syncError } from './errors';
22
22
  import { processPushOperationsWithTrace } from './push';
23
23
  import { compileSchema } from './schema';
24
24
  import { authorizeWrite, type ResolvedScopes } from './scopes';
@@ -450,6 +450,7 @@ export function registerRemoteQuery<Params>(
450
450
  );
451
451
  } catch (error) {
452
452
  if (error instanceof SyncError) throw error;
453
+ reportError(error, ctx.onError, 'operations');
453
454
  throw syncError(
454
455
  'operation.query_failed',
455
456
  'registered query execution failed',
@@ -463,6 +464,7 @@ export function registerRemoteQuery<Params>(
463
464
  rows = normalizeQueryRows(result.rows, descriptor.resultColumns);
464
465
  } catch (error) {
465
466
  if (error instanceof SyncError) throw error;
467
+ reportError(error, ctx.onError, 'operations');
466
468
  throw syncError(
467
469
  'operation.query_failed',
468
470
  'registered query result decoding failed',
@@ -766,6 +768,9 @@ export async function handleRemoteOperation(
766
768
  ),
767
769
  );
768
770
  } catch (error) {
771
+ if (!(error instanceof SyncError)) {
772
+ reportError(error, ctx.onError, 'operations');
773
+ }
769
774
  return encodeRemoteOperationError(error, 'operation.execution_failed');
770
775
  }
771
776
  }