@syncular/client 0.30.7 → 0.30.10

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.
@@ -0,0 +1,102 @@
1
+ import { ClientSyncError } from './errors.js';
2
+ import { jsonToRowValue, quoteIdent } from './schema.js';
3
+ import { deleteLocalRow, upsertLocalRow } from './apply.js';
4
+ /** Protected failure state. The server base advances independently of intent. */
5
+ export function ensureFailedOverlaySchema(db) {
6
+ db.exec(`CREATE TABLE IF NOT EXISTS _syncular_failed_rows(
7
+ commit_id TEXT NOT NULL, idx INTEGER NOT NULL,
8
+ tbl TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL,
9
+ op TEXT NOT NULL, intent TEXT, base TEXT, version INTEGER,
10
+ PRIMARY KEY(commit_id, idx))`);
11
+ }
12
+ export function retainedBaseWrite(db, table, rowId, values, version) {
13
+ db.exec('UPDATE _syncular_failed_rows SET base = ?, version = ? WHERE tbl = ? AND id = ?', [
14
+ values === undefined ? null : JSON.stringify(values),
15
+ version ?? null,
16
+ table,
17
+ rowId,
18
+ ]);
19
+ }
20
+ export function retainFailedRows(db, commit, images) {
21
+ for (const [index, operation] of commit.operations.entries()) {
22
+ const image = images.find((image) => image.opIndex === index);
23
+ const initial = operation.op === 'upsert'
24
+ ? { ...image?.values, ...operation.values }
25
+ : undefined;
26
+ if (!image)
27
+ throw new ClientSyncError('sync.local_corrupt', 'retained intent has no before-image');
28
+ db.exec(`INSERT INTO _syncular_failed_rows(commit_id, idx, tbl, id, at, op, intent, base, version)
29
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
30
+ commit.clientCommitId,
31
+ index,
32
+ operation.table,
33
+ operation.rowId,
34
+ commit.createdAtMs,
35
+ JSON.stringify(operation),
36
+ initial ? JSON.stringify(initial) : null,
37
+ image.values ? JSON.stringify(image.values) : null,
38
+ image.syncVersion ?? null,
39
+ ]);
40
+ }
41
+ }
42
+ export function restoreFailedBases(db, schema, absentIntent = false) {
43
+ const seen = new Set();
44
+ for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
45
+ const key = JSON.stringify([row.tbl, row.id]);
46
+ if (!absentIntent && seen.has(key))
47
+ continue;
48
+ seen.add(key);
49
+ const table = schema.tables.get(String(row.tbl));
50
+ if (!table)
51
+ throw new ClientSyncError('sync.unknown_table', 'retained intent references an unknown table');
52
+ const json = absentIntent ? row.intent : row.base;
53
+ if (absentIntent &&
54
+ (json === null ||
55
+ db.query(`SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [String(row.id)]).length))
56
+ continue;
57
+ if (json === null)
58
+ deleteLocalRow(db, table, String(row.id), false);
59
+ else {
60
+ const values = JSON.parse(String(json));
61
+ if (absentIntent) {
62
+ const operation = JSON.parse(String(row.op));
63
+ if (!table.columns.every((column) => Object.hasOwn(operation.values ?? {}, column.name)))
64
+ continue;
65
+ }
66
+ upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), Number(row.version ?? -1), false);
67
+ }
68
+ }
69
+ }
70
+ export function failedOverlayCommits(db) {
71
+ const commits = new Map();
72
+ for (const row of db.query('SELECT * FROM _syncular_failed_rows ORDER BY at, commit_id, idx')) {
73
+ const id = String(row.commit_id);
74
+ const operation = JSON.parse(String(row.op));
75
+ const prior = commits.get(id);
76
+ if (prior)
77
+ prior.operations.push(operation);
78
+ else
79
+ commits.set(id, {
80
+ seq: 0,
81
+ clientCommitId: id,
82
+ createdAtMs: Number(row.at),
83
+ operations: [operation],
84
+ });
85
+ }
86
+ return [...commits.values()];
87
+ }
88
+ export function dropFailedRows(db, matches) {
89
+ const ids = new Set();
90
+ for (const row of db.query('SELECT * FROM _syncular_failed_rows')) {
91
+ const json = row.intent ?? row.base;
92
+ if (json === null)
93
+ continue;
94
+ const values = JSON.parse(String(json));
95
+ if (matches(String(row.tbl), values))
96
+ ids.add(String(row.commit_id));
97
+ }
98
+ const dropped = failedOverlayCommits(db).filter((commit) => ids.has(commit.clientCommitId));
99
+ for (const id of ids)
100
+ db.exec('DELETE FROM _syncular_failed_rows WHERE commit_id = ?', [id]);
101
+ return dropped;
102
+ }
@@ -83,6 +83,7 @@ export declare class ChangeAccumulator {
83
83
  scopeMap(table: CompiledClientTable, scopes: ScopeMap): void;
84
84
  /** Add precise keys for a COMMIT change's stored scope values. */
85
85
  changeScopes(table: CompiledClientTable, scopes: Readonly<Record<string, string>>): void;
86
+ hasTable(name: string): boolean;
86
87
  get touched(): boolean;
87
88
  get statusChanged(): boolean;
88
89
  finish(revision: LocalRevision, status: SyncStatusSnapshot | undefined): ClientChangeBatch;
@@ -73,6 +73,9 @@ export class ChangeAccumulator {
73
73
  }
74
74
  }
75
75
  }
76
+ hasTable(name) {
77
+ return this.#tables.has(name);
78
+ }
76
79
  get touched() {
77
80
  return (this.#tables.size > 0 ||
78
81
  this.#windows.size > 0 ||
@@ -50,6 +50,15 @@ export type CommitOperationOutcome = {
50
50
  readonly status: 'error';
51
51
  readonly rejection: RejectionRecord;
52
52
  };
53
+ export interface RetainedCommitRow {
54
+ readonly table: string;
55
+ readonly rowId: string;
56
+ /** Complete intended row, or null for a local deletion. */
57
+ readonly localRow: Readonly<Record<string, RowValue>> | null;
58
+ /** Latest authorized server base, or null for an absent server row. */
59
+ readonly serverRow: Readonly<Record<string, RowValue>> | null;
60
+ readonly serverVersion: number | null;
61
+ }
53
62
  export interface CommitOutcome {
54
63
  /** Monotonic local journal order; not a server sequence. */
55
64
  readonly sequence: number;
@@ -63,6 +72,7 @@ export interface CommitOutcome {
63
72
  * for successful and historical outcomes. Never sent over the wire.
64
73
  */
65
74
  readonly operations?: readonly OutboxOperation[];
75
+ readonly retainedRows?: readonly RetainedCommitRow[];
66
76
  readonly resolution: CommitOutcomeResolution;
67
77
  readonly resolvedAtMs?: number;
68
78
  readonly replacementClientCommitId?: string;
package/dist/outcomes.js CHANGED
@@ -36,7 +36,21 @@ function decodeResults(raw) {
36
36
  };
37
37
  });
38
38
  }
39
- function parseOutcome(row) {
39
+ function parseOutcome(db, row) {
40
+ const retainedRows = db
41
+ .query('SELECT tbl, id, intent, base, version FROM _syncular_failed_rows WHERE commit_id = ? ORDER BY idx', [String(row.client_commit_id)])
42
+ .map((retained) => {
43
+ const decode = (value) => value === null
44
+ ? null
45
+ : Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
46
+ return {
47
+ table: String(retained.tbl),
48
+ rowId: String(retained.id),
49
+ localRow: decode(retained.intent),
50
+ serverRow: decode(retained.base),
51
+ serverVersion: retained.version === null ? null : Number(retained.version),
52
+ };
53
+ });
40
54
  return {
41
55
  sequence: row.seq,
42
56
  clientCommitId: row.client_commit_id,
@@ -46,6 +60,7 @@ function parseOutcome(row) {
46
60
  ...(typeof row.operations === 'string'
47
61
  ? { operations: JSON.parse(row.operations) }
48
62
  : {}),
63
+ ...(retainedRows.length > 0 ? { retainedRows } : {}),
49
64
  resolution: row.resolution,
50
65
  ...(typeof row.resolved_at_ms === 'number'
51
66
  ? { resolvedAtMs: row.resolved_at_ms }
@@ -73,7 +88,7 @@ export function commitOutcome(db, clientCommitId) {
73
88
  const row = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results, operations,
74
89
  resolution, resolved_at_ms, replacement_client_commit_id
75
90
  FROM _syncular_commit_outcomes WHERE client_commit_id = ?`, [clientCommitId])[0];
76
- return row === undefined ? undefined : parseOutcome(row);
91
+ return row === undefined ? undefined : parseOutcome(db, row);
77
92
  }
78
93
  export function listCommitOutcomes(db, query = {}) {
79
94
  const limit = query.limit;
@@ -87,7 +102,7 @@ export function listCommitOutcomes(db, query = {}) {
87
102
  resolution, resolved_at_ms, replacement_client_commit_id
88
103
  FROM _syncular_commit_outcomes ${where}
89
104
  ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
90
- return rows.map(parseOutcome);
105
+ return rows.map((row) => parseOutcome(db, row));
91
106
  }
92
107
  export function persistCommitOutcomeResolution(db, input, nowMs) {
93
108
  db.exec(`UPDATE _syncular_commit_outcomes
package/dist/remote.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeSparseRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
6
6
  import { encryptRowValues } from './encryption.js';
7
7
  import { ClientSyncError } from './errors.js';
8
- import { compileClientSchema, recordToRowValues, } from './schema.js';
8
+ import { compileClientSchema, recordToRowValues, normalizeRecordKeys, coerceSqlRepresentation, } from './schema.js';
9
9
  export function remoteCommand(id) {
10
10
  if (id.length === 0)
11
11
  throw invalid('remote command id must be non-empty');
@@ -157,7 +157,12 @@ export class SyncRemoteClient {
157
157
  });
158
158
  continue;
159
159
  }
160
- let values = recordToRowValues(table, mutation.values);
160
+ const normalized = normalizeRecordKeys(table, mutation.values);
161
+ let values = mutation.op === 'patch'
162
+ ? table.columns.map((column) => normalized.has(column.name)
163
+ ? coerceSqlRepresentation(column, normalized.get(column.name))
164
+ : undefined)
165
+ : recordToRowValues(table, mutation.values);
161
166
  const rowId = values[table.primaryKeyIndex];
162
167
  if (typeof rowId !== 'string' || rowId.length === 0) {
163
168
  throw invalid('remote upsert requires a non-empty string primary key');
package/dist/schema.js CHANGED
@@ -1,4 +1,5 @@
1
- import { ClientSyncError } from './errors.js';
1
+ import { ensureFailedOverlaySchema } from './failed-overlay.js';
2
+ import { invalidRequest } from './errors.js';
2
3
  import { snakeToCamel } from './naming.js';
3
4
  const PATTERN_RE = /^([^{}]+):\{([^{}:]+)\}$/;
4
5
  const ALLOWED_FTS_TOKENIZERS = new Set([
@@ -361,6 +362,7 @@ export function ensureLocalSyncedSchema(db, schema) {
361
362
  */
362
363
  export function ensureLocalBookkeepingSchema(db) {
363
364
  db.transaction(() => {
365
+ ensureFailedOverlaySchema(db);
364
366
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
365
367
  key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
366
368
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
@@ -510,13 +512,13 @@ export function normalizeRecordKeys(table, record) {
510
512
  const index = table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
511
513
  if (index === undefined) {
512
514
  if (key.startsWith('_sync_')) {
513
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: ${JSON.stringify(key)} is an internal sync column and cannot appear in mutation values — did you build this record from a raw SELECT * row? (client.query() strips _sync_* columns; rows read via client.database keep them)`);
515
+ throw invalidRequest(`table ${table.name}: ${JSON.stringify(key)} is an internal sync column and cannot appear in mutation values — did you build this record from a raw SELECT * row? (client.query() strips _sync_* columns; rows read via client.database keep them)`);
514
516
  }
515
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`);
517
+ throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`);
516
518
  }
517
519
  const sqlName = table.columns[index].name;
518
520
  if (normalized.has(sqlName)) {
519
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`);
521
+ throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`);
520
522
  }
521
523
  normalized.set(sqlName, value);
522
524
  }
@@ -551,7 +553,7 @@ export function recordToRowValues(table, record) {
551
553
  const value = normalized.get(column.name);
552
554
  if (value === undefined || value === null) {
553
555
  if (!column.nullable) {
554
- throw new ClientSyncError('sync.invalid_request', `table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1 full-row payloads)`);
556
+ throw invalidRequest(`table ${table.name}: column ${JSON.stringify(column.name)} is not nullable (§6.1 full-row payloads)`);
555
557
  }
556
558
  return null;
557
559
  }
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
26
26
  import { assertImageAlias, runTransaction, } from './database.js';
27
- import { ClientSyncError, STORAGE_BUSY_CODE, STORAGE_UNAVAILABLE_CODE, } from './errors.js';
27
+ import { ClientSyncError, invalidRequest, STORAGE_BUSY_CODE, STORAGE_UNAVAILABLE_CODE, } from './errors.js';
28
28
  function coerceParams(params) {
29
29
  return params.map((value) => {
30
30
  if (typeof value === 'boolean')
@@ -108,7 +108,7 @@ class WasmClientDatabase {
108
108
  assertImageAlias(alias);
109
109
  const pointer = this.#db.pointer;
110
110
  if (pointer === undefined) {
111
- throw new ClientSyncError('sync.invalid_request', 'sqlite-wasm database exposes no native handle for image import');
111
+ throw invalidRequest('sqlite-wasm database exposes no native handle for image import');
112
112
  }
113
113
  const { capi, wasm } = this.#sqlite3;
114
114
  this.exec(`ATTACH ':memory:' AS ${alias}`);
@@ -119,7 +119,7 @@ class WasmClientDatabase {
119
119
  // immutable inputs (§5.1).
120
120
  capi.SQLITE_DESERIALIZE_FREEONCLOSE | capi.SQLITE_DESERIALIZE_READONLY);
121
121
  if (rc !== 0) {
122
- throw new ClientSyncError('sync.invalid_request', `sqlite3_deserialize failed with code ${rc} (§5.3)`);
122
+ throw invalidRequest(`sqlite3_deserialize failed with code ${rc} (§5.3)`);
123
123
  }
124
124
  return await fn();
125
125
  }
@@ -235,10 +235,10 @@ function configureSahCrashRecovery(db, sqlite3) {
235
235
  */
236
236
  export async function openPersistentWasmDatabase(name, options) {
237
237
  if (!/^[A-Za-z0-9._-]+$/.test(name)) {
238
- throw new ClientSyncError('sync.invalid_request', `invalid persistent database name ${JSON.stringify(name)}`);
238
+ throw invalidRequest(`invalid persistent database name ${JSON.stringify(name)}`);
239
239
  }
240
240
  if (!inWorkerContext()) {
241
- throw new ClientSyncError('sync.invalid_request', 'openPersistentWasmDatabase() must run inside a Web Worker: the ' +
241
+ throw invalidRequest('openPersistentWasmDatabase() must run inside a Web Worker: the ' +
242
242
  'persistent mode is whole-core-in-a-worker by design (Direction ' +
243
243
  'decision 2, 2026-07-03). Use the worker handle from the main ' +
244
244
  'thread, or openWasmDatabase() for explicitly ephemeral state.');
package/dist/window.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * transaction and the invalidation choke point.
14
14
  */
15
15
  import { canonicalScopeJson } from '@syncular/core';
16
- import { ClientSyncError } from './errors.js';
16
+ import { invalidRequest } from './errors.js';
17
17
  const MAX_TIME_BUCKET_MS = 253_402_300_799_999;
18
18
  function monthBucket(year, month) {
19
19
  return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}`;
@@ -24,7 +24,7 @@ export function creationTimeBucket(createdAtMs, unit) {
24
24
  !Number.isSafeInteger(createdAtMs) ||
25
25
  createdAtMs < 0 ||
26
26
  createdAtMs > MAX_TIME_BUCKET_MS) {
27
- throw new ClientSyncError('sync.invalid_request', 'creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999');
27
+ throw invalidRequest('creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999');
28
28
  }
29
29
  const date = new Date(createdAtMs);
30
30
  return monthBucket(date.getUTCFullYear(), date.getUTCMonth() + 1);
@@ -38,12 +38,12 @@ export function last(count, unit, nowMs = Date.now()) {
38
38
  !Number.isSafeInteger(nowMs) ||
39
39
  nowMs < 0 ||
40
40
  nowMs > MAX_TIME_BUCKET_MS) {
41
- throw new ClientSyncError('sync.invalid_request', 'last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999');
41
+ throw invalidRequest('last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999');
42
42
  }
43
43
  const date = new Date(nowMs);
44
44
  const current = date.getUTCFullYear() * 12 + date.getUTCMonth();
45
45
  if (current - (count - 1) < 1970 * 12) {
46
- throw new ClientSyncError('sync.invalid_request', 'last requires every returned UTC month to fall from 1970 through 9999');
46
+ throw invalidRequest('last requires every returned UTC month to fall from 1970 through 9999');
47
47
  }
48
48
  const units = [];
49
49
  for (let offset = count - 1; offset >= 0; offset -= 1) {
@@ -250,6 +250,9 @@ export function startSyncWorker(overrides = {}) {
250
250
  },
251
251
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
252
252
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
253
+ ...(config.retainFailedCommits !== undefined
254
+ ? { retainFailedCommits: config.retainFailedCommits }
255
+ : {}),
253
256
  ...(config.realtimePolicy !== undefined
254
257
  ? { realtimePolicy: config.realtimePolicy }
255
258
  : {}),
@@ -87,6 +87,7 @@ export interface SyncClientHandleConfig {
87
87
  readonly securityPreflight?: boolean;
88
88
  readonly clientId?: string;
89
89
  readonly limits?: SyncClientLimits;
90
+ readonly retainFailedCommits?: boolean;
90
91
  /**
91
92
  * SPEC §8.8 realtime policy for the worker-owned core, forwarded into
92
93
  * `WorkerInitConfig`. Absent ⇒ `optional`.
@@ -545,6 +545,9 @@ function buildInitConfig(config, headers = config.headers) {
545
545
  : {}),
546
546
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
547
547
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
548
+ ...(config.retainFailedCommits !== undefined
549
+ ? { retainFailedCommits: config.retainFailedCommits }
550
+ : {}),
548
551
  ...(config.realtimePolicy !== undefined
549
552
  ? { realtimePolicy: config.realtimePolicy }
550
553
  : {}),
@@ -80,6 +80,7 @@ export interface WorkerInitConfig {
80
80
  readonly securityPreflight?: boolean;
81
81
  readonly clientId?: string;
82
82
  readonly limits?: SyncClientLimits;
83
+ readonly retainFailedCommits?: boolean;
83
84
  /**
84
85
  * SPEC §8.8 realtime policy for the worker-owned core; forwarded verbatim.
85
86
  * Absent ⇒ `optional` (a connected socket, otherwise `POST /sync`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.7",
3
+ "version": "0.30.10",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -89,10 +89,11 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.30.7"
92
+ "@syncular/core": "0.30.10"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.7",
96
- "playwright": "1.63.0"
95
+ "@syncular/server": "0.30.10",
96
+ "playwright": "1.63.0",
97
+ "@electric-sql/pglite": "^0.5.4"
97
98
  }
98
99
  }
package/src/apply.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { retainedBaseWrite } from './failed-overlay';
1
2
  /**
2
3
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
3
4
  * (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
@@ -13,13 +14,14 @@ import {
13
14
  } from '@syncular/core';
14
15
  import type { ClientDatabase, SqlValue } from './database';
15
16
  import type { EncryptionConfig } from './encryption';
16
- import { ClientSyncError } from './errors';
17
+ import { ClientSyncError, invalidRequest } from './errors';
17
18
  import {
18
19
  type CompiledClientSchema,
19
20
  type CompiledClientTable,
20
21
  quoteIdent,
21
22
  SYNC_VERSION_COLUMN,
22
23
  toSqlValue,
24
+ rowValueToJson,
23
25
  } from './schema';
24
26
 
25
27
  /** Lets the client wrap the physical write in its revision transaction. */
@@ -44,7 +46,21 @@ export function upsertLocalRow(
44
46
  table: CompiledClientTable,
45
47
  values: readonly RowValue[],
46
48
  syncVersion: number,
49
+ serverWrite = true,
47
50
  ): void {
51
+ if (serverWrite)
52
+ retainedBaseWrite(
53
+ db,
54
+ table.name,
55
+ String(values[table.primaryKeyIndex]),
56
+ Object.fromEntries(
57
+ table.columns.map((column, index) => [
58
+ column.name,
59
+ rowValueToJson(values[index] ?? null),
60
+ ]),
61
+ ),
62
+ syncVersion,
63
+ );
48
64
  db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
49
65
  }
50
66
 
@@ -52,7 +68,9 @@ export function deleteLocalRow(
52
68
  db: ClientDatabase,
53
69
  table: CompiledClientTable,
54
70
  rowId: string,
71
+ serverWrite = true,
55
72
  ): void {
73
+ if (serverWrite) retainedBaseWrite(db, table.name, rowId);
56
74
  db.exec(
57
75
  `DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
58
76
  [rowId],
@@ -90,8 +108,7 @@ export async function applyCommitFrame(
90
108
  for (const change of frame.changes) {
91
109
  const tableName = frame.tables[change.tableIndex];
92
110
  if (tableName === undefined) {
93
- throw new ClientSyncError(
94
- 'sync.invalid_request',
111
+ throw invalidRequest(
95
112
  `COMMIT change tableIndex ${change.tableIndex} out of range`,
96
113
  );
97
114
  }
@@ -107,10 +124,7 @@ export async function applyCommitFrame(
107
124
  continue;
108
125
  }
109
126
  if (change.row === undefined || change.rowVersion === undefined) {
110
- throw new ClientSyncError(
111
- 'sync.invalid_request',
112
- 'upsert change without row payload',
113
- );
127
+ throw invalidRequest('upsert change without row payload');
114
128
  }
115
129
  let values = decodeRow(table.columns, change.row);
116
130
  if (encryption !== undefined && table.hasEncryptedColumns) {
@@ -204,6 +218,12 @@ export function deleteScopedRows(
204
218
  );
205
219
  params.push(...values);
206
220
  }
221
+ for (const row of db.query(
222
+ `SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
223
+ params,
224
+ )) {
225
+ retainedBaseWrite(db, table.name, String(row.id));
226
+ }
207
227
  db.exec(
208
228
  `DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
209
229
  params,
@@ -283,10 +303,7 @@ export interface SqliteSegmentDescriptor {
283
303
  const IMAGE_ALIAS = 'syncular_image';
284
304
 
285
305
  function imageInvalid(detail: string): never {
286
- throw new ClientSyncError(
287
- 'sync.invalid_request',
288
- `sqlite segment rejected: ${detail} (§5.3)`,
289
- );
306
+ throw invalidRequest(`sqlite segment rejected: ${detail} (§5.3)`);
290
307
  }
291
308
 
292
309
  /**
@@ -313,8 +330,7 @@ export async function applySqliteSegment(
313
330
  ): Promise<number> {
314
331
  const withImage = db.withSqliteImage?.bind(db);
315
332
  if (withImage === undefined) {
316
- throw new ClientSyncError(
317
- 'sync.invalid_request',
333
+ throw invalidRequest(
318
334
  'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)',
319
335
  );
320
336
  }
@@ -422,6 +438,23 @@ export async function applySqliteSegment(
422
438
  params,
423
439
  );
424
440
  applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
441
+ for (const row of db.query(
442
+ `SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`,
443
+ params,
444
+ )) {
445
+ retainedBaseWrite(
446
+ db,
447
+ table.name,
448
+ String(row[table.primaryKey]),
449
+ Object.fromEntries(
450
+ table.columns.map((column) => [
451
+ column.name,
452
+ rowValueToJson(row[column.name] as RowValue),
453
+ ]),
454
+ ),
455
+ Number(row._syncular_version),
456
+ );
457
+ }
425
458
  });
426
459
  options.onProgress?.(applied);
427
460
  await yieldToHost();