@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.
@@ -1,44 +1,45 @@
1
+ export type ContentEncoding = 'zstd' | 'gzip';
2
+ /** Does the `Accept-Encoding` header value accept `encoding` (q > 0)? */
3
+ export declare function acceptsEncoding(header: string, encoding: ContentEncoding): boolean;
1
4
  /**
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.
6
- *
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).
11
- *
12
- * Decision data (2026-07-03, 100k-row bench table, Bun 1.3): rows
13
- * segments 6.2× in 8 ms (zstd) / 7.7× in 42 ms (gzip); sqlite images
14
- * 3.4× in 14 ms (zstd) / 51 ms (gzip); client decompression ≤ 8 ms.
5
+ * Add `Accept-Encoding` to a response's `Vary` header. Every response whose
6
+ * body was negotiated carries it, so a cache never serves one encoding to a
7
+ * client that asked for another.
15
8
  */
16
- export type SegmentContentEncoding = 'zstd' | 'gzip';
17
- export interface EncodedSegmentBody {
9
+ export declare function varyOnAcceptEncoding(headers: Headers): void;
10
+ export interface EncodedBody {
18
11
  readonly bytes: Uint8Array;
19
12
  /** Absent ⇒ identity (no `Content-Encoding` header). */
20
- readonly contentEncoding?: SegmentContentEncoding;
13
+ readonly contentEncoding?: ContentEncoding;
21
14
  }
22
15
  /**
23
- * Compress a buffered segment body per §5.8. Returns the original bytes
16
+ * Compress a buffered response body per §1.3. Returns the original bytes
24
17
  * (identity) when the client offered no supported encoding, the body is
25
18
  * tiny, or the runtime has no codec.
26
19
  */
27
- export declare function encodeSegmentBody(bytes: Uint8Array, acceptEncoding: string | undefined): EncodedSegmentBody;
20
+ export declare function encodeBody(bytes: Uint8Array, acceptEncoding: string | undefined): Promise<EncodedBody>;
28
21
  /**
29
22
  * Segments above this size are relayed as a stream instead of being
30
23
  * buffered and compressed in memory: a sqlite image can exceed the memory of
31
24
  * a Workers isolate (128 MB).
32
25
  */
33
26
  export declare const SEGMENT_STREAM_THRESHOLD_BYTES: number;
34
- export interface EncodedSegmentStream {
27
+ export interface EncodedStreamBody {
35
28
  readonly body: ReadableStream<Uint8Array<ArrayBuffer>>;
36
29
  /** Absent ⇒ identity (no `Content-Encoding` header). */
37
30
  readonly contentEncoding?: 'gzip';
38
31
  }
39
32
  /**
40
- * Compress a streamed segment body per §5.8 with the runtime's
41
- * `CompressionStream`: gzip when the client accepts it, identity otherwise
42
- * (zstd has no standard streaming codec).
33
+ * Compress a streamed body per §1.3 with the runtime's `CompressionStream`:
34
+ * gzip when the client accepts it, identity otherwise (zstd has no standard
35
+ * streaming codec).
43
36
  */
44
- export declare function encodeSegmentStream(body: ReadableStream<Uint8Array<ArrayBuffer>>, acceptEncoding: string | undefined): EncodedSegmentStream;
37
+ export declare function encodeStream(body: ReadableStream<Uint8Array<ArrayBuffer>>, acceptEncoding: string | undefined): EncodedStreamBody;
38
+ /**
39
+ * The bytes of a request body, decompressed when the client sent
40
+ * `Content-Encoding: gzip` (§1.3: clients MAY compress a request body and
41
+ * servers MUST accept it). Returns `undefined` for any other declared
42
+ * encoding — the adapter answers 415 rather than parse bytes it cannot read.
43
+ * A gzip body that does not decompress is a `sync.invalid_request`.
44
+ */
45
+ export declare function decodeRequestBody(request: Request): Promise<Uint8Array | undefined>;
@@ -1,20 +1,33 @@
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
  */
16
- /** Preference order is zstd, gzip (§5.8). Non-Bun runtimes without these
17
- * codecs serve identity — negotiation, never a hard requirement here. */
20
+ import { syncError } from './errors.js';
21
+ /** Bodies below this size are served identity (headers outweigh gains). */
22
+ const MIN_COMPRESS_BYTES = 1024;
23
+ async function gzipWithCompressionStream(bytes) {
24
+ const compressed = await new Response(new Blob([bytes.slice().buffer])
25
+ .stream()
26
+ .pipeThrough(new CompressionStream('gzip'))).arrayBuffer();
27
+ return new Uint8Array(compressed);
28
+ }
29
+ /** Preference order is zstd, gzip (§1.3). A runtime with neither codec
30
+ * serves identity: negotiation, never a hard requirement here. */
18
31
  function detectCodecs() {
19
32
  const bun = globalThis.Bun;
20
33
  const codecs = [];
@@ -26,13 +39,14 @@ function detectCodecs() {
26
39
  if (typeof gzip === 'function') {
27
40
  codecs.push({ encoding: 'gzip', compress: (bytes) => gzip(bytes) });
28
41
  }
42
+ else if (typeof CompressionStream === 'function') {
43
+ codecs.push({ encoding: 'gzip', compress: gzipWithCompressionStream });
44
+ }
29
45
  return codecs;
30
46
  }
31
47
  const CODECS = detectCodecs();
32
- /** Bodies below this size are served identity (headers outweigh gains). */
33
- const MIN_COMPRESS_BYTES = 1024;
34
48
  /** Does the `Accept-Encoding` header value accept `encoding` (q > 0)? */
35
- function accepts(header, encoding) {
49
+ export function acceptsEncoding(header, encoding) {
36
50
  for (const entry of header.split(',')) {
37
51
  const [rawName, ...params] = entry.trim().split(';');
38
52
  const name = rawName?.trim().toLowerCase();
@@ -49,18 +63,31 @@ function accepts(header, encoding) {
49
63
  return false;
50
64
  }
51
65
  /**
52
- * Compress a buffered segment body per §5.8. Returns the original bytes
66
+ * Add `Accept-Encoding` to a response's `Vary` header. Every response whose
67
+ * body was negotiated carries it, so a cache never serves one encoding to a
68
+ * client that asked for another.
69
+ */
70
+ export function varyOnAcceptEncoding(headers) {
71
+ const vary = headers.get('vary');
72
+ if (vary === null)
73
+ headers.set('vary', 'Accept-Encoding');
74
+ else if (!/\baccept-encoding\b/i.test(vary)) {
75
+ headers.set('vary', `${vary}, Accept-Encoding`);
76
+ }
77
+ }
78
+ /**
79
+ * Compress a buffered response body per §1.3. Returns the original bytes
53
80
  * (identity) when the client offered no supported encoding, the body is
54
81
  * tiny, or the runtime has no codec.
55
82
  */
56
- export function encodeSegmentBody(bytes, acceptEncoding) {
83
+ export async function encodeBody(bytes, acceptEncoding) {
57
84
  if (acceptEncoding === undefined || bytes.length < MIN_COMPRESS_BYTES) {
58
85
  return { bytes };
59
86
  }
60
87
  for (const codec of CODECS) {
61
- if (accepts(acceptEncoding, codec.encoding)) {
88
+ if (acceptsEncoding(acceptEncoding, codec.encoding)) {
62
89
  return {
63
- bytes: codec.compress(bytes),
90
+ bytes: await codec.compress(bytes),
64
91
  contentEncoding: codec.encoding,
65
92
  };
66
93
  }
@@ -74,13 +101,13 @@ export function encodeSegmentBody(bytes, acceptEncoding) {
74
101
  */
75
102
  export const SEGMENT_STREAM_THRESHOLD_BYTES = 16 * 1024 * 1024;
76
103
  /**
77
- * Compress a streamed segment body per §5.8 with the runtime's
78
- * `CompressionStream`: gzip when the client accepts it, identity otherwise
79
- * (zstd has no standard streaming codec).
104
+ * Compress a streamed body per §1.3 with the runtime's `CompressionStream`:
105
+ * gzip when the client accepts it, identity otherwise (zstd has no standard
106
+ * streaming codec).
80
107
  */
81
- export function encodeSegmentStream(body, acceptEncoding) {
108
+ export function encodeStream(body, acceptEncoding) {
82
109
  if (acceptEncoding === undefined ||
83
- !accepts(acceptEncoding, 'gzip') ||
110
+ !acceptsEncoding(acceptEncoding, 'gzip') ||
84
111
  typeof CompressionStream !== 'function') {
85
112
  return { body };
86
113
  }
@@ -89,3 +116,31 @@ export function encodeSegmentStream(body, acceptEncoding) {
89
116
  contentEncoding: 'gzip',
90
117
  };
91
118
  }
119
+ /**
120
+ * The bytes of a request body, decompressed when the client sent
121
+ * `Content-Encoding: gzip` (§1.3: clients MAY compress a request body and
122
+ * servers MUST accept it). Returns `undefined` for any other declared
123
+ * encoding — the adapter answers 415 rather than parse bytes it cannot read.
124
+ * A gzip body that does not decompress is a `sync.invalid_request`.
125
+ */
126
+ export async function decodeRequestBody(request) {
127
+ const encoding = request.headers
128
+ .get('content-encoding')
129
+ ?.trim()
130
+ .toLowerCase();
131
+ if (encoding === undefined || encoding === '' || encoding === 'identity') {
132
+ return new Uint8Array(await request.arrayBuffer());
133
+ }
134
+ if (encoding !== 'gzip' || typeof DecompressionStream !== 'function') {
135
+ return undefined;
136
+ }
137
+ if (request.body === null) {
138
+ throw syncError('sync.invalid_request', 'malformed gzip request body');
139
+ }
140
+ try {
141
+ return new Uint8Array(await new Response(request.body.pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());
142
+ }
143
+ catch {
144
+ throw syncError('sync.invalid_request', 'malformed gzip request body');
145
+ }
146
+ }
@@ -1,6 +1,7 @@
1
1
  var _a;
2
2
  import { validateCommitPruneQuery } from './prune.js';
3
3
  import { StorageQueryError } from './storage-errors.js';
4
+ import { PARTITION_TOUCH_INTERVAL_MS } from './storage.js';
4
5
  /**
5
6
  * Cloudflare D1 server storage for Workers deployments.
6
7
  *
@@ -45,7 +46,7 @@ import { StorageQueryError } from './storage-errors.js';
45
46
  import { decodeRow } from '@syncular/core';
46
47
  import { bindAuthoritativePartition, prepareAuthoritativeQuery, } from './authoritative-query.js';
47
48
  import { syncError } from './errors.js';
48
- import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
49
+ import { assertAppendOnlyMigration, assertPhysicalColumns, assertStoredLayouts, commitWindowPageSql, LATEST_SCOPED_CHANGE_SEQ_SQLITE_SQL, deleteRowSql, deleteSqliteRowScopesSql, dropTableDdl, indexRowPageStatement, layoutsOf, migratePayload, parseLayouts, quoteIdent, retiredTableNames, rewritePlan, rewriteRowSql, rewriteValues, SCHEMA_META_DDL_SQLITE, SYNC_COLUMN_VERSIONS_COLUMN, SYNC_PARTITION_COLUMN, SYNC_PAYLOAD_COLUMN, SYNC_ROW_ID_COLUMN, SYNC_SCOPES_COLUMN, SYNC_VERSION_COLUMN, scanRowPageSql, schemaDdl, SQLITE_TABLE_INDEXES_SQL, sqliteIndexSignatures, selectRowScopesSql, selectRowSql, selectRowsForRewriteSql, tableColumnNames, toSqlValue, upsertSql, upsertValues, } from './relational-rows.js';
49
50
  import { matchesEffective } from './scopes.js';
50
51
  import { asUint8Array, collectCommitWindowPage, deserializePushResult, placeholders, ERASE_SCOPE_VALUE_HISTORY_SQLITE_SQL, eraseScopeValueHistoryParams, LIST_SCOPE_VALUE_ROWS_SQLITE_SQL, serializePushResult, sqliteDdlStatements, toStoredChange, toStoredRow, } from './sqlite-dialect.js';
51
52
  import { isD1ConstraintError, StorageConstraintError } from './storage-errors.js';
@@ -833,7 +834,8 @@ export class D1ServerStorage {
833
834
  .prepare(`PRAGMA table_info(${quoteIdent(table.name)})`)
834
835
  .all();
835
836
  const indexes = await this.#db
836
- .prepare(`PRAGMA index_list(${quoteIdent(table.name)})`)
837
+ .prepare(SQLITE_TABLE_INDEXES_SQL)
838
+ .bind(table.name)
837
839
  .all();
838
840
  // Validate the old codec before any application DDL or row rewrite.
839
841
  rewritePlan(table, sourceLayouts[table.name], columns.results.length > 0
@@ -852,9 +854,7 @@ export class D1ServerStorage {
852
854
  },
853
855
  indexes: {
854
856
  ...state.indexes,
855
- [table.name]: indexes.results
856
- .filter((index) => index.origin === 'c')
857
- .map((index) => index.name),
857
+ [table.name]: sqliteIndexSignatures(indexes.results),
858
858
  },
859
859
  }, [])))
860
860
  break;
@@ -864,9 +864,9 @@ export class D1ServerStorage {
864
864
  name,
865
865
  new Set(names),
866
866
  ]));
867
- const indexes = new Map(Object.entries(state.indexes).map(([name, names]) => [
867
+ const indexes = new Map(Object.entries(state.indexes).map(([name, signatures]) => [
868
868
  name,
869
- new Set(names),
869
+ new Map(Object.entries(signatures)),
870
870
  ]));
871
871
  const rewrites = tables.flatMap((table) => {
872
872
  const plan = rewritePlan(table, sourceLayouts[table.name], columns.get(table.name));
@@ -943,7 +943,7 @@ export class D1ServerStorage {
943
943
  ? migratePayload(plan.oldLayout, table, bytes)
944
944
  : bytes;
945
945
  return this.#db
946
- .prepare(rewriteRowSql(table, 'sqlite'))
946
+ .prepare(rewriteRowSql(table))
947
947
  .bind(...rewriteValues(table, row.partition, row.row_id, payload, 'sqlite'));
948
948
  });
949
949
  const last = results.at(-1);
@@ -1068,19 +1068,24 @@ export class D1ServerStorage {
1068
1068
  if (initialLogEpoch.length === 0) {
1069
1069
  throw new Error('initial log epoch must be non-empty');
1070
1070
  }
1071
- await this.#db
1072
- .prepare(`INSERT INTO sync_partition_registry(
1073
- partition, log_epoch, last_authenticated_at_ms
1074
- ) VALUES (?,?,?)
1075
- ON CONFLICT(partition) DO UPDATE SET
1076
- last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
1077
- .bind(partition, initialLogEpoch, authenticatedAtMs)
1078
- .run();
1079
- const row = await this.#db
1071
+ const select = this.#db
1080
1072
  .prepare(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
1081
1073
  FROM sync_partition_registry WHERE partition=?`)
1082
- .bind(partition)
1083
- .first();
1074
+ .bind(partition);
1075
+ let row = await select.first();
1076
+ if (row === null ||
1077
+ authenticatedAtMs - row.last_authenticated_at_ms >=
1078
+ PARTITION_TOUCH_INTERVAL_MS) {
1079
+ await this.#db
1080
+ .prepare(`INSERT INTO sync_partition_registry(
1081
+ partition, log_epoch, last_authenticated_at_ms
1082
+ ) VALUES (?,?,?)
1083
+ ON CONFLICT(partition) DO UPDATE SET
1084
+ last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
1085
+ .bind(partition, initialLogEpoch, authenticatedAtMs)
1086
+ .run();
1087
+ row = await select.first();
1088
+ }
1084
1089
  if (row === null)
1085
1090
  throw new Error('partition registry write did not persist');
1086
1091
  return {
@@ -53,7 +53,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, CheckpointDec
53
53
  * their table.
54
54
  *
55
55
  * `sync_row_scopes_by_row (partition, tbl, row_id)` is the one secondary
56
- * scope index: `writeRowOn` and `deleteRow` replace a row's scope entries
56
+ * scope index: `writeRowsOn` and `deleteRow` replace a row's scope entries
57
57
  * with a `(partition, tbl, row_id)` predicate, and `row_id` is the LAST
58
58
  * column of the inverted PRIMARY KEY, so without this index that predicate
59
59
  * scans the table's whole scope range on every row written (a bulk import