@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/content-encoding.d.ts +24 -23
- package/dist/content-encoding.js +77 -22
- package/dist/d1-storage.js +24 -19
- package/dist/postgres-storage.d.ts +1 -1
- package/dist/postgres-storage.js +240 -74
- package/dist/pull.d.ts +7 -3
- package/dist/pull.js +21 -12
- package/dist/relational-rows.d.ts +49 -23
- package/dist/relational-rows.js +133 -59
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +22 -0
- package/dist/sqlite-bun-driver.js +1 -1
- package/dist/sqlite-node-driver.d.ts +1 -0
- package/dist/sqlite-node-driver.js +13 -3
- package/dist/sqlite-storage.js +36 -21
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +3 -0
- package/package.json +2 -2
- package/src/content-encoding.ts +99 -31
- package/src/d1-storage.ts +36 -22
- package/src/postgres-storage.ts +338 -109
- package/src/pull.ts +31 -18
- package/src/relational-rows.ts +182 -66
- package/src/schema.ts +55 -0
- package/src/sqlite-bun-driver.ts +1 -1
- package/src/sqlite-node-driver.ts +18 -3
- package/src/sqlite-storage.ts +64 -34
- package/src/storage.ts +9 -1
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
|
-
/**
|
|
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.
|
|
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.
|
|
71
|
+
"@syncular/core": "0.39.0"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@electric-sql/pglite": "^0.5.4"
|
package/src/content-encoding.ts
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* §
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
22
|
+
export type ContentEncoding = 'zstd' | 'gzip';
|
|
18
23
|
|
|
19
24
|
interface Codec {
|
|
20
|
-
readonly encoding:
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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?:
|
|
104
|
+
readonly contentEncoding?: ContentEncoding;
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
/**
|
|
74
|
-
* Compress a buffered
|
|
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
|
|
112
|
+
export async function encodeBody(
|
|
79
113
|
bytes: Uint8Array,
|
|
80
114
|
acceptEncoding: string | undefined,
|
|
81
|
-
):
|
|
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 (
|
|
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
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
148
|
+
export function encodeStream(
|
|
115
149
|
body: ReadableStream<Uint8Array<ArrayBuffer>>,
|
|
116
150
|
acceptEncoding: string | undefined,
|
|
117
|
-
):
|
|
151
|
+
): EncodedStreamBody {
|
|
118
152
|
if (
|
|
119
153
|
acceptEncoding === undefined ||
|
|
120
|
-
!
|
|
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(
|
|
1273
|
-
.
|
|
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,
|
|
1320
|
+
Object.entries(state.indexes).map(([name, signatures]) => [
|
|
1318
1321
|
name,
|
|
1319
|
-
new
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|