@syncular/client 0.30.9 → 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.
package/README.md CHANGED
@@ -375,7 +375,9 @@ 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
376
  full-row `mutate` upsert marks every column present. `mutate` also accepts
377
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
378
+ patches, upserts and deletes atomically. Every sparse patch requires a row in
379
+ the local replica at call time. An absent row rejects the entire batch with
380
+ `sync.row_missing` before any local write or outbox insert. Omitted encrypted columns require no
379
381
  key and retain their ciphertext.
380
382
 
381
383
  Set `retainFailedCommits: true` to keep the complete failed aggregate visible
@@ -383,7 +385,9 @@ and durable after its outbox drains. Incoming server state advances a separate
383
385
  base. `outcome.retainedRows` exposes complete intended rows and the latest
384
386
  server rows and versions. Resolve explicitly with `resolved_keep_server`, or
385
387
  create a reviewed replacement and link it through `superseded`. Security purge
386
- and scope revocation remove whole retained aggregates.
388
+ and scope revocation remove whole retained aggregates. If a sparse conflict
389
+ loses its server base, the client keeps the sparse operation and conflict
390
+ evidence without materializing a local row from the saved before-image.
387
391
 
388
392
  ## Application-authorized local security purge
389
393
 
package/dist/client.js CHANGED
@@ -1827,6 +1827,10 @@ export class SyncClient {
1827
1827
  const normalized = normalizeRecordKeys(table, mutation.values);
1828
1828
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
1829
1829
  const pkColumn = table.columns[table.primaryKeyIndex];
1830
+ const pkValue = requireRowId(normalized.get(pkColumn.name));
1831
+ const local = this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0];
1832
+ if (!local)
1833
+ throw new ClientSyncError('sync.row_missing', 'a sparse patch requires a local row');
1830
1834
  for (const column of table.columns) {
1831
1835
  if (!normalized.has(column.name))
1832
1836
  continue;
@@ -1841,12 +1845,7 @@ export class SyncClient {
1841
1845
  // by construction the row id being patched (§6.1), so its value is
1842
1846
  // proven equal already.
1843
1847
  if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
1844
- const pkValue = normalized.get(pkColumn.name);
1845
- const local = typeof pkValue === 'string' && pkValue.length > 0
1846
- ? this.#db.query(`SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [pkValue])[0]
1847
- : undefined;
1848
- if (local === undefined ||
1849
- !Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
1848
+ if (!Object.is(rowValueToJson(fromSqlValue(column, local[column.name] ?? null)), rowValueToJson(coerceSqlRepresentation(column, normalized.get(column.name))))) {
1850
1849
  throw invalidRequest(`table ${table.name}: patch cannot write scope column ${JSON.stringify(column.name)} (§3.4)`);
1851
1850
  }
1852
1851
  continue;
@@ -1868,10 +1867,7 @@ export class SyncClient {
1868
1867
  json[column.name] = rowValueToJson(values[index] ?? null);
1869
1868
  });
1870
1869
  }
1871
- const pkValue = json[table.primaryKey];
1872
- if (typeof pkValue !== 'string' || pkValue.length === 0) {
1873
- throw invalidRequest(`table ${table.name}: upsert requires a non-empty string primary key`);
1874
- }
1870
+ const pkValue = requireRowId(json[table.primaryKey]);
1875
1871
  return {
1876
1872
  table: mutation.table,
1877
1873
  rowId: pkValue,
@@ -3826,3 +3822,8 @@ export class SyncClient {
3826
3822
  }
3827
3823
  }
3828
3824
  }
3825
+ function requireRowId(value) {
3826
+ if (typeof value !== 'string' || !value.length)
3827
+ throw invalidRequest('a mutation requires a non-empty string primary key');
3828
+ return value;
3829
+ }
@@ -58,6 +58,11 @@ export function restoreFailedBases(db, schema, absentIntent = false) {
58
58
  deleteLocalRow(db, table, String(row.id), false);
59
59
  else {
60
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
+ }
61
66
  upsertLocalRow(db, table, table.columns.map((column) => jsonToRowValue(values[column.name] ?? null)), Number(row.version ?? -1), false);
62
67
  }
63
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.9",
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.9"
92
+ "@syncular/core": "0.30.10"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.9",
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/client.ts CHANGED
@@ -2750,6 +2750,16 @@ export class SyncClient {
2750
2750
  const normalized = normalizeRecordKeys(table, mutation.values);
2751
2751
  const scopeColumns = new Set(table.scopeColumnByVariable.values());
2752
2752
  const pkColumn = table.columns[table.primaryKeyIndex] as RowColumn;
2753
+ const pkValue = requireRowId(normalized.get(pkColumn.name));
2754
+ const local = this.#db.query(
2755
+ `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2756
+ [pkValue],
2757
+ )[0];
2758
+ if (!local)
2759
+ throw new ClientSyncError(
2760
+ 'sync.row_missing',
2761
+ 'a sparse patch requires a local row',
2762
+ );
2753
2763
  for (const column of table.columns) {
2754
2764
  if (!normalized.has(column.name)) continue;
2755
2765
  // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
@@ -2763,16 +2773,7 @@ export class SyncClient {
2763
2773
  // by construction the row id being patched (§6.1), so its value is
2764
2774
  // proven equal already.
2765
2775
  if (scopeColumns.has(column.name) && column.name !== pkColumn.name) {
2766
- const pkValue = normalized.get(pkColumn.name);
2767
- const local =
2768
- typeof pkValue === 'string' && pkValue.length > 0
2769
- ? this.#db.query(
2770
- `SELECT * FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
2771
- [pkValue],
2772
- )[0]
2773
- : undefined;
2774
2776
  if (
2775
- local === undefined ||
2776
2777
  !Object.is(
2777
2778
  rowValueToJson(
2778
2779
  fromSqlValue(column, local[column.name] ?? null),
@@ -2811,12 +2812,7 @@ export class SyncClient {
2811
2812
  json[column.name] = rowValueToJson(values[index] ?? null);
2812
2813
  });
2813
2814
  }
2814
- const pkValue = json[table.primaryKey];
2815
- if (typeof pkValue !== 'string' || pkValue.length === 0) {
2816
- throw invalidRequest(
2817
- `table ${table.name}: upsert requires a non-empty string primary key`,
2818
- );
2819
- }
2815
+ const pkValue = requireRowId(json[table.primaryKey]);
2820
2816
  return {
2821
2817
  table: mutation.table,
2822
2818
  rowId: pkValue,
@@ -5239,3 +5235,9 @@ export class SyncClient {
5239
5235
  }
5240
5236
  }
5241
5237
  }
5238
+
5239
+ function requireRowId(value: unknown): string {
5240
+ if (typeof value !== 'string' || !value.length)
5241
+ throw invalidRequest('a mutation requires a non-empty string primary key');
5242
+ return value;
5243
+ }
@@ -101,6 +101,15 @@ export function restoreFailedBases(
101
101
  if (json === null) deleteLocalRow(db, table, String(row.id), false);
102
102
  else {
103
103
  const values: Record<string, JsonRowValue> = JSON.parse(String(json));
104
+ if (absentIntent) {
105
+ const operation: OutboxOperation = JSON.parse(String(row.op));
106
+ if (
107
+ !table.columns.every((column) =>
108
+ Object.hasOwn(operation.values ?? {}, column.name),
109
+ )
110
+ )
111
+ continue;
112
+ }
104
113
  upsertLocalRow(
105
114
  db,
106
115
  table,