@syncular/server 0.26.5 → 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 (47) 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 +12 -1
  15. package/dist/pull.d.ts +46 -6
  16. package/dist/pull.js +129 -34
  17. package/dist/realtime.d.ts +3 -0
  18. package/dist/realtime.js +32 -26
  19. package/dist/relational-rows.d.ts +13 -0
  20. package/dist/relational-rows.js +25 -0
  21. package/dist/s3-segment-store.d.ts +3 -1
  22. package/dist/s3-segment-store.js +16 -0
  23. package/dist/segment-download.d.ts +16 -0
  24. package/dist/segment-download.js +71 -21
  25. package/dist/segment-store.d.ts +16 -0
  26. package/dist/segment-store.js +19 -0
  27. package/dist/sqlite-storage.d.ts +5 -0
  28. package/dist/sqlite-storage.js +18 -1
  29. package/dist/storage.d.ts +12 -0
  30. package/package.json +2 -2
  31. package/src/content-encoding.ts +35 -0
  32. package/src/context.ts +9 -0
  33. package/src/d1-storage.ts +20 -1
  34. package/src/errors.ts +56 -0
  35. package/src/handler.ts +2 -0
  36. package/src/index.ts +1 -0
  37. package/src/operations-realtime.ts +4 -1
  38. package/src/operations.ts +6 -1
  39. package/src/postgres-storage.ts +22 -1
  40. package/src/pull.ts +212 -39
  41. package/src/realtime.ts +45 -32
  42. package/src/relational-rows.ts +29 -0
  43. package/src/s3-segment-store.ts +15 -0
  44. package/src/segment-download.ts +129 -21
  45. package/src/segment-store.ts +36 -0
  46. package/src/sqlite-storage.ts +27 -0
  47. package/src/storage.ts +13 -0
@@ -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
@@ -589,6 +589,18 @@ export interface ServerStorage {
589
589
  * reach `limitChanges` or the window is exhausted.
590
590
  */
591
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>;
592
604
  /** Scope-filtered snapshot scan, ordered by rowId (bootstrap paging). */
593
605
  scanRows(partition: string, query: RowScanQuery): Promise<StoredRow[]>;
594
606
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.26.5",
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.5"
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
  }
@@ -40,7 +40,7 @@ import { serveNotReadyError } from './readiness';
40
40
  * would leave gaps on rollback, which the pull-window arithmetic in §4.5
41
41
  * does not tolerate). Cross-partition pushes never contend.
42
42
  */
43
- import type { PushOperationResult } from '@syncular/core';
43
+ import type { PushOperationResult, ScopeMap } from '@syncular/core';
44
44
  import {
45
45
  bindAuthoritativePartition,
46
46
  type BoundAuthoritativeQuery,
@@ -59,6 +59,7 @@ import {
59
59
  assertPhysicalColumns,
60
60
  assertStoredLayouts,
61
61
  COMMIT_WINDOW_BATCH_SQL,
62
+ LATEST_SCOPED_CHANGE_BATCH_SQL,
62
63
  deleteRowSql,
63
64
  dropTableDdl,
64
65
  indexRowPageStatement,
@@ -2206,6 +2207,26 @@ FOR EACH ROW EXECUTE FUNCTION syncular_writer_fence()`);
2206
2207
  };
2207
2208
  }
2208
2209
 
2210
+ async latestScopedChangeSeq(
2211
+ partition: string,
2212
+ query: { readonly table: string; readonly scopeFilter: ScopeMap },
2213
+ ): Promise<number> {
2214
+ const firstVariable = Object.keys(query.scopeFilter).sort()[0];
2215
+ const values =
2216
+ firstVariable === undefined
2217
+ ? []
2218
+ : (query.scopeFilter[firstVariable] ?? []);
2219
+ if (firstVariable === undefined || values.length === 0) return 0;
2220
+ // Concurrent reads of one table in a pull share one statement.
2221
+ const [row] = await this.#batchedPage<{ seq: unknown }>(
2222
+ LATEST_SCOPED_CHANGE_BATCH_SQL,
2223
+ partition,
2224
+ query.table,
2225
+ { var: firstVariable, vals: values },
2226
+ );
2227
+ return row === undefined ? 0 : asNumber(row.seq);
2228
+ }
2229
+
2209
2230
  async readCommitWindow(
2210
2231
  partition: string,
2211
2232
  query: CommitWindowQuery,