@syncular/server 0.21.0 → 0.22.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.
@@ -3,12 +3,19 @@
3
3
  */
4
4
  import {
5
5
  DEFAULT_SEGMENT_TTL_MS,
6
+ mergeSegmentRecord,
7
+ publicationKey,
8
+ publicationRecord,
9
+ publicationsForRecord,
6
10
  type SegmentFindKey,
7
11
  type SegmentMetadata,
12
+ type SegmentPublication,
8
13
  type SegmentRecord,
14
+ segmentBytesEqual,
9
15
  type SegmentStore,
10
16
  type SegmentStoreStats,
11
17
  segmentIdFor,
18
+ selectSegmentPublication,
12
19
  } from './segment-store';
13
20
  import {
14
21
  SqliteAdapterRequiredError,
@@ -40,6 +47,33 @@ export class SqliteSegmentStore implements SegmentStore {
40
47
  expires_at_ms INTEGER NOT NULL, bytes BLOB NOT NULL
41
48
  );
42
49
  `);
50
+ // One row per scope digest a content address was published under (§5.1):
51
+ // identical bytes from two scopes share a `segment_id` and must both stay
52
+ // downloadable (§5.5).
53
+ this.db.exec(`
54
+ CREATE TABLE IF NOT EXISTS sync_segment_scopes(
55
+ segment_id TEXT NOT NULL, scope_digest TEXT NOT NULL,
56
+ PRIMARY KEY (segment_id, scope_digest)
57
+ );
58
+ `);
59
+ // One row per publication of a content address (§5.1): the same bytes
60
+ // under a different partition, epoch, table, or pin are distinct grants
61
+ // and must each stay downloadable. Rows written before this table existed
62
+ // materialize from the top-level record plus `sync_segment_scopes` on
63
+ // read, so an in-flight 0.21 object loses no publication.
64
+ this.db.exec(`
65
+ CREATE TABLE IF NOT EXISTS sync_segment_publications(
66
+ segment_id TEXT NOT NULL, pub_key TEXT NOT NULL,
67
+ partition TEXT NOT NULL, log_epoch TEXT NOT NULL,
68
+ tbl TEXT NOT NULL, schema_version INTEGER NOT NULL,
69
+ media_type TEXT NOT NULL, scope_digest TEXT NOT NULL,
70
+ as_of_commit_seq INTEGER NOT NULL, row_count INTEGER NOT NULL,
71
+ row_cursor TEXT, next_row_cursor TEXT,
72
+ byte_length INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,
73
+ expires_at_ms INTEGER NOT NULL,
74
+ PRIMARY KEY (segment_id, pub_key)
75
+ );
76
+ `);
43
77
  const columns = this.db
44
78
  .query<{ name: string }, []>('PRAGMA table_info(sync_segments)')
45
79
  .all();
@@ -48,6 +82,64 @@ export class SqliteSegmentStore implements SegmentStore {
48
82
  "ALTER TABLE sync_segments ADD COLUMN log_epoch TEXT NOT NULL DEFAULT ''",
49
83
  );
50
84
  }
85
+ // Backfill digests recorded before the side table existed, so an
86
+ // upgraded store keeps `find`/download working for cached segments.
87
+ this.db.exec(
88
+ `INSERT OR IGNORE INTO sync_segment_scopes(segment_id, scope_digest)
89
+ SELECT segment_id, scope_digest FROM sync_segments`,
90
+ );
91
+ }
92
+
93
+ /** Every digest recorded for a content address, primary first. */
94
+ #scopeDigestsFor(segmentId: string, primary: string): string[] {
95
+ const rows = this.db
96
+ .query<{ scope_digest: string }, [string]>(
97
+ 'SELECT scope_digest FROM sync_segment_scopes WHERE segment_id=?',
98
+ )
99
+ .all(segmentId);
100
+ const digests = rows.map((row) => row.scope_digest);
101
+ return [primary, ...digests.filter((digest) => digest !== primary)];
102
+ }
103
+
104
+ /** Stored publications for a content address, oldest first. */
105
+ #publicationsFor(segmentId: string): SegmentPublication[] {
106
+ const rows = this.db
107
+ .query<
108
+ {
109
+ partition: string;
110
+ log_epoch: string;
111
+ tbl: string;
112
+ schema_version: number;
113
+ media_type: string;
114
+ scope_digest: string;
115
+ as_of_commit_seq: number;
116
+ row_count: number;
117
+ row_cursor: string | null;
118
+ next_row_cursor: string | null;
119
+ byte_length: number;
120
+ created_at_ms: number;
121
+ expires_at_ms: number;
122
+ },
123
+ [string]
124
+ >(
125
+ 'SELECT * FROM sync_segment_publications WHERE segment_id=? ORDER BY rowid',
126
+ )
127
+ .all(segmentId);
128
+ return rows.map((row) => ({
129
+ partition: row.partition,
130
+ logEpoch: row.log_epoch,
131
+ table: row.tbl,
132
+ schemaVersion: row.schema_version,
133
+ mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
134
+ scopeDigest: row.scope_digest,
135
+ asOfCommitSeq: row.as_of_commit_seq,
136
+ rowCount: row.row_count,
137
+ rowCursor: row.row_cursor,
138
+ nextRowCursor: row.next_row_cursor,
139
+ byteLength: row.byte_length,
140
+ createdAtMs: row.created_at_ms,
141
+ expiresAtMs: row.expires_at_ms,
142
+ }));
51
143
  }
52
144
 
53
145
  async put(
@@ -56,38 +148,99 @@ export class SqliteSegmentStore implements SegmentStore {
56
148
  nowMs: number,
57
149
  ): Promise<SegmentRecord> {
58
150
  const segmentId = await segmentIdFor(bytes);
59
- const record: SegmentRecord = {
60
- ...metadata,
151
+ const existing = await this.get(segmentId);
152
+ if (existing !== undefined && !segmentBytesEqual(existing.bytes, bytes)) {
153
+ throw new Error(
154
+ `SqliteSegmentStore: content-address collision at ${segmentId} (§5.1)`,
155
+ );
156
+ }
157
+ const record = mergeSegmentRecord(
158
+ existing?.record,
159
+ metadata,
61
160
  segmentId,
62
- byteLength: bytes.length,
63
- createdAtMs: nowMs,
64
- expiresAtMs: nowMs + this.#ttlMs,
65
- };
66
- this.db
67
- .query(
68
- `INSERT OR REPLACE INTO sync_segments(
69
- segment_id, partition, log_epoch, tbl, schema_version, media_type,
70
- scope_digest, as_of_commit_seq, row_count, row_cursor,
71
- next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
72
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
73
- )
74
- .run(
75
- record.segmentId,
76
- record.partition,
77
- record.logEpoch,
78
- record.table,
79
- record.schemaVersion,
80
- record.mediaType,
81
- record.scopeDigest,
82
- record.asOfCommitSeq,
83
- record.rowCount,
84
- record.rowCursor,
85
- record.nextRowCursor,
86
- record.byteLength,
87
- record.createdAtMs,
88
- record.expiresAtMs,
89
- bytes,
161
+ bytes.length,
162
+ nowMs,
163
+ this.#ttlMs,
164
+ );
165
+ // The bytes are proven identical to the stored ones, so replacing the
166
+ // row with the merged record (metadata, digest set, merged times) is the
167
+ // merge: the other digests live in `sync_segment_scopes` below. The
168
+ // publication set is rewritten from the merged record in one transaction
169
+ // so a crash cannot leave provenance half-applied.
170
+ this.db.exec('BEGIN IMMEDIATE');
171
+ try {
172
+ this.db
173
+ .query(
174
+ `INSERT OR REPLACE INTO sync_segments(
175
+ segment_id, partition, log_epoch, tbl, schema_version, media_type,
176
+ scope_digest, as_of_commit_seq, row_count, row_cursor,
177
+ next_row_cursor, byte_length, created_at_ms, expires_at_ms, bytes
178
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
179
+ )
180
+ .run(
181
+ record.segmentId,
182
+ record.partition,
183
+ record.logEpoch,
184
+ record.table,
185
+ record.schemaVersion,
186
+ record.mediaType,
187
+ record.scopeDigest,
188
+ record.asOfCommitSeq,
189
+ record.rowCount,
190
+ record.rowCursor,
191
+ record.nextRowCursor,
192
+ record.byteLength,
193
+ record.createdAtMs,
194
+ record.expiresAtMs,
195
+ bytes,
196
+ );
197
+ // Rebuild the digest side table from the merged record so a pruned
198
+ // (expired) publication's digest disappears from the compatibility
199
+ // view instead of lingering as a stale scope row.
200
+ this.db
201
+ .query('DELETE FROM sync_segment_scopes WHERE segment_id=?')
202
+ .run(record.segmentId);
203
+ const insertScope = this.db.query(
204
+ `INSERT OR IGNORE INTO sync_segment_scopes(segment_id, scope_digest)
205
+ VALUES (?,?)`,
206
+ );
207
+ for (const digest of record.scopeDigests) {
208
+ insertScope.run(record.segmentId, digest);
209
+ }
210
+ this.db
211
+ .query('DELETE FROM sync_segment_publications WHERE segment_id=?')
212
+ .run(record.segmentId);
213
+ const insertPublication = this.db.query(
214
+ `INSERT INTO sync_segment_publications(
215
+ segment_id, pub_key, partition, log_epoch, tbl, schema_version,
216
+ media_type, scope_digest, as_of_commit_seq, row_count, row_cursor,
217
+ next_row_cursor, byte_length, created_at_ms, expires_at_ms
218
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
90
219
  );
220
+ for (const publication of record.publications) {
221
+ insertPublication.run(
222
+ record.segmentId,
223
+ publicationKey(publication),
224
+ publication.partition,
225
+ publication.logEpoch,
226
+ publication.table,
227
+ publication.schemaVersion,
228
+ publication.mediaType,
229
+ publication.scopeDigest,
230
+ publication.asOfCommitSeq,
231
+ publication.rowCount,
232
+ publication.rowCursor,
233
+ publication.nextRowCursor,
234
+ publication.byteLength,
235
+ publication.createdAtMs,
236
+ publication.expiresAtMs,
237
+ );
238
+ }
239
+ this.db.exec('COMMIT');
240
+ } catch (error) {
241
+ this.db.exec('ROLLBACK');
242
+ throw error;
243
+ }
91
244
  return record;
92
245
  }
93
246
 
@@ -117,22 +270,33 @@ export class SqliteSegmentStore implements SegmentStore {
117
270
  >('SELECT * FROM sync_segments WHERE segment_id=?')
118
271
  .get(segmentId);
119
272
  if (row === null) return undefined;
273
+ const scopeDigests = this.#scopeDigestsFor(
274
+ row.segment_id,
275
+ row.scope_digest,
276
+ );
277
+ const top = {
278
+ segmentId: row.segment_id,
279
+ partition: row.partition,
280
+ logEpoch: row.log_epoch,
281
+ table: row.tbl,
282
+ schemaVersion: row.schema_version,
283
+ mediaType:
284
+ row.media_type === 'sqlite' ? ('sqlite' as const) : ('rows' as const),
285
+ scopeDigest: row.scope_digest,
286
+ scopeDigests,
287
+ asOfCommitSeq: row.as_of_commit_seq,
288
+ rowCount: row.row_count,
289
+ rowCursor: row.row_cursor,
290
+ nextRowCursor: row.next_row_cursor,
291
+ byteLength: row.byte_length,
292
+ createdAtMs: row.created_at_ms,
293
+ expiresAtMs: row.expires_at_ms,
294
+ };
295
+ const stored = this.#publicationsFor(row.segment_id);
120
296
  return {
121
297
  record: {
122
- segmentId: row.segment_id,
123
- partition: row.partition,
124
- logEpoch: row.log_epoch,
125
- table: row.tbl,
126
- schemaVersion: row.schema_version,
127
- mediaType: row.media_type === 'sqlite' ? 'sqlite' : 'rows',
128
- scopeDigest: row.scope_digest,
129
- asOfCommitSeq: row.as_of_commit_seq,
130
- rowCount: row.row_count,
131
- rowCursor: row.row_cursor,
132
- nextRowCursor: row.next_row_cursor,
133
- byteLength: row.byte_length,
134
- createdAtMs: row.created_at_ms,
135
- expiresAtMs: row.expires_at_ms,
298
+ ...top,
299
+ publications: stored.length > 0 ? stored : publicationsForRecord(top),
136
300
  },
137
301
  bytes: new Uint8Array(row.bytes),
138
302
  };
@@ -142,21 +306,14 @@ export class SqliteSegmentStore implements SegmentStore {
142
306
  key: SegmentFindKey,
143
307
  nowMs: number,
144
308
  ): Promise<SegmentRecord | undefined> {
145
- const row = this.db
309
+ // A publication row is the provenance index; a record written before the
310
+ // table existed falls back to the legacy top-level/scope join below.
311
+ const publication = this.db
146
312
  .query<
147
- {
148
- segment_id: string;
149
- row_count: number;
150
- next_row_cursor: string | null;
151
- byte_length: number;
152
- created_at_ms: number;
153
- expires_at_ms: number;
154
- },
313
+ { segment_id: string },
155
314
  [string, string, string, number, string, string, number, number]
156
315
  >(
157
- `SELECT segment_id, row_count, next_row_cursor, byte_length,
158
- created_at_ms, expires_at_ms
159
- FROM sync_segments
316
+ `SELECT segment_id FROM sync_segment_publications
160
317
  WHERE partition=? AND log_epoch=? AND tbl=? AND schema_version=? AND media_type=?
161
318
  AND scope_digest=? AND as_of_commit_seq=? AND row_cursor IS NULL
162
319
  AND expires_at_ms > ?
@@ -172,9 +329,34 @@ export class SqliteSegmentStore implements SegmentStore {
172
329
  key.asOfCommitSeq,
173
330
  nowMs,
174
331
  );
175
- if (row === null) return undefined;
176
- return {
177
- segmentId: row.segment_id,
332
+ const legacy = this.db
333
+ .query<
334
+ { segment_id: string },
335
+ [string, string, string, number, string, string, number, number]
336
+ >(
337
+ `SELECT s.segment_id
338
+ FROM sync_segments s
339
+ JOIN sync_segment_scopes sc ON sc.segment_id = s.segment_id
340
+ WHERE s.partition=? AND s.log_epoch=? AND s.tbl=? AND s.schema_version=? AND s.media_type=?
341
+ AND sc.scope_digest=? AND s.as_of_commit_seq=? AND s.row_cursor IS NULL
342
+ AND s.expires_at_ms > ?
343
+ LIMIT 1`,
344
+ )
345
+ .get(
346
+ key.partition,
347
+ key.logEpoch,
348
+ key.table,
349
+ key.schemaVersion,
350
+ key.mediaType,
351
+ key.scopeDigest,
352
+ key.asOfCommitSeq,
353
+ nowMs,
354
+ );
355
+ const segmentId = publication?.segment_id ?? legacy?.segment_id;
356
+ if (segmentId === undefined) return undefined;
357
+ const entry = await this.get(segmentId);
358
+ if (entry === undefined) return undefined;
359
+ const match = selectSegmentPublication(entry.record, {
178
360
  partition: key.partition,
179
361
  logEpoch: key.logEpoch,
180
362
  table: key.table,
@@ -182,13 +364,11 @@ export class SqliteSegmentStore implements SegmentStore {
182
364
  mediaType: key.mediaType,
183
365
  scopeDigest: key.scopeDigest,
184
366
  asOfCommitSeq: key.asOfCommitSeq,
185
- rowCount: row.row_count,
186
367
  rowCursor: null,
187
- nextRowCursor: row.next_row_cursor,
188
- byteLength: row.byte_length,
189
- createdAtMs: row.created_at_ms,
190
- expiresAtMs: row.expires_at_ms,
191
- };
368
+ nowMs,
369
+ });
370
+ if (match === undefined) return undefined;
371
+ return publicationRecord(entry.record, match);
192
372
  }
193
373
 
194
374
  async stats(): Promise<SegmentStoreStats> {
@@ -15,6 +15,7 @@ import {
15
15
  } from './authoritative-query';
16
16
  import { syncError } from './errors';
17
17
  import {
18
+ assertPhysicalColumns,
18
19
  commitWindowPageSql,
19
20
  deleteRowSql,
20
21
  deleteSqliteRowScopesSql,
@@ -481,6 +482,60 @@ export class SqliteServerStorage implements ServerStorage {
481
482
  `stored schema version ${marker.schema_version} is newer than the configured schema (${schema.version}) — refusing to run an older server against a migrated database`,
482
483
  );
483
484
  }
485
+ if (marker !== null && marker.schema_version === schema.version) {
486
+ // Version equality is not layout equality: a marker written by another
487
+ // build at the same version describes rows the running codec cannot
488
+ // decode. Compare the stored layouts instead of trusting the number.
489
+ const storedLayouts = parseLayouts(marker.layouts);
490
+ const configuredLayouts = parseLayouts(layoutsOf(schema));
491
+ let mismatch: string | undefined;
492
+ for (const [tableName, columns] of Object.entries(configuredLayouts)) {
493
+ const stored = storedLayouts[tableName];
494
+ const columnCount = Math.max(columns.length, stored?.length ?? 0);
495
+ for (let index = 0; index < columnCount; index++) {
496
+ const expected = columns[index];
497
+ const actual = stored?.[index];
498
+ if (
499
+ expected !== undefined &&
500
+ actual !== undefined &&
501
+ actual.name === expected.name &&
502
+ actual.type === expected.type &&
503
+ actual.nullable === expected.nullable
504
+ ) {
505
+ continue;
506
+ }
507
+ mismatch = `table ${JSON.stringify(tableName)} column ${JSON.stringify(expected?.name ?? actual?.name ?? '')}`;
508
+ break;
509
+ }
510
+ if (mismatch !== undefined) break;
511
+ }
512
+ if (mismatch === undefined) {
513
+ for (const tableName of Object.keys(storedLayouts)) {
514
+ if (!(tableName in configuredLayouts)) {
515
+ mismatch = `table ${JSON.stringify(tableName)}`;
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ if (mismatch !== undefined) {
521
+ throw new Error(
522
+ `stored schema layouts disagree with the configured schema at version ${schema.version} (${mismatch}) — refusing to serve a database whose stored rows the running code cannot decode`,
523
+ );
524
+ }
525
+ // The persisted layouts describe the codec's app columns only: read the
526
+ // physical tables so a same-version database missing a
527
+ // storage-internal column is refused at startup instead of failing at
528
+ // the first write.
529
+ for (const table of schema.tables.values()) {
530
+ const escapedTableName = table.name.replaceAll('"', '""');
531
+ const columns = this.db
532
+ .query<{ name: string }, []>(
533
+ `PRAGMA table_info("${escapedTableName}")`,
534
+ )
535
+ .all();
536
+ assertPhysicalColumns(table, new Set(columns.map((c) => c.name)));
537
+ }
538
+ }
484
539
  if (marker === null || marker.schema_version < schema.version) {
485
540
  // Introspect existing app tables, then apply the migration subset
486
541
  // (CREATE TABLE / ADD COLUMN / rebuild indexes) to reach `schema`, then
package/src/storage.ts CHANGED
@@ -458,6 +458,19 @@ export interface ServerStorage {
458
458
  /** Read continuity without refreshing authenticated activity. */
459
459
  getPartitionLogEpoch(partition: string): Promise<string | undefined>;
460
460
 
461
+ /**
462
+ * Open a storage transaction. This is the raw primitive, not a command
463
+ * boundary: it acquires NO partition write lock. In-tree SQLite begins
464
+ * `IMMEDIATE`, so a hand-rolled command on SQLite happens to be serialized
465
+ * against a concurrent push; PostgreSQL's `BEGIN` takes no row lock, so the
466
+ * same command races a push on Postgres and a SQLite-only test conceals it.
467
+ *
468
+ * A trusted command must therefore run through `registerRemoteCommand`,
469
+ * which builds after `lockPartitionForPush` and the idempotency recheck and
470
+ * shares the committed transaction, or call `lockPartitionForPush` before
471
+ * its first authority, version, or replay read. SSP2 request handling does
472
+ * this for you; only host-authored commands choose.
473
+ */
461
474
  begin(partition: string): Promise<StorageTransaction>;
462
475
 
463
476
  getMaxCommitSeq(partition: string): Promise<number>;
@@ -560,6 +573,18 @@ export interface ServerStorage {
560
573
  * Optional registered-query capability. The implementation MUST replace
561
574
  * every generated app-table relation with a partition-filtered relation and
562
575
  * return rows plus maxCommitSeq from one consistent database snapshot.
576
+ *
577
+ * The storage serializes this call behind an open transaction on a
578
+ * single-connection executor (SQLite, PGlite). A validator runs inside the
579
+ * push transaction, so on that executor a validator that calls
580
+ * `queryAuthoritative` waits on the transaction it is already inside and
581
+ * the push never completes. A pool-backed executor (`pg`, `Bun.sql`) hands
582
+ * the call a second connection instead: the validator does not deadlock,
583
+ * but it reads committed state without the candidate write. On either
584
+ * executor read candidate state through the whole-commit `CommitValidator`
585
+ * reader (§6.8). A call made from any other context waits for the open
586
+ * transaction to finish on a single-connection executor, and runs
587
+ * concurrently on a pool-backed one.
563
588
  */
564
589
  queryAuthoritative?(
565
590
  partition: string,
package/src/validate.ts CHANGED
@@ -123,7 +123,19 @@ export type CommitValidator = (
123
123
  input: ValidateCommitInput,
124
124
  ) => void | Promise<void>;
125
125
 
126
- /** Ambient context a validator may consult (§6.7). */
126
+ /**
127
+ * Ambient context a validator may consult (§6.7).
128
+ *
129
+ * A validator runs inside the push transaction. On a single-connection
130
+ * executor (SQLite, PGlite) the storage serializes authoritative queries
131
+ * behind that transaction, so a validator that calls `queryAuthoritative`
132
+ * waits for the transaction it is already inside and the push never
133
+ * completes. On a pool-backed executor (`pg`, `Bun.sql`) the query takes a
134
+ * second connection and returns committed state without the candidate write.
135
+ * Either way a rule that must read rows belongs in the whole-commit
136
+ * `CommitValidator`, whose reader runs get and scan operations on the same
137
+ * transaction (§6.8).
138
+ */
127
139
  export interface ValidateContext {
128
140
  /** Host-authenticated actor (§1.1) performing the write. */
129
141
  readonly actorId: string;
@@ -139,6 +151,13 @@ export interface ValidateContext {
139
151
  * any other error is treated as a rejection with a generic
140
152
  * `sync.constraint_violation` code (a validator SHOULD throw
141
153
  * `ValidationRejection` to control the code). MUST NOT mutate the row.
154
+ *
155
+ * The hook runs inside the push transaction and cannot read candidate rows
156
+ * from an authoritative query: on a single-connection executor
157
+ * `queryAuthoritative` serializes behind that transaction and the push never
158
+ * completes, and on a pool-backed executor it reads committed state without
159
+ * the candidate write. Read rows with the whole-commit `CommitValidator`
160
+ * reader instead (§6.8).
142
161
  */
143
162
  export type Validator = (
144
163
  op: ValidateOperation,