@syncular/client 0.18.0 → 0.20.0

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.
@@ -1,3 +1,4 @@
1
+ import { type SyncProgress, type SyncProgressListener } from './progress.js';
1
2
  import type { PromiseMethods } from './client.js';
2
3
  /**
3
4
  * Main-thread side of the worker mode and the
@@ -179,6 +180,8 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
179
180
  * unsubscribe function.
180
181
  */
181
182
  onInvalidate(listener: InvalidationListener): () => void;
183
+ onProgress(listener: SyncProgressListener): () => void;
184
+ progressSnapshot(): SyncProgress | undefined;
182
185
  onChange(listener: ClientChangeListener): () => void;
183
186
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
184
187
  /**
@@ -1,3 +1,4 @@
1
+ import { ProgressEmitter, } from './progress.js';
1
2
  import { registerDevtools } from './devtools.js';
2
3
  import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnostics.js';
3
4
  import { ClientSyncError } from './errors.js';
@@ -68,6 +69,7 @@ export class SyncClientHandle {
68
69
  #core;
69
70
  #follower;
70
71
  #invalidation;
72
+ #progress = new ProgressEmitter();
71
73
  #changes;
72
74
  #presence;
73
75
  #diagnostics;
@@ -156,7 +158,10 @@ export class SyncClientHandle {
156
158
  }
157
159
  /** @internal — dispatch a worker/relayed event to handle-local listeners. */
158
160
  __dispatchEvent(event) {
159
- if (event.kind === 'presence') {
161
+ if (event.kind === 'progress') {
162
+ this.#progress.emit(event.progress);
163
+ }
164
+ else if (event.kind === 'presence') {
160
165
  for (const listener of this.#presence) {
161
166
  try {
162
167
  listener(event.scopeKey);
@@ -189,6 +194,12 @@ export class SyncClientHandle {
189
194
  onInvalidate(listener) {
190
195
  return this.#invalidation.on(listener);
191
196
  }
197
+ onProgress(listener) {
198
+ return this.#progress.on(listener);
199
+ }
200
+ progressSnapshot() {
201
+ return this.#progress.snapshot();
202
+ }
192
203
  onChange(listener) {
193
204
  return this.#changes.on(listener);
194
205
  }
@@ -349,6 +360,7 @@ export class SyncClientHandle {
349
360
  if (this.#closed)
350
361
  return;
351
362
  this.#closed = true;
363
+ this.#progress.clear();
352
364
  this.#devtoolsUnregister();
353
365
  if (this.#follower !== undefined) {
354
366
  this.#follower.close();
@@ -1,3 +1,4 @@
1
+ import type { SyncProgress } from './progress.js';
1
2
  /**
2
3
  * The worker RPC protocol: the whole
3
4
  * client core runs in a Web Worker; the UI thread talks to it through
@@ -151,6 +152,9 @@ export interface WorkerErrorShape {
151
152
  readonly retryable: boolean;
152
153
  }
153
154
  export type SyncWorkerEvent = {
155
+ readonly kind: 'progress';
156
+ readonly progress: SyncProgress;
157
+ } | {
154
158
  readonly kind: 'sync-needed';
155
159
  readonly reason: 'startup' | 'hello' | WakeReason;
156
160
  } | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
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,9 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.18.0"
92
+ "@syncular/core": "0.20.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.18.0"
95
+ "@syncular/server": "0.20.0",
96
+ "playwright": "1.63.0"
96
97
  }
97
98
  }
package/src/apply.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type ScopeMap,
12
12
  type SegmentRow,
13
13
  } from '@syncular/core';
14
- import type { ClientDatabase } from './database';
14
+ import type { ClientDatabase, SqlValue } from './database';
15
15
  import type { EncryptionConfig } from './encryption';
16
16
  import { ClientSyncError } from './errors';
17
17
  import {
@@ -303,6 +303,7 @@ export function applySqliteSegment(
303
303
  readonly clearFirst: boolean;
304
304
  readonly effective: ScopeMap;
305
305
  readonly transaction?: ApplyTransaction;
306
+ readonly onProgress?: (rowsProcessed: number) => void;
306
307
  },
307
308
  ): number {
308
309
  const withImage = db.withSqliteImage?.bind(db);
@@ -381,23 +382,45 @@ export function applySqliteSegment(
381
382
  if (options.clearFirst) {
382
383
  deleteScopedRows(db, table, options.effective);
383
384
  }
384
- db.exec(
385
- `INSERT INTO ${quoteIdent(table.name)}
386
- (${insertNames.join(', ')})
387
- SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
388
- FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}
389
- WHERE true
390
- ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`,
391
- );
392
- const counted = db.query(
393
- `SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`,
394
- )[0];
395
- const applied = Number(counted?.n ?? 0);
385
+ const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
386
+ const primaryKey = quoteIdent(table.primaryKey);
387
+ let after: SqlValue | undefined;
388
+ let applied = 0;
389
+ for (;;) {
390
+ const boundary = db.query(
391
+ `SELECT ${primaryKey} AS boundary FROM ${source}
392
+ ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
393
+ ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`,
394
+ after === undefined ? [] : [after],
395
+ )[0]?.boundary;
396
+ const predicates: string[] = [];
397
+ const params: SqlValue[] = [];
398
+ if (after !== undefined) {
399
+ predicates.push(`${primaryKey} > ?`);
400
+ params.push(after);
401
+ }
402
+ if (boundary !== undefined) {
403
+ predicates.push(`${primaryKey} <= ?`);
404
+ params.push(boundary);
405
+ }
406
+ db.exec(
407
+ `INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
408
+ SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
409
+ FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
410
+ ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`,
411
+ params,
412
+ );
413
+ applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
414
+ if (boundary === undefined) break;
415
+ options.onProgress?.(applied);
416
+ after = boundary;
417
+ }
396
418
  if (applied !== descriptor.rowCount) {
397
419
  imageInvalid(
398
420
  `image holds ${applied} rows, descriptor says ${descriptor.rowCount}`,
399
421
  );
400
422
  }
423
+ options.onProgress?.(applied);
401
424
  return applied;
402
425
  });
403
426
  });
@@ -420,6 +443,7 @@ export async function applyRowsSegment(
420
443
  readonly clearFirst: boolean;
421
444
  readonly effective: ScopeMap;
422
445
  readonly transaction?: ApplyTransaction;
446
+ readonly onProgress?: (rowsProcessed: number) => void;
423
447
  },
424
448
  encryption?: EncryptionConfig,
425
449
  ): Promise<number> {
@@ -453,8 +477,10 @@ export async function applyRowsSegment(
453
477
  for (const row of rows) {
454
478
  upsertLocalRow(db, table, row.values, row.serverVersion);
455
479
  applied += 1;
480
+ if (applied % 1024 === 0) options.onProgress?.(applied);
456
481
  }
457
482
  });
458
483
  }
484
+ options.onProgress?.(applied);
459
485
  return applied;
460
486
  }
package/src/blob.ts CHANGED
@@ -1,14 +1,9 @@
1
1
  /**
2
2
  * Client-side blob cache + transport (SPEC.md §5.9.7).
3
3
  *
4
- * Blob bytes are cached content-addressed by `blobId` and refcounted by the
5
- * local rows whose `blob_ref` columns reference them (B1). The cache is
6
- * derived from live-row references: after any apply/purge, refcounts are
7
- * reconciled from the current `blob_ref` column contents, and a body whose
8
- * only referencing rows were revocation-purged is deleted (B2, evicted ≠
9
- * revoked). BlobRefs stay resolvable at any time (B3): the `blobId` in the
10
- * row value is the whole download key. Pending uploads are tracked in the
11
- * outbox-adjacent uploads table (B4) and flushed before push.
4
+ * Blob bytes are cached by `blobId`. Retention reads live references directly
5
+ * from `blob_ref` columns and keeps explicit pending-commit dependencies.
6
+ * A small upload row queues each staged body for transfer before push.
12
7
  */
13
8
  import { type BlobRef, parseBlobRef, serializeBlobRef } from '@syncular/core';
14
9
  import type { ClientDatabase } from './database';
@@ -109,36 +104,43 @@ export async function computeBlobId(bytes: Uint8Array): Promise<string> {
109
104
  }
110
105
 
111
106
  export function ensureBlobSchema(db: ClientDatabase): void {
112
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blobs(
113
- blob_id TEXT PRIMARY KEY,
114
- bytes BLOB NOT NULL,
115
- byte_length INTEGER NOT NULL,
116
- media_type TEXT,
117
- refcount INTEGER NOT NULL DEFAULT 0,
118
- created_at_ms INTEGER NOT NULL,
119
- last_used_ms INTEGER NOT NULL DEFAULT 0)`);
120
- // Migrate a cache created before the §5.9.7 B1 LRU column: additive,
121
- // idempotent (a duplicate-column error on an already-migrated DB is
122
- // swallowed). last_used_ms drives cap eviction (LRU of zero-ref bodies).
123
- try {
124
- db.exec(
125
- 'ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0',
126
- );
127
- } catch {
128
- // column already exists — the CREATE above included it
107
+ const existing = db.query(
108
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs'",
109
+ );
110
+ if (existing.length > 0) {
111
+ const columns = db
112
+ .query('PRAGMA table_info(_syncular_blobs)')
113
+ .map((row) => row.name)
114
+ .sort();
115
+ const expected = [
116
+ 'blob_id',
117
+ 'byte_length',
118
+ 'bytes',
119
+ 'created_at_ms',
120
+ 'media_type',
121
+ ].sort();
122
+ if (
123
+ columns.length !== expected.length ||
124
+ columns.some((column, index) => column !== expected[index])
125
+ ) {
126
+ throw new ClientSyncError(
127
+ 'sync.schema_mismatch',
128
+ 'Local blob schema is incompatible',
129
+ );
130
+ }
131
+ } else {
132
+ db.exec(`CREATE TABLE _syncular_blobs(
133
+ blob_id TEXT PRIMARY KEY,
134
+ bytes BLOB NOT NULL,
135
+ byte_length INTEGER NOT NULL,
136
+ media_type TEXT,
137
+ created_at_ms INTEGER NOT NULL)`);
129
138
  }
130
139
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
131
- blob_id TEXT PRIMARY KEY,
132
- media_type TEXT,
133
- created_at_ms INTEGER NOT NULL)`);
134
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
135
- commit_id TEXT NOT NULL, blob_id TEXT NOT NULL, PRIMARY KEY(commit_id, blob_id))`);
136
- db.exec(
137
- 'CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)',
138
- );
140
+ blob_id TEXT PRIMARY KEY, media_type TEXT, created_at_ms INTEGER NOT NULL)`);
139
141
  }
140
142
 
141
- /** Put bytes into the content-addressed cache (idempotent); touches LRU. */
143
+ /** Put bytes into the content-addressed cache without rewriting an existing body. */
142
144
  export function putCachedBlob(
143
145
  db: ClientDatabase,
144
146
  blobId: string,
@@ -148,17 +150,15 @@ export function putCachedBlob(
148
150
  ): void {
149
151
  db.exec(
150
152
  `INSERT INTO _syncular_blobs(
151
- blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms)
152
- VALUES (?,?,?,?,0,?,?)
153
- ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms`,
154
- [blobId, bytes, bytes.length, mediaType ?? null, nowMs, nowMs],
153
+ blob_id, bytes, byte_length, media_type, created_at_ms)
154
+ VALUES (?,?,?,?,?) ON CONFLICT(blob_id) DO NOTHING`,
155
+ [blobId, bytes, bytes.length, mediaType ?? null, nowMs],
155
156
  );
156
157
  }
157
158
 
158
159
  export function getCachedBlob(
159
160
  db: ClientDatabase,
160
161
  blobId: string,
161
- nowMs?: number,
162
162
  ): CachedBlob | undefined {
163
163
  const rows = db.query(
164
164
  'SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?',
@@ -166,14 +166,6 @@ export function getCachedBlob(
166
166
  );
167
167
  const row = rows[0];
168
168
  if (row === undefined) return undefined;
169
- // §5.9.7 B1 LRU: a cache-hit read touches "recently used" so a hot image
170
- // survives a cap trim. Skipped when no clock is supplied (pure read).
171
- if (nowMs !== undefined) {
172
- db.exec('UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?', [
173
- nowMs,
174
- blobId,
175
- ]);
176
- }
177
169
  return {
178
170
  blobId,
179
171
  bytes: row.bytes as Uint8Array,
@@ -183,17 +175,13 @@ export function getCachedBlob(
183
175
  }
184
176
 
185
177
  /**
186
- * §5.9.7 B1 size cap + LRU eviction. When the sum of cached body sizes exceeds
187
- * `maxBytes`, evict **zero-ref, non-pinned** bodies in least-recently-used
188
- * order until back under the cap. NEVER evicts a referenced body (refcount > 0
189
- * — it must stay resolvable without a re-download) nor a pending-upload-pinned
190
- * body (its bytes are the only copy until push, B4). If every over-cap body is
191
- * referenced or pinned, the cache stays over the cap (correctness beats the
192
- * cap). Evicting a zero-ref body is always safe: B3 re-enables the fetch from
193
- * any surviving `blob_ref` value. Returns the evicted blobIds.
178
+ * §5.9.7 B1 size cap. When the sum of cached body sizes exceeds `maxBytes`,
179
+ * evict unreferenced, non-pinned bodies by creation timestamp, then blob ID,
180
+ * until back under the cap. Returns the evicted blobIds.
194
181
  */
195
182
  export function enforceBlobCacheCap(
196
183
  db: ClientDatabase,
184
+ schema: CompiledClientSchema,
197
185
  maxBytes: number,
198
186
  ): string[] {
199
187
  const totalRow = db.query(
@@ -201,14 +189,13 @@ export function enforceBlobCacheCap(
201
189
  )[0];
202
190
  let total = Number(totalRow?.total ?? 0);
203
191
  if (total <= maxBytes) return [];
204
- // Eviction candidates: zero-ref AND not pinned by a pending upload, oldest
205
- // (LRU) first, then oldest created as a stable tiebreak.
192
+ // Oldest eligible body first, with blob ID as a stable tie.
206
193
  const candidates = db.query(
207
194
  `SELECT blob_id, byte_length FROM _syncular_blobs
208
- WHERE refcount = 0
209
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
195
+ WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
210
196
  AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
211
- ORDER BY last_used_ms ASC, created_at_ms ASC`,
197
+ AND blob_id NOT IN (${visibleBlobIdsSql(schema)})
198
+ ORDER BY created_at_ms ASC, blob_id ASC`,
212
199
  );
213
200
  const evicted: string[] = [];
214
201
  db.transaction(() => {
@@ -223,20 +210,6 @@ export function enforceBlobCacheCap(
223
210
  return evicted;
224
211
  }
225
212
 
226
- /** Record a pending upload (§5.9.7 B4); flushed before the next push. */
227
- export function recordPendingUpload(
228
- db: ClientDatabase,
229
- blobId: string,
230
- nowMs: number,
231
- mediaType?: string,
232
- ): void {
233
- db.exec(
234
- `INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
235
- VALUES (?,?,?)`,
236
- [blobId, mediaType ?? null, nowMs],
237
- );
238
- }
239
-
240
213
  export function listPendingUploads(
241
214
  db: ClientDatabase,
242
215
  ): { blobId: string; mediaType?: string }[] {
@@ -267,13 +240,25 @@ export function listPendingUploads(
267
240
  });
268
241
  }
269
242
 
243
+ export function recordPendingUpload(
244
+ db: ClientDatabase,
245
+ blobId: string,
246
+ nowMs: number,
247
+ mediaType?: string,
248
+ ): void {
249
+ db.exec(
250
+ `INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
251
+ VALUES (?,?,?)`,
252
+ [blobId, mediaType ?? null, nowMs],
253
+ );
254
+ }
255
+
270
256
  export function clearPendingUpload(db: ClientDatabase, blobId: string): void {
271
257
  db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id = ?', [blobId]);
272
258
  }
273
259
 
274
260
  /**
275
- * All `blob_ref` column names per table (for refcount reconciliation).
276
- * Blank result ⇒ the schema has no attachments; callers skip reconciliation.
261
+ * All `blob_ref` column names per table.
277
262
  */
278
263
  export function blobRefColumnsBySchema(
279
264
  schema: CompiledClientSchema,
@@ -295,91 +280,35 @@ export function schemaHasBlobs(schema: CompiledClientSchema): boolean {
295
280
  return false;
296
281
  }
297
282
 
298
- /**
299
- * §5.9.7 B1/B2: recompute cache refcounts from the current `blob_ref` column
300
- * contents across all synced tables, then delete cache bodies that dropped
301
- * to zero refs *and* have no pending upload (a pending upload pins its body,
302
- * B4). Called after every apply/purge that may add or remove references.
303
- *
304
- * `blobId` narrows a fresh-download refresh to the inserted cache row.
305
- * `deleteOrphans` distinguishes the two B2 transitions: revocation purge
306
- * passes `true` (drop the now-unauthorized body); a benign apply passes
307
- * `false` (retain zero-ref bodies as LRU cache entries — the shipped
308
- * default). Bodies pinned by a pending upload are always retained.
309
- */
310
- export function reconcileBlobRefcounts(
311
- db: ClientDatabase,
312
- schema: CompiledClientSchema,
313
- options?: {
314
- readonly blobId?: string;
315
- readonly deleteOrphans?: boolean;
316
- },
317
- ): void {
318
- const byTable = blobRefColumnsBySchema(schema);
319
- if (byTable.size === 0) return;
320
- if (options?.blobId !== undefined) {
321
- let count = 0;
322
- for (const [tableName, columns] of byTable) {
323
- for (const column of columns) {
324
- const identifier = quoteIdent(column);
325
- const row = db.query(
326
- `SELECT count(*) AS n FROM ${quoteIdent(tableName)}
327
- WHERE CASE
328
- WHEN typeof(${identifier}) = 'text' AND json_valid(${identifier})
329
- THEN json_extract(${identifier}, '$.blobId')
330
- END = ?`,
331
- [options.blobId],
332
- )[0];
333
- count += Number(row?.n ?? 0);
334
- }
335
- }
336
- db.exec('UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?', [
337
- count,
338
- options.blobId,
339
- ]);
340
- return;
341
- }
342
- // Count references to each blobId across every blob_ref column.
343
- const counts = new Map<string, number>();
344
- for (const [tableName, columns] of byTable) {
283
+ function visibleBlobIdsSql(schema: CompiledClientSchema): string {
284
+ const selects: string[] = [];
285
+ for (const [tableName, columns] of blobRefColumnsBySchema(schema)) {
345
286
  for (const column of columns) {
346
- const rows = db.query(
347
- `SELECT ${quoteIdent(column)} AS v FROM ${quoteIdent(tableName)}
348
- WHERE ${quoteIdent(column)} IS NOT NULL`,
287
+ const identifier = quoteIdent(column);
288
+ selects.push(
289
+ `SELECT json_extract(${identifier}, '$.blobId') AS blob_id
290
+ FROM ${quoteIdent(tableName)}
291
+ WHERE typeof(${identifier}) = 'text'
292
+ AND json_valid(${identifier})
293
+ AND json_type(${identifier}, '$.blobId') = 'text'`,
349
294
  );
350
- for (const row of rows) {
351
- const raw = row.v;
352
- if (typeof raw !== 'string') continue;
353
- let ref: BlobRef;
354
- try {
355
- ref = parseBlobRef(raw);
356
- } catch {
357
- continue;
358
- }
359
- counts.set(ref.blobId, (counts.get(ref.blobId) ?? 0) + 1);
360
- }
361
295
  }
362
296
  }
363
- db.transaction(() => {
364
- // Reset all refcounts, then apply the recomputed counts.
365
- db.exec('UPDATE _syncular_blobs SET refcount = 0');
366
- for (const [blobId, count] of counts) {
367
- db.exec('UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?', [
368
- count,
369
- blobId,
370
- ]);
371
- }
372
- if (options?.deleteOrphans === true) {
373
- // §5.9.7 B2 revocation side: delete zero-ref bodies not pinned by a
374
- // pending upload.
375
- db.exec(
376
- `DELETE FROM _syncular_blobs
377
- WHERE refcount = 0
378
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
379
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)`,
380
- );
381
- }
382
- });
297
+ return selects.length === 0
298
+ ? 'SELECT NULL AS blob_id WHERE 0'
299
+ : selects.join(' UNION ');
300
+ }
301
+
302
+ export function deleteUnreferencedCachedBlobs(
303
+ db: ClientDatabase,
304
+ schema: CompiledClientSchema,
305
+ ): void {
306
+ db.exec(
307
+ `DELETE FROM _syncular_blobs
308
+ WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
309
+ AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
310
+ AND blob_id NOT IN (${visibleBlobIdsSql(schema)})`,
311
+ );
383
312
  }
384
313
 
385
314
  export type { BlobRef };