@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.
Files changed (91) hide show
  1. package/README.md +17 -4
  2. package/dist/admin.d.ts +1 -5
  3. package/dist/admin.js +2 -7
  4. package/dist/blob-handlers.js +4 -1
  5. package/dist/context.d.ts +5 -2
  6. package/dist/context.js +4 -0
  7. package/dist/d1-storage.d.ts +4 -1
  8. package/dist/d1-storage.js +75 -11
  9. package/dist/errors.js +6 -0
  10. package/dist/events.d.ts +2 -1
  11. package/dist/frame-bytes.d.ts +2 -2
  12. package/dist/frame-bytes.js +33 -14
  13. package/dist/handler.js +58 -14
  14. package/dist/index-bun.d.ts +2 -0
  15. package/dist/index-bun.js +2 -0
  16. package/dist/index-node.d.ts +2 -0
  17. package/dist/index-node.js +2 -0
  18. package/dist/index.d.ts +4 -2
  19. package/dist/index.js +4 -6
  20. package/dist/operations.js +2 -1
  21. package/dist/postgres-storage.d.ts +5 -2
  22. package/dist/postgres-storage.js +71 -10
  23. package/dist/pull.d.ts +1 -1
  24. package/dist/pull.js +12 -6
  25. package/dist/realtime.d.ts +4 -1
  26. package/dist/realtime.js +33 -9
  27. package/dist/restore.d.ts +13 -0
  28. package/dist/restore.js +13 -0
  29. package/dist/s3-segment-store.js +10 -1
  30. package/dist/seed.js +36 -4
  31. package/dist/segment-download.js +5 -2
  32. package/dist/segment-store.d.ts +3 -0
  33. package/dist/segment-store.js +1 -0
  34. package/dist/sqlite-blob-store.d.ts +4 -9
  35. package/dist/sqlite-blob-store.js +5 -10
  36. package/dist/sqlite-bun-driver.d.ts +12 -0
  37. package/dist/sqlite-bun-driver.js +30 -0
  38. package/dist/sqlite-bun.d.ts +24 -0
  39. package/dist/sqlite-bun.js +40 -0
  40. package/dist/sqlite-dialect.d.ts +8 -8
  41. package/dist/sqlite-dialect.js +9 -2
  42. package/dist/sqlite-driver.d.ts +26 -0
  43. package/dist/sqlite-driver.js +8 -0
  44. package/dist/sqlite-image.d.ts +7 -9
  45. package/dist/sqlite-image.js +26 -28
  46. package/dist/sqlite-lease-store.d.ts +4 -9
  47. package/dist/sqlite-lease-store.js +5 -10
  48. package/dist/sqlite-node-driver.d.ts +10 -0
  49. package/dist/sqlite-node-driver.js +30 -0
  50. package/dist/sqlite-node.d.ts +24 -0
  51. package/dist/sqlite-node.js +50 -0
  52. package/dist/sqlite-segment-store.d.ts +4 -10
  53. package/dist/sqlite-segment-store.js +20 -14
  54. package/dist/sqlite-storage.d.ts +7 -12
  55. package/dist/sqlite-storage.js +89 -16
  56. package/dist/storage-errors.js +4 -1
  57. package/dist/storage.d.ts +18 -5
  58. package/package.json +18 -3
  59. package/src/admin.ts +3 -9
  60. package/src/blob-handlers.ts +8 -1
  61. package/src/context.ts +18 -2
  62. package/src/d1-storage.ts +107 -12
  63. package/src/errors.ts +6 -0
  64. package/src/events.ts +2 -1
  65. package/src/frame-bytes.ts +40 -14
  66. package/src/handler.ts +102 -29
  67. package/src/index-bun.ts +9 -0
  68. package/src/index-node.ts +9 -0
  69. package/src/index.ts +9 -6
  70. package/src/operations.ts +6 -1
  71. package/src/postgres-storage.ts +102 -13
  72. package/src/pull.ts +12 -1
  73. package/src/realtime.ts +46 -7
  74. package/src/restore.ts +28 -0
  75. package/src/s3-segment-store.ts +10 -1
  76. package/src/seed.ts +46 -4
  77. package/src/segment-download.ts +11 -2
  78. package/src/segment-store.ts +4 -0
  79. package/src/sqlite-blob-store.ts +11 -10
  80. package/src/sqlite-bun-driver.ts +46 -0
  81. package/src/sqlite-bun.ts +53 -0
  82. package/src/sqlite-dialect.ts +14 -7
  83. package/src/sqlite-driver.ts +44 -0
  84. package/src/sqlite-image.ts +44 -49
  85. package/src/sqlite-lease-store.ts +11 -10
  86. package/src/sqlite-node-driver.ts +46 -0
  87. package/src/sqlite-node.ts +62 -0
  88. package/src/sqlite-segment-store.ts +29 -15
  89. package/src/sqlite-storage.ts +131 -19
  90. package/src/storage-errors.ts +4 -1
  91. 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** | `@syncular/server-hono` | HTTP (`POST /sync`, segments, blobs) **+ WS realtime** (§8, host-driven upgrade) | any: `SqliteServerStorage`, `PostgresServerStorage`, memory | **Supported now** — the reference deployment; runs the full conformance catalog on both bindings. |
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
- | `listPartitions()` / `partitionsOverview()` | Every partition the storage holds state for; the overview adds retained backlog, client counts (known/active), and the prune recommendation per partition — the fleet view. |
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` (bun:sqlite) is the dev-speed default. For
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
- const list = required(this.#storage.listPartitions?.bind(this.#storage), 'storage');
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
@@ -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/Node host wires `buildSqliteImage` from `@syncular/server`.
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
+ }
@@ -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>;
@@ -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
- // Union: the registry row appears on first commit, the client row on
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;
@@ -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 const RESPONSE_ENVELOPE_HEADER: Uint8Array;
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;
@@ -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 STUB_HEADER = { type: 'RESP_HEADER' };
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: PROTOCOL_WIRE_VERSION,
29
+ wireVersion: 1,
24
30
  msgKind: 'response',
25
- frames: [STUB_HEADER],
31
+ frames: [stubHeader(1)],
26
32
  });
27
33
  /** The 8-byte SSP2 response envelope header (§1.2). */
28
- export const RESPONSE_ENVELOPE_HEADER = probe.slice(0, 8);
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: [STUB_HEADER, frame], index: 1 };
50
+ return { frames: [stubHeader(wireVersion), frame], index: 1 };
38
51
  case 'PUSH_RESULT':
39
52
  case 'ERROR':
40
53
  case 'UNKNOWN':
41
- return { frames: [STUB_HEADER, frame], index: 1 };
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: [STUB_HEADER, result, frame], index: 2 };
68
+ return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
56
69
  }
57
70
  case 'SUB_START':
58
- return { frames: [STUB_HEADER, frame, STUB_SUB_END], index: 1 };
71
+ return {
72
+ frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
73
+ index: 1,
74
+ };
59
75
  case 'SUB_END':
60
- return { frames: [STUB_HEADER, STUB_SUB_START, frame], index: 2 };
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: [STUB_HEADER, STUB_SUB_START, frame, STUB_SUB_END],
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: PROTOCOL_WIRE_VERSION,
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, RESPONSE_ENVELOPE_HEADER, } from './frame-bytes.js';
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 RESPONSE_ENVELOPE_HEADER;
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 plan = await planRequest(request, ctx, schema);
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' };
@@ -0,0 +1,2 @@
1
+ export * from './index.js';
2
+ export { buildSqliteImage, BunSqliteDatabase, SqliteBlobStore, SqliteLeaseStore, SqliteSegmentStore, SqliteServerStorage, } from './sqlite-bun.js';
@@ -0,0 +1,2 @@
1
+ export * from './index.js';
2
+ export { buildSqliteImage, BunSqliteDatabase, SqliteBlobStore, SqliteLeaseStore, SqliteSegmentStore, SqliteServerStorage, } from './sqlite-bun.js';
@@ -0,0 +1,2 @@
1
+ export * from './index.js';
2
+ export { buildSqliteImage, NodeSqliteDatabase, SqliteBlobStore, SqliteLeaseStore, SqliteSegmentStore, SqliteServerStorage, } from './sqlite-node.js';
@@ -0,0 +1,2 @@
1
+ export * from './index.js';
2
+ export { buildSqliteImage, NodeSqliteDatabase, SqliteBlobStore, SqliteLeaseStore, SqliteSegmentStore, SqliteServerStorage, } from './sqlite-node.js';