@syncular/client 0.30.12 → 0.30.14

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/dist/apply.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type OverlayScope } 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
@@ -8,7 +9,7 @@ import type { ClientDatabase } from './database.js';
8
9
  import type { EncryptionConfig } from './encryption.js';
9
10
  import { type CompiledClientSchema, type CompiledClientTable } from './schema.js';
10
11
  /** Lets the client wrap the physical write in its revision transaction. */
11
- export type ApplyTransaction = <T>(fn: () => T) => T;
12
+ export type ApplyTransaction = (fn: () => OverlayScope) => unknown;
12
13
  export declare function upsertLocalRow(db: ClientDatabase, table: CompiledClientTable, values: readonly RowValue[], syncVersion: number, serverWrite?: boolean): void;
13
14
  export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClientTable, rowId: string, serverWrite?: boolean): void;
14
15
  /**
@@ -23,7 +24,7 @@ export declare function deleteLocalRow(db: ClientDatabase, table: CompiledClient
23
24
  * (`client.decrypt_failed`) aborts before any local write, so the commit
24
25
  * never half-applies.
25
26
  */
26
- export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => readonly string[] | undefined) => unknown): Promise<void>;
27
+ export declare function applyCommitFrame(db: ClientDatabase, schema: CompiledClientSchema, frame: CommitFrame, encryption?: EncryptionConfig, transaction?: (fn: () => OverlayScope) => unknown): Promise<void>;
27
28
  /**
28
29
  * §5.2: the segment's column table must match the generated schema for
29
30
  * (table, schemaVersion) — order, names, types, nullability. A mismatch is
package/dist/apply.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { hasUniqueIndex } from './schema.js';
2
+ import { OUTBOX_TABLE, OUTBOX_IMAGES, countOutbox } from './outbox.js';
1
3
  import { RETAINED_ROWS } from './failed-overlay.js';
2
4
  import { retainedBaseWrite, restoreFailedBases } from './failed-overlay.js';
3
5
  /**
@@ -76,13 +78,13 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
76
78
  });
77
79
  }
78
80
  transaction(() => {
79
- const rowKeys = frame.tables.some((name) => schema.tables.get(name)?.indexes.some((index) => index.unique))
80
- ? undefined
81
- : frame.changes.map((change) => JSON.stringify([frame.tables[change.tableIndex], change.rowId]));
82
- restoreFailedBases(db, schema, false, rowKeys);
81
+ const scope = resolved.map((change, index) => JSON.stringify(hasUniqueIndex(change.table)
82
+ ? [change.table.name]
83
+ : [change.table.name, frame.changes[index].rowId]));
84
+ restoreFailedBases(db, schema, scope);
83
85
  for (const [index, change] of resolved.entries()) {
84
86
  const rowId = frame.changes[index].rowId;
85
- db.exec("UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)", [frame.commitSeq, change.table.name, rowId]);
87
+ db.exec(`UPDATE ${OUTBOX_IMAGES} SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM ${OUTBOX_TABLE},json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)`, [frame.commitSeq, change.table.name, rowId]);
86
88
  db.exec(`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`, [change.table.name, rowId, frame.commitSeq]);
87
89
  if (change.op === 'delete') {
88
90
  deleteLocalRow(db, change.table, change.rowId);
@@ -91,7 +93,7 @@ export async function applyCommitFrame(db, schema, frame, encryption, transactio
91
93
  upsertLocalRow(db, change.table, change.values, change.rowVersion);
92
94
  }
93
95
  }
94
- return rowKeys;
96
+ return scope;
95
97
  });
96
98
  }
97
99
  /**
@@ -147,12 +149,27 @@ export function localScopePredicate(table, effective) {
147
149
  .join(' AND ');
148
150
  return { sql, params };
149
151
  }
152
+ function scopedRows(db, table, scope) {
153
+ return scope
154
+ ? db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${scope.sql}`, scope.params)
155
+ : [];
156
+ }
157
+ function bootstrapScope(db, table, effective) {
158
+ const rows = scopedRows(db, table, localScopePredicate(table, effective));
159
+ rows.push(...db
160
+ .query(`SELECT id,base,intent FROM ${RETAINED_ROWS} WHERE tbl=?`, [
161
+ table.name,
162
+ ])
163
+ .filter((row) => [row.base, row.intent].some((json) => json !== null &&
164
+ matchesLocalScopes(table, effective, JSON.parse(json)))));
165
+ return rows.map((row) => JSON.stringify([table.name, String(row.id)]));
166
+ }
150
167
  export function deleteScopedRows(db, table, effective) {
151
168
  const scope = localScopePredicate(table, effective);
152
169
  if (!scope)
153
170
  return;
154
171
  const { sql, params } = scope;
155
- for (const row of db.query(`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`, params)) {
172
+ for (const row of scopedRows(db, table, scope)) {
156
173
  retainedBaseWrite(db, table, String(row.id));
157
174
  }
158
175
  db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
@@ -287,18 +304,37 @@ export async function applySqliteSegment(db, schema, table, bytes, descriptor, o
287
304
  predicates.push(`${primaryKey} <= ?`);
288
305
  params.push(boundary);
289
306
  }
307
+ const replay = countOutbox(db) ||
308
+ db.query(`SELECT 1 FROM ${RETAINED_ROWS} WHERE tbl=? LIMIT 1`, [
309
+ table.name,
310
+ ]).length;
311
+ const rows = replay
312
+ ? db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)
313
+ : [];
290
314
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
291
- restoreFailedBases(db, schema);
315
+ const scope = !replay
316
+ ? []
317
+ : hasUniqueIndex(table)
318
+ ? [JSON.stringify([table.name])]
319
+ : [
320
+ ...(after === undefined && options.clearFirst
321
+ ? bootstrapScope(db, table, options.effective)
322
+ : []),
323
+ ...rows.map((row) => JSON.stringify([table.name, String(row[table.primaryKey])])),
324
+ ];
325
+ const protectedRows = restoreFailedBases(db, schema, scope);
292
326
  if (after === undefined && options.clearFirst)
293
327
  deleteScopedRows(db, table, options.effective);
294
328
  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);
295
329
  applied += Number(db.query('SELECT changes()AS n')[0]?.n ?? 0);
296
- for (const row of db.query(`SELECT * FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}`, params)) {
297
- retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
298
- column.name,
299
- rowValueToJson(row[column.name]),
300
- ])), Number(row._syncular_version));
301
- }
330
+ if (protectedRows)
331
+ for (const row of rows) {
332
+ retainedBaseWrite(db, table, String(row[table.primaryKey]), Object.fromEntries(table.columns.map((column) => [
333
+ column.name,
334
+ rowValueToJson(row[column.name]),
335
+ ])), Number(row._syncular_version));
336
+ }
337
+ return scope;
302
338
  });
303
339
  options.onProgress?.(applied);
304
340
  await yieldToHost();
@@ -347,7 +383,18 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
347
383
  if (!clearThisBlock && rows.length === 0)
348
384
  continue;
349
385
  (options.transaction ?? ((fn) => db.transaction(fn)))(() => {
350
- const protectedRows = restoreFailedBases(db, schema);
386
+ const scope = hasUniqueIndex(table)
387
+ ? [JSON.stringify([table.name])]
388
+ : [
389
+ ...(clearThisBlock
390
+ ? bootstrapScope(db, table, options.effective)
391
+ : []),
392
+ ...rows.map((row) => JSON.stringify([
393
+ table.name,
394
+ String(row.values[table.primaryKeyIndex]),
395
+ ])),
396
+ ];
397
+ const protectedRows = restoreFailedBases(db, schema, scope);
351
398
  if (clearThisBlock) {
352
399
  deleteScopedRows(db, table, options.effective);
353
400
  }
@@ -357,6 +404,7 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
357
404
  if (applied % 1024 === 0)
358
405
  options.onProgress?.(applied);
359
406
  }
407
+ return scope;
360
408
  });
361
409
  options.onProgress?.(applied);
362
410
  await yieldToHost();
package/dist/blob.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare const BLOB_COMMIT_REFS = "_syncular_blob_commit_refs";
1
2
  /**
2
3
  * Client-side blob cache + transport (SPEC.md §5.9.7).
3
4
  *
package/dist/blob.js CHANGED
@@ -1,3 +1,4 @@
1
+ export const BLOB_COMMIT_REFS = '_syncular_blob_commit_refs';
1
2
  /**
2
3
  * Client-side blob cache + transport (SPEC.md §5.9.7).
3
4
  *
@@ -70,7 +71,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
70
71
  if (total <= maxBytes)
71
72
  return [];
72
73
  // Oldest eligible body first, with blob ID as a stable tie.
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`);
74
+ 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 ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})ORDER BY created_at_ms ASC,blob_id ASC`);
74
75
  const evicted = [];
75
76
  db.transaction(() => {
76
77
  for (const row of candidates) {
@@ -86,7 +87,7 @@ export function enforceBlobCacheCap(db, schema, maxBytes) {
86
87
  }
87
88
  export function listPendingUploads(db) {
88
89
  return db
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')
90
+ .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 ${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`)
90
91
  .map((row) => {
91
92
  if (typeof row.blob_id !== 'string' ||
92
93
  (row.media_type !== null && typeof row.media_type !== 'string')) {
@@ -138,6 +139,6 @@ function visibleBlobIdsSql(schema) {
138
139
  : selects.join(' UNION ');
139
140
  }
140
141
  export function deleteUnreferencedCachedBlobs(db, 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)})`);
142
+ 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 ${BLOB_COMMIT_REFS})AND blob_id NOT IN(${visibleBlobIdsSql(schema)})`);
142
143
  }
143
144
  export { parseBlobRef, serializeBlobRef };
package/dist/client.d.ts CHANGED
@@ -104,6 +104,7 @@ export interface SyncClientLimits {
104
104
  readonly outcomeRetentionMaxEntries?: number;
105
105
  }
106
106
  export interface SyncClientConfig {
107
+ readonly transportEnabled?: boolean;
107
108
  /** Keep rejected/conflicting local intent visible until explicit resolution. */
108
109
  readonly retainFailedCommits?: boolean;
109
110
  readonly database: ClientDatabase;
@@ -295,6 +296,8 @@ export declare class SyncClient {
295
296
  /** Acquire leadership, create local tables, resolve the clientId. */
296
297
  start(): Promise<void>;
297
298
  close(): Promise<void>;
299
+ /** Host-owned transport policy; the local replica remains available. */
300
+ setTransportEnabled(enabled: boolean): void;
298
301
  /** Current fail-closed local-replica security state. */
299
302
  securityLifecycle(): SecurityLifecycle;
300
303
  /**