@syncular/server 0.22.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.22.0",
3
+ "version": "0.23.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.22.0"
71
+ "@syncular/core": "0.23.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/admin.ts CHANGED
@@ -22,6 +22,7 @@ import { DEFAULT_RETENTION, type RetentionPolicy } from './prune';
22
22
  import { compileSchema, type ServerSchema } from './schema';
23
23
  import type { SegmentStore, SegmentStoreStats } from './segment-store';
24
24
  import type {
25
+ CheckpointDeclaration,
25
26
  ClientRecord,
26
27
  CommitMetadata,
27
28
  ReactionStatus,
@@ -157,6 +158,8 @@ export interface SyncularAdminOptions {
157
158
  * a storage instance that has not served a sync request yet.
158
159
  */
159
160
  readonly schema?: ServerSchema;
161
+ /** RFC 0007: declarations `inspectRow` carries into `ensureSchema`. */
162
+ readonly checkpoints?: readonly CheckpointDeclaration[];
160
163
  /** The event ring feeding the event tail. Absent ⇒ `events()` is empty. */
161
164
  readonly ring?: RingBufferEvents;
162
165
  readonly segments?: SegmentStore;
@@ -218,6 +221,7 @@ function toAdminClient(
218
221
  export class SyncularAdmin {
219
222
  readonly #storage: ServerStorage;
220
223
  readonly #schema?: ServerSchema;
224
+ readonly #checkpoints?: readonly CheckpointDeclaration[];
221
225
  readonly #ring?: RingBufferEvents;
222
226
  readonly #segments?: SegmentStore;
223
227
  readonly #blobs?: BlobStore;
@@ -228,6 +232,8 @@ export class SyncularAdmin {
228
232
  constructor(options: SyncularAdminOptions) {
229
233
  this.#storage = options.storage;
230
234
  if (options.schema !== undefined) this.#schema = options.schema;
235
+ if (options.checkpoints !== undefined)
236
+ this.#checkpoints = options.checkpoints;
231
237
  if (options.ring !== undefined) this.#ring = options.ring;
232
238
  if (options.segments !== undefined) this.#segments = options.segments;
233
239
  if (options.blobs !== undefined) this.#blobs = options.blobs;
@@ -248,6 +254,9 @@ export class SyncularAdmin {
248
254
  return new SyncularAdmin({
249
255
  storage: config.storage,
250
256
  schema: config.schema,
257
+ ...(config.checkpoints !== undefined
258
+ ? { checkpoints: config.checkpoints }
259
+ : {}),
251
260
  segments: config.segments,
252
261
  ...(config.blobs !== undefined ? { blobs: config.blobs } : {}),
253
262
  ...(config.leases !== undefined ? { leases: config.leases.store } : {}),
@@ -352,7 +361,10 @@ export class SyncularAdmin {
352
361
  'storage',
353
362
  );
354
363
  if (this.#schema !== undefined) {
355
- await this.#storage.ensureSchema(compileSchema(this.#schema));
364
+ await this.#storage.ensureSchema(
365
+ compileSchema(this.#schema),
366
+ this.#checkpoints,
367
+ );
356
368
  }
357
369
  const row = await read(partition, table, rowId);
358
370
  if (row === undefined) {
@@ -233,7 +233,7 @@ async function downloadBlob(
233
233
  const schema = compileSchema(ctx.schema);
234
234
  // A blob download can hit a fresh storage instance before any sync
235
235
  // request has run (Workers): make sure the row tables exist first.
236
- await ctx.storage.ensureSchema(schema);
236
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
237
237
  const rows = await listRows.call(ctx.storage, ctx.partition, blobId);
238
238
  let authorized = false;
239
239
  for (const row of rows) {
package/src/context.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  } from './signed-url';
20
20
  import type { SqliteImageBuilder } from './sqlite-image';
21
21
  import type {
22
+ CheckpointDeclaration,
22
23
  PartitionRegistryEntry,
23
24
  ServerStorage,
24
25
  StoredCommit,
@@ -96,6 +97,13 @@ export interface RealtimeNotifier {
96
97
  export interface SyncServerConfig {
97
98
  readonly schema: ServerSchema;
98
99
  readonly storage: ServerStorage;
100
+ /**
101
+ * RFC 0007: the backfill checkpoints this host installs. Every readiness
102
+ * path carries this set into `ensureSchema`. A process that omits it
103
+ * declares nothing, and the storage refuses to serve a partition whose
104
+ * stored state shows a checkpoint that is not activated.
105
+ */
106
+ readonly checkpoints?: readonly CheckpointDeclaration[];
99
107
  readonly segments: SegmentStore;
100
108
  /**
101
109
  * Blob store (§5.9). Absent ⇒ blobs unsupported: a table with a
package/src/d1-storage.ts CHANGED
@@ -90,6 +90,7 @@ import {
90
90
  import type {
91
91
  AuthoritativeQueryRequest,
92
92
  AuthoritativeQueryResult,
93
+ CheckpointDeclaration,
93
94
  ClientCursorInfo,
94
95
  ClientRecord,
95
96
  ClientSubscription,
@@ -111,8 +112,10 @@ import type {
111
112
  ScopeActivityQuery,
112
113
  ScopeCommitActivity,
113
114
  ServerStorage,
115
+ ServeGate,
114
116
  StorageTransaction,
115
117
  StoredCommit,
118
+ StoredCheckpoint,
116
119
  StoredPushResult,
117
120
  StoredReaction,
118
121
  StoredRow,
@@ -447,6 +450,31 @@ class D1Transaction implements StorageTransaction {
447
450
  this.#pushApplyCheckpoint = this.#buffer.length;
448
451
  }
449
452
 
453
+ advanceCheckpoint(
454
+ _name: string,
455
+ _ownerEpoch: number,
456
+ _watermark: number,
457
+ _observedRows: number,
458
+ _nowMs: number,
459
+ ): Promise<boolean> {
460
+ // D1 installs no fence and declares no checkpoints in this release.
461
+ return Promise.reject(
462
+ new StorageQueryError('sync.storage.checkpoint_unsupported'),
463
+ );
464
+ }
465
+
466
+ async readServeGate(_runningSchemaVersion: number): Promise<ServeGate> {
467
+ this.#assertOpen();
468
+ const marker = await this.#db
469
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
470
+ .first<{ schema_version: number }>();
471
+ return {
472
+ storedSchemaVersion: marker?.schema_version ?? 0,
473
+ logEpoch: undefined,
474
+ pending: [],
475
+ };
476
+ }
477
+
450
478
  async commitRejectedPushResult(
451
479
  clientId: string,
452
480
  clientCommitId: string,
@@ -834,7 +862,15 @@ export class D1ServerStorage implements ServerStorage {
834
862
  return table;
835
863
  }
836
864
 
837
- async ensureSchema(schema: CompiledSchema): Promise<void> {
865
+ async ensureSchema(
866
+ schema: CompiledSchema,
867
+ checkpoints?: readonly CheckpointDeclaration[],
868
+ ): Promise<void> {
869
+ if (checkpoints !== undefined && checkpoints.length > 0) {
870
+ // No database-side fence can be installed atomically with schema
871
+ // visibility here; refuse instead of accepting a weaker guarantee.
872
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
873
+ }
838
874
  if (this.#schemaVersion === schema.version) {
839
875
  await this.#schemaDatabase().batch([]);
840
876
  return;
@@ -1427,7 +1463,11 @@ export class D1ServerStorage implements ServerStorage {
1427
1463
  async queryAuthoritative(
1428
1464
  partition: string,
1429
1465
  query: AuthoritativeQueryRequest,
1466
+ _checkpoints?: readonly CheckpointDeclaration[],
1430
1467
  ): Promise<AuthoritativeQueryResult> {
1468
+ // D1 declares no checkpoints. `#schemaDatabase` prepends a guard that
1469
+ // re-checks the published marker inside this same batch, so condition 2
1470
+ // is already evaluated with the query (§2.4 D1 schema readiness).
1431
1471
  const db = this.#schemaDatabase();
1432
1472
  if (this.#tables === undefined) {
1433
1473
  throw new Error(
@@ -1591,6 +1631,108 @@ export class D1ServerStorage implements ServerStorage {
1591
1631
  return row?.seq ?? 0;
1592
1632
  }
1593
1633
 
1634
+ async readCheckpoints(_partition: string): Promise<StoredCheckpoint[]> {
1635
+ // D1 supports no declared backfill checkpoints in this release: migrations
1636
+ // run out of band and no database-side fence can be installed atomically
1637
+ // with schema visibility. No checkpoint row can exist.
1638
+ return [];
1639
+ }
1640
+
1641
+ async readServeGate(
1642
+ partition: string,
1643
+ _runningSchemaVersion: number,
1644
+ ): Promise<ServeGate> {
1645
+ // D1 has no checkpoints. The published marker is the only gate input;
1646
+ // `#schemaDatabase` already re-checks it per protected batch.
1647
+ const marker = await this.#db
1648
+ .prepare('SELECT schema_version FROM sync_schema_meta WHERE id=1')
1649
+ .first<{ schema_version: number }>();
1650
+ return {
1651
+ storedSchemaVersion: marker?.schema_version ?? 0,
1652
+ logEpoch: await this.getPartitionLogEpoch(partition),
1653
+ pending: [],
1654
+ };
1655
+ }
1656
+
1657
+ async declareCheckpoint(
1658
+ _partition: string,
1659
+ _name: string,
1660
+ _schemaVersion: number,
1661
+ _nowMs: number,
1662
+ ): Promise<StoredCheckpoint> {
1663
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1664
+ }
1665
+
1666
+ async activateCheckpoint(
1667
+ _partition: string,
1668
+ _name: string,
1669
+ _ownerEpoch: number,
1670
+ _watermark: number,
1671
+ _sources: readonly string[],
1672
+ _nowMs: number,
1673
+ ): Promise<'activated' | 'stale' | 'unverifiable'> {
1674
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1675
+ }
1676
+
1677
+ async claimCheckpoint(
1678
+ _partition: string,
1679
+ _name: string,
1680
+ _schemaVersion: number,
1681
+ _nowMs: number,
1682
+ ): Promise<StoredCheckpoint> {
1683
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1684
+ }
1685
+
1686
+ async advanceCheckpoint(
1687
+ _partition: string,
1688
+ _name: string,
1689
+ _ownerEpoch: number,
1690
+ _watermark: number,
1691
+ _observedRows: number,
1692
+ _nowMs: number,
1693
+ ): Promise<boolean> {
1694
+ throw new StorageQueryError('sync.storage.checkpoint_unsupported');
1695
+ }
1696
+
1697
+ async sourceCoverageSeq(
1698
+ partition: string,
1699
+ tables: readonly string[],
1700
+ ): Promise<number> {
1701
+ if (tables.length === 0) return 0;
1702
+ const tableParams = tables.map(() => '?').join(',');
1703
+ const row = await this.#db
1704
+ .prepare(
1705
+ `SELECT max(commit_seq) AS seq FROM sync_changes
1706
+ WHERE partition=? AND tbl IN (${tableParams})`,
1707
+ )
1708
+ .bind(partition, ...tables)
1709
+ .first<{ seq: number | null }>();
1710
+ return row?.seq ?? 0;
1711
+ }
1712
+
1713
+ async hasSourceChangesAbove(
1714
+ partition: string,
1715
+ tables: readonly string[],
1716
+ seq: number,
1717
+ ): Promise<'clean' | 'changed' | 'unverifiable'> {
1718
+ // Read the horizon before the window scan: a horizon past `seq` means the
1719
+ // history that would answer the question is gone, so an empty scan proves
1720
+ // nothing and must not be reported as `clean`.
1721
+ const horizon = await this.getHorizonSeq(partition);
1722
+ if (tables.length === 0) return 'clean';
1723
+ const tableParams = tables.map(() => '?').join(',');
1724
+ const hit = await this.#db
1725
+ .prepare(
1726
+ `SELECT 1 AS hit FROM sync_changes
1727
+ WHERE partition=? AND tbl IN (${tableParams}) AND commit_seq>?
1728
+ LIMIT 1`,
1729
+ )
1730
+ .bind(partition, ...tables, seq)
1731
+ .first<{ hit: number }>();
1732
+ if (hit !== null) return 'changed';
1733
+ return horizon > seq ? 'unverifiable' : 'clean';
1734
+ }
1735
+
1594
1736
  async getRow(
1595
1737
  partition: string,
1596
1738
  table: string,
package/src/errors.ts CHANGED
@@ -167,6 +167,16 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
167
167
  recommendedAction: 'retryLater',
168
168
  httpStatus: 500,
169
169
  },
170
+ // §2.4 on a non-D1 backend: the server refuses while a declared backfill
171
+ // checkpoint is incomplete or the database is newer than the running build.
172
+ // Formerly host-only; promoted to the §10.2 catalogue so an in-flight
173
+ // request can be refused with a distinguishable identity.
174
+ 'sync.schema_not_ready': {
175
+ category: 'internal',
176
+ retryable: true,
177
+ recommendedAction: 'retryLater',
178
+ httpStatus: 503,
179
+ },
170
180
  'sync.too_many_operations': {
171
181
  category: 'invalid-request',
172
182
  retryable: false,
package/src/handler.ts CHANGED
@@ -51,10 +51,17 @@ import {
51
51
  subscriptionSection,
52
52
  } from './pull';
53
53
  import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
54
+ import { serveNotReadyError } from './readiness';
54
55
  import type { CompiledSchema } from './schema';
55
56
  import { compileSchema } from './schema';
56
57
  import { computeEffective, type ResolvedScopes } from './scopes';
57
- import type { ClientSubscription, PartitionRegistryEntry } from './storage';
58
+ import { StorageQueryError } from './storage-errors';
59
+ import {
60
+ type ClientSubscription,
61
+ type PartitionRegistryEntry,
62
+ serveGateRefusal,
63
+ serveGateTokenChanged,
64
+ } from './storage';
58
65
 
59
66
  interface RequestPlan {
60
67
  readonly wireVersion: number;
@@ -709,13 +716,64 @@ async function createStreamCore(
709
716
  const schema = compileSchema(ctx.schema);
710
717
  // Relational row tables: create/
711
718
  // migrate on first contact; memoized per storage instance thereafter.
712
- await ctx.storage.ensureSchema(schema);
719
+ try {
720
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
721
+ } catch (error) {
722
+ if (
723
+ error instanceof StorageQueryError &&
724
+ error.code === 'sync.storage.checkpoint_incomplete'
725
+ ) {
726
+ const refusal = serveGateRefusal(
727
+ await ctx.storage.readServeGate(ctx.partition, schema.version),
728
+ schema.version,
729
+ ctx.checkpoints,
730
+ );
731
+ throw refusal !== undefined ? serveNotReadyError(refusal) : error;
732
+ }
733
+ throw error;
734
+ }
735
+ // RFC 0007 serve gate: re-read stored state on every request so an
736
+ // already-serving process notices a migration or a newly declared
737
+ // checkpoint. Read after `touchAuthenticatedPartition` establishes the
738
+ // partition's log epoch, so the entry token is the epoch this request
739
+ // serves under. Nothing has escaped at this point; the push transaction
740
+ // re-evaluates the gate under the partition lock.
713
741
  const registry = await touchAuthenticatedPartition(ctx);
742
+ const gate = await ctx.storage.readServeGate(ctx.partition, schema.version);
743
+ const refusal = serveGateRefusal(gate, schema.version, ctx.checkpoints);
744
+ if (refusal !== undefined) throw serveNotReadyError(refusal);
714
745
  const plan = await planRequest(request, ctx, schema, registry);
715
- if (events === undefined) return streamResponse(plan, ctx, schema);
716
746
  const report: RequestReport = { outcome: 'ok' };
747
+ // RFC 0007 read-verify-refuse for the streamed read path: the generator
748
+ // builds the whole response (its data reads) before yielding the first
749
+ // frame, then compares the gate token across those reads. Nothing escapes
750
+ // before the verification, and the buffered size is bounded by the request's
751
+ // pull limits and the server's inline segment cap. Every request verifies,
752
+ // including a mixed push+pull: a write that already applied when the token
753
+ // changed is durable and replayable under the same commit id / idempotency
754
+ // key, so refusing the request is safe (proved in the serve-gate lane).
755
+ const verified = async function* (): AsyncGenerator<Uint8Array> {
756
+ const buffered: Uint8Array[] = [];
757
+ for await (const chunk of streamResponse(plan, ctx, schema, report)) {
758
+ buffered.push(chunk);
759
+ }
760
+ const after = await ctx.storage.readServeGate(
761
+ ctx.partition,
762
+ schema.version,
763
+ );
764
+ const changed = serveGateTokenChanged(gate, after);
765
+ const afterRefusal = serveGateRefusal(
766
+ after,
767
+ schema.version,
768
+ ctx.checkpoints,
769
+ );
770
+ if (afterRefusal !== undefined) throw serveNotReadyError(afterRefusal);
771
+ if (changed !== undefined) throw serveNotReadyError(changed);
772
+ for (const chunk of buffered) yield chunk;
773
+ };
774
+ if (events === undefined) return verified();
717
775
  return instrumentedStream(
718
- streamResponse(plan, ctx, schema, report),
776
+ verified(),
719
777
  ctx,
720
778
  events,
721
779
  plan,
package/src/operations.ts CHANGED
@@ -415,22 +415,26 @@ export function registerRemoteQuery<Params>(
415
415
  'selected SQL has no generated relation plan; regenerate queries',
416
416
  );
417
417
  }
418
- await ctx.storage.ensureSchema(schema);
418
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
419
419
  const prefix = 'SELECT * FROM (';
420
420
  let result;
421
421
  try {
422
- result = await ctx.storage.queryAuthoritative(ctx.partition, {
423
- plan: {
424
- sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
425
- relations: plan.relations.map((relation) => ({
426
- ...relation,
427
- start: relation.start + prefix.length,
428
- end: relation.end + prefix.length,
429
- })),
422
+ result = await ctx.storage.queryAuthoritative(
423
+ ctx.partition,
424
+ {
425
+ plan: {
426
+ sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
427
+ relations: plan.relations.map((relation) => ({
428
+ ...relation,
429
+ start: relation.start + prefix.length,
430
+ end: relation.end + prefix.length,
431
+ })),
432
+ },
433
+ params: [...descriptor.bind(params), options.maxRows + 1],
434
+ tables: descriptor.tables,
430
435
  },
431
- params: [...descriptor.bind(params), options.maxRows + 1],
432
- tables: descriptor.tables,
433
- });
436
+ ctx.checkpoints,
437
+ );
434
438
  } catch (error) {
435
439
  if (error instanceof SyncError) throw error;
436
440
  throw syncError(
@@ -603,7 +607,7 @@ export function registerRemoteCommand<Input>(
603
607
  }
604
608
  const resolved: ResolvedScopes = { ok: true, allowed };
605
609
  const schema = compileSchema(ctx.schema);
606
- await ctx.storage.ensureSchema(schema);
610
+ await ctx.storage.ensureSchema(schema, ctx.checkpoints);
607
611
  const processed = await processPushOperationsWithTrace(
608
612
  ctx,
609
613
  schema,