@syncular/server 0.38.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.
@@ -464,24 +464,22 @@ export function upsertValues(table, partition, row, dialect) {
464
464
  row.columnVersions ?? null,
465
465
  ];
466
466
  }
467
- function placeholderList(count, dialect) {
468
- if (dialect === 'sqlite')
469
- return new Array(count).fill('?').join(',');
470
- const out = [];
471
- for (let i = 1; i <= count; i++)
472
- out.push(`$${i}`);
473
- return out.join(',');
474
- }
475
- /** Upsert statement text (bind with `upsertValues`). */
476
- export function upsertSql(table, dialect) {
467
+ /**
468
+ * Upsert statement text (bind with `upsertValues`). PostgreSQL takes `rows`
469
+ * rows in one statement, bound as consecutive `upsertValues` lists; their
470
+ * row ids must be distinct.
471
+ */
472
+ export function upsertSql(table, dialect, rows = 1) {
477
473
  const names = tableColumnNames(table);
478
474
  const quoted = names.map((name) => quoteIdent(name)).join(', ');
479
- const values = placeholderList(names.length, dialect);
475
+ const values = dialect === 'sqlite'
476
+ ? `(${new Array(names.length).fill('?').join(',')})`
477
+ : Array.from({ length: rows }, (_, row) => `(${names.map((_, column) => `$${row * names.length + column + 1}`).join(',')})`).join(',');
480
478
  const updates = names
481
479
  .slice(2) // partition + row id are the conflict key
482
480
  .map((name) => `${quoteIdent(name)}=excluded.${quoteIdent(name)}`)
483
481
  .join(', ');
484
- return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES (${values})
482
+ return `INSERT INTO ${quoteIdent(table.name)} (${quoted}) VALUES ${values}
485
483
  ON CONFLICT (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)}) DO UPDATE SET ${updates}`;
486
484
  }
487
485
  /**
@@ -611,26 +609,40 @@ export function commitWindowPageSql(valueCount, dialect) {
611
609
  }
612
610
  /**
613
611
  * PostgreSQL set-based form of `scanRowPageSql`: one statement answers the
614
- * page of every subscription scanning `table` in a pull. `$2` is a JSON array
615
- * of `{i, tbl, var, vals, after, lim}` records; each record runs the same
616
- * index-first candidate subquery through a LATERAL join, so every
617
- * subscription keeps its own keyset cursor and LIMIT. Rows arrive ordered
618
- * by (i, row_id).
612
+ * page of every subscription scanning any table in a pull. `$2` is a JSON
613
+ * array of `{i, tbl, var, vals, after, lim}` records; each record runs the
614
+ * same index-first candidate subquery through a LATERAL join, so every
615
+ * subscription keeps its own keyset cursor and LIMIT. The candidate side is
616
+ * table-agnostic (a spec names its table), so the statement joins the row
617
+ * table once per table present in the batch through a UNION ALL branch that
618
+ * keeps only its own candidates. Rows arrive ordered by (i, row_id).
619
619
  *
620
620
  * Params: [partition, specs (JSON text)]. The specs bind as text before
621
621
  * the JSONB cast, so a driver that JSON-encodes a string bound to a `jsonb`
622
622
  * parameter (Bun.sql) cannot turn the array into a JSON string.
623
623
  */
624
- export function scanRowBatchSql(table) {
625
- return `SELECT s.i AS sub, c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
626
- FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
627
- CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
628
- WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
629
- AND row_id>s.after
630
- ORDER BY row_id LIMIT s.lim) c
631
- LEFT JOIN ${quoteIdent(table.name)} r
632
- ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
633
- ORDER BY s.i, c.row_id`;
624
+ export function scanRowBatchSql(tables) {
625
+ // One branch per table, each keeping only its own candidates and selecting
626
+ // the same meta columns so the caller decodes one row shape.
627
+ const projection = tables
628
+ .map((table) => {
629
+ const quoted = quoteIdent(table.name);
630
+ return `SELECT c.sub AS sub, c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
631
+ FROM cand c
632
+ LEFT JOIN ${quoted} r
633
+ ON r.${quoteIdent(SYNC_PARTITION_COLUMN)}=$1 AND r.${quoteIdent(SYNC_ROW_ID_COLUMN)}=c.row_id
634
+ WHERE c.tbl='${table.name.replaceAll("'", "''")}'`;
635
+ })
636
+ .join('\n UNION ALL ');
637
+ return `WITH cand AS (
638
+ SELECT s.i AS sub, c.row_id AS row_id, s.tbl AS tbl
639
+ FROM jsonb_to_recordset($2::text::jsonb) AS s(i int, tbl text, var text, vals text[], after text, lim int)
640
+ CROSS JOIN LATERAL (SELECT DISTINCT row_id FROM sync_row_scopes
641
+ WHERE partition=$1 AND tbl=s.tbl AND var=s.var AND value=ANY(s.vals)
642
+ AND row_id>s.after
643
+ ORDER BY row_id LIMIT s.lim) c)
644
+ ${projection}
645
+ ORDER BY sub, row_id`;
634
646
  }
635
647
  /**
636
648
  * PostgreSQL set-based form of `commitWindowPageSql`: one statement answers
@@ -863,23 +875,17 @@ export function selectRowsForRewriteSql(table, dialect) {
863
875
  LIMIT ${p[2]}`;
864
876
  }
865
877
  /**
866
- * Rewrite one row during a migration: refresh the projection columns (when
878
+ * SQLite/D1: rewrite one row during a migration: refresh the projection columns (when
867
879
  * materialized) and the payload. Bind with `rewriteValues`.
868
880
  */
869
- export function rewriteRowSql(table, dialect) {
870
- const sets = [];
871
- let i = 1;
872
- if (table.materialize) {
873
- for (const column of table.columns) {
874
- sets.push(`${quoteIdent(column.name)}=${dialect === 'sqlite' ? '?' : `$${i}`}`);
875
- i++;
876
- }
877
- }
878
- sets.push(`${quoteIdent(SYNC_PAYLOAD_COLUMN)}=${dialect === 'sqlite' ? '?' : `$${i}`}`);
879
- i++;
880
- const wherePartition = dialect === 'sqlite' ? '?' : `$${i}`;
881
- const whereRowId = dialect === 'sqlite' ? '?' : `$${i + 1}`;
882
- return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${wherePartition} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${whereRowId}`;
881
+ export function rewriteRowSql(table) {
882
+ const sets = [
883
+ ...(table.materialize
884
+ ? table.columns.map((column) => `${quoteIdent(column.name)}=?`)
885
+ : []),
886
+ `${quoteIdent(SYNC_PAYLOAD_COLUMN)}=?`,
887
+ ];
888
+ return `UPDATE ${quoteIdent(table.name)} SET ${sets.join(', ')} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=? AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=?`;
883
889
  }
884
890
  /** Bind values for `rewriteRowSql` from the (already migrated) payload. */
885
891
  export function rewriteValues(table, partition, rowId, payload, dialect) {
@@ -364,8 +364,6 @@ END`);
364
364
  // rewrite stored rows (payload re-encode for layout changes, and/or
365
365
  // projection backfill for flipped-on materialization). One
366
366
  // transaction: a failed bump leaves no half-state.
367
- const layouts = parseLayouts(marker?.layouts);
368
- const retiredTables = retiredTableNames(schema, layouts);
369
367
  const existing = new Map();
370
368
  const existingIndexes = new Map();
371
369
  for (const table of schema.tables.values()) {
@@ -388,6 +386,21 @@ END`);
388
386
  await this.#serializeWrite(() => {
389
387
  this.db.exec('BEGIN IMMEDIATE');
390
388
  try {
389
+ // Another process sharing the database may have bumped between the
390
+ // marker read above and the writer lock; the locked marker decides
391
+ // (SPEC "Schema upgrade writer fence").
392
+ const locked = this.db
393
+ .query('SELECT schema_version, layouts FROM sync_schema_meta WHERE id=1')
394
+ .get();
395
+ if (locked !== null && locked.schema_version > schema.version) {
396
+ throw new Error(`stored schema version ${locked.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`);
397
+ }
398
+ if (locked !== null && locked.schema_version === schema.version) {
399
+ this.db.exec('COMMIT');
400
+ return;
401
+ }
402
+ const layouts = parseLayouts(locked?.layouts);
403
+ const retiredTables = retiredTableNames(schema, layouts);
391
404
  for (const tableName of retiredTables) {
392
405
  this.db
393
406
  .query('DELETE FROM sync_row_scopes WHERE tbl=?')
@@ -521,7 +534,7 @@ END`);
521
534
  */
522
535
  #rewriteRows(table, oldLayout) {
523
536
  const select = selectRowsForRewriteSql(table, 'sqlite');
524
- const update = this.db.query(rewriteRowSql(table, 'sqlite'));
537
+ const update = this.db.query(rewriteRowSql(table));
525
538
  const BATCH = 500;
526
539
  let afterPartition = '';
527
540
  let afterRowId = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.38.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.38.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
@@ -1425,7 +1425,7 @@ export class D1ServerStorage implements ServerStorage {
1425
1425
  ? migratePayload(plan.oldLayout, table, bytes)
1426
1426
  : bytes;
1427
1427
  return this.#db
1428
- .prepare(rewriteRowSql(table, 'sqlite'))
1428
+ .prepare(rewriteRowSql(table))
1429
1429
  .bind(
1430
1430
  ...rewriteValues(
1431
1431
  table,