@syncular/server 0.15.46 → 0.15.48
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/README.md +17 -4
- package/dist/admin.d.ts +1 -5
- package/dist/admin.js +2 -7
- package/dist/blob-handlers.js +4 -1
- package/dist/context.d.ts +5 -2
- package/dist/context.js +4 -0
- package/dist/d1-storage.d.ts +4 -1
- package/dist/d1-storage.js +75 -11
- package/dist/errors.js +6 -0
- package/dist/events.d.ts +2 -1
- package/dist/frame-bytes.d.ts +2 -2
- package/dist/frame-bytes.js +33 -14
- package/dist/handler.js +58 -14
- package/dist/index-bun.d.ts +2 -0
- package/dist/index-bun.js +2 -0
- package/dist/index-node.d.ts +2 -0
- package/dist/index-node.js +2 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -6
- package/dist/operations.js +2 -1
- package/dist/postgres-storage.d.ts +5 -2
- package/dist/postgres-storage.js +71 -10
- package/dist/pull.d.ts +1 -1
- package/dist/pull.js +12 -6
- package/dist/realtime.d.ts +4 -1
- package/dist/realtime.js +33 -9
- package/dist/restore.d.ts +13 -0
- package/dist/restore.js +13 -0
- package/dist/s3-segment-store.js +10 -1
- package/dist/seed.js +36 -4
- package/dist/segment-download.js +5 -2
- package/dist/segment-store.d.ts +3 -0
- package/dist/segment-store.js +1 -0
- package/dist/sqlite-blob-store.d.ts +4 -9
- package/dist/sqlite-blob-store.js +5 -10
- package/dist/sqlite-bun-driver.d.ts +12 -0
- package/dist/sqlite-bun-driver.js +30 -0
- package/dist/sqlite-bun.d.ts +24 -0
- package/dist/sqlite-bun.js +40 -0
- package/dist/sqlite-dialect.d.ts +8 -8
- package/dist/sqlite-dialect.js +9 -2
- package/dist/sqlite-driver.d.ts +26 -0
- package/dist/sqlite-driver.js +8 -0
- package/dist/sqlite-image.d.ts +7 -9
- package/dist/sqlite-image.js +26 -28
- package/dist/sqlite-lease-store.d.ts +4 -9
- package/dist/sqlite-lease-store.js +5 -10
- package/dist/sqlite-node-driver.d.ts +10 -0
- package/dist/sqlite-node-driver.js +30 -0
- package/dist/sqlite-node.d.ts +24 -0
- package/dist/sqlite-node.js +50 -0
- package/dist/sqlite-segment-store.d.ts +4 -10
- package/dist/sqlite-segment-store.js +20 -14
- package/dist/sqlite-storage.d.ts +7 -12
- package/dist/sqlite-storage.js +89 -16
- package/dist/storage-errors.js +4 -1
- package/dist/storage.d.ts +18 -5
- package/package.json +18 -3
- package/src/admin.ts +3 -9
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +18 -2
- package/src/d1-storage.ts +107 -12
- package/src/errors.ts +6 -0
- package/src/events.ts +2 -1
- package/src/frame-bytes.ts +40 -14
- package/src/handler.ts +102 -29
- package/src/index-bun.ts +9 -0
- package/src/index-node.ts +9 -0
- package/src/index.ts +9 -6
- package/src/operations.ts +6 -1
- package/src/postgres-storage.ts +102 -13
- package/src/pull.ts +12 -1
- package/src/realtime.ts +46 -7
- package/src/restore.ts +28 -0
- package/src/s3-segment-store.ts +10 -1
- package/src/seed.ts +46 -4
- package/src/segment-download.ts +11 -2
- package/src/segment-store.ts +4 -0
- package/src/sqlite-blob-store.ts +11 -10
- package/src/sqlite-bun-driver.ts +46 -0
- package/src/sqlite-bun.ts +53 -0
- package/src/sqlite-dialect.ts +14 -7
- package/src/sqlite-driver.ts +44 -0
- package/src/sqlite-image.ts +44 -49
- package/src/sqlite-lease-store.ts +11 -10
- package/src/sqlite-node-driver.ts +46 -0
- package/src/sqlite-node.ts +62 -0
- package/src/sqlite-segment-store.ts +29 -15
- package/src/sqlite-storage.ts +131 -19
- package/src/storage-errors.ts +4 -1
- package/src/storage.ts +28 -5
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ The supported set, and what deliberately does **not** get an adapter:
|
|
|
29
29
|
|
|
30
30
|
| Runtime | Adapter | Transport | Storage | Status |
|
|
31
31
|
| --- | --- | --- | --- | --- |
|
|
32
|
-
| **Bun / Node
|
|
32
|
+
| **Bun / Node 22.13+** | `@syncular/server-hono` | HTTP (`POST /sync`, segments, blobs) **+ WS realtime** (§8, host-driven upgrade) | `SqliteServerStorage` through `@syncular/server/sqlite`, Postgres, memory | **Supported now** — the reference deployment; runs the full conformance catalog on both bindings. |
|
|
33
33
|
| **Cloudflare Workers** | `@syncular/server-workers` | HTTP binding via Hono (Workers-native) **+ optional WS realtime** (§8) | `D1ServerStorage` behind one per-partition Durable Object queue; R2-as-S3 for segments/blobs | **Supported now** — D1 sync writes always traverse the DO; WebSocket upgrades remain optional. |
|
|
34
34
|
| Raw Deno / edge-misc | — | — | — | **Not adapted** (policy below). |
|
|
35
35
|
|
|
@@ -76,9 +76,9 @@ or the storage projection cannot migrate:
|
|
|
76
76
|
```ts
|
|
77
77
|
import {
|
|
78
78
|
ensureSyncServerReady,
|
|
79
|
-
SqliteServerStorage,
|
|
80
79
|
type SyncServerConfig,
|
|
81
80
|
} from '@syncular/server';
|
|
81
|
+
import { SqliteServerStorage } from '@syncular/server/sqlite';
|
|
82
82
|
|
|
83
83
|
const config: SyncServerConfig = {
|
|
84
84
|
schema,
|
|
@@ -91,6 +91,12 @@ await ensureSyncServerReady(config);
|
|
|
91
91
|
Bun.serve({ fetch: app.fetch });
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
`@syncular/server/sqlite` selects `bun:sqlite` on Bun and the built-in
|
|
95
|
+
`node:sqlite` module on Node. It covers server storage, segment storage, blob
|
|
96
|
+
storage, leases, and SQLite-image generation without an external SQLite
|
|
97
|
+
package. The runtime-specific database wrappers are `BunSqliteDatabase` and
|
|
98
|
+
`NodeSqliteDatabase` when a host needs direct access to the native handle.
|
|
99
|
+
|
|
94
100
|
The helper accepts the generated `ServerSchema`, compiles it, and calls the
|
|
95
101
|
storage backend's low-level `ensureSchema(CompiledSchema)`. A failure is a
|
|
96
102
|
`SyncServerReadinessError` with stable code `sync.schema_not_ready`, a `phase`
|
|
@@ -99,6 +105,12 @@ Log the cause for operators and stop startup. Do not catch readiness errors in
|
|
|
99
105
|
authentication or convert them to a 401; request-time schema checks are only a
|
|
100
106
|
defensive fallback.
|
|
101
107
|
|
|
108
|
+
After restoring an authoritative database, keep traffic stopped and call
|
|
109
|
+
`rotatePartitionLogEpoch({ storage, partition })` for every restored
|
|
110
|
+
partition. The rotation clears stale client cursors and requires version 2
|
|
111
|
+
clients to reset their server-derived rows while preserving the outbox. Follow
|
|
112
|
+
the complete [backup and restore runbook](https://syncular.dev/server-backup-restore/).
|
|
113
|
+
|
|
102
114
|
## Write validators and recovery metadata
|
|
103
115
|
|
|
104
116
|
`validators` is the server-authoritative seam for row business rules that
|
|
@@ -501,7 +513,7 @@ by design:
|
|
|
501
513
|
| `horizonStatus(partition)` | `{maxCommitSeq, horizonSeq, retainedCommits, activeCursorFloor, recommendedHorizonSeq, recommendation}` — the horizon a prune pass would reach now (§4.6) + a coarse `up-to-date` / `prune-recommended`. |
|
|
502
514
|
| `segmentStats()` / `blobStats(partition)` / `stats(partition)` | Counts/bytes where the stores expose them (segments split rows/sqlite). `undefined` when a store omits `stats()`. |
|
|
503
515
|
| `metrics(partition, {windowMs?, buckets?})` | Ring-derived request/push health over a trailing window (default 5 min): request count/rate, error share, p50/p95 duration, push applied/rejected/conflicted, and per-bucket counts for a sparkline. Zero new server state; all zeros when no ring is wired. |
|
|
504
|
-
| `
|
|
516
|
+
| `listPartitionRegistry()` / `partitionsOverview()` | Every authenticated partition; the registry carries log continuity and last-authenticated time, while the overview adds retained backlog, client counts, and the prune recommendation. |
|
|
505
517
|
| `events({type?, sinceMs?, clientId?, actorId?, limit?})` | The ring tail, newest first. Empty when no ring is wired (`hasEventStream` reports which). |
|
|
506
518
|
| `subscribeEvents(listener)` | Live events as they land in the ring; returns the unsubscribe function, `undefined` when no ring is wired. |
|
|
507
519
|
|
|
@@ -974,7 +986,8 @@ same table+scope during a storm, your TTL is shorter than the storm.
|
|
|
974
986
|
|
|
975
987
|
## Postgres storage (the production database path)
|
|
976
988
|
|
|
977
|
-
`SqliteServerStorage`
|
|
989
|
+
`SqliteServerStorage` from `@syncular/server/sqlite` uses `bun:sqlite` or
|
|
990
|
+
Node's built-in `node:sqlite`. For
|
|
978
991
|
production, `PostgresServerStorage` implements the same `ServerStorage`
|
|
979
992
|
contract against Postgres, with the inverted scope index carried through
|
|
980
993
|
as **covering indexes** so scope fanout is an index range scan, never a
|
package/dist/admin.d.ts
CHANGED
|
@@ -223,11 +223,7 @@ export declare class SyncularAdmin {
|
|
|
223
223
|
readonly windowMs?: number;
|
|
224
224
|
readonly buckets?: number;
|
|
225
225
|
}): AdminMetrics;
|
|
226
|
-
/**
|
|
227
|
-
* Every partition the storage knows (commit log + client records) — the
|
|
228
|
-
* fleet-view backing and the console's partition picker. Fails loud when
|
|
229
|
-
* the backend omits the optional `listPartitions`.
|
|
230
|
-
*/
|
|
226
|
+
/** Every authenticated partition in the storage-backed registry. */
|
|
231
227
|
listPartitions(): Promise<string[]>;
|
|
232
228
|
/**
|
|
233
229
|
* The fleet view: one row per known partition — retained backlog, client
|
package/dist/admin.js
CHANGED
|
@@ -283,14 +283,9 @@ export class SyncularAdmin {
|
|
|
283
283
|
buckets: { widthMs, counts, errors },
|
|
284
284
|
};
|
|
285
285
|
}
|
|
286
|
-
/**
|
|
287
|
-
* Every partition the storage knows (commit log + client records) — the
|
|
288
|
-
* fleet-view backing and the console's partition picker. Fails loud when
|
|
289
|
-
* the backend omits the optional `listPartitions`.
|
|
290
|
-
*/
|
|
286
|
+
/** Every authenticated partition in the storage-backed registry. */
|
|
291
287
|
async listPartitions() {
|
|
292
|
-
|
|
293
|
-
return list();
|
|
288
|
+
return (await this.#storage.listPartitionRegistry()).map((entry) => entry.partition);
|
|
294
289
|
}
|
|
295
290
|
/**
|
|
296
291
|
* The fleet view: one row per known partition — retained backlog, client
|
package/dist/blob-handlers.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* adapters map these onto `PUT/GET <mount>/blobs/{blobId}`.
|
|
8
8
|
*/
|
|
9
9
|
import { blobIdFor, isBlobId } from './blob-store.js';
|
|
10
|
-
import { clockOf, RESOLVER_OUTAGE } from './context.js';
|
|
10
|
+
import { clockOf, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
11
11
|
import { SyncError, syncError } from './errors.js';
|
|
12
12
|
import { emitEvent } from './events.js';
|
|
13
13
|
import { compileSchema } from './schema.js';
|
|
@@ -16,6 +16,7 @@ import { issueBlobUploadUrl, issueBlobUrl } from './signed-url.js';
|
|
|
16
16
|
const DEFAULT_MAX_BLOB_BYTES = 64 * 1024 * 1024;
|
|
17
17
|
/** `PUT <mount>/blobs/{blobId}` (§5.9.3). Host auth is the adapter's job. */
|
|
18
18
|
export async function handleBlobUpload(ctx, request) {
|
|
19
|
+
await touchAuthenticatedPartition(ctx);
|
|
19
20
|
const store = ctx.blobs;
|
|
20
21
|
if (store === undefined) {
|
|
21
22
|
throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
|
|
@@ -50,6 +51,7 @@ export async function handleBlobUpload(ctx, request) {
|
|
|
50
51
|
* reference index passes the §3.4 scope check for the actor.
|
|
51
52
|
*/
|
|
52
53
|
export async function handleBlobDownload(ctx, blobId) {
|
|
54
|
+
await touchAuthenticatedPartition(ctx);
|
|
53
55
|
const events = ctx.events;
|
|
54
56
|
if (events === undefined)
|
|
55
57
|
return downloadBlob(ctx, blobId);
|
|
@@ -213,6 +215,7 @@ async function downloadBlob(ctx, blobId) {
|
|
|
213
215
|
* result when no presigned-upload store is configured (client streams direct).
|
|
214
216
|
*/
|
|
215
217
|
export async function handleBlobUploadGrant(ctx, request) {
|
|
218
|
+
await touchAuthenticatedPartition(ctx);
|
|
216
219
|
const store = ctx.blobs;
|
|
217
220
|
if (store === undefined) {
|
|
218
221
|
throw syncError('blob.not_found', 'this server has no blob store (§5.9)');
|
package/dist/context.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ServerSchema } from './schema.js';
|
|
|
14
14
|
import type { SegmentStore } from './segment-store.js';
|
|
15
15
|
import type { BlobPresignConfig, BlobUploadPresignConfig, SegmentUrlConfig } from './signed-url.js';
|
|
16
16
|
import type { SqliteImageBuilder } from './sqlite-image.js';
|
|
17
|
-
import type { ServerStorage, StoredCommit } from './storage.js';
|
|
17
|
+
import type { PartitionRegistryEntry, ServerStorage, StoredCommit } from './storage.js';
|
|
18
18
|
import type { CommitValidator, ValidatorRegistry } from './validate.js';
|
|
19
19
|
/** SSP2 body content type (§1.1). */
|
|
20
20
|
export declare const SSP2_CONTENT_TYPE = "application/vnd.syncular.sync.v2";
|
|
@@ -148,7 +148,8 @@ export interface SyncServerConfig {
|
|
|
148
148
|
* §5.3 sqlite-image builder, injected so the pull path never
|
|
149
149
|
* statically imports `bun:sqlite`. Absent ⇒ the sqlite-image lane is off
|
|
150
150
|
* (bit-2 clients are served the rows lane) — the Workers/edge posture. A
|
|
151
|
-
* Bun
|
|
151
|
+
* Bun or Node host wires `buildSqliteImage` from
|
|
152
|
+
* `@syncular/server/sqlite`.
|
|
152
153
|
*/
|
|
153
154
|
readonly sqliteImageBuilder?: SqliteImageBuilder;
|
|
154
155
|
readonly realtime?: RealtimeNotifier;
|
|
@@ -165,3 +166,5 @@ export interface SyncRequestContext extends SyncServerConfig {
|
|
|
165
166
|
}
|
|
166
167
|
export declare function clockOf(ctx: SyncServerConfig): () => number;
|
|
167
168
|
export declare function limitsOf(ctx: SyncServerConfig): ServerLimits;
|
|
169
|
+
/** Refresh the registry after host authentication and return log continuity. */
|
|
170
|
+
export declare function touchAuthenticatedPartition(ctx: SyncRequestContext): Promise<PartitionRegistryEntry>;
|
package/dist/context.js
CHANGED
|
@@ -21,3 +21,7 @@ export function clockOf(ctx) {
|
|
|
21
21
|
export function limitsOf(ctx) {
|
|
22
22
|
return { ...DEFAULT_LIMITS, ...ctx.limits };
|
|
23
23
|
}
|
|
24
|
+
/** Refresh the registry after host authentication and return log continuity. */
|
|
25
|
+
export function touchAuthenticatedPartition(ctx) {
|
|
26
|
+
return ctx.storage.touchPartition(ctx.partition, clockOf(ctx)(), crypto.randomUUID());
|
|
27
|
+
}
|
package/dist/d1-storage.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
2
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
2
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
3
3
|
export interface D1PreparedStatement {
|
|
4
4
|
bind(...values: unknown[]): D1PreparedStatement;
|
|
5
5
|
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
@@ -36,6 +36,9 @@ export declare class D1ServerStorage implements ServerStorage {
|
|
|
36
36
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
37
37
|
table(name: string): CompiledTable;
|
|
38
38
|
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
39
|
+
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
40
|
+
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
41
|
+
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
39
42
|
begin(partition: string): Promise<StorageTransaction>;
|
|
40
43
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
41
44
|
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
package/dist/d1-storage.js
CHANGED
|
@@ -479,6 +479,12 @@ export class D1ServerStorage {
|
|
|
479
479
|
for (const statement of sqliteDdlStatements()) {
|
|
480
480
|
await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
|
|
481
481
|
}
|
|
482
|
+
const { results } = await this.#db
|
|
483
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
484
|
+
.all();
|
|
485
|
+
if (!results.some((column) => column.name === 'wire_version')) {
|
|
486
|
+
await this.#db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;');
|
|
487
|
+
}
|
|
482
488
|
}
|
|
483
489
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
484
490
|
table(name) {
|
|
@@ -563,6 +569,68 @@ export class D1ServerStorage {
|
|
|
563
569
|
this.#tables = schema.tables;
|
|
564
570
|
this.#schemaVersion = schema.version;
|
|
565
571
|
}
|
|
572
|
+
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
573
|
+
if (initialLogEpoch.length === 0) {
|
|
574
|
+
throw new Error('initial log epoch must be non-empty');
|
|
575
|
+
}
|
|
576
|
+
await this.#db
|
|
577
|
+
.prepare(`INSERT INTO sync_partition_registry(
|
|
578
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
579
|
+
) VALUES (?,?,?)
|
|
580
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
581
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
582
|
+
.bind(partition, initialLogEpoch, authenticatedAtMs)
|
|
583
|
+
.run();
|
|
584
|
+
const row = await this.#db
|
|
585
|
+
.prepare(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
586
|
+
FROM sync_partition_registry WHERE partition=?`)
|
|
587
|
+
.bind(partition)
|
|
588
|
+
.first();
|
|
589
|
+
if (row === null)
|
|
590
|
+
throw new Error('partition registry write did not persist');
|
|
591
|
+
return {
|
|
592
|
+
partition,
|
|
593
|
+
logEpoch: row.log_epoch,
|
|
594
|
+
epochRequired: row.epoch_required === 1,
|
|
595
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
|
|
599
|
+
if (logEpoch.length === 0)
|
|
600
|
+
throw new Error('log epoch must be non-empty');
|
|
601
|
+
await this.#db.batch([
|
|
602
|
+
this.#db
|
|
603
|
+
.prepare(`INSERT INTO sync_partition_registry(
|
|
604
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
605
|
+
) VALUES (?,?,1,?)
|
|
606
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
607
|
+
log_epoch=excluded.log_epoch,
|
|
608
|
+
epoch_required=1,
|
|
609
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
610
|
+
.bind(partition, logEpoch, authenticatedAtMs),
|
|
611
|
+
this.#db
|
|
612
|
+
.prepare('DELETE FROM sync_clients WHERE partition=?')
|
|
613
|
+
.bind(partition),
|
|
614
|
+
]);
|
|
615
|
+
return {
|
|
616
|
+
partition,
|
|
617
|
+
logEpoch,
|
|
618
|
+
epochRequired: true,
|
|
619
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
async listPartitionRegistry() {
|
|
623
|
+
const { results } = await this.#db
|
|
624
|
+
.prepare(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
625
|
+
FROM sync_partition_registry ORDER BY partition`)
|
|
626
|
+
.all();
|
|
627
|
+
return results.map((row) => ({
|
|
628
|
+
partition: row.partition,
|
|
629
|
+
logEpoch: row.log_epoch,
|
|
630
|
+
epochRequired: row.epoch_required === 1,
|
|
631
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
632
|
+
}));
|
|
633
|
+
}
|
|
566
634
|
/** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
|
|
567
635
|
async #rewriteRows(table, oldLayout) {
|
|
568
636
|
const select = selectRowsForRewriteSql(table, 'sqlite');
|
|
@@ -912,7 +980,7 @@ export class D1ServerStorage {
|
|
|
912
980
|
}
|
|
913
981
|
async getClientRecord(partition, clientId) {
|
|
914
982
|
const record = await this.#db
|
|
915
|
-
.prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
983
|
+
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
916
984
|
.bind(partition, clientId)
|
|
917
985
|
.first();
|
|
918
986
|
if (record === null)
|
|
@@ -920,6 +988,7 @@ export class D1ServerStorage {
|
|
|
920
988
|
return {
|
|
921
989
|
clientId: record.client_id,
|
|
922
990
|
actorId: record.actor_id,
|
|
991
|
+
wireVersion: record.wire_version,
|
|
923
992
|
cursor: record.cursor,
|
|
924
993
|
updatedAtMs: record.updated_at_ms,
|
|
925
994
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -927,8 +996,8 @@ export class D1ServerStorage {
|
|
|
927
996
|
}
|
|
928
997
|
async putClientRecord(partition, record) {
|
|
929
998
|
await this.#db
|
|
930
|
-
.prepare('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (
|
|
931
|
-
.bind(partition, record.clientId, record.actorId, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
|
|
999
|
+
.prepare('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
|
|
1000
|
+
.bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
|
|
932
1001
|
.run();
|
|
933
1002
|
}
|
|
934
1003
|
async listClientCursors(partition) {
|
|
@@ -976,12 +1045,13 @@ export class D1ServerStorage {
|
|
|
976
1045
|
// -- admin/console read surface --------------------------------------------
|
|
977
1046
|
async listClientRecords(partition) {
|
|
978
1047
|
const { results } = await this.#db
|
|
979
|
-
.prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
1048
|
+
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
980
1049
|
.bind(partition)
|
|
981
1050
|
.all();
|
|
982
1051
|
return results.map((record) => ({
|
|
983
1052
|
clientId: record.client_id,
|
|
984
1053
|
actorId: record.actor_id,
|
|
1054
|
+
wireVersion: record.wire_version,
|
|
985
1055
|
cursor: record.cursor,
|
|
986
1056
|
updatedAtMs: record.updated_at_ms,
|
|
987
1057
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -1065,12 +1135,6 @@ export class D1ServerStorage {
|
|
|
1065
1135
|
};
|
|
1066
1136
|
}
|
|
1067
1137
|
async listPartitions() {
|
|
1068
|
-
|
|
1069
|
-
// first pull — a partition with only one of the two still shows up.
|
|
1070
|
-
const { results } = await this.#db
|
|
1071
|
-
.prepare(`SELECT partition FROM sync_partitions
|
|
1072
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`)
|
|
1073
|
-
.all();
|
|
1074
|
-
return results.map((r) => r.partition);
|
|
1138
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1075
1139
|
}
|
|
1076
1140
|
}
|
package/dist/errors.js
CHANGED
|
@@ -191,6 +191,12 @@ export const ERROR_CATALOG = {
|
|
|
191
191
|
recommendedAction: 'upgradeClient',
|
|
192
192
|
httpStatus: 400,
|
|
193
193
|
},
|
|
194
|
+
'sync.client_wire_unsupported': {
|
|
195
|
+
category: 'schema-mismatch',
|
|
196
|
+
retryable: false,
|
|
197
|
+
recommendedAction: 'upgradeClient',
|
|
198
|
+
httpStatus: 400,
|
|
199
|
+
},
|
|
194
200
|
'sync.websocket_connection_limit': {
|
|
195
201
|
category: 'rate-limited',
|
|
196
202
|
retryable: true,
|
package/dist/events.d.ts
CHANGED
|
@@ -28,10 +28,11 @@ export interface RequestHandledEvent {
|
|
|
28
28
|
/**
|
|
29
29
|
* `ok` — response streamed to END;
|
|
30
30
|
* `schema_floor` — §2.4 required-schema answer;
|
|
31
|
+
* `reset` — §2.1 log-epoch reset answer;
|
|
31
32
|
* `rejected` — request validation failed before any bytes (§1.7);
|
|
32
33
|
* `error` — in-band ERROR frame (§1.6) or a thrown host failure.
|
|
33
34
|
*/
|
|
34
|
-
readonly outcome: 'ok' | 'schema_floor' | 'rejected' | 'error';
|
|
35
|
+
readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
|
|
35
36
|
/** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
|
|
36
37
|
readonly errorCode?: string;
|
|
37
38
|
readonly pushCommits: number;
|
package/dist/frame-bytes.d.ts
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { type ResponseFrame } from '@syncular/core';
|
|
12
12
|
/** The 8-byte SSP2 response envelope header (§1.2). */
|
|
13
|
-
export declare
|
|
13
|
+
export declare function responseEnvelopeHeader(wireVersion: number): Uint8Array;
|
|
14
14
|
/** The terminating END frame (§1.2 rule 1). */
|
|
15
15
|
export declare const END_FRAME_BYTES: Uint8Array;
|
|
16
16
|
/**
|
|
17
17
|
* Encode one response frame (5-byte frame header + payload) using the
|
|
18
18
|
* reference codec.
|
|
19
19
|
*/
|
|
20
|
-
export declare function encodeResponseFrame(frame: ResponseFrame): Uint8Array;
|
|
20
|
+
export declare function encodeResponseFrame(frame: ResponseFrame, wireVersion?: number): Uint8Array;
|
package/dist/frame-bytes.js
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* bytes; the reference codec remains the single source of wire bytes.
|
|
10
10
|
*/
|
|
11
11
|
import { encodeMessage, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
12
|
-
const
|
|
12
|
+
const stubHeader = (wireVersion) => wireVersion >= 2
|
|
13
|
+
? {
|
|
14
|
+
type: 'RESP_HEADER',
|
|
15
|
+
logEpoch: 'frame-probe',
|
|
16
|
+
resetRequired: false,
|
|
17
|
+
}
|
|
18
|
+
: { type: 'RESP_HEADER' };
|
|
13
19
|
const STUB_SUB_START = {
|
|
14
20
|
type: 'SUB_START',
|
|
15
21
|
id: '',
|
|
@@ -20,25 +26,32 @@ const STUB_SUB_START = {
|
|
|
20
26
|
};
|
|
21
27
|
const STUB_SUB_END = { type: 'SUB_END', nextCursor: 0 };
|
|
22
28
|
const probe = encodeMessage({
|
|
23
|
-
wireVersion:
|
|
29
|
+
wireVersion: 1,
|
|
24
30
|
msgKind: 'response',
|
|
25
|
-
frames: [
|
|
31
|
+
frames: [stubHeader(1)],
|
|
26
32
|
});
|
|
27
33
|
/** The 8-byte SSP2 response envelope header (§1.2). */
|
|
28
|
-
export
|
|
34
|
+
export function responseEnvelopeHeader(wireVersion) {
|
|
35
|
+
const encoded = encodeMessage({
|
|
36
|
+
wireVersion,
|
|
37
|
+
msgKind: 'response',
|
|
38
|
+
frames: [stubHeader(wireVersion)],
|
|
39
|
+
});
|
|
40
|
+
return encoded.slice(0, 8);
|
|
41
|
+
}
|
|
29
42
|
/** The terminating END frame (§1.2 rule 1). */
|
|
30
43
|
export const END_FRAME_BYTES = probe.slice(probe.length - 5);
|
|
31
|
-
function wrapperFor(frame) {
|
|
44
|
+
function wrapperFor(frame, wireVersion) {
|
|
32
45
|
switch (frame.type) {
|
|
33
46
|
case 'RESP_HEADER':
|
|
34
47
|
return { frames: [frame], index: 0 };
|
|
35
48
|
case 'LEASE':
|
|
36
49
|
// §7.3.2: LEASE rides immediately after RESP_HEADER.
|
|
37
|
-
return { frames: [
|
|
50
|
+
return { frames: [stubHeader(wireVersion), frame], index: 1 };
|
|
38
51
|
case 'PUSH_RESULT':
|
|
39
52
|
case 'ERROR':
|
|
40
53
|
case 'UNKNOWN':
|
|
41
|
-
return { frames: [
|
|
54
|
+
return { frames: [stubHeader(wireVersion), frame], index: 1 };
|
|
42
55
|
case 'PUSH_RESULT_DETAILS': {
|
|
43
56
|
const result = {
|
|
44
57
|
type: 'PUSH_RESULT',
|
|
@@ -52,17 +65,23 @@ function wrapperFor(frame) {
|
|
|
52
65
|
retryable: false,
|
|
53
66
|
})),
|
|
54
67
|
};
|
|
55
|
-
return { frames: [
|
|
68
|
+
return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
|
|
56
69
|
}
|
|
57
70
|
case 'SUB_START':
|
|
58
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
|
|
73
|
+
index: 1,
|
|
74
|
+
};
|
|
59
75
|
case 'SUB_END':
|
|
60
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
frames: [stubHeader(wireVersion), STUB_SUB_START, frame],
|
|
78
|
+
index: 2,
|
|
79
|
+
};
|
|
61
80
|
case 'COMMIT':
|
|
62
81
|
case 'SEGMENT_REF':
|
|
63
82
|
case 'SEGMENT_INLINE':
|
|
64
83
|
return {
|
|
65
|
-
frames: [
|
|
84
|
+
frames: [stubHeader(wireVersion), STUB_SUB_START, frame, STUB_SUB_END],
|
|
66
85
|
index: 2,
|
|
67
86
|
};
|
|
68
87
|
}
|
|
@@ -71,10 +90,10 @@ function wrapperFor(frame) {
|
|
|
71
90
|
* Encode one response frame (5-byte frame header + payload) using the
|
|
72
91
|
* reference codec.
|
|
73
92
|
*/
|
|
74
|
-
export function encodeResponseFrame(frame) {
|
|
75
|
-
const { frames, index } = wrapperFor(frame);
|
|
93
|
+
export function encodeResponseFrame(frame, wireVersion = PROTOCOL_WIRE_VERSION) {
|
|
94
|
+
const { frames, index } = wrapperFor(frame, wireVersion);
|
|
76
95
|
const encoded = encodeMessage({
|
|
77
|
-
wireVersion
|
|
96
|
+
wireVersion,
|
|
78
97
|
msgKind: 'response',
|
|
79
98
|
frames,
|
|
80
99
|
});
|
package/dist/handler.js
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* frame (§1.6).
|
|
11
11
|
*/
|
|
12
12
|
import { DecodeError, decodeMessage, } from '@syncular/core';
|
|
13
|
-
import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, } from './context.js';
|
|
13
|
+
import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
14
14
|
import { SyncError, syncError } from './errors.js';
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
|
-
import { END_FRAME_BYTES, encodeResponseFrame,
|
|
16
|
+
import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
|
|
17
17
|
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
|
|
18
18
|
import { processPushCommitWithTrace } from './push.js';
|
|
19
19
|
import { compileSchema } from './schema.js';
|
|
@@ -96,7 +96,7 @@ async function resolveOnce(ctx, clientId, schema) {
|
|
|
96
96
|
}
|
|
97
97
|
return { resolved: { ok: true, allowed }, leaseToEmit };
|
|
98
98
|
}
|
|
99
|
-
async function planRequest(request, ctx, schema) {
|
|
99
|
+
async function planRequest(request, ctx, schema, registry) {
|
|
100
100
|
const header = request.frames[0];
|
|
101
101
|
if (header === undefined || header.type !== 'REQ_HEADER') {
|
|
102
102
|
throw syncError('sync.invalid_request', 'missing REQ_HEADER');
|
|
@@ -112,10 +112,22 @@ async function planRequest(request, ctx, schema) {
|
|
|
112
112
|
else if (frame.type === 'SUBSCRIPTION')
|
|
113
113
|
subFrames.push(frame);
|
|
114
114
|
}
|
|
115
|
+
if (request.wireVersion === 1 && registry.epochRequired) {
|
|
116
|
+
throw syncError('sync.client_wire_unsupported', 'this partition requires a client that validates log epochs (§2.1)');
|
|
117
|
+
}
|
|
118
|
+
if (request.wireVersion >= 2 &&
|
|
119
|
+
header.logEpoch === undefined &&
|
|
120
|
+
pushes.length > 0) {
|
|
121
|
+
throw syncError('sync.invalid_request', 'epoch acquisition requests must not carry push commits (§2.1)');
|
|
122
|
+
}
|
|
123
|
+
const epochReset = request.wireVersion >= 2 && header.logEpoch !== registry.logEpoch;
|
|
115
124
|
if (header.schemaVersion !== schema.version) {
|
|
116
125
|
// §2.4: no degraded encoding — answer with the schema floor (§1.6).
|
|
117
126
|
// No lease is issued on a floor round (§7.3.3).
|
|
118
127
|
return {
|
|
128
|
+
wireVersion: request.wireVersion,
|
|
129
|
+
logEpoch: registry.logEpoch,
|
|
130
|
+
epochReset,
|
|
119
131
|
header,
|
|
120
132
|
pushes,
|
|
121
133
|
pull,
|
|
@@ -125,6 +137,20 @@ async function planRequest(request, ctx, schema) {
|
|
|
125
137
|
leaseToEmit: undefined,
|
|
126
138
|
};
|
|
127
139
|
}
|
|
140
|
+
if (epochReset) {
|
|
141
|
+
return {
|
|
142
|
+
wireVersion: request.wireVersion,
|
|
143
|
+
logEpoch: registry.logEpoch,
|
|
144
|
+
epochReset: true,
|
|
145
|
+
header,
|
|
146
|
+
pushes: [],
|
|
147
|
+
pull: undefined,
|
|
148
|
+
subscriptions: [],
|
|
149
|
+
resolved: { ok: false },
|
|
150
|
+
schemaFloor: false,
|
|
151
|
+
leaseToEmit: undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
128
154
|
if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
129
155
|
throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
|
|
130
156
|
}
|
|
@@ -181,6 +207,9 @@ async function planRequest(request, ctx, schema) {
|
|
|
181
207
|
return { frame, table, status: 'revoked', effective: {} };
|
|
182
208
|
});
|
|
183
209
|
return {
|
|
210
|
+
wireVersion: request.wireVersion,
|
|
211
|
+
logEpoch: registry.logEpoch,
|
|
212
|
+
epochReset: false,
|
|
184
213
|
header,
|
|
185
214
|
pushes,
|
|
186
215
|
pull,
|
|
@@ -256,7 +285,7 @@ function emitPushEvent(events, ctx, clientId, push, processed) {
|
|
|
256
285
|
}
|
|
257
286
|
async function* streamResponse(plan, ctx, schema, report) {
|
|
258
287
|
const events = ctx.events;
|
|
259
|
-
yield
|
|
288
|
+
yield responseEnvelopeHeader(plan.wireVersion);
|
|
260
289
|
if (plan.schemaFloor) {
|
|
261
290
|
if (report !== undefined)
|
|
262
291
|
report.outcome = 'schema_floor';
|
|
@@ -264,21 +293,33 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
264
293
|
type: 'RESP_HEADER',
|
|
265
294
|
requiredSchemaVersion: schema.version,
|
|
266
295
|
latestSchemaVersion: schema.version,
|
|
267
|
-
|
|
296
|
+
...(plan.wireVersion >= 2
|
|
297
|
+
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
298
|
+
: {}),
|
|
299
|
+
}, plan.wireVersion);
|
|
268
300
|
yield END_FRAME_BYTES;
|
|
269
301
|
return;
|
|
270
302
|
}
|
|
271
303
|
yield encodeResponseFrame({
|
|
272
304
|
type: 'RESP_HEADER',
|
|
273
305
|
latestSchemaVersion: schema.version,
|
|
274
|
-
|
|
306
|
+
...(plan.wireVersion >= 2
|
|
307
|
+
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
308
|
+
: {}),
|
|
309
|
+
}, plan.wireVersion);
|
|
310
|
+
if (plan.epochReset) {
|
|
311
|
+
if (report !== undefined)
|
|
312
|
+
report.outcome = 'reset';
|
|
313
|
+
yield END_FRAME_BYTES;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
275
316
|
// §7.3.2: the LEASE frame rides immediately after RESP_HEADER.
|
|
276
317
|
if (plan.leaseToEmit !== undefined) {
|
|
277
318
|
yield encodeResponseFrame({
|
|
278
319
|
type: 'LEASE',
|
|
279
320
|
leaseId: plan.leaseToEmit.leaseId,
|
|
280
321
|
expiresAtMs: plan.leaseToEmit.expiresAtMs,
|
|
281
|
-
});
|
|
322
|
+
}, plan.wireVersion);
|
|
282
323
|
if (events !== undefined) {
|
|
283
324
|
emitEvent(events, {
|
|
284
325
|
type: 'lease.issued',
|
|
@@ -299,10 +340,11 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
299
340
|
if (events !== undefined) {
|
|
300
341
|
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
301
342
|
}
|
|
302
|
-
yield encodeResponseFrame(frame);
|
|
343
|
+
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
303
344
|
const details = pushResultDetailsFrame(frame);
|
|
304
|
-
if (details !== undefined)
|
|
305
|
-
yield encodeResponseFrame(details);
|
|
345
|
+
if (details !== undefined) {
|
|
346
|
+
yield encodeResponseFrame(details, plan.wireVersion);
|
|
347
|
+
}
|
|
306
348
|
}
|
|
307
349
|
// Pull half (§4): subscriptions echoed in request order.
|
|
308
350
|
const cursors = [];
|
|
@@ -313,7 +355,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
313
355
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
314
356
|
for (const subscription of plan.subscriptions) {
|
|
315
357
|
const trace = summaries !== undefined ? { segments: [] } : undefined;
|
|
316
|
-
const section = subscriptionSection(ctx, schema, limits, subscription, maxSeq, horizonSeq, trace);
|
|
358
|
+
const section = subscriptionSection(ctx, schema, limits, subscription, maxSeq, horizonSeq, trace, plan.logEpoch);
|
|
317
359
|
let status = 'active';
|
|
318
360
|
let bootstrap = false;
|
|
319
361
|
let commits = 0;
|
|
@@ -331,7 +373,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
331
373
|
changes += frame.changes.length;
|
|
332
374
|
}
|
|
333
375
|
}
|
|
334
|
-
yield encodeResponseFrame(frame);
|
|
376
|
+
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
335
377
|
step = await section.next();
|
|
336
378
|
}
|
|
337
379
|
if (step.value.active)
|
|
@@ -379,6 +421,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
379
421
|
await ctx.storage.putClientRecord(ctx.partition, {
|
|
380
422
|
clientId: plan.header.clientId,
|
|
381
423
|
actorId: ctx.actorId,
|
|
424
|
+
wireVersion: plan.wireVersion,
|
|
382
425
|
cursor,
|
|
383
426
|
updatedAtMs: clockOf(ctx)(),
|
|
384
427
|
subscriptions,
|
|
@@ -399,7 +442,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
399
442
|
retryable: error.retryable,
|
|
400
443
|
recommendedAction: error.recommendedAction,
|
|
401
444
|
...(error.details !== undefined ? { details: error.details } : {}),
|
|
402
|
-
});
|
|
445
|
+
}, plan.wireVersion);
|
|
403
446
|
yield END_FRAME_BYTES;
|
|
404
447
|
return;
|
|
405
448
|
}
|
|
@@ -461,7 +504,8 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
461
504
|
// Relational row tables: create/
|
|
462
505
|
// migrate on first contact; memoized per storage instance thereafter.
|
|
463
506
|
await ctx.storage.ensureSchema(schema);
|
|
464
|
-
const
|
|
507
|
+
const registry = await touchAuthenticatedPartition(ctx);
|
|
508
|
+
const plan = await planRequest(request, ctx, schema, registry);
|
|
465
509
|
if (events === undefined)
|
|
466
510
|
return streamResponse(plan, ctx, schema);
|
|
467
511
|
const report = { outcome: 'ok' };
|