@syncular/server 0.37.0 → 0.39.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/dist/storage.d.ts CHANGED
@@ -283,6 +283,8 @@ export interface ClientCursorInfo {
283
283
  readonly updatedAtMs: number;
284
284
  }
285
285
  /** Durable partition identity refreshed after host authentication (§2.1). */
286
+ /** Longest age of `lastAuthenticatedAtMs` that `touchPartition` leaves unwritten. */
287
+ export declare const PARTITION_TOUCH_INTERVAL_MS = 60000;
286
288
  export interface PartitionRegistryEntry {
287
289
  readonly partition: string;
288
290
  readonly logEpoch: string;
@@ -526,7 +528,12 @@ export interface ServerStorage {
526
528
  * lazily as a defensive backstop.
527
529
  */
528
530
  ensureSchema(schema: CompiledSchema, checkpoints?: readonly CheckpointDeclaration[]): Promise<void>;
529
- /** Create or refresh the authenticated partition registry row (§2.1). */
531
+ /**
532
+ * Create or refresh the authenticated partition registry row (§2.1). A
533
+ * row whose `lastAuthenticatedAtMs` is less than
534
+ * {@link PARTITION_TOUCH_INTERVAL_MS} behind `authenticatedAtMs` is read
535
+ * back without a write, so a request that only reads takes no write lock.
536
+ */
530
537
  touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
531
538
  /** Rotate log continuity after restore and discard stale cursor records. */
532
539
  rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
package/dist/storage.js CHANGED
@@ -38,3 +38,6 @@ export function serveGateTokenChanged(before, after) {
38
38
  }
39
39
  return undefined;
40
40
  }
41
+ /** Durable partition identity refreshed after host authentication (§2.1). */
42
+ /** Longest age of `lastAuthenticatedAtMs` that `touchPartition` leaves unwritten. */
43
+ export const PARTITION_TOUCH_INTERVAL_MS = 60_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.37.0",
3
+ "version": "0.39.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.37.0"
71
+ "@syncular/core": "0.39.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
@@ -1,24 +1,29 @@
1
1
  /**
2
- * §5.8 shipped compression posture for the direct segment endpoint:
3
- * negotiate `Content-Encoding` from the request's `Accept-Encoding`
4
- * (zstd preferred, gzip fallback, identity when the client offers
5
- * neither), feature-detecting the runtime's codecs — zero dependencies.
2
+ * §1.3 transport compression for every buffered HTTP body the adapters
3
+ * serve — `<mount>/sync`, `<mount>/operations`, blob GETs and segment
4
+ * downloads: negotiate `Content-Encoding` from the request's
5
+ * `Accept-Encoding` (zstd preferred, gzip fallback, identity when the client
6
+ * offers neither), feature-detecting the runtime's codecs, zero
7
+ * dependencies. Bun supplies zstd + gzip natively; every other runtime
8
+ * (workerd, Node, Deno) gets gzip through the standard `CompressionStream`.
6
9
  *
7
- * Segment bytes are stored and content-addressed UNCOMPRESSED (§5.1);
8
- * this is a per-response serving concern only. It is never applied to
9
- * stored objects or the signed-URL path (§5.8: presigned GETs serve the
10
- * addressed bytes verbatim; edge compression is a deployment concern).
10
+ * Body bytes are stored and content-addressed UNCOMPRESSED (§5.1); this is a
11
+ * per-response serving concern only. It is never applied to stored objects or
12
+ * the signed-URL path (§5.8: presigned GETs serve the addressed bytes
13
+ * verbatim; edge compression is a deployment concern). Clients MAY gzip a
14
+ * request body (§1.3) — `decodeRequestBody` is the accepting half.
11
15
  *
12
16
  * Decision data (2026-07-03, 100k-row bench table, Bun 1.3): rows
13
17
  * segments 6.2× in 8 ms (zstd) / 7.7× in 42 ms (gzip); sqlite images
14
18
  * 3.4× in 14 ms (zstd) / 51 ms (gzip); client decompression ≤ 8 ms.
15
19
  */
20
+ import { syncError } from './errors';
16
21
 
17
- export type SegmentContentEncoding = 'zstd' | 'gzip';
22
+ export type ContentEncoding = 'zstd' | 'gzip';
18
23
 
19
24
  interface Codec {
20
- readonly encoding: SegmentContentEncoding;
21
- readonly compress: (bytes: Uint8Array) => Uint8Array;
25
+ readonly encoding: ContentEncoding;
26
+ readonly compress: (bytes: Uint8Array) => Uint8Array | Promise<Uint8Array>;
22
27
  }
23
28
 
24
29
  interface BunCompressors {
@@ -26,8 +31,22 @@ interface BunCompressors {
26
31
  readonly gzipSync?: (bytes: Uint8Array) => Uint8Array;
27
32
  }
28
33
 
29
- /** Preference order is zstd, gzip (§5.8). Non-Bun runtimes without these
30
- * codecs serve identity — negotiation, never a hard requirement here. */
34
+ /** Bodies below this size are served identity (headers outweigh gains). */
35
+ const MIN_COMPRESS_BYTES = 1024;
36
+
37
+ async function gzipWithCompressionStream(
38
+ bytes: Uint8Array,
39
+ ): Promise<Uint8Array> {
40
+ const compressed = await new Response(
41
+ new Blob([bytes.slice().buffer as ArrayBuffer])
42
+ .stream()
43
+ .pipeThrough(new CompressionStream('gzip')),
44
+ ).arrayBuffer();
45
+ return new Uint8Array(compressed);
46
+ }
47
+
48
+ /** Preference order is zstd, gzip (§1.3). A runtime with neither codec
49
+ * serves identity: negotiation, never a hard requirement here. */
31
50
  function detectCodecs(): readonly Codec[] {
32
51
  const bun = (globalThis as { Bun?: BunCompressors }).Bun;
33
52
  const codecs: Codec[] = [];
@@ -38,17 +57,19 @@ function detectCodecs(): readonly Codec[] {
38
57
  const gzip = bun?.gzipSync;
39
58
  if (typeof gzip === 'function') {
40
59
  codecs.push({ encoding: 'gzip', compress: (bytes) => gzip(bytes) });
60
+ } else if (typeof CompressionStream === 'function') {
61
+ codecs.push({ encoding: 'gzip', compress: gzipWithCompressionStream });
41
62
  }
42
63
  return codecs;
43
64
  }
44
65
 
45
66
  const CODECS = detectCodecs();
46
67
 
47
- /** Bodies below this size are served identity (headers outweigh gains). */
48
- const MIN_COMPRESS_BYTES = 1024;
49
-
50
68
  /** Does the `Accept-Encoding` header value accept `encoding` (q > 0)? */
51
- function accepts(header: string, encoding: SegmentContentEncoding): boolean {
69
+ export function acceptsEncoding(
70
+ header: string,
71
+ encoding: ContentEncoding,
72
+ ): boolean {
52
73
  for (const entry of header.split(',')) {
53
74
  const [rawName, ...params] = entry.trim().split(';');
54
75
  const name = rawName?.trim().toLowerCase();
@@ -64,28 +85,41 @@ function accepts(header: string, encoding: SegmentContentEncoding): boolean {
64
85
  return false;
65
86
  }
66
87
 
67
- export interface EncodedSegmentBody {
88
+ /**
89
+ * Add `Accept-Encoding` to a response's `Vary` header. Every response whose
90
+ * body was negotiated carries it, so a cache never serves one encoding to a
91
+ * client that asked for another.
92
+ */
93
+ export function varyOnAcceptEncoding(headers: Headers): void {
94
+ const vary = headers.get('vary');
95
+ if (vary === null) headers.set('vary', 'Accept-Encoding');
96
+ else if (!/\baccept-encoding\b/i.test(vary)) {
97
+ headers.set('vary', `${vary}, Accept-Encoding`);
98
+ }
99
+ }
100
+
101
+ export interface EncodedBody {
68
102
  readonly bytes: Uint8Array;
69
103
  /** Absent ⇒ identity (no `Content-Encoding` header). */
70
- readonly contentEncoding?: SegmentContentEncoding;
104
+ readonly contentEncoding?: ContentEncoding;
71
105
  }
72
106
 
73
107
  /**
74
- * Compress a buffered segment body per §5.8. Returns the original bytes
108
+ * Compress a buffered response body per §1.3. Returns the original bytes
75
109
  * (identity) when the client offered no supported encoding, the body is
76
110
  * tiny, or the runtime has no codec.
77
111
  */
78
- export function encodeSegmentBody(
112
+ export async function encodeBody(
79
113
  bytes: Uint8Array,
80
114
  acceptEncoding: string | undefined,
81
- ): EncodedSegmentBody {
115
+ ): Promise<EncodedBody> {
82
116
  if (acceptEncoding === undefined || bytes.length < MIN_COMPRESS_BYTES) {
83
117
  return { bytes };
84
118
  }
85
119
  for (const codec of CODECS) {
86
- if (accepts(acceptEncoding, codec.encoding)) {
120
+ if (acceptsEncoding(acceptEncoding, codec.encoding)) {
87
121
  return {
88
- bytes: codec.compress(bytes),
122
+ bytes: await codec.compress(bytes),
89
123
  contentEncoding: codec.encoding,
90
124
  };
91
125
  }
@@ -100,24 +134,24 @@ export function encodeSegmentBody(
100
134
  */
101
135
  export const SEGMENT_STREAM_THRESHOLD_BYTES = 16 * 1024 * 1024;
102
136
 
103
- export interface EncodedSegmentStream {
137
+ export interface EncodedStreamBody {
104
138
  readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
105
139
  /** Absent ⇒ identity (no `Content-Encoding` header). */
106
140
  readonly contentEncoding?: 'gzip';
107
141
  }
108
142
 
109
143
  /**
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).
144
+ * Compress a streamed body per §1.3 with the runtime's `CompressionStream`:
145
+ * gzip when the client accepts it, identity otherwise (zstd has no standard
146
+ * streaming codec).
113
147
  */
114
- export function encodeSegmentStream(
148
+ export function encodeStream(
115
149
  body: ReadableStream<Uint8Array<ArrayBuffer>>,
116
150
  acceptEncoding: string | undefined,
117
- ): EncodedSegmentStream {
151
+ ): EncodedStreamBody {
118
152
  if (
119
153
  acceptEncoding === undefined ||
120
- !accepts(acceptEncoding, 'gzip') ||
154
+ !acceptsEncoding(acceptEncoding, 'gzip') ||
121
155
  typeof CompressionStream !== 'function'
122
156
  ) {
123
157
  return { body };
@@ -127,3 +161,37 @@ export function encodeSegmentStream(
127
161
  contentEncoding: 'gzip',
128
162
  };
129
163
  }
164
+
165
+ /**
166
+ * The bytes of a request body, decompressed when the client sent
167
+ * `Content-Encoding: gzip` (§1.3: clients MAY compress a request body and
168
+ * servers MUST accept it). Returns `undefined` for any other declared
169
+ * encoding — the adapter answers 415 rather than parse bytes it cannot read.
170
+ * A gzip body that does not decompress is a `sync.invalid_request`.
171
+ */
172
+ export async function decodeRequestBody(
173
+ request: Request,
174
+ ): Promise<Uint8Array | undefined> {
175
+ const encoding = request.headers
176
+ .get('content-encoding')
177
+ ?.trim()
178
+ .toLowerCase();
179
+ if (encoding === undefined || encoding === '' || encoding === 'identity') {
180
+ return new Uint8Array(await request.arrayBuffer());
181
+ }
182
+ if (encoding !== 'gzip' || typeof DecompressionStream !== 'function') {
183
+ return undefined;
184
+ }
185
+ if (request.body === null) {
186
+ throw syncError('sync.invalid_request', 'malformed gzip request body');
187
+ }
188
+ try {
189
+ return new Uint8Array(
190
+ await new Response(
191
+ request.body.pipeThrough(new DecompressionStream('gzip')),
192
+ ).arrayBuffer(),
193
+ );
194
+ } catch {
195
+ throw syncError('sync.invalid_request', 'malformed gzip request body');
196
+ }
197
+ }
package/src/d1-storage.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { validateCommitPruneQuery } from './prune';
2
2
  import { StorageQueryError } from './storage-errors';
3
3
  import type { CommitPruneQuery, CommitPruneResult } from './storage';
4
+ import { PARTITION_TOUCH_INTERVAL_MS } from './storage';
4
5
  /**
5
6
  * Cloudflare D1 server storage for Workers deployments.
6
7
  *
@@ -77,6 +78,9 @@ import {
77
78
  type StoredColumnLayout,
78
79
  scanRowPageSql,
79
80
  schemaDdl,
81
+ SQLITE_TABLE_INDEXES_SQL,
82
+ type SqliteTableIndexRow,
83
+ sqliteIndexSignatures,
80
84
  selectRowScopesSql,
81
85
  selectRowSql,
82
86
  selectRowsForRewriteSql,
@@ -938,7 +942,7 @@ interface D1MigrationState {
938
942
  phase: 'core' | 'prepare' | 'ddl' | 'rewrite';
939
943
  offset: number;
940
944
  columns: Record<string, string[]>;
941
- indexes: Record<string, string[]>;
945
+ indexes: Record<string, Record<string, string>>;
942
946
  ddl: string[];
943
947
  rewrites: Array<{ table: string; oldLayout?: readonly StoredColumnLayout[] }>;
944
948
  afterPartition: string;
@@ -1269,8 +1273,9 @@ export class D1ServerStorage implements ServerStorage {
1269
1273
  .prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
1270
1274
  .all<{ name: string }>();
1271
1275
  const indexes = await this.#db
1272
- .prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
1273
- .all<{ name: string; origin: string }>();
1276
+ .prepare(SQLITE_TABLE_INDEXES_SQL)
1277
+ .bind(table.name)
1278
+ .all<SqliteTableIndexRow>();
1274
1279
  // Validate the old codec before any application DDL or row rewrite.
1275
1280
  rewritePlan(
1276
1281
  table,
@@ -1296,9 +1301,7 @@ export class D1ServerStorage implements ServerStorage {
1296
1301
  },
1297
1302
  indexes: {
1298
1303
  ...state.indexes,
1299
- [table.name]: indexes.results
1300
- .filter((index) => index.origin === 'c')
1301
- .map((index) => index.name),
1304
+ [table.name]: sqliteIndexSignatures(indexes.results),
1302
1305
  },
1303
1306
  },
1304
1307
  [],
@@ -1314,9 +1317,9 @@ export class D1ServerStorage implements ServerStorage {
1314
1317
  ]),
1315
1318
  );
1316
1319
  const indexes = new Map(
1317
- Object.entries(state.indexes).map(([name, names]) => [
1320
+ Object.entries(state.indexes).map(([name, signatures]) => [
1318
1321
  name,
1319
- new Set(names),
1322
+ new Map(Object.entries(signatures)),
1320
1323
  ]),
1321
1324
  );
1322
1325
  const rewrites = tables.flatMap((table) => {
@@ -1422,7 +1425,7 @@ export class D1ServerStorage implements ServerStorage {
1422
1425
  ? migratePayload(plan.oldLayout, table, bytes)
1423
1426
  : bytes;
1424
1427
  return this.#db
1425
- .prepare(rewriteRowSql(table, 'sqlite'))
1428
+ .prepare(rewriteRowSql(table))
1426
1429
  .bind(
1427
1430
  ...rewriteValues(
1428
1431
  table,
@@ -1581,27 +1584,38 @@ export class D1ServerStorage implements ServerStorage {
1581
1584
  if (initialLogEpoch.length === 0) {
1582
1585
  throw new Error('initial log epoch must be non-empty');
1583
1586
  }
1584
- await this.#db
1585
- .prepare(
1586
- `INSERT INTO sync_partition_registry(
1587
- partition, log_epoch, last_authenticated_at_ms
1588
- ) VALUES (?,?,?)
1589
- ON CONFLICT(partition) DO UPDATE SET
1590
- last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
1591
- )
1592
- .bind(partition, initialLogEpoch, authenticatedAtMs)
1593
- .run();
1594
- const row = await this.#db
1587
+ const select = this.#db
1595
1588
  .prepare(
1596
1589
  `SELECT log_epoch, epoch_required, last_authenticated_at_ms
1597
1590
  FROM sync_partition_registry WHERE partition=?`,
1598
1591
  )
1599
- .bind(partition)
1600
- .first<{
1592
+ .bind(partition);
1593
+ let row = await select.first<{
1594
+ log_epoch: string;
1595
+ epoch_required: number;
1596
+ last_authenticated_at_ms: number;
1597
+ }>();
1598
+ if (
1599
+ row === null ||
1600
+ authenticatedAtMs - row.last_authenticated_at_ms >=
1601
+ PARTITION_TOUCH_INTERVAL_MS
1602
+ ) {
1603
+ await this.#db
1604
+ .prepare(
1605
+ `INSERT INTO sync_partition_registry(
1606
+ partition, log_epoch, last_authenticated_at_ms
1607
+ ) VALUES (?,?,?)
1608
+ ON CONFLICT(partition) DO UPDATE SET
1609
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
1610
+ )
1611
+ .bind(partition, initialLogEpoch, authenticatedAtMs)
1612
+ .run();
1613
+ row = await select.first<{
1601
1614
  log_epoch: string;
1602
1615
  epoch_required: number;
1603
1616
  last_authenticated_at_ms: number;
1604
1617
  }>();
1618
+ }
1605
1619
  if (row === null)
1606
1620
  throw new Error('partition registry write did not persist');
1607
1621
  return {