@syncular/client 0.30.9 → 0.30.11

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,17 @@ 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.
391
+
392
+ A retained insert whose unique key belongs to another server primary key remains
393
+ journal intent while reads show the server winner. `retainedRows[].uniqueConflicts`
394
+ contains each matching index, its columns, competing `rowId`, authorized
395
+ `serverRow` and `serverVersion`. Keep-mine patches that competing ID at its
396
+ current version and links the replacement through `superseded`. Edit can choose
397
+ a free unique key. Take-server uses `resolved_keep_server`. Revocation and local
398
+ security purge erase the retained aggregate's protected journal payloads.
387
399
 
388
400
  ## Application-authorized local security purge
389
401
 
package/dist/apply.js CHANGED
@@ -1,4 +1,4 @@
1
- import { retainedBaseWrite } from './failed-overlay.js';
1
+ import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
2
2
  /**
3
3
  * Local application of server data: `COMMIT` frames (§4.5), rows segments
4
4
  * (§5.2, §5.6), and the scope-matched delete shared by the §3.3 purge
@@ -17,21 +17,20 @@ function upsertSql(table) {
17
17
  .filter((name) => name !== quoteIdent(table.primaryKey))
18
18
  .map((name) => `${name}=excluded.${name}`)
19
19
  .join(', ');
20
- return `INSERT INTO ${quoteIdent(table.name)} (${names.join(', ')}) VALUES (${placeholders})
21
- ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`;
20
+ return `INSERT INTO ${quoteIdent(table.name)}(${names.join(', ')})VALUES(${placeholders})ON CONFLICT(${quoteIdent(table.primaryKey)})DO UPDATE SET ${updates}`;
22
21
  }
23
22
  export function upsertLocalRow(db, table, values, syncVersion, serverWrite = true) {
23
+ db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
24
24
  if (serverWrite)
25
- retainedBaseWrite(db, table.name, String(values[table.primaryKeyIndex]), Object.fromEntries(table.columns.map((column, index) => [
25
+ retainedBaseWrite(db, table, String(values[table.primaryKeyIndex]), Object.fromEntries(table.columns.map((column, index) => [
26
26
  column.name,
27
27
  rowValueToJson(values[index] ?? null),
28
28
  ])), syncVersion);
29
- db.exec(upsertSql(table), [...values.map(toSqlValue), syncVersion]);
30
29
  }
31
30
  export function deleteLocalRow(db, table, rowId, serverWrite = true) {
32
31
  if (serverWrite)
33
- retainedBaseWrite(db, table.name, rowId);
34
- db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId]);
32
+ retainedBaseWrite(db, table, rowId);
33
+ db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId]);
35
34
  }
36
35
  /**
37
36
  * Apply one `COMMIT` frame in one local transaction (§1.4 rule 4).
@@ -76,6 +75,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
76
75
  });
77
76
  }
78
77
  transaction(() => {
78
+ restoreFailedBases(db, schema);
79
79
  for (const change of resolved) {
80
80
  if (change.op === 'delete') {
81
81
  deleteLocalRow(db, change.table, change.rowId);
@@ -140,7 +140,7 @@ export function deleteScopedRows(db, table, effective) {
140
140
  params.push(...values);
141
141
  }
142
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));
143
+ retainedBaseWrite(db, table, String(row.id));
144
144
  }
145
145
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`, params);
146
146
  }
@@ -178,9 +178,7 @@ export function evictScopedRows(db, table, effective, pinnedRowIds) {
178
178
  params.push(...ids);
179
179
  }
180
180
  const target = quoteIdent(table.name);
181
- db.exec(`DELETE FROM ${target} WHERE ${pk} IN (
182
- SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024
183
- )`, params);
181
+ db.exec(`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1024)`, params);
184
182
  const remaining = db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')}${pinnedClause} LIMIT 1`, params).length > 0;
185
183
  const deferred = remaining ||
186
184
  db.query(`SELECT 1 FROM ${target} WHERE ${clauses.join(' AND ')} LIMIT 1`, params.slice(0, params.length - pinnedRowIds.size)).length > 0;
@@ -211,10 +209,7 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
211
209
  // SQLite database or lacks the metadata table fails right here.
212
210
  let meta;
213
211
  try {
214
- meta = db.query(`SELECT format, "table" AS tbl, "schemaVersion" AS sv,
215
- "asOfCommitSeq" AS pin, "scopeDigest" AS sd,
216
- "rowCount" AS rc
217
- FROM ${IMAGE_ALIAS}."_syncular_segment"`);
212
+ meta = db.query(`SELECT format,"table" AS tbl,"schemaVersion" AS sv,"asOfCommitSeq" AS pin,"scopeDigest" AS sd,"rowCount" AS rc FROM ${IMAGE_ALIAS}."_syncular_segment"`);
218
213
  }
219
214
  catch {
220
215
  imageInvalid('bytes are not a SQLite image with _syncular_segment');
@@ -256,7 +251,7 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
256
251
  if (primaryKeys.length !== 1 || primaryKeys[0]?.name !== table.primaryKey)
257
252
  imageInvalid('image primary key does not match generated schema');
258
253
  const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
259
- const count = Number(db.query(`SELECT count(*) AS n FROM ${source}`)[0]?.n);
254
+ const count = Number(db.query(`SELECT count(*)AS n FROM ${source}`)[0]?.n);
260
255
  if (count !== descriptor.rowCount)
261
256
  imageInvalid('image row count does not match descriptor');
262
257
  const names = table.columns.map((column) => quoteIdent(column.name));
@@ -271,9 +266,7 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
271
266
  for (;;) {
272
267
  if (options.isCurrent?.() === false)
273
268
  return applied;
274
- const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source}
275
- ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
276
- ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
269
+ const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source} ${after === undefined ? '' : `WHERE ${primaryKey} > ?`} ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
277
270
  if (boundary === null)
278
271
  imageInvalid('image primary key must not be null');
279
272
  const predicates = [];
@@ -287,15 +280,13 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
287
280
  params.push(boundary);
288
281
  }
289
282
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
283
+ restoreFailedBases(db, schema);
290
284
  if (after === undefined && options.clearFirst)
291
285
  deleteScopedRows(db, table, options.effective);
292
- db.exec(`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
293
- SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
294
- FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
295
- ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`, params);
296
- applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
286
+ db.exec(`INSERT INTO ${quoteIdent(table.name)}(${insertNames.join(', ')})SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')} FROM ${source} WHERE ${predicates.join(' AND ') || 'true'} ON CONFLICT(${primaryKey})DO UPDATE SET ${updates}`, params);
287
+ applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
297
288
  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) => [
289
+ retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
299
290
  column.name,
300
291
  rowValueToJson(row[column.name]),
301
292
  ])), Number(row._syncular_version));
@@ -348,6 +339,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
348
339
  if (!clearThisBlock && rows.length === 0)
349
340
  continue;
350
341
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
342
+ restoreFailedBases(db, schema);
351
343
  if (clearThisBlock) {
352
344
  deleteScopedRows(db, table, options.effective);
353
345
  }
package/dist/blob.js CHANGED
@@ -20,7 +20,7 @@ export async function computeBlobId(bytes) {
20
20
  return `sha256:${hex}`;
21
21
  }
22
22
  export function ensureBlobSchema(db) {
23
- const existing = db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs'");
23
+ const existing = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='_syncular_blobs'");
24
24
  if (existing.length > 0) {
25
25
  const columns = db
26
26
  .query('PRAGMA table_info(_syncular_blobs)')
@@ -39,24 +39,16 @@ export function ensureBlobSchema(db) {
39
39
  }
40
40
  }
41
41
  else {
42
- db.exec(`CREATE TABLE _syncular_blobs(
43
- blob_id TEXT PRIMARY KEY,
44
- bytes BLOB NOT NULL,
45
- byte_length INTEGER NOT NULL,
46
- media_type TEXT,
47
- created_at_ms INTEGER NOT NULL)`);
42
+ db.exec('CREATE TABLE _syncular_blobs(blob_id TEXT PRIMARY KEY,bytes BLOB NOT NULL,byte_length INTEGER NOT NULL,media_type TEXT,created_at_ms INTEGER NOT NULL)');
48
43
  }
49
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
50
- blob_id TEXT PRIMARY KEY, media_type TEXT, created_at_ms INTEGER NOT NULL)`);
44
+ db.exec('CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(blob_id TEXT PRIMARY KEY,media_type TEXT,created_at_ms INTEGER NOT NULL)');
51
45
  }
52
46
  /** Put bytes into the content-addressed cache without rewriting an existing body. */
53
47
  export function putCachedBlob(db, blobId, bytes, nowMs, mediaType) {
54
- db.exec(`INSERT INTO _syncular_blobs(
55
- blob_id, bytes, byte_length, media_type, created_at_ms)
56
- VALUES (?,?,?,?,?) ON CONFLICT(blob_id) DO NOTHING`, [blobId, bytes, bytes.length, mediaType ?? null, nowMs]);
48
+ db.exec('INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)VALUES(?,?,?,?,?)ON CONFLICT(blob_id)DO NOTHING', [blobId, bytes, bytes.length, mediaType ?? null, nowMs]);
57
49
  }
58
50
  export function getCachedBlob(db, blobId) {
59
- const rows = db.query('SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?', [blobId]);
51
+ const rows = db.query('SELECT bytes,byte_length,media_type FROM _syncular_blobs WHERE blob_id=?', [blobId]);
60
52
  const row = rows[0];
61
53
  if (row === undefined)
62
54
  return undefined;
@@ -73,23 +65,19 @@ export function getCachedBlob(db, blobId) {
73
65
  * until back under the cap. Returns the evicted blobIds.
74
66
  */
75
67
  export function enforceBlobCacheCap(db, schema, maxBytes) {
76
- const totalRow = db.query('SELECT COALESCE(SUM(byte_length), 0) AS total FROM _syncular_blobs')[0];
68
+ const totalRow = db.query('SELECT COALESCE(SUM(byte_length),0)AS total FROM _syncular_blobs')[0];
77
69
  let total = Number(totalRow?.total ?? 0);
78
70
  if (total <= maxBytes)
79
71
  return [];
80
72
  // Oldest eligible body first, with blob ID as a stable tie.
81
- const candidates = db.query(`SELECT blob_id, byte_length FROM _syncular_blobs
82
- WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
83
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
84
- AND blob_id NOT IN (${visibleBlobIdsSql(schema)})
85
- ORDER BY created_at_ms ASC, blob_id ASC`);
73
+ const candidates = db.query(`SELECT blob_id,byte_length FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
86
74
  const evicted = [];
87
75
  db.transaction(() => {
88
76
  for (const row of candidates) {
89
77
  if (total <= maxBytes)
90
78
  break;
91
79
  const blobId = row.blob_id;
92
- db.exec('DELETE FROM _syncular_blobs WHERE blob_id = ?', [blobId]);
80
+ db.exec('DELETE FROM _syncular_blobs WHERE blob_id=?', [blobId]);
93
81
  total -= Number(row.byte_length);
94
82
  evicted.push(blobId);
95
83
  }
@@ -98,13 +86,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
98
86
  }
99
87
  export function listPendingUploads(db) {
100
88
  return db
101
- .query(`SELECT blob_id, media_type FROM (
102
- SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
103
- UNION ALL
104
- SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
105
- FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
106
- WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
107
- ) ORDER BY created_at_ms, blob_id`)
89
+ .query('SELECT blob_id,media_type FROM(SELECT blob_id,media_type,created_at_ms FROM _syncular_blob_uploads UNION ALL SELECT DISTINCT r.blob_id,b.media_type,9223372036854775807 AS created_at_ms FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id=r.blob_id WHERE NOT EXISTS(SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id=r.blob_id))ORDER BY created_at_ms,blob_id')
108
90
  .map((row) => {
109
91
  if (typeof row.blob_id !== 'string' ||
110
92
  (row.media_type !== null && typeof row.media_type !== 'string')) {
@@ -117,11 +99,10 @@ export function listPendingUploads(db) {
117
99
  });
118
100
  }
119
101
  export function recordPendingUpload(db, blobId, nowMs, mediaType) {
120
- db.exec(`INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
121
- VALUES (?,?,?)`, [blobId, mediaType ?? null, nowMs]);
102
+ db.exec('INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id,media_type,created_at_ms)VALUES(?,?,?)', [blobId, mediaType ?? null, nowMs]);
122
103
  }
123
104
  export function clearPendingUpload(db, blobId) {
124
- db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id = ?', [blobId]);
105
+ db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id=?', [blobId]);
125
106
  }
126
107
  /**
127
108
  * All `blob_ref` column names per table.
@@ -149,11 +130,7 @@ function visibleBlobIdsSql(schema) {
149
130
  for (const [tableName, columns] of blobRefColumnsBySchema(schema)) {
150
131
  for (const column of columns) {
151
132
  const identifier = quoteIdent(column);
152
- selects.push(`SELECT json_extract(${identifier}, '$.blobId') AS blob_id
153
- FROM ${quoteIdent(tableName)}
154
- WHERE typeof(${identifier}) = 'text'
155
- AND json_valid(${identifier})
156
- AND json_type(${identifier}, '$.blobId') = 'text'`);
133
+ selects.push(`SELECT json_extract(${identifier},'$.blobId')AS blob_id FROM ${quoteIdent(tableName)} WHERE typeof(${identifier})='text' AND json_valid(${identifier})AND json_type(${identifier},'$.blobId')='text'`);
157
134
  }
158
135
  }
159
136
  return selects.length === 0
@@ -161,9 +138,6 @@ function visibleBlobIdsSql(schema) {
161
138
  : selects.join(' UNION ');
162
139
  }
163
140
  export function deleteUnreferencedCachedBlobs(db, schema) {
164
- db.exec(`DELETE FROM _syncular_blobs
165
- WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
166
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
167
- AND blob_id NOT IN (${visibleBlobIdsSql(schema)})`);
141
+ db.exec(`DELETE FROM _syncular_blobs WHERE blob_id NOT IN(SELECT blob_id FROM _syncular_blob_uploads)AND blob_id NOT IN(SELECT blob_id FROM _syncular_blob_commit_refs)AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
168
142
  }
169
143
  export { parseBlobRef, serializeBlobRef };