@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 +112 -19
- package/dist/apply.d.ts +2 -0
- package/dist/apply.js +32 -8
- package/dist/blob.d.ts +12 -39
- package/dist/blob.js +63 -134
- package/dist/client.d.ts +6 -3
- package/dist/client.js +95 -58
- package/dist/http.d.ts +0 -8
- package/dist/http.js +36 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/outbox.js +3 -0
- package/dist/progress.d.ts +24 -0
- package/dist/progress.js +42 -0
- package/dist/schema.js +3 -0
- package/dist/transport.d.ts +2 -1
- package/dist/wasm-database.js +41 -1
- package/dist/worker-entry.d.ts +2 -0
- package/dist/worker-entry.js +34 -7
- package/dist/worker-host.d.ts +3 -0
- package/dist/worker-host.js +13 -1
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +4 -3
- package/src/apply.ts +39 -13
- package/src/blob.ts +86 -157
- package/src/client.ts +110 -69
- package/src/http.ts +41 -3
- package/src/index.ts +6 -0
- package/src/outbox.ts +3 -0
- package/src/progress.ts +62 -0
- package/src/schema.ts +5 -0
- package/src/transport.ts +5 -1
- package/src/wasm-database.ts +79 -1
- package/src/worker-entry.ts +38 -7
- package/src/worker-host.ts +17 -1
- package/src/worker-protocol.ts +2 -0
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,
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
488
|
-
|
|
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
|
|
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
|
|
502
|
-
|
|
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**
|
|
514
|
+
- A body **referenced by a live row** is **never** evicted. It
|
|
509
515
|
stays resolvable without a re-download.
|
|
510
|
-
- A body **
|
|
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
|
|
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).
|
|
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 (
|
|
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`)
|
|
590
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
82
|
+
export declare function getCachedBlob(db: ClientDatabase, blobId: string): CachedBlob | undefined;
|
|
88
83
|
/**
|
|
89
|
-
* §5.9.7 B1 size cap
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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,
|
|
57
|
-
VALUES (
|
|
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
|
|
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
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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,
|
|
114
|
-
* cache write
|
|
115
|
-
*
|
|
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;
|