@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.
package/README.md CHANGED
@@ -375,7 +375,7 @@ lifecycle with an `EncryptionConfig`; Worker handles use the portable keyring.
375
375
  Within one local SQLite transaction the engine deletes exactly the matching
376
376
  synced rows, lets generated FTS triggers remove their projections, drops every
377
377
  whole pending commit with a matching operation, restores/replays unrelated
378
- optimistic state, reconciles blob references, persists the `purgeId`, and emits
378
+ optimistic state, removes unreferenced cached blob bodies, persists the `purgeId`, and emits
379
379
  one revisioned change batch. A retry with the same canonical plan returns
380
380
  `alreadyApplied: true`; reusing an id with different selectors fails closed.
381
381
  Only bounded, non-empty, code-like values on plaintext string schema columns
@@ -457,10 +457,17 @@ still happen during rapid hot-module replacement, or in an embedded/test host
457
457
  that shares OPFS data without sharing the same Web Locks and BroadcastChannel
458
458
  coordination domain.
459
459
 
460
- Pool acquisition failures surface as `ClientSyncError` with code
461
- `client.storage_busy` and `retryable === true`. Treat that as a startup state:
462
- close the competing instance or let it finish shutting down, then create the
463
- handle again. **Do not delete, rename, or silently replace the database with an
460
+ Persistent worker startup retries retryable `client.storage_busy` failures
461
+ while retaining the leader lease. It opens the same directory at most seven
462
+ times, waiting 50, 100, 200, 400, 800, and 1000 ms between attempts. These delays
463
+ total 2550 ms; browser scheduling and storage operations can extend elapsed time.
464
+ Other errors fail immediately. Direct `openPersistentWasmDatabase` calls remain
465
+ single attempts.
466
+
467
+ If ownership remains unavailable, handle creation rejects with `ClientSyncError`,
468
+ code `client.storage_busy`, and `retryable === true`, then releases the worker
469
+ and leader lease. Close the competing instance or let it finish shutting down,
470
+ then create the handle again. **Do not delete, rename, or silently replace the database with an
464
471
  in-memory one**; the local database and pending outbox may be perfectly healthy.
465
472
  Missing or obsolete OPFS APIs instead use the non-retryable
466
473
  `client.storage_unavailable` code.
@@ -484,9 +491,8 @@ and are cached locally. **Blob bytes live as `BLOB` columns in the client's own
484
491
  SQLite database** (a `_syncular_blobs` cache table), not in a separate OPFS
485
492
  directory or IndexedDB store. This is the pinned decision (SPEC §5.9.7 B1):
486
493
 
487
- - **One storage system.** The bytes are transactional with the refcount rows
488
- that pin them — a refcount adjust and a body insert/delete commit atomically,
489
- so a crash never strands a body against a stale count.
494
+ - **One storage system.** The bytes, upload state, and pending-commit references
495
+ share the client database. SQLite commits each change atomically.
490
496
  - **Survives restarts for free.** The client DB already rides OPFS via the
491
497
  sahpool VFS in the browser (and a plain file under `rusqlite`, `bun:sqlite`,
492
498
  or `node:sqlite` on native runtimes), so there is no second persistence
@@ -495,25 +501,24 @@ directory or IndexedDB store. This is the pinned decision (SPEC §5.9.7 B1):
495
501
  - **SQLite handles multi-MB images fine.** A page-cached `BLOB` read is a memory
496
502
  copy, well within the image/document envelope this targets.
497
503
 
498
- ### Size cap + LRU eviction
504
+ ### Size cap
499
505
 
500
506
  Pass `blobCacheMaxBytes` to cap the on-device cache. When the sum of cached body
501
- sizes exceeds the cap, the client evicts **zero-ref, non-pinned** bodies in
502
- least-recently-used order until back under the cap:
507
+ sizes exceeds the cap, the client evicts bodies with no visible row or pending
508
+ commit by creation timestamp, then blob ID, until back under the cap:
503
509
 
504
510
  ```ts
505
511
  new SyncClient({ /* … */, blobCacheMaxBytes: 256 * 1024 * 1024 }); // 256 MiB
506
512
  ```
507
513
 
508
- - A body **referenced by a live row** (refcount > 0) is **never** evicted — it
514
+ - A body **referenced by a live row** is **never** evicted. It
509
515
  stays resolvable without a re-download.
510
- - A body **pinned by a pending upload** (not yet pushed) is never evicted — its
516
+ - A body **needed by a pending upload** (not yet pushed) is never evicted. Its
511
517
  bytes are the only copy until the commit drains.
512
- - Evicting a zero-ref body only costs a future re-download, never correctness:
518
+ - Evicting an unreferenced body only costs a future re-download, never correctness:
513
519
  any surviving `blob_ref` value re-enables the fetch (§5.9.7 B3). If every
514
520
  over-cap body is referenced or pinned, the cache stays over the cap
515
- (correctness beats the cap). A cache-hit read touches "recently used", so a
516
- hot image survives a trim. Absent `blobCacheMaxBytes` ⇒ retain until storage
521
+ (correctness beats the cap). Absent `blobCacheMaxBytes` ⇒ retain until storage
517
522
  pressure (the default).
518
523
 
519
524
  ### Very large media — the escape hatch
@@ -522,7 +527,7 @@ SQLite is **not** the store for gigabyte video: a single `BLOB` must fit the
522
527
  client's memory and the SQLite row-size envelope. For very large media, run the
523
528
  server with presigned downloads (`blobSignedUrls`) and hand the presigned URL
524
529
  straight to a media element instead of pulling bytes through the cache — the
525
- image-app default (refcounted `BLOB` cache) and the large-media path (presigned
530
+ image-app default (`BLOB` cache) and the large-media path (presigned
526
531
  URL, no byte cache) coexist per attachment.
527
532
 
528
533
  ## Node and Bun SQLite backend (`./sqlite`)
@@ -586,8 +591,96 @@ they own their buffer.
586
591
 
587
592
  Tests drive the real worker entry in a bun `Worker` with bun:sqlite
588
593
  injected through the bootstrap's database-factory override
589
- (`test/worker-rpc.test.ts`); the OPFS path itself is browser-only and is
590
- exercised by `apps/demo`.
594
+ (`test/worker-rpc.test.ts`). The separate Chromium gate exercises actual
595
+ sqlite-wasm, OPFS SAH pools and FTS through the production worker and HTTP paths:
596
+
597
+ ```sh
598
+ cd packages/web-client
599
+ bunx --no-install playwright install chromium
600
+ cd ../..
601
+ bun run test:browser
602
+ ```
603
+
604
+ `test/opfs-bootstrap.browser.test.ts` covers five interruption boundaries:
605
+
606
+ - Partial segment download: hold the asynchronous transfer, then terminate the worker.
607
+ - Before image import: inject an exception before applying the verified image.
608
+ - Mid-import: terminate the worker after an actual OPFS database page write during
609
+ `INSERT SELECT`. A commit notification must not precede termination.
610
+ - After import: inject an exception after the transaction commits, before `SUB_END`.
611
+ - After checkpoint: complete the round before terminating the worker.
612
+
613
+ Each case reloads the page using the same replica identity and requires successful
614
+ startup in the same browser session. It checks SQLite and FTS integrity, search
615
+ results, exact rows, persisted client identity, and subscription checkpoints.
616
+ Incomplete sections must download again; completed sections must not. A held
617
+ synchronous test request after a physical page write keeps the mid-import crash
618
+ boundary stable until worker termination. Two additional ownership cases verify
619
+ recovery when a competing owner closes during retry and bounded failure while
620
+ that owner remains live, including pending outbox preservation.
621
+
622
+ Each case owns a temporary browser profile and a loopback server. A small SQLite
623
+ page cache forces dirty pages to disk before COMMIT. Tests use no timing sleeps,
624
+ COOP/COEP requirement, existing profiles or application replicas. Failed profiles
625
+ and `recovery-test.json` remain at the path printed by the test; CI retains them
626
+ as an artifact. Successful profiles are removed. `bun run check` keeps its
627
+ Bun-only test lane; CI runs the browser gate separately.
628
+
629
+ `openPersistentWasmDatabase` corrects the SAH-pool VFS's reserved-lock callback
630
+ before the first SQL statement. sqlite-wasm 3.53 reports a reserved lock
631
+ unconditionally, suppressing SQLite's hot-journal recovery after an interrupted
632
+ write. The pool already excludes other owners. Syncular reports no competing
633
+ writer through SQLite's I/O method binding, matching the other OPFS VFS, and
634
+ retains that callback across database closes and pool opens. The correction ships
635
+ in the client package and preserves the existing database format, DELETE journal
636
+ mode and FULL synchronous mode.
637
+
638
+ On 2026-09-12, Chromium 153 with sqlite-wasm 3.53.0 failed the mid-import case
639
+ without this correction and passed all five cases with it. The fixture serves
640
+ the unmodified dependency. Bounded worker startup retries handle transient
641
+ `client.storage_busy` while the browser releases old handles. This correction enables
642
+ rollback from an intact hot journal; it does not repair existing corruption
643
+ whose recovery journal has already been lost or overwritten.
644
+
645
+ ## Live sync progress
646
+
647
+ `onProgress` observes work during a sync round. It immediately delivers the latest
648
+ snapshot when one exists and returns an unsubscribe function. `progressSnapshot()`
649
+ reads the cached snapshot synchronously, including on worker handles. Worker
650
+ leaders and followers forward events while download and import are running.
651
+
652
+ ```ts
653
+ const stop = client.onProgress((progress) => {
654
+ if (progress.phase === 'download') {
655
+ renderDownload(progress.bytesReceived, progress.bytesTotal);
656
+ } else if (progress.phase === 'import') {
657
+ renderImport(progress.rowsProcessed, progress.rowsTotal);
658
+ }
659
+ if (progress.state === 'failed') showError(progress.errorCode);
660
+ });
661
+ // Stop listening when the view is disposed. Sync continues.
662
+ stop();
663
+ ```
664
+
665
+ Each snapshot identifies the round with `attempt`, and the current payload with
666
+ `subscriptionId`, `table`, and optional `segmentId`. Counters reset for each
667
+ payload; `syncUntilIdle` starts a new attempt for each round. `phase` is `request`,
668
+ `download`, or `import`; `state` is `running`, `complete`, or `failed`. Unknown
669
+ totals remain absent. A new attempt clears prior errors and counters.
670
+
671
+ HTTP downloads report decoded body bytes at 64 KiB intervals or larger network
672
+ chunks. Custom segment downloaders receive `request.onProgress`; signed-URL
673
+ implementations receive an optional second callback argument. Call it with the
674
+ cumulative received byte count. Buffered custom transports report a final count
675
+ through the core, without intermediate download updates.
676
+
677
+ Import reports every 1,024 rows and its final count. `rowsProcessed` includes work
678
+ inside the current transaction; a failed import can roll those rows back. SQLite
679
+ images retain one atomic transaction. `complete` follows checkpoint persistence
680
+ and optimistic-state reconciliation for the round. More bootstrap pages can
681
+ remain; use subscription coverage to determine whether a query is complete.
682
+ Listener exceptions do not fail sync. Listeners should hand UI work off and avoid
683
+ reentering database commands during an import.
591
684
 
592
685
  ## Snapshot API migration
593
686
 
package/dist/apply.d.ts CHANGED
@@ -68,6 +68,7 @@ export declare function applySqliteSegment(db: ClientDatabase, schema: CompiledC
68
68
  readonly clearFirst: boolean;
69
69
  readonly effective: ScopeMap;
70
70
  readonly transaction?: ApplyTransaction;
71
+ readonly onProgress?: (rowsProcessed: number) => void;
71
72
  }): number;
72
73
  /**
73
74
  * Apply a decoded rows segment: each block in one local transaction
@@ -81,4 +82,5 @@ export declare function applyRowsSegment(db: ClientDatabase, schema: CompiledCli
81
82
  readonly clearFirst: boolean;
82
83
  readonly effective: ScopeMap;
83
84
  readonly transaction?: ApplyTransaction;
85
+ readonly onProgress?: (rowsProcessed: number) => void;
84
86
  }, encryption?: EncryptionConfig): Promise<number>;
package/dist/apply.js CHANGED
@@ -255,17 +255,38 @@ export function applySqliteSegment(db, schema, table, bytes, descriptor, options
255
255
  if (options.clearFirst) {
256
256
  deleteScopedRows(db, table, options.effective);
257
257
  }
258
- db.exec(`INSERT INTO ${quoteIdent(table.name)}
259
- (${insertNames.join(', ')})
260
- SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
261
- FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}
262
- WHERE true
263
- ON CONFLICT (${quoteIdent(table.primaryKey)}) DO UPDATE SET ${updates}`);
264
- const counted = db.query(`SELECT count(*) AS n FROM ${IMAGE_ALIAS}.${quoteIdent(table.name)}`)[0];
265
- const applied = Number(counted?.n ?? 0);
258
+ const source = `${IMAGE_ALIAS}.${quoteIdent(table.name)}`;
259
+ const primaryKey = quoteIdent(table.primaryKey);
260
+ let after;
261
+ let applied = 0;
262
+ for (;;) {
263
+ const boundary = db.query(`SELECT ${primaryKey} AS boundary FROM ${source}
264
+ ${after === undefined ? '' : `WHERE ${primaryKey} > ?`}
265
+ ORDER BY ${primaryKey} LIMIT 1 OFFSET 1023`, after === undefined ? [] : [after])[0]?.boundary;
266
+ const predicates = [];
267
+ const params = [];
268
+ if (after !== undefined) {
269
+ predicates.push(`${primaryKey} > ?`);
270
+ params.push(after);
271
+ }
272
+ if (boundary !== undefined) {
273
+ predicates.push(`${primaryKey} <= ?`);
274
+ params.push(boundary);
275
+ }
276
+ db.exec(`INSERT INTO ${quoteIdent(table.name)} (${insertNames.join(', ')})
277
+ SELECT ${[...names, quoteIdent('_syncular_version')].join(', ')}
278
+ FROM ${source} WHERE ${predicates.join(' AND ') || 'true'}
279
+ ON CONFLICT (${primaryKey}) DO UPDATE SET ${updates}`, params);
280
+ applied += Number(db.query('SELECT changes() AS n')[0]?.n ?? 0);
281
+ if (boundary === undefined)
282
+ break;
283
+ options.onProgress?.(applied);
284
+ after = boundary;
285
+ }
266
286
  if (applied !== descriptor.rowCount) {
267
287
  imageInvalid(`image holds ${applied} rows, descriptor says ${descriptor.rowCount}`);
268
288
  }
289
+ options.onProgress?.(applied);
269
290
  return applied;
270
291
  });
271
292
  });
@@ -308,8 +329,11 @@ export async function applyRowsSegment(db, schema, table, segment, options, encr
308
329
  for (const row of rows) {
309
330
  upsertLocalRow(db, table, row.values, row.serverVersion);
310
331
  applied += 1;
332
+ if (applied % 1024 === 0)
333
+ options.onProgress?.(applied);
311
334
  }
312
335
  });
313
336
  }
337
+ options.onProgress?.(applied);
314
338
  return applied;
315
339
  }
package/dist/blob.d.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.js';
@@ -82,48 +77,26 @@ export interface CachedBlob {
82
77
  /** `"sha256:" + hex` of the bytes — the content address (§5.9.1). */
83
78
  export declare function computeBlobId(bytes: Uint8Array): Promise<string>;
84
79
  export declare function ensureBlobSchema(db: ClientDatabase): void;
85
- /** Put bytes into the content-addressed cache (idempotent); touches LRU. */
80
+ /** Put bytes into the content-addressed cache without rewriting an existing body. */
86
81
  export declare function putCachedBlob(db: ClientDatabase, blobId: string, bytes: Uint8Array, nowMs: number, mediaType?: string): void;
87
- export declare function getCachedBlob(db: ClientDatabase, blobId: string, nowMs?: number): CachedBlob | undefined;
82
+ export declare function getCachedBlob(db: ClientDatabase, blobId: string): CachedBlob | undefined;
88
83
  /**
89
- * §5.9.7 B1 size cap + LRU eviction. When the sum of cached body sizes exceeds
90
- * `maxBytes`, evict **zero-ref, non-pinned** bodies in least-recently-used
91
- * order until back under the cap. NEVER evicts a referenced body (refcount > 0
92
- * — it must stay resolvable without a re-download) nor a pending-upload-pinned
93
- * body (its bytes are the only copy until push, B4). If every over-cap body is
94
- * referenced or pinned, the cache stays over the cap (correctness beats the
95
- * cap). Evicting a zero-ref body is always safe: B3 re-enables the fetch from
96
- * any surviving `blob_ref` value. Returns the evicted blobIds.
84
+ * §5.9.7 B1 size cap. When the sum of cached body sizes exceeds `maxBytes`,
85
+ * evict unreferenced, non-pinned bodies by creation timestamp, then blob ID,
86
+ * until back under the cap. Returns the evicted blobIds.
97
87
  */
98
- export declare function enforceBlobCacheCap(db: ClientDatabase, maxBytes: number): string[];
99
- /** Record a pending upload (§5.9.7 B4); flushed before the next push. */
100
- export declare function recordPendingUpload(db: ClientDatabase, blobId: string, nowMs: number, mediaType?: string): void;
88
+ export declare function enforceBlobCacheCap(db: ClientDatabase, schema: CompiledClientSchema, maxBytes: number): string[];
101
89
  export declare function listPendingUploads(db: ClientDatabase): {
102
90
  blobId: string;
103
91
  mediaType?: string;
104
92
  }[];
93
+ export declare function recordPendingUpload(db: ClientDatabase, blobId: string, nowMs: number, mediaType?: string): void;
105
94
  export declare function clearPendingUpload(db: ClientDatabase, blobId: string): void;
106
95
  /**
107
- * All `blob_ref` column names per table (for refcount reconciliation).
108
- * Blank result ⇒ the schema has no attachments; callers skip reconciliation.
96
+ * All `blob_ref` column names per table.
109
97
  */
110
98
  export declare function blobRefColumnsBySchema(schema: CompiledClientSchema): Map<string, string[]>;
111
99
  export declare function schemaHasBlobs(schema: CompiledClientSchema): boolean;
112
- /**
113
- * §5.9.7 B1/B2: recompute cache refcounts from the current `blob_ref` column
114
- * contents across all synced tables, then delete cache bodies that dropped
115
- * to zero refs *and* have no pending upload (a pending upload pins its body,
116
- * B4). Called after every apply/purge that may add or remove references.
117
- *
118
- * `blobId` narrows a fresh-download refresh to the inserted cache row.
119
- * `deleteOrphans` distinguishes the two B2 transitions: revocation purge
120
- * passes `true` (drop the now-unauthorized body); a benign apply passes
121
- * `false` (retain zero-ref bodies as LRU cache entries — the shipped
122
- * default). Bodies pinned by a pending upload are always retained.
123
- */
124
- export declare function reconcileBlobRefcounts(db: ClientDatabase, schema: CompiledClientSchema, options?: {
125
- readonly blobId?: string;
126
- readonly deleteOrphans?: boolean;
127
- }): void;
100
+ export declare function deleteUnreferencedCachedBlobs(db: ClientDatabase, schema: CompiledClientSchema): void;
128
101
  export type { BlobRef };
129
102
  export { parseBlobRef, serializeBlobRef };
package/dist/blob.js 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 { parseBlobRef, serializeBlobRef } from '@syncular/core';
14
9
  import { ClientSyncError } from './errors.js';
@@ -25,51 +20,46 @@ export async function computeBlobId(bytes) {
25
20
  return `sha256:${hex}`;
26
21
  }
27
22
  export function ensureBlobSchema(db) {
28
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blobs(
29
- blob_id TEXT PRIMARY KEY,
30
- bytes BLOB NOT NULL,
31
- byte_length INTEGER NOT NULL,
32
- media_type TEXT,
33
- refcount INTEGER NOT NULL DEFAULT 0,
34
- created_at_ms INTEGER NOT NULL,
35
- last_used_ms INTEGER NOT NULL DEFAULT 0)`);
36
- // Migrate a cache created before the §5.9.7 B1 LRU column: additive,
37
- // idempotent (a duplicate-column error on an already-migrated DB is
38
- // swallowed). last_used_ms drives cap eviction (LRU of zero-ref bodies).
39
- try {
40
- db.exec('ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0');
23
+ const existing = db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs'");
24
+ if (existing.length > 0) {
25
+ const columns = db
26
+ .query('PRAGMA table_info(_syncular_blobs)')
27
+ .map((row) => row.name)
28
+ .sort();
29
+ const expected = [
30
+ 'blob_id',
31
+ 'byte_length',
32
+ 'bytes',
33
+ 'created_at_ms',
34
+ 'media_type',
35
+ ].sort();
36
+ if (columns.length !== expected.length ||
37
+ columns.some((column, index) => column !== expected[index])) {
38
+ throw new ClientSyncError('sync.schema_mismatch', 'Local blob schema is incompatible');
39
+ }
41
40
  }
42
- catch {
43
- // column already exists — the CREATE above included it
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)`);
44
48
  }
45
49
  db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
46
- blob_id TEXT PRIMARY KEY,
47
- media_type TEXT,
48
- created_at_ms INTEGER NOT NULL)`);
49
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
50
- commit_id TEXT NOT NULL, blob_id TEXT NOT NULL, PRIMARY KEY(commit_id, blob_id))`);
51
- db.exec('CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)');
50
+ blob_id TEXT PRIMARY KEY, media_type TEXT, created_at_ms INTEGER NOT NULL)`);
52
51
  }
53
- /** Put bytes into the content-addressed cache (idempotent); touches LRU. */
52
+ /** Put bytes into the content-addressed cache without rewriting an existing body. */
54
53
  export function putCachedBlob(db, blobId, bytes, nowMs, mediaType) {
55
54
  db.exec(`INSERT INTO _syncular_blobs(
56
- blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms)
57
- VALUES (?,?,?,?,0,?,?)
58
- ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms`, [blobId, bytes, bytes.length, mediaType ?? null, nowMs, nowMs]);
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]);
59
57
  }
60
- export function getCachedBlob(db, blobId, nowMs) {
58
+ export function getCachedBlob(db, blobId) {
61
59
  const rows = db.query('SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?', [blobId]);
62
60
  const row = rows[0];
63
61
  if (row === undefined)
64
62
  return undefined;
65
- // §5.9.7 B1 LRU: a cache-hit read touches "recently used" so a hot image
66
- // survives a cap trim. Skipped when no clock is supplied (pure read).
67
- if (nowMs !== undefined) {
68
- db.exec('UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?', [
69
- nowMs,
70
- blobId,
71
- ]);
72
- }
73
63
  return {
74
64
  blobId,
75
65
  bytes: row.bytes,
@@ -78,27 +68,21 @@ export function getCachedBlob(db, blobId, nowMs) {
78
68
  };
79
69
  }
80
70
  /**
81
- * §5.9.7 B1 size cap + LRU eviction. When the sum of cached body sizes exceeds
82
- * `maxBytes`, evict **zero-ref, non-pinned** bodies in least-recently-used
83
- * order until back under the cap. NEVER evicts a referenced body (refcount > 0
84
- * — it must stay resolvable without a re-download) nor a pending-upload-pinned
85
- * body (its bytes are the only copy until push, B4). If every over-cap body is
86
- * referenced or pinned, the cache stays over the cap (correctness beats the
87
- * cap). Evicting a zero-ref body is always safe: B3 re-enables the fetch from
88
- * any surviving `blob_ref` value. Returns the evicted blobIds.
71
+ * §5.9.7 B1 size cap. When the sum of cached body sizes exceeds `maxBytes`,
72
+ * evict unreferenced, non-pinned bodies by creation timestamp, then blob ID,
73
+ * until back under the cap. Returns the evicted blobIds.
89
74
  */
90
- export function enforceBlobCacheCap(db, maxBytes) {
75
+ export function enforceBlobCacheCap(db, schema, maxBytes) {
91
76
  const totalRow = db.query('SELECT COALESCE(SUM(byte_length), 0) AS total FROM _syncular_blobs')[0];
92
77
  let total = Number(totalRow?.total ?? 0);
93
78
  if (total <= maxBytes)
94
79
  return [];
95
- // Eviction candidates: zero-ref AND not pinned by a pending upload, oldest
96
- // (LRU) first, then oldest created as a stable tiebreak.
80
+ // Oldest eligible body first, with blob ID as a stable tie.
97
81
  const candidates = db.query(`SELECT blob_id, byte_length FROM _syncular_blobs
98
- WHERE refcount = 0
99
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
82
+ WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
100
83
  AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
101
- ORDER BY last_used_ms ASC, created_at_ms ASC`);
84
+ AND blob_id NOT IN (${visibleBlobIdsSql(schema)})
85
+ ORDER BY created_at_ms ASC, blob_id ASC`);
102
86
  const evicted = [];
103
87
  db.transaction(() => {
104
88
  for (const row of candidates) {
@@ -112,11 +96,6 @@ export function enforceBlobCacheCap(db, maxBytes) {
112
96
  });
113
97
  return evicted;
114
98
  }
115
- /** Record a pending upload (§5.9.7 B4); flushed before the next push. */
116
- export function recordPendingUpload(db, blobId, nowMs, mediaType) {
117
- db.exec(`INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms)
118
- VALUES (?,?,?)`, [blobId, mediaType ?? null, nowMs]);
119
- }
120
99
  export function listPendingUploads(db) {
121
100
  return db
122
101
  .query(`SELECT blob_id, media_type FROM (
@@ -137,12 +116,15 @@ export function listPendingUploads(db) {
137
116
  };
138
117
  });
139
118
  }
119
+ 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]);
122
+ }
140
123
  export function clearPendingUpload(db, blobId) {
141
124
  db.exec('DELETE FROM _syncular_blob_uploads WHERE blob_id = ?', [blobId]);
142
125
  }
143
126
  /**
144
- * All `blob_ref` column names per table (for refcount reconciliation).
145
- * Blank result ⇒ the schema has no attachments; callers skip reconciliation.
127
+ * All `blob_ref` column names per table.
146
128
  */
147
129
  export function blobRefColumnsBySchema(schema) {
148
130
  const out = new Map();
@@ -162,79 +144,26 @@ export function schemaHasBlobs(schema) {
162
144
  }
163
145
  return false;
164
146
  }
165
- /**
166
- * §5.9.7 B1/B2: recompute cache refcounts from the current `blob_ref` column
167
- * contents across all synced tables, then delete cache bodies that dropped
168
- * to zero refs *and* have no pending upload (a pending upload pins its body,
169
- * B4). Called after every apply/purge that may add or remove references.
170
- *
171
- * `blobId` narrows a fresh-download refresh to the inserted cache row.
172
- * `deleteOrphans` distinguishes the two B2 transitions: revocation purge
173
- * passes `true` (drop the now-unauthorized body); a benign apply passes
174
- * `false` (retain zero-ref bodies as LRU cache entries — the shipped
175
- * default). Bodies pinned by a pending upload are always retained.
176
- */
177
- export function reconcileBlobRefcounts(db, schema, options) {
178
- const byTable = blobRefColumnsBySchema(schema);
179
- if (byTable.size === 0)
180
- return;
181
- if (options?.blobId !== undefined) {
182
- let count = 0;
183
- for (const [tableName, columns] of byTable) {
184
- for (const column of columns) {
185
- const identifier = quoteIdent(column);
186
- const row = db.query(`SELECT count(*) AS n FROM ${quoteIdent(tableName)}
187
- WHERE CASE
188
- WHEN typeof(${identifier}) = 'text' AND json_valid(${identifier})
189
- THEN json_extract(${identifier}, '$.blobId')
190
- END = ?`, [options.blobId])[0];
191
- count += Number(row?.n ?? 0);
192
- }
193
- }
194
- db.exec('UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?', [
195
- count,
196
- options.blobId,
197
- ]);
198
- return;
199
- }
200
- // Count references to each blobId across every blob_ref column.
201
- const counts = new Map();
202
- for (const [tableName, columns] of byTable) {
147
+ function visibleBlobIdsSql(schema) {
148
+ const selects = [];
149
+ for (const [tableName, columns] of blobRefColumnsBySchema(schema)) {
203
150
  for (const column of columns) {
204
- const rows = db.query(`SELECT ${quoteIdent(column)} AS v FROM ${quoteIdent(tableName)}
205
- WHERE ${quoteIdent(column)} IS NOT NULL`);
206
- for (const row of rows) {
207
- const raw = row.v;
208
- if (typeof raw !== 'string')
209
- continue;
210
- let ref;
211
- try {
212
- ref = parseBlobRef(raw);
213
- }
214
- catch {
215
- continue;
216
- }
217
- counts.set(ref.blobId, (counts.get(ref.blobId) ?? 0) + 1);
218
- }
151
+ 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'`);
219
157
  }
220
158
  }
221
- db.transaction(() => {
222
- // Reset all refcounts, then apply the recomputed counts.
223
- db.exec('UPDATE _syncular_blobs SET refcount = 0');
224
- for (const [blobId, count] of counts) {
225
- db.exec('UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?', [
226
- count,
227
- blobId,
228
- ]);
229
- }
230
- if (options?.deleteOrphans === true) {
231
- // §5.9.7 B2 revocation side: delete zero-ref bodies not pinned by a
232
- // pending upload.
233
- db.exec(`DELETE FROM _syncular_blobs
234
- WHERE refcount = 0
235
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
236
- AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)`);
237
- }
238
- });
159
+ return selects.length === 0
160
+ ? 'SELECT NULL AS blob_id WHERE 0'
161
+ : selects.join(' UNION ');
162
+ }
163
+ 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)})`);
239
168
  }
240
169
  export { parseBlobRef, serializeBlobRef };
package/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type SyncProgress, type SyncProgressListener } from './progress.js';
1
2
  /**
2
3
  * SyncClient implements the client side of SPEC.md §§3–8.
3
4
  *
@@ -110,9 +111,9 @@ export interface SyncClientConfig {
110
111
  readonly blobs?: BlobTransport;
111
112
  /**
112
113
  * §5.9.7 B1 blob-cache size cap (bytes). When the sum of cached body sizes
113
- * exceeds this, zero-ref, non-pinned bodies are evicted LRU-first after each
114
- * cache write (referenced/pinned bodies are never evicted — correctness
115
- * beats the cap). Absent ⇒ retain until storage pressure (the shipped
114
+ * exceeds this, unreferenced bodies without pending work are evicted in
115
+ * creation timestamp order after each cache write. Referenced bodies remain cached
116
+ * even when that exceeds the cap. Absent means retain until storage pressure (the shipped
116
117
  * default; a referenced body always stays resolvable without a re-download).
117
118
  */
118
119
  readonly blobCacheMaxBytes?: number;
@@ -294,6 +295,8 @@ export declare class SyncClient {
294
295
  */
295
296
  onInvalidate(listener: InvalidationListener): () => void;
296
297
  /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
298
+ onProgress(listener: SyncProgressListener): () => void;
299
+ progressSnapshot(): SyncProgress | undefined;
297
300
  onChange(listener: ClientChangeListener): () => void;
298
301
  /** Subscribe to host wake signals raised by startup and realtime. */
299
302
  onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;