@syncular/server 0.20.1 → 0.21.1

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,7 +3,7 @@
3
3
  *
4
4
  * Every synced table is a REAL table in the server database — the app's
5
5
  * columns with proper type affinities, queryable with plain SQL/joins/BI —
6
- * plus five `_sync_*` meta columns:
6
+ * plus six `_sync_*` meta columns:
7
7
  *
8
8
  * _sync_partition TEXT partition key (multi-partition servers
9
9
  * share one database; same app PK in two
@@ -23,6 +23,10 @@
23
23
  * so sync round-trips are byte-identical by
24
24
  * construction. Typed columns are a
25
25
  * queryable projection.
26
+ * _sync_column_versions BLOB/BYTEA §2.2 per-column versions: LEB128
27
+ * (ordinal, version) pairs for columns
28
+ * above 1; NULL when none exceeds 1
29
+ * (every fresh insert). Server-internal.
26
30
  *
27
31
  * PRIMARY KEY (_sync_partition, _sync_row_id).
28
32
  *
@@ -59,6 +63,67 @@ export const SYNC_ROW_ID_COLUMN = '_sync_row_id';
59
63
  export const SYNC_VERSION_COLUMN = '_sync_server_version';
60
64
  export const SYNC_SCOPES_COLUMN = '_sync_scopes';
61
65
  export const SYNC_PAYLOAD_COLUMN = '_sync_payload';
66
+ export const SYNC_COLUMN_VERSIONS_COLUMN = '_sync_column_versions';
67
+
68
+ /**
69
+ * §2.2 column-version storage: LEB128 `(ordinal, version)` varint pairs,
70
+ * ascending ordinal, for the columns whose version exceeds 1. An empty (or
71
+ * NULL) blob means no column exceeds 1. Server-internal: these bytes never
72
+ * ride the wire.
73
+ */
74
+ export function encodeColumnVersions(
75
+ versions: ReadonlyMap<number, number>,
76
+ ): Uint8Array | undefined {
77
+ const bytes: number[] = [];
78
+ const varint = (value: number): void => {
79
+ let rest = value;
80
+ while (rest >= 0x80) {
81
+ bytes.push((rest & 0x7f) | 0x80);
82
+ rest = Math.floor(rest / 128);
83
+ }
84
+ bytes.push(rest);
85
+ };
86
+ for (const ordinal of [...versions.keys()].sort((a, b) => a - b)) {
87
+ const version = versions.get(ordinal) ?? 0;
88
+ if (version <= 1) continue;
89
+ varint(ordinal);
90
+ varint(version);
91
+ }
92
+ return bytes.length === 0 ? undefined : Uint8Array.from(bytes);
93
+ }
94
+
95
+ export function decodeColumnVersions(
96
+ blob: Uint8Array | undefined | null,
97
+ ): Map<number, number> {
98
+ const versions = new Map<number, number>();
99
+ if (blob === undefined || blob === null) return versions;
100
+ let offset = 0;
101
+ const varint = (): number => {
102
+ let value = 0;
103
+ let shift = 1;
104
+ for (;;) {
105
+ const byte = blob[offset];
106
+ if (byte === undefined) {
107
+ throw new Error('truncated _sync_column_versions blob');
108
+ }
109
+ offset++;
110
+ value += (byte & 0x7f) * shift;
111
+ if ((byte & 0x80) === 0) return value;
112
+ shift *= 128;
113
+ }
114
+ };
115
+ while (offset < blob.length) {
116
+ const ordinal = varint();
117
+ const version = varint();
118
+ if (version <= 1) {
119
+ throw new Error(
120
+ 'corrupt _sync_column_versions blob: version at or below 1 is never stored',
121
+ );
122
+ }
123
+ versions.set(ordinal, version);
124
+ }
125
+ return versions;
126
+ }
62
127
 
63
128
  /** SQL-standard identifier quoting (both dialects). */
64
129
  export function quoteIdent(name: string): string {
@@ -122,6 +187,7 @@ export function tableColumnNames(table: CompiledTable): string[] {
122
187
  SYNC_VERSION_COLUMN,
123
188
  SYNC_SCOPES_COLUMN,
124
189
  SYNC_PAYLOAD_COLUMN,
190
+ SYNC_COLUMN_VERSIONS_COLUMN,
125
191
  ];
126
192
  }
127
193
 
@@ -145,6 +211,7 @@ export function createTableDdl(
145
211
  `${quoteIdent(SYNC_VERSION_COLUMN)} ${versionType} NOT NULL`,
146
212
  `${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
147
213
  `${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
214
+ `${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
148
215
  `PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
149
216
  ];
150
217
  return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
@@ -153,14 +220,22 @@ export function createTableDdl(
153
220
  /**
154
221
  * ALTER TABLE ADD COLUMN statements for schema columns missing from
155
222
  * `existingColumns` (introspected). Added columns are nullable (header note).
223
+ * The `_sync_column_versions` meta column is added to pre-column-version
224
+ * tables too, materialized or not: it is storage-internal, nullable, and
225
+ * never part of the app-column migration subset.
156
226
  */
157
227
  export function addColumnDdl(
158
228
  table: CompiledTable,
159
229
  existingColumns: ReadonlySet<string>,
160
230
  dialect: RelationalDialect,
161
231
  ): string[] {
162
- if (!table.materialize) return [];
163
232
  const out: string[] = [];
233
+ if (!existingColumns.has(SYNC_COLUMN_VERSIONS_COLUMN)) {
234
+ out.push(
235
+ `ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${dialect === 'postgres' ? 'BYTEA' : 'BLOB'}`,
236
+ );
237
+ }
238
+ if (!table.materialize) return out;
164
239
  for (const column of table.columns) {
165
240
  if (existingColumns.has(column.name)) continue;
166
241
  out.push(
@@ -267,7 +342,7 @@ export function upsertValues(
267
342
  dialect: RelationalDialect,
268
343
  ): unknown[] {
269
344
  // A non-materialized table skips the decode entirely — the upsert is a
270
- // five-column meta write (the old blob-store cost).
345
+ // six-column meta write (the old blob-store cost).
271
346
  const projection = table.materialize
272
347
  ? decodeRow(table.columns, row.payload)
273
348
  : undefined;
@@ -282,6 +357,7 @@ export function upsertValues(
282
357
  row.serverVersion,
283
358
  JSON.stringify(row.scopes),
284
359
  row.payload,
360
+ row.columnVersions ?? null,
285
361
  ];
286
362
  }
287
363
 
@@ -318,7 +394,7 @@ export function selectRowSql(
318
394
  dialect: RelationalDialect,
319
395
  ): string {
320
396
  const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
321
- return `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
397
+ return `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${p[0]} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}=${p[1]}`;
322
398
  }
323
399
 
324
400
  export interface IndexRowPageStatement {
@@ -360,7 +436,7 @@ export function indexRowPageStatement(
360
436
  params.push(limit);
361
437
  const pageLimit = placeholder();
362
438
  return {
363
- sql: `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${dialect === 'sqlite' ? '?' : '$1'} AND ${predicates.join(' AND ')} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>${after} ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ${pageLimit}`,
439
+ sql: `SELECT ${quoteIdent(SYNC_ROW_ID_COLUMN)} AS row_id, ${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, ${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, ${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(SYNC_PARTITION_COLUMN)}=${dialect === 'sqlite' ? '?' : '$1'} AND ${predicates.join(' AND ')} AND ${quoteIdent(SYNC_ROW_ID_COLUMN)}>${after} ORDER BY ${quoteIdent(SYNC_ROW_ID_COLUMN)} LIMIT ${pageLimit}`,
364
440
  params,
365
441
  };
366
442
  }
@@ -396,7 +472,7 @@ export function scanRowPageSql(
396
472
  const after = p(4 + valueCount);
397
473
  const limit = p(5 + valueCount);
398
474
  const joinPartition = dialect === 'sqlite' ? '?' : '$1';
399
- return `SELECT c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload
475
+ return `SELECT c.row_id AS row_id, r.${quoteIdent(SYNC_VERSION_COLUMN)} AS server_version, r.${quoteIdent(SYNC_SCOPES_COLUMN)} AS scopes, r.${quoteIdent(SYNC_PAYLOAD_COLUMN)} AS payload, r.${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} AS column_versions
400
476
  FROM (SELECT DISTINCT row_id FROM sync_row_scopes
401
477
  WHERE partition=${p(1)} AND tbl=${p(2)} AND var=${p(3)} AND value IN (${values.join(',')})
402
478
  AND row_id>${after}
package/src/schema.ts CHANGED
@@ -23,6 +23,34 @@ export interface IndexSchema {
23
23
  readonly unique?: boolean;
24
24
  }
25
25
 
26
+ /** One declared reference (§6.11). Absent `onDelete` means `RESTRICT`. */
27
+ export interface ReferenceSchema {
28
+ readonly column: string;
29
+ readonly parentTable: string;
30
+ readonly onDelete?: ReferenceOnDelete;
31
+ }
32
+
33
+ export type ReferenceOnDelete = 'RESTRICT' | 'CASCADE' | 'SET NULL';
34
+
35
+ /** A compiled outgoing reference (§6.11). */
36
+ export interface CompiledReference {
37
+ readonly column: string;
38
+ readonly columnIndex: number;
39
+ readonly parentTable: string;
40
+ readonly onDelete: ReferenceOnDelete;
41
+ }
42
+
43
+ /** A compiled INCOMING reference: rows of `table` whose `column` points at a
44
+ * row of the table that carries this entry. `index` is the declared
45
+ * single-column index the server probes through `scanRowsByIndex` (§6.8). */
46
+ export interface CompiledIncomingReference {
47
+ readonly table: string;
48
+ readonly column: string;
49
+ readonly columnIndex: number;
50
+ readonly onDelete: ReferenceOnDelete;
51
+ readonly index: string;
52
+ }
53
+
26
54
  export interface TableSchema {
27
55
  readonly name: string;
28
56
  /** Columns in schema-IR declaration order (the row-codec order, §2.4). */
@@ -33,6 +61,10 @@ export interface TableSchema {
33
61
  readonly scopes: readonly ScopePatternSpec[];
34
62
  /** User indexes (optional) — created on the server's relational tables. */
35
63
  readonly indexes?: readonly IndexSchema[];
64
+ /** Declared references (§6.11). Optional so pre-reference schemas stay
65
+ * valid; the reference index over each child column is declared in
66
+ * `indexes` (typegen emits it). */
67
+ readonly references?: readonly ReferenceSchema[];
36
68
  /**
37
69
  * Server-side column materialization
38
70
  * "optional materialization"). When `true` (the usual default) the server's
@@ -72,6 +104,10 @@ export interface CompiledTable {
72
104
  readonly scopePatterns: readonly CompiledScopePattern[];
73
105
  /** User indexes (validated: unique names, existing columns). */
74
106
  readonly indexes: readonly IndexSchema[];
107
+ /** Outgoing declared references (§6.11), resolved to column indices. */
108
+ readonly references: readonly CompiledReference[];
109
+ /** Incoming declared references: child rows to reach on a parent delete. */
110
+ readonly referencedBy: readonly CompiledIncomingReference[];
75
111
  /** Resolved materialization (see `TableSchema.materialize`). */
76
112
  readonly materialize: boolean;
77
113
  readonly columnIndex: ReadonlyMap<string, number>;
@@ -216,6 +252,21 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
216
252
  const blobRefColumnIndices: number[] = [];
217
253
  const crdtColumns: { index: number; crdtType: string }[] = [];
218
254
  const encryptedColumnIndices: number[] = [];
255
+ const references: CompiledReference[] = [];
256
+ for (const reference of table.references ?? []) {
257
+ const index = columnIndex.get(reference.column);
258
+ if (index === undefined) {
259
+ throw new Error(
260
+ `table ${table.name}: reference column ${JSON.stringify(reference.column)} is not a column`,
261
+ );
262
+ }
263
+ references.push({
264
+ column: reference.column,
265
+ columnIndex: index,
266
+ parentTable: reference.parentTable,
267
+ onDelete: reference.onDelete ?? 'RESTRICT',
268
+ });
269
+ }
219
270
  table.columns.forEach((column, index) => {
220
271
  if (column.type === 'blob_ref') blobRefColumnIndices.push(index);
221
272
  // §5.11: an encrypted column carries wire type `bytes` + `encrypted`.
@@ -253,6 +304,8 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
253
304
  primaryKeyIndex,
254
305
  scopePatterns,
255
306
  indexes,
307
+ references,
308
+ referencedBy: [],
256
309
  materialize,
257
310
  columnIndex,
258
311
  declaredVariables: variables,
@@ -261,6 +314,62 @@ export function compileSchema(schema: ServerSchema): CompiledSchema {
261
314
  encryptedColumnIndices,
262
315
  });
263
316
  }
317
+ // §6.11: resolve incoming references and enforce the declaration rules the
318
+ // generator enforces too, so a hand-written server schema fails loud.
319
+ for (const child of tables.values()) {
320
+ for (const reference of child.references) {
321
+ const parent = tables.get(reference.parentTable);
322
+ if (parent === undefined) {
323
+ throw new Error(
324
+ `table ${child.name}: reference column ${JSON.stringify(reference.column)} names unknown table ${JSON.stringify(reference.parentTable)}`,
325
+ );
326
+ }
327
+ const childColumn = child.columns[reference.columnIndex];
328
+ const parentKey = parent.columns[parent.primaryKeyIndex];
329
+ if (childColumn === undefined || parentKey === undefined) {
330
+ throw new Error(
331
+ `unreachable: ${child.name}.${reference.column} or a missing primary key on ${parent.name}`,
332
+ );
333
+ }
334
+ if (childColumn.type !== parentKey.type) {
335
+ throw new Error(
336
+ `table ${child.name}: reference column ${JSON.stringify(reference.column)} has type ${JSON.stringify(childColumn.type)} but ${parent.name}.${parentKey.name} has type ${JSON.stringify(parentKey.type)} — the types must match (§6.11)`,
337
+ );
338
+ }
339
+ if (reference.onDelete === 'SET NULL' && !childColumn.nullable) {
340
+ throw new Error(
341
+ `table ${child.name}: reference column ${JSON.stringify(reference.column)} is not nullable but declares ON DELETE SET NULL (§6.11)`,
342
+ );
343
+ }
344
+ const patterns = (compiled: CompiledTable): string =>
345
+ compiled.scopePatterns
346
+ .map((pattern) => `${pattern.prefix}:{${pattern.variable}}`)
347
+ .sort()
348
+ .join('\u0000');
349
+ if (patterns(child) !== patterns(parent)) {
350
+ throw new Error(
351
+ `table ${child.name}: reference column ${JSON.stringify(reference.column)} targets ${parent.name}, whose scope patterns differ — a cascade MUST NOT cross an authorization boundary (§6.11)`,
352
+ );
353
+ }
354
+ const index = child.indexes.find(
355
+ (candidate) =>
356
+ candidate.columns.length === 1 &&
357
+ candidate.columns[0] === reference.column,
358
+ );
359
+ if (index === undefined) {
360
+ throw new Error(
361
+ `table ${child.name}: reference column ${JSON.stringify(reference.column)} needs a declared single-column index so the server reaches children through scanRowsByIndex (§6.11)`,
362
+ );
363
+ }
364
+ (parent.referencedBy as CompiledIncomingReference[]).push({
365
+ table: child.name,
366
+ column: reference.column,
367
+ columnIndex: reference.columnIndex,
368
+ onDelete: reference.onDelete,
369
+ index: index.name,
370
+ });
371
+ }
372
+ }
264
373
  const compiled: CompiledSchema = {
265
374
  version: schema.version,
266
375
  tables,
package/src/seed.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import {
13
13
  decodeMessage,
14
14
  encodeMessage,
15
- encodeRow,
15
+ encodeSparseRow,
16
16
  PROTOCOL_WIRE_VERSION,
17
17
  type PushOperation,
18
18
  type PushResultFrame,
@@ -182,7 +182,7 @@ export async function seedMutations(
182
182
  table: mutation.table,
183
183
  rowId,
184
184
  op: 'upsert',
185
- payload: encodeRow(table.columns, values),
185
+ payload: encodeSparseRow(table.columns, pkIndex, values),
186
186
  };
187
187
  });
188
188
 
@@ -114,6 +114,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
114
114
  );
115
115
  CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
116
116
  ON sync_blob_refs(partition, blob_id);
117
+ CREATE TABLE IF NOT EXISTS sync_tombstones(
118
+ partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
119
+ commit_seq INTEGER NOT NULL,
120
+ PRIMARY KEY(partition, tbl, row_id)
121
+ );
117
122
  `;
118
123
 
119
124
  /** Split the DDL into individual statements (D1 applies them one by one). */
@@ -151,6 +156,7 @@ interface SerializedResult {
151
156
  message?: string;
152
157
  serverVersion?: number;
153
158
  serverRow?: string;
159
+ conflictColumns?: string;
154
160
  retryable?: boolean;
155
161
  details?: import('@syncular/core').RejectionDetails;
156
162
  }
@@ -175,6 +181,7 @@ export function serializePushResult(result: StoredPushResult): string {
175
181
  message: record.message,
176
182
  serverVersion: record.serverVersion,
177
183
  serverRow: bytesToBase64(record.serverRow),
184
+ conflictColumns: bytesToBase64(record.conflictColumns),
178
185
  };
179
186
  }
180
187
  if (record.status === 'error') {
@@ -209,6 +216,7 @@ export function deserializePushResult(text: string): StoredPushResult {
209
216
  message: record.message ?? '',
210
217
  serverVersion: record.serverVersion ?? 0,
211
218
  serverRow: base64ToBytes(record.serverRow ?? ''),
219
+ conflictColumns: base64ToBytes(record.conflictColumns ?? ''),
212
220
  };
213
221
  }
214
222
  if (record.status === 'error') {
@@ -242,6 +250,7 @@ export interface SqliteRowRecord {
242
250
  server_version: number;
243
251
  scopes: string;
244
252
  payload: Uint8Array;
253
+ column_versions: Uint8Array | null;
245
254
  }
246
255
 
247
256
  export interface SqliteChangeRecord {
@@ -267,6 +276,9 @@ export function toStoredRow(record: SqliteRowRecord): StoredRow {
267
276
  serverVersion: record.server_version,
268
277
  scopes: JSON.parse(record.scopes) as Record<string, string>,
269
278
  payload: asUint8Array(record.payload),
279
+ ...(record.column_versions !== null
280
+ ? { columnVersions: asUint8Array(record.column_versions) }
281
+ : {}),
270
282
  };
271
283
  }
272
284
 
@@ -163,6 +163,28 @@ class SqliteTransaction implements StorageTransaction {
163
163
  return this.#storage.getRow(this.#partition, table, rowId);
164
164
  }
165
165
 
166
+ async getTombstoneSeq(
167
+ table: string,
168
+ rowId: string,
169
+ ): Promise<number | undefined> {
170
+ this.#assertOpen();
171
+ const row = this.#storage.db
172
+ .query<{ commit_seq: number }, [string, string, string]>(
173
+ 'SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
174
+ )
175
+ .get(this.#partition, table, rowId);
176
+ return row?.commit_seq;
177
+ }
178
+
179
+ async clearTombstone(table: string, rowId: string): Promise<void> {
180
+ this.#assertOpen();
181
+ this.#storage.db
182
+ .query(
183
+ 'DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
184
+ )
185
+ .run(this.#partition, table, rowId);
186
+ }
187
+
166
188
  getPushResult(
167
189
  clientId: string,
168
190
  clientCommitId: string,
@@ -302,6 +324,14 @@ class SqliteTransaction implements StorageTransaction {
302
324
  JSON.stringify(change.scopes),
303
325
  change.payload ?? null,
304
326
  );
327
+ if (change.op === 'delete') {
328
+ // §5 delete precedence: every applied delete leaves a tombstone,
329
+ // pruned with the commit log (§4.6).
330
+ db.query(
331
+ `INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
332
+ ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
333
+ ).run(p, change.table, change.rowId, commitSeq);
334
+ }
305
335
  for (const [variable, value] of Object.entries(change.scopes)) {
306
336
  db.query(
307
337
  'INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)',
@@ -833,6 +863,12 @@ export class SqliteServerStorage implements ServerStorage {
833
863
  'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
834
864
  )
835
865
  .run(partition, horizonSeq);
866
+ // §4.6: tombstones prune in the same pass as the commit log.
867
+ this.db
868
+ .query(
869
+ 'DELETE FROM sync_tombstones WHERE partition=? AND commit_seq<=?',
870
+ )
871
+ .run(partition, horizonSeq);
836
872
  this.db.exec('COMMIT');
837
873
  return {
838
874
  previousHorizonSeq,
package/src/storage.ts CHANGED
@@ -40,6 +40,13 @@ export interface StoredRow {
40
40
  readonly scopes: Record<string, string>;
41
41
  /** Row-codec payload (§2.4) for the server's schema version. */
42
42
  readonly payload: Uint8Array;
43
+ /**
44
+ * §2.2 per-column versions: the `_sync_column_versions` blob (LEB128
45
+ * (ordinal, version) pairs for columns above 1). Absent when no column
46
+ * exceeds 1, and for rows written before column versions existed — a row
47
+ * without the blob carries every column at `serverVersion`.
48
+ */
49
+ readonly columnVersions?: Uint8Array;
43
50
  }
44
51
 
45
52
  export interface NewChange {
@@ -312,6 +319,20 @@ export interface AuthoritativeQueryResult {
312
319
  */
313
320
  export interface StorageTransaction {
314
321
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
322
+ /**
323
+ * §5 delete-precedence tombstone read: the `commit_seq` of the newest
324
+ * applied delete of this row still within the pruning horizon, or
325
+ * `undefined` when no tombstone exists. Consulted only when an upsert's
326
+ * stored row is absent. Required: in-tree adapters implement it on the
327
+ * transaction's own connection with read-your-own-writes semantics.
328
+ */
329
+ getTombstoneSeq(table: string, rowId: string): Promise<number | undefined>;
330
+ /**
331
+ * Remove the row's tombstone because the row was recreated in this
332
+ * transaction (§5: an explicit insert recreates). Called on every insert
333
+ * over an absent row; a no-op when no tombstone exists.
334
+ */
335
+ clearTombstone(table: string, rowId: string): Promise<void>;
315
336
  /**
316
337
  * Optional transaction-scoped idempotency lookup (§2.3) with the same
317
338
  * semantics as `ServerStorage.getPushResult`, including the