@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
  *
@@ -49,6 +53,62 @@ export const SYNC_ROW_ID_COLUMN = '_sync_row_id';
49
53
  export const SYNC_VERSION_COLUMN = '_sync_server_version';
50
54
  export const SYNC_SCOPES_COLUMN = '_sync_scopes';
51
55
  export const SYNC_PAYLOAD_COLUMN = '_sync_payload';
56
+ export const SYNC_COLUMN_VERSIONS_COLUMN = '_sync_column_versions';
57
+ /**
58
+ * §2.2 column-version storage: LEB128 `(ordinal, version)` varint pairs,
59
+ * ascending ordinal, for the columns whose version exceeds 1. An empty (or
60
+ * NULL) blob means no column exceeds 1. Server-internal: these bytes never
61
+ * ride the wire.
62
+ */
63
+ export function encodeColumnVersions(versions) {
64
+ const bytes = [];
65
+ const varint = (value) => {
66
+ let rest = value;
67
+ while (rest >= 0x80) {
68
+ bytes.push((rest & 0x7f) | 0x80);
69
+ rest = Math.floor(rest / 128);
70
+ }
71
+ bytes.push(rest);
72
+ };
73
+ for (const ordinal of [...versions.keys()].sort((a, b) => a - b)) {
74
+ const version = versions.get(ordinal) ?? 0;
75
+ if (version <= 1)
76
+ continue;
77
+ varint(ordinal);
78
+ varint(version);
79
+ }
80
+ return bytes.length === 0 ? undefined : Uint8Array.from(bytes);
81
+ }
82
+ export function decodeColumnVersions(blob) {
83
+ const versions = new Map();
84
+ if (blob === undefined || blob === null)
85
+ return versions;
86
+ let offset = 0;
87
+ const varint = () => {
88
+ let value = 0;
89
+ let shift = 1;
90
+ for (;;) {
91
+ const byte = blob[offset];
92
+ if (byte === undefined) {
93
+ throw new Error('truncated _sync_column_versions blob');
94
+ }
95
+ offset++;
96
+ value += (byte & 0x7f) * shift;
97
+ if ((byte & 0x80) === 0)
98
+ return value;
99
+ shift *= 128;
100
+ }
101
+ };
102
+ while (offset < blob.length) {
103
+ const ordinal = varint();
104
+ const version = varint();
105
+ if (version <= 1) {
106
+ throw new Error('corrupt _sync_column_versions blob: version at or below 1 is never stored');
107
+ }
108
+ versions.set(ordinal, version);
109
+ }
110
+ return versions;
111
+ }
52
112
  /** SQL-standard identifier quoting (both dialects). */
53
113
  export function quoteIdent(name) {
54
114
  return `"${name.replaceAll('"', '""')}"`;
@@ -106,6 +166,7 @@ export function tableColumnNames(table) {
106
166
  SYNC_VERSION_COLUMN,
107
167
  SYNC_SCOPES_COLUMN,
108
168
  SYNC_PAYLOAD_COLUMN,
169
+ SYNC_COLUMN_VERSIONS_COLUMN,
109
170
  ];
110
171
  }
111
172
  /** CREATE TABLE IF NOT EXISTS for one app table. */
@@ -125,6 +186,7 @@ export function createTableDdl(table, dialect) {
125
186
  `${quoteIdent(SYNC_VERSION_COLUMN)} ${versionType} NOT NULL`,
126
187
  `${quoteIdent(SYNC_SCOPES_COLUMN)} ${scopesType} NOT NULL`,
127
188
  `${quoteIdent(SYNC_PAYLOAD_COLUMN)} ${payloadType} NOT NULL`,
189
+ `${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${payloadType}`,
128
190
  `PRIMARY KEY (${quoteIdent(SYNC_PARTITION_COLUMN)}, ${quoteIdent(SYNC_ROW_ID_COLUMN)})`,
129
191
  ];
130
192
  return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs.join(', ')})`;
@@ -132,11 +194,17 @@ export function createTableDdl(table, dialect) {
132
194
  /**
133
195
  * ALTER TABLE ADD COLUMN statements for schema columns missing from
134
196
  * `existingColumns` (introspected). Added columns are nullable (header note).
197
+ * The `_sync_column_versions` meta column is added to pre-column-version
198
+ * tables too, materialized or not: it is storage-internal, nullable, and
199
+ * never part of the app-column migration subset.
135
200
  */
136
201
  export function addColumnDdl(table, existingColumns, dialect) {
137
- if (!table.materialize)
138
- return [];
139
202
  const out = [];
203
+ if (!existingColumns.has(SYNC_COLUMN_VERSIONS_COLUMN)) {
204
+ out.push(`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${quoteIdent(SYNC_COLUMN_VERSIONS_COLUMN)} ${dialect === 'postgres' ? 'BYTEA' : 'BLOB'}`);
205
+ }
206
+ if (!table.materialize)
207
+ return out;
140
208
  for (const column of table.columns) {
141
209
  if (existingColumns.has(column.name))
142
210
  continue;
@@ -231,7 +299,7 @@ export function toSqlValue(column, value, dialect) {
231
299
  */
232
300
  export function upsertValues(table, partition, row, dialect) {
233
301
  // A non-materialized table skips the decode entirely — the upsert is a
234
- // five-column meta write (the old blob-store cost).
302
+ // six-column meta write (the old blob-store cost).
235
303
  const projection = table.materialize
236
304
  ? decodeRow(table.columns, row.payload)
237
305
  : undefined;
@@ -244,6 +312,7 @@ export function upsertValues(table, partition, row, dialect) {
244
312
  row.serverVersion,
245
313
  JSON.stringify(row.scopes),
246
314
  row.payload,
315
+ row.columnVersions ?? null,
247
316
  ];
248
317
  }
249
318
  function placeholderList(count, dialect) {
@@ -273,7 +342,7 @@ export function upsertSql(table, dialect) {
273
342
  */
274
343
  export function selectRowSql(table, dialect) {
275
344
  const p = dialect === 'sqlite' ? ['?', '?'] : ['$1', '$2'];
276
- 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]}`;
345
+ 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]}`;
277
346
  }
278
347
  /**
279
348
  * Bounded exact lookup through one declared relational index. Unlike
@@ -300,7 +369,7 @@ export function indexRowPageStatement(table, index, values, partition, afterRowI
300
369
  params.push(limit);
301
370
  const pageLimit = placeholder();
302
371
  return {
303
- 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}`,
372
+ 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}`,
304
373
  params,
305
374
  };
306
375
  }
@@ -332,7 +401,7 @@ export function scanRowPageSql(table, valueCount, dialect) {
332
401
  const after = p(4 + valueCount);
333
402
  const limit = p(5 + valueCount);
334
403
  const joinPartition = dialect === 'sqlite' ? '?' : '$1';
335
- 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
404
+ 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
336
405
  FROM (SELECT DISTINCT row_id FROM sync_row_scopes
337
406
  WHERE partition=${p(1)} AND tbl=${p(2)} AND var=${p(3)} AND value IN (${values.join(',')})
338
407
  AND row_id>${after}
package/dist/schema.d.ts CHANGED
@@ -20,6 +20,30 @@ export interface IndexSchema {
20
20
  readonly columns: readonly string[];
21
21
  readonly unique?: boolean;
22
22
  }
23
+ /** One declared reference (§6.11). Absent `onDelete` means `RESTRICT`. */
24
+ export interface ReferenceSchema {
25
+ readonly column: string;
26
+ readonly parentTable: string;
27
+ readonly onDelete?: ReferenceOnDelete;
28
+ }
29
+ export type ReferenceOnDelete = 'RESTRICT' | 'CASCADE' | 'SET NULL';
30
+ /** A compiled outgoing reference (§6.11). */
31
+ export interface CompiledReference {
32
+ readonly column: string;
33
+ readonly columnIndex: number;
34
+ readonly parentTable: string;
35
+ readonly onDelete: ReferenceOnDelete;
36
+ }
37
+ /** A compiled INCOMING reference: rows of `table` whose `column` points at a
38
+ * row of the table that carries this entry. `index` is the declared
39
+ * single-column index the server probes through `scanRowsByIndex` (§6.8). */
40
+ export interface CompiledIncomingReference {
41
+ readonly table: string;
42
+ readonly column: string;
43
+ readonly columnIndex: number;
44
+ readonly onDelete: ReferenceOnDelete;
45
+ readonly index: string;
46
+ }
23
47
  export interface TableSchema {
24
48
  readonly name: string;
25
49
  /** Columns in schema-IR declaration order (the row-codec order, §2.4). */
@@ -30,6 +54,10 @@ export interface TableSchema {
30
54
  readonly scopes: readonly ScopePatternSpec[];
31
55
  /** User indexes (optional) — created on the server's relational tables. */
32
56
  readonly indexes?: readonly IndexSchema[];
57
+ /** Declared references (§6.11). Optional so pre-reference schemas stay
58
+ * valid; the reference index over each child column is declared in
59
+ * `indexes` (typegen emits it). */
60
+ readonly references?: readonly ReferenceSchema[];
33
61
  /**
34
62
  * Server-side column materialization
35
63
  * "optional materialization"). When `true` (the usual default) the server's
@@ -66,6 +94,10 @@ export interface CompiledTable {
66
94
  readonly scopePatterns: readonly CompiledScopePattern[];
67
95
  /** User indexes (validated: unique names, existing columns). */
68
96
  readonly indexes: readonly IndexSchema[];
97
+ /** Outgoing declared references (§6.11), resolved to column indices. */
98
+ readonly references: readonly CompiledReference[];
99
+ /** Incoming declared references: child rows to reach on a parent delete. */
100
+ readonly referencedBy: readonly CompiledIncomingReference[];
69
101
  /** Resolved materialization (see `TableSchema.materialize`). */
70
102
  readonly materialize: boolean;
71
103
  readonly columnIndex: ReadonlyMap<string, number>;
package/dist/schema.js CHANGED
@@ -88,6 +88,19 @@ export function compileSchema(schema) {
88
88
  const blobRefColumnIndices = [];
89
89
  const crdtColumns = [];
90
90
  const encryptedColumnIndices = [];
91
+ const references = [];
92
+ for (const reference of table.references ?? []) {
93
+ const index = columnIndex.get(reference.column);
94
+ if (index === undefined) {
95
+ throw new Error(`table ${table.name}: reference column ${JSON.stringify(reference.column)} is not a column`);
96
+ }
97
+ references.push({
98
+ column: reference.column,
99
+ columnIndex: index,
100
+ parentTable: reference.parentTable,
101
+ onDelete: reference.onDelete ?? 'RESTRICT',
102
+ });
103
+ }
91
104
  table.columns.forEach((column, index) => {
92
105
  if (column.type === 'blob_ref')
93
106
  blobRefColumnIndices.push(index);
@@ -118,6 +131,8 @@ export function compileSchema(schema) {
118
131
  primaryKeyIndex,
119
132
  scopePatterns,
120
133
  indexes,
134
+ references,
135
+ referencedBy: [],
121
136
  materialize,
122
137
  columnIndex,
123
138
  declaredVariables: variables,
@@ -126,6 +141,46 @@ export function compileSchema(schema) {
126
141
  encryptedColumnIndices,
127
142
  });
128
143
  }
144
+ // §6.11: resolve incoming references and enforce the declaration rules the
145
+ // generator enforces too, so a hand-written server schema fails loud.
146
+ for (const child of tables.values()) {
147
+ for (const reference of child.references) {
148
+ const parent = tables.get(reference.parentTable);
149
+ if (parent === undefined) {
150
+ throw new Error(`table ${child.name}: reference column ${JSON.stringify(reference.column)} names unknown table ${JSON.stringify(reference.parentTable)}`);
151
+ }
152
+ const childColumn = child.columns[reference.columnIndex];
153
+ const parentKey = parent.columns[parent.primaryKeyIndex];
154
+ if (childColumn === undefined || parentKey === undefined) {
155
+ throw new Error(`unreachable: ${child.name}.${reference.column} or a missing primary key on ${parent.name}`);
156
+ }
157
+ if (childColumn.type !== parentKey.type) {
158
+ throw new Error(`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)`);
159
+ }
160
+ if (reference.onDelete === 'SET NULL' && !childColumn.nullable) {
161
+ throw new Error(`table ${child.name}: reference column ${JSON.stringify(reference.column)} is not nullable but declares ON DELETE SET NULL (§6.11)`);
162
+ }
163
+ const patterns = (compiled) => compiled.scopePatterns
164
+ .map((pattern) => `${pattern.prefix}:{${pattern.variable}}`)
165
+ .sort()
166
+ .join('\u0000');
167
+ if (patterns(child) !== patterns(parent)) {
168
+ throw new Error(`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)`);
169
+ }
170
+ const index = child.indexes.find((candidate) => candidate.columns.length === 1 &&
171
+ candidate.columns[0] === reference.column);
172
+ if (index === undefined) {
173
+ throw new Error(`table ${child.name}: reference column ${JSON.stringify(reference.column)} needs a declared single-column index so the server reaches children through scanRowsByIndex (§6.11)`);
174
+ }
175
+ parent.referencedBy.push({
176
+ table: child.name,
177
+ column: reference.column,
178
+ columnIndex: reference.columnIndex,
179
+ onDelete: reference.onDelete,
180
+ index: index.name,
181
+ });
182
+ }
183
+ }
129
184
  const compiled = {
130
185
  version: schema.version,
131
186
  tables,
package/dist/seed.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * Idempotent by construction (§2.3): the commit id is stable, so re-running
10
10
  * the seed replays the cached push result and writes nothing twice.
11
11
  */
12
- import { decodeMessage, encodeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
12
+ import { decodeMessage, encodeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
13
13
  import { SyncError } from './errors.js';
14
14
  import { composeEvents } from './events-ring.js';
15
15
  import { handleSyncRequest } from './handler.js';
@@ -102,7 +102,7 @@ export async function seedMutations(config, target, mutations) {
102
102
  table: mutation.table,
103
103
  rowId,
104
104
  op: 'upsert',
105
- payload: encodeRow(table.columns, values),
105
+ payload: encodeSparseRow(table.columns, pkIndex, values),
106
106
  };
107
107
  });
108
108
  const requestContext = {
@@ -30,7 +30,7 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
30
30
  * the Postgres storage documents (§3.1, performance-by-
31
31
  * construction).
32
32
  */
33
- export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
33
+ export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\nCREATE TABLE IF NOT EXISTS sync_tombstones(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, row_id)\n);\n";
34
34
  /** Split the DDL into individual statements (D1 applies them one by one). */
35
35
  export declare function sqliteDdlStatements(): string[];
36
36
  /** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
@@ -44,6 +44,7 @@ export interface SqliteRowRecord {
44
44
  server_version: number;
45
45
  scopes: string;
46
46
  payload: Uint8Array;
47
+ column_versions: Uint8Array | null;
47
48
  }
48
49
  export interface SqliteChangeRecord {
49
50
  tbl: string;
@@ -89,6 +89,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
89
89
  );
90
90
  CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
91
91
  ON sync_blob_refs(partition, blob_id);
92
+ CREATE TABLE IF NOT EXISTS sync_tombstones(
93
+ partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
94
+ commit_seq INTEGER NOT NULL,
95
+ PRIMARY KEY(partition, tbl, row_id)
96
+ );
92
97
  `;
93
98
  /** Split the DDL into individual statements (D1 applies them one by one). */
94
99
  export function sqliteDdlStatements() {
@@ -135,6 +140,7 @@ export function serializePushResult(result) {
135
140
  message: record.message,
136
141
  serverVersion: record.serverVersion,
137
142
  serverRow: bytesToBase64(record.serverRow),
143
+ conflictColumns: bytesToBase64(record.conflictColumns),
138
144
  };
139
145
  }
140
146
  if (record.status === 'error') {
@@ -162,6 +168,7 @@ export function deserializePushResult(text) {
162
168
  message: record.message ?? '',
163
169
  serverVersion: record.serverVersion ?? 0,
164
170
  serverRow: base64ToBytes(record.serverRow ?? ''),
171
+ conflictColumns: base64ToBytes(record.conflictColumns ?? ''),
165
172
  };
166
173
  }
167
174
  if (record.status === 'error') {
@@ -204,6 +211,9 @@ export function toStoredRow(record) {
204
211
  serverVersion: record.server_version,
205
212
  scopes: JSON.parse(record.scopes),
206
213
  payload: asUint8Array(record.payload),
214
+ ...(record.column_versions !== null
215
+ ? { columnVersions: asUint8Array(record.column_versions) }
216
+ : {}),
207
217
  };
208
218
  }
209
219
  export function toStoredChange(record) {
@@ -62,6 +62,19 @@ class SqliteTransaction {
62
62
  this.#assertOpen();
63
63
  return this.#storage.getRow(this.#partition, table, rowId);
64
64
  }
65
+ async getTombstoneSeq(table, rowId) {
66
+ this.#assertOpen();
67
+ const row = this.#storage.db
68
+ .query('SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
69
+ .get(this.#partition, table, rowId);
70
+ return row?.commit_seq;
71
+ }
72
+ async clearTombstone(table, rowId) {
73
+ this.#assertOpen();
74
+ this.#storage.db
75
+ .query('DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?')
76
+ .run(this.#partition, table, rowId);
77
+ }
65
78
  getPushResult(clientId, clientCommitId) {
66
79
  this.#assertOpen();
67
80
  // One shared SQLite connection: this read runs inside this
@@ -136,6 +149,12 @@ class SqliteTransaction {
136
149
  db.query('INSERT INTO sync_commits(partition, commit_seq, client_id, client_commit_id, actor_id, created_at_ms) VALUES (?,?,?,?,?,?)').run(p, commitSeq, commit.clientId, commit.clientCommitId, commit.actorId, commit.createdAtMs);
137
150
  commit.changes.forEach((change, idx) => {
138
151
  db.query('INSERT INTO sync_changes(partition, commit_seq, idx, tbl, row_id, op, row_version, scopes, payload) VALUES (?,?,?,?,?,?,?,?,?)').run(p, commitSeq, idx, change.table, change.rowId, change.op === 'upsert' ? 1 : 2, change.rowVersion ?? null, JSON.stringify(change.scopes), change.payload ?? null);
152
+ if (change.op === 'delete') {
153
+ // §5 delete precedence: every applied delete leaves a tombstone,
154
+ // pruned with the commit log (§4.6).
155
+ db.query(`INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
156
+ ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`).run(p, change.table, change.rowId, commitSeq);
157
+ }
139
158
  for (const [variable, value] of Object.entries(change.scopes)) {
140
159
  db.query('INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)').run(p, change.table, variable, value, commitSeq);
141
160
  }
@@ -525,6 +544,10 @@ export class SqliteServerStorage {
525
544
  this.db
526
545
  .query('DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?')
527
546
  .run(partition, horizonSeq);
547
+ // §4.6: tombstones prune in the same pass as the commit log.
548
+ this.db
549
+ .query('DELETE FROM sync_tombstones WHERE partition=? AND commit_seq<=?')
550
+ .run(partition, horizonSeq);
528
551
  this.db.exec('COMMIT');
529
552
  return {
530
553
  previousHorizonSeq,
package/dist/storage.d.ts CHANGED
@@ -37,6 +37,13 @@ export interface StoredRow {
37
37
  readonly scopes: Record<string, string>;
38
38
  /** Row-codec payload (§2.4) for the server's schema version. */
39
39
  readonly payload: Uint8Array;
40
+ /**
41
+ * §2.2 per-column versions: the `_sync_column_versions` blob (LEB128
42
+ * (ordinal, version) pairs for columns above 1). Absent when no column
43
+ * exceeds 1, and for rows written before column versions existed — a row
44
+ * without the blob carries every column at `serverVersion`.
45
+ */
46
+ readonly columnVersions?: Uint8Array;
40
47
  }
41
48
  export interface NewChange {
42
49
  readonly table: string;
@@ -271,6 +278,20 @@ export interface AuthoritativeQueryResult {
271
278
  */
272
279
  export interface StorageTransaction {
273
280
  getRow(table: string, rowId: string): Promise<StoredRow | undefined>;
281
+ /**
282
+ * §5 delete-precedence tombstone read: the `commit_seq` of the newest
283
+ * applied delete of this row still within the pruning horizon, or
284
+ * `undefined` when no tombstone exists. Consulted only when an upsert's
285
+ * stored row is absent. Required: in-tree adapters implement it on the
286
+ * transaction's own connection with read-your-own-writes semantics.
287
+ */
288
+ getTombstoneSeq(table: string, rowId: string): Promise<number | undefined>;
289
+ /**
290
+ * Remove the row's tombstone because the row was recreated in this
291
+ * transaction (§5: an explicit insert recreates). Called on every insert
292
+ * over an absent row; a no-op when no tombstone exists.
293
+ */
294
+ clearTombstone(table: string, rowId: string): Promise<void>;
274
295
  /**
275
296
  * Optional transaction-scoped idempotency lookup (§2.3) with the same
276
297
  * semantics as `ServerStorage.getPushResult`, including the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
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.20.1"
71
+ "@syncular/core": "0.21.1"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@electric-sql/pglite": "^0.5.4"
package/src/context.ts CHANGED
@@ -78,11 +78,14 @@ export interface ServerLimits {
78
78
  readonly maxOperationsPerRequest: number;
79
79
  /** Inline rows segments up to this size (§5.7 SHOULD: 256 KiB). */
80
80
  readonly inlineSegmentMaxBytes: number;
81
+ /** §6.11 declared-reference cascade cap per commit (reference default). */
82
+ readonly maxCascadeOperationsPerCommit: number;
81
83
  }
82
84
 
83
85
  export const DEFAULT_LIMITS: ServerLimits = {
84
86
  maxOperationsPerRequest: 500,
85
87
  inlineSegmentMaxBytes: 256 * 1024,
88
+ maxCascadeOperationsPerCommit: 1_000,
86
89
  };
87
90
 
88
91
  /** Receives applied commits for realtime fanout (§8.2). */
package/src/d1-storage.ts CHANGED
@@ -271,6 +271,31 @@ class D1Transaction implements StorageTransaction {
271
271
  return record === null ? undefined : toStoredRow(record);
272
272
  }
273
273
 
274
+ async getTombstoneSeq(
275
+ table: string,
276
+ rowId: string,
277
+ ): Promise<number | undefined> {
278
+ this.#assertOpen();
279
+ // Persisted read, no #pending overlay: a delete buffered by this commit
280
+ // records its tombstone at appendCommit, matching the SQLite/Postgres
281
+ // adapters where the tombstone insert also runs at commit append.
282
+ const record = await this.#db
283
+ .prepare(
284
+ 'SELECT commit_seq FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
285
+ )
286
+ .bind(this.#partition, table, rowId)
287
+ .first<{ commit_seq: number }>();
288
+ return record?.commit_seq;
289
+ }
290
+
291
+ async clearTombstone(table: string, rowId: string): Promise<void> {
292
+ this.#assertOpen();
293
+ this.#buffer_(
294
+ 'DELETE FROM sync_tombstones WHERE partition=? AND tbl=? AND row_id=?',
295
+ [this.#partition, table, rowId],
296
+ );
297
+ }
298
+
274
299
  async getPushResult(
275
300
  clientId: string,
276
301
  clientCommitId: string,
@@ -637,6 +662,15 @@ class D1Transaction implements StorageTransaction {
637
662
  change.payload ?? null,
638
663
  ],
639
664
  );
665
+ if (change.op === 'delete') {
666
+ // §5 delete precedence: every applied delete leaves a tombstone,
667
+ // pruned with the commit log (§4.6).
668
+ this.#buffer_(
669
+ `INSERT INTO sync_tombstones(partition, tbl, row_id, commit_seq) VALUES (?,?,?,?)
670
+ ON CONFLICT(partition, tbl, row_id) DO UPDATE SET commit_seq=excluded.commit_seq`,
671
+ [p, change.table, change.rowId, commitSeq],
672
+ );
673
+ }
640
674
  for (const [variable, value] of Object.entries(change.scopes)) {
641
675
  this.#buffer_(
642
676
  'INSERT OR IGNORE INTO sync_change_scopes(partition, tbl, var, value, commit_seq) VALUES (?,?,?,?,?)',
@@ -1501,7 +1535,12 @@ export class D1ServerStorage implements ServerStorage {
1501
1535
  `SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`,
1502
1536
  )
1503
1537
  .bind(partition, partition, partition),
1504
- ...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) =>
1538
+ ...[
1539
+ 'sync_commits',
1540
+ 'sync_changes',
1541
+ 'sync_change_scopes',
1542
+ 'sync_tombstones',
1543
+ ].map((table) =>
1505
1544
  this.#db
1506
1545
  .prepare(
1507
1546
  `DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`,
package/src/errors.ts CHANGED
@@ -119,6 +119,22 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
119
119
  recommendedAction: 'forceResync',
120
120
  httpStatus: 404,
121
121
  },
122
+ // §5 delete precedence: an unversioned upsert lost to a delete inside the
123
+ // tombstone horizon. The client drops the operation on rebuild.
124
+ 'sync.row_deleted': {
125
+ category: 'not-found',
126
+ retryable: false,
127
+ recommendedAction: 'fixRequest',
128
+ httpStatus: 404,
129
+ },
130
+ // §6.11 declared references: absent parent, RESTRICT delete with live
131
+ // children, or the cascade cap. A push operation-result error only.
132
+ 'sync.reference_violation': {
133
+ category: 'invalid-request',
134
+ retryable: false,
135
+ recommendedAction: 'fixRequest',
136
+ httpStatus: 400,
137
+ },
122
138
  'sync.version_conflict': {
123
139
  category: 'conflict',
124
140
  retryable: false,
@@ -199,12 +215,6 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
199
215
  recommendedAction: 'upgradeClient',
200
216
  httpStatus: 400,
201
217
  },
202
- 'sync.client_wire_unsupported': {
203
- category: 'schema-mismatch',
204
- retryable: false,
205
- recommendedAction: 'upgradeClient',
206
- httpStatus: 400,
207
- },
208
218
  'sync.websocket_connection_limit': {
209
219
  category: 'rate-limited',
210
220
  retryable: true,