@syncular/client 0.30.6 → 0.30.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -361,7 +361,7 @@ that rolled back with the terminating operation. It stays in the protected
361
361
  client database and is never added to the wire protocol, preferences, or
362
362
  telemetry; successful and historical outcomes may omit it.
363
363
 
364
- Rejected optimistic state is removed immediately, even when a validator emits
364
+ By default, rejected optimistic state is removed immediately, even when a validator emits
365
365
  no server row in the pull half. The TypeScript client stores protected,
366
366
  restart-safe before-images beside each pending outbox commit, restores the last
367
367
  confirmed rows (including rejected deletes and atomic siblings), and then
@@ -373,7 +373,17 @@ Use `patch(table, rowId, partial, { baseVersion? })` for editor-style partial
373
373
  updates. The wire carries a sparse operation: the primary key plus the supplied
374
374
  columns are present, and the durable local operation keeps the same presence
375
375
  set so conflict and rejection UI knows which columns the user touched. A
376
- full-row `mutate` marks every column present.
376
+ full-row `mutate` upsert marks every column present. `mutate` also accepts
377
+ `op: 'patch'` with the primary key and supplied columns. A batch can mix sparse
378
+ patches, upserts and deletes atomically. Omitted encrypted columns require no
379
+ key and retain their ciphertext.
380
+
381
+ Set `retainFailedCommits: true` to keep the complete failed aggregate visible
382
+ and durable after its outbox drains. Incoming server state advances a separate
383
+ base. `outcome.retainedRows` exposes complete intended rows and the latest
384
+ server rows and versions. Resolve explicitly with `resolved_keep_server`, or
385
+ create a reviewed replacement and link it through `superseded`. Security purge
386
+ and scope revocation remove whole retained aggregates.
377
387
 
378
388
  ## Application-authorized local security purge
379
389
 
package/dist/apply.d.ts CHANGED
@@ -9,8 +9,8 @@ import type { EncryptionConfig } from './encryption.js';
9
9
  import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
10
10
  /** Lets the client wrap the physical write in its revision transaction. */
11
11
  export type ApplyTransaction = <T>(fn: () => T) => T;
12
- export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number): void;
13
- export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string): void;
12
+ export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number, serverWrite?: boolean): void;
13
+ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string, serverWrite?: boolean): void;
14
14
  /**
15
15
  * Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
16
16
  * Upserts land with `_sync_version = rowVersion`; deletes remove the row.
package/dist/apply.js CHANGED
@@ -1,11 +1,12 @@
1
+ import { retainedBaseWrite } from './failed-overlay.js';
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
4
5
  * contract and the §5.6 first-page rule.
5
6
  */
6
7
  import { decodeRow, } from '@syncular/core';
7
- import { ClientSyncError } from './errors.js';
8
- import { quoteIdent, SYNC_VERSION_COLUMN, toSqlValue, } from './schema.js';
8
+ import { ClientSyncError, invalidRequest } from './errors.js';
9
+ import { quoteIdent, SYNC_VERSION_COLUMN, toSqlValue, rowValueToJson, } from './schema.js';
9
10
  function upsertSql(table) {
10
11
  const names = [
11
12
  ...table.columns.map((column) => quoteIdent(column.name)),
@@ -19,10 +20,17 @@ function upsertSql(table) {
19
20
  return `INSERT INTO ${quoteIdent(table.name)} (${names.join(', ')}) VALUES (${placeholders})
20
21
  ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`;
21
22
  }
22
- export function upsertLocalRow(db, table, values, syncVersion) {
23
+ export function upsertLocalRow(db, table, values, syncVersion, serverWrite = true) {
24
+ if (serverWrite)
25
+ retainedBaseWrite(db, table.name, String(values[table.primaryKeyIndex]), Object.fromEntries(table.columns.map((column, index) => [
26
+ column.name,
27
+ rowValueToJson(values[index] ?? null),
28
+ ])), syncVersion);
23
29
  db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
24
30
  }
25
- export function deleteLocalRow(db, table, rowId) {
31
+ export function deleteLocalRow(db, table, rowId, serverWrite = true) {
32
+ if (serverWrite)
33
+ retainedBaseWrite(db, table.name, rowId);
26
34
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId]);
27
35
  }
28
36
  /**
@@ -42,7 +50,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
42
50
  for (const change of frame.changes) {
43
51
  const tableName = frame.tables[change.tableIndex];
44
52
  if (tableName === undefined) {
45
- throw new ClientSyncError('sync.invalid_request', `COMMIT change tableIndex ${change.tableIndex} out of range`);
53
+ throw invalidRequest(`COMMIT change tableIndex ${change.tableIndex} out of range`);
46
54
  }
47
55
  const table = schema.tables.get(tableName);
48
56
  if (table === undefined) {
@@ -53,7 +61,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
53
61
  continue;
54
62
  }
55
63
  if (change.row === undefined || change.rowVersion === undefined) {
56
- throw new ClientSyncError('sync.invalid_request', 'upsert change without row payload');
64
+ throw invalidRequest('upsert change without row payload');
57
65
  }
58
66
  let values = decodeRow(table.columns, change.row);
59
67
  if (encryption !== undefined && table.hasEncryptedColumns) {
@@ -131,6 +139,9 @@ export function deleteScopedRows(db, table, effective) {
131
139
  clauses.push(`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`);
132
140
  params.push(...values);
133
141
  }
142
+ for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params)) {
143
+ retainedBaseWrite(db, table.name, String(row.id));
144
+ }
134
145
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params);
135
146
  }
136
147
  /**
@@ -177,7 +188,7 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
177
188
  }
178
189
  const IMAGE_ALIAS = 'syncular_image';
179
190
  function imageInvalid(detail) {
180
- throw new ClientSyncError('sync.invalid_request', `sqlite segment rejected: ${detail} (§5.3)`);
191
+ throw invalidRequest(`sqlite segment rejected: ${detail} (§5.3)`);
181
192
  }
182
193
  /**
183
194
  * Apply a §5.3 sqlite image in committed chunks of at most 1,024 rows: validate
@@ -190,7 +201,7 @@ function imageInvalid(detail) {
190
201
  export async function applySqliteSegment(db, schema, table, bytes, descriptor, options) {
191
202
  const withImage = db.withSqliteImage?.bind(db);
192
203
  if (withImage === undefined) {
193
- throw new ClientSyncError('sync.invalid_request', 'received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
204
+ throw invalidRequest('received a sqlite segment but the database backend cannot import images (§4.2: do not advertise accept bit 2)');
194
205
  }
195
206
  if (descriptor.table !== table.name) {
196
207
  imageInvalid(`descriptor table ${JSON.stringify(descriptor.table)}`);
@@ -283,6 +294,12 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
283
294
  FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
284
295
  ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`, params);
285
296
  applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
297
+ for (const row of db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)) {
298
+ retainedBaseWrite(db, table.name, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
299
+ column.name,
300
+ rowValueToJson(row[column.name]),
301
+ ])), Number(row._syncular_version));
302
+ }
286
303
  });
287
304
  options.onProgress?.(applied);
288
305
  await yieldToHost();
package/dist/client.d.ts CHANGED
@@ -26,8 +26,8 @@ import type { RealtimeConnector, SegmentDownloader, SyncTransport } from './tran
26
26
  import { type WindowBase } from './window.js';
27
27
  export type MutationInput = {
28
28
  readonly table: string;
29
- readonly op: 'upsert';
30
- /** Full-row values keyed by column name (§6.1: full row payloads). */
29
+ readonly op: 'upsert' | 'patch';
30
+ /** Column-keyed full row for upsert, or primary key plus present columns for patch. */
31
31
  readonly values: Readonly<Record<string, unknown>>;
32
32
  readonly baseVersion?: number;
33
33
  } | {
@@ -36,7 +36,7 @@ export type MutationInput = {
36
36
  readonly rowId: string;
37
37
  readonly baseVersion?: number;
38
38
  };
39
- export type { CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, ConflictRecord, RejectionRecord, ResolveCommitOutcomeInput, } from './outcomes.js';
39
+ export type { CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, RetainedCommitRow, ConflictRecord, RejectionRecord, ResolveCommitOutcomeInput, } from './outcomes.js';
40
40
  export interface SchemaFloor {
41
41
  readonly requiredSchemaVersion?: number;
42
42
  readonly latestSchemaVersion?: number;
@@ -104,6 +104,8 @@ export interface SyncClientLimits {
104
104
  readonly outcomeRetentionMaxEntries?: number;
105
105
  }
106
106
  export interface SyncClientConfig {
107
+ /** Keep rejected/conflicting local intent visible until explicit resolution. */
108
+ readonly retainFailedCommits?: boolean;
107
109
  readonly database: ClientDatabase;
108
110
  readonly schema: ClientSchema;
109
111
  readonly transport: SyncTransport;
@@ -408,7 +410,7 @@ export declare class SyncClient {
408
410
  /**
409
411
  * Mark a durable failure handled without deleting its evidence. Conflicts
410
412
  * may keep the server row or link to a replacement commit; rejections may
411
- * only be superseded by a named replacement. Applied/cached history may be
413
+ * also keep the server row or link to a named replacement. Applied/cached history may be
412
414
  * dismissed. The transition is one-way and survives restart.
413
415
  */
414
416
  resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;