@spooky-sync/core 0.0.1-canary.162 → 0.0.1-canary.163

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/AGENTS.md CHANGED
@@ -24,7 +24,8 @@ Local mutations are applied optimistically, ingested into a DBSP layer that driv
24
24
  ## Key exports (`src/index.ts`)
25
25
 
26
26
  - `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, idOrSelector)`, `query(table, opts?)`, `run(backend, route, payload)`, `bucket(name)`, `useRemote(fn)`, `authenticate(token)`, `signOut()`. Plus `pendingMutationCount` and `subscribeToPendingMutations(cb)`.
27
- - `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`).
27
+ - `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
28
+ - `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
28
29
  - `AuthService` — token management, sign-in/sign-out events.
29
30
  - `CrdtManager`, `CrdtField`, `cursorColorFromName`, `CURSOR_COLORS` — Loro-CRDT integration.
30
31
  - Types: `Sp00kyConfig`, `SyncedDbConfig` (re-exported by client-solid as the consumer-facing shape), `QueryTimeToLive`, `PersistenceClient`, `StoreType`, `UpdateOptions`, `RunOptions`.
package/dist/index.d.ts CHANGED
@@ -1712,13 +1712,279 @@ declare class AppReleaseModule<S extends SchemaStructure> {
1712
1712
  private applyRecords;
1713
1713
  }
1714
1714
  //#endregion
1715
+ //#region src/services/blobs/blob-store.d.ts
1716
+ /**
1717
+ * Byte storage for cached bucket files.
1718
+ *
1719
+ * The default implementation is OPFS. Bucket files never arrive over HTTP in
1720
+ * this client — `BucketHandle.get()` is a SurrealQL RPC on the sync socket — so
1721
+ * neither the browser's HTTP cache nor the Cache API can hold them. We persist
1722
+ * the bytes ourselves, and OPFS is the cheapest place to put them: a read is
1723
+ * `getFile()` → a disk-backed lazy `File` that `URL.createObjectURL` can serve
1724
+ * without ever moving the bytes through the JS heap.
1725
+ *
1726
+ * Layout is real nested directories rather than one hashed filename:
1727
+ *
1728
+ * sp00ky-blobs/<namespace>/<bucket>/<...path segments>
1729
+ *
1730
+ * That costs a `getDirectoryHandle` per segment on write, and buys the property
1731
+ * the whole orphan story rests on: the full `(bucket, path)` key is recoverable
1732
+ * from a directory walk alone. The `_00_blob` manifest can therefore be wiped
1733
+ * (memory fallback, SQLite pool wipe, IndexedDB corruption recovery) and be
1734
+ * rebuilt from disk instead of taking the cached bytes down with it.
1735
+ */
1736
+ /** Identifies one cached file: the bucket it lives in and its path within. */
1737
+ interface BlobKey {
1738
+ bucket: string;
1739
+ path: string;
1740
+ }
1741
+ /** What a directory walk can tell us about a stored file, with no manifest. */
1742
+ interface BlobStat {
1743
+ key: BlobKey;
1744
+ size: number;
1745
+ /** File mtime. Seeds `lastAccess` when a manifest row has to be rebuilt. */
1746
+ mtime: number;
1747
+ }
1748
+ interface BlobStore {
1749
+ /** False for {@link MemoryBlobStore} and for OPFS-less environments: the
1750
+ * cache still dedupes and serves within a tab, but nothing survives reload. */
1751
+ readonly persistent: boolean;
1752
+ /** Namespace (the local bucketId) all keys are resolved under. */
1753
+ readonly namespace: string;
1754
+ read(key: BlobKey): Promise<Blob | null>;
1755
+ /** Returns the number of bytes written. Throws on quota exhaustion. */
1756
+ write(key: BlobKey, bytes: Blob): Promise<number>;
1757
+ remove(key: BlobKey): Promise<void>;
1758
+ /** Every committed file under the current namespace. Sweeps torn writes. */
1759
+ list(): Promise<BlobStat[]>;
1760
+ /** Drop the whole namespace (sign-out with `clearOnSignOut`, or a reset). */
1761
+ clear(): Promise<void>;
1762
+ /** Point at another namespace. Does not touch the bytes of the old one. */
1763
+ setNamespace(namespace: string): void;
1764
+ }
1765
+ //#endregion
1766
+ //#region src/services/blobs/blob-manifest.d.ts
1767
+ interface BlobEntry {
1768
+ /** `${bucket}/${path}` — also the `_00_blob` row id. */
1769
+ id: string;
1770
+ bucket: string;
1771
+ path: string;
1772
+ size: number;
1773
+ contentType: string;
1774
+ createdAt: number;
1775
+ lastAccess: number;
1776
+ hits: number;
1777
+ /** Exempt from pressure eviction. Never expires on its own. */
1778
+ pinned: boolean;
1779
+ }
1780
+ declare class BlobManifest {
1781
+ private local;
1782
+ private entries;
1783
+ /** Ids whose in-memory state has not been written back yet. */
1784
+ private dirty;
1785
+ private removed;
1786
+ private flushing;
1787
+ constructor(local: LocalStore);
1788
+ /**
1789
+ * Hydrate from the rows matching `keys`. Ids come from the OPFS listing, so
1790
+ * this never needs a full-table scan (and therefore never needs a QueryPlan).
1791
+ * Any read failure yields an empty manifest: reconcile then rebuilds every
1792
+ * row from disk, which is exactly the desired degradation.
1793
+ */
1794
+ load(ids: string[]): Promise<void>;
1795
+ get(key: BlobKey): BlobEntry | undefined;
1796
+ getById(id: string): BlobEntry | undefined;
1797
+ all(): BlobEntry[];
1798
+ totalBytes(): number;
1799
+ pinnedBytes(): number;
1800
+ put(entry: BlobEntry): void;
1801
+ touch(id: string, now: number): void;
1802
+ setPinned(id: string, pinned: boolean): boolean;
1803
+ remove(id: string): void;
1804
+ /** Forget everything without scheduling deletes — for a bucket switch, where
1805
+ * the rows belong to the store we are leaving and must stay put. */
1806
+ reset(): void;
1807
+ hasPendingWrites(): boolean;
1808
+ /**
1809
+ * Write back pending changes. Serialized: a second concurrent flush awaits
1810
+ * the first rather than racing it into the same rows. Failures are swallowed
1811
+ * on purpose — a lost metadata write costs an LRU timestamp, and the entry is
1812
+ * rebuilt from disk on the next reconcile.
1813
+ */
1814
+ flush(): Promise<void>;
1815
+ private doFlush;
1816
+ }
1817
+ //#endregion
1818
+ //#region src/services/blobs/blob-cache.d.ts
1819
+ interface BlobUrlLease {
1820
+ url: string;
1821
+ release(): void;
1822
+ }
1823
+ interface BlobReadOptions {
1824
+ /** Write through to L1 on a miss. Default true. */
1825
+ persist?: boolean;
1826
+ /** Mark the entry exempt from pressure eviction. */
1827
+ pin?: boolean;
1828
+ /**
1829
+ * Default `'never'`: a bucket path is treated as immutable, which is how the
1830
+ * client writes them (`crypto.randomUUID() + ext`). `'head'` spends a remote
1831
+ * `head()` to compare sizes before trusting L1.
1832
+ */
1833
+ revalidate?: 'never' | 'head';
1834
+ /** Skip L0/L1 entirely and refill from remote. Backs `refetch()`. */
1835
+ reload?: boolean;
1836
+ }
1837
+ interface BlobCacheStats {
1838
+ entries: number;
1839
+ totalBytes: number;
1840
+ budgetBytes: number;
1841
+ pinnedBytes: number;
1842
+ evictedEntries: number;
1843
+ evictedBytes: number;
1844
+ reconciledEntries: number;
1845
+ hits: number;
1846
+ misses: number;
1847
+ persistent: boolean;
1848
+ /** True when pinned bytes alone exceed the budget: new entries stop being
1849
+ * written rather than pinned ones being thrown away. */
1850
+ persistPaused: boolean;
1851
+ }
1852
+ interface BlobCacheOptions {
1853
+ store: BlobStore;
1854
+ manifest: BlobManifest;
1855
+ /** L2 read. Resolves to null when the file does not exist remotely. */
1856
+ fetchRemote(key: BlobKey): Promise<Blob | null>;
1857
+ /** L2 metadata, for `revalidate: 'head'`. */
1858
+ headRemote?(key: BlobKey): Promise<Record<string, unknown> | null>;
1859
+ logger: Logger$1;
1860
+ maxBytes: number;
1861
+ now?: () => number;
1862
+ /** Injected so the URL layer is exercisable off a DOM (node tests). */
1863
+ urls?: {
1864
+ create(blob: Blob): string;
1865
+ revoke(url: string): void;
1866
+ };
1867
+ }
1868
+ declare class BlobCache {
1869
+ private readonly store;
1870
+ private readonly manifest;
1871
+ private readonly fetchRemote;
1872
+ private readonly headRemote?;
1873
+ private readonly logger;
1874
+ private readonly now;
1875
+ private readonly urlFactory;
1876
+ private maxBytes;
1877
+ private persistPaused;
1878
+ /** Set after a quota failure survives one forced eviction. */
1879
+ private persistDisabled;
1880
+ private readonly urls;
1881
+ /** Ids at zero references, oldest first — the hot-URL window. */
1882
+ private idleUrls;
1883
+ private readonly inflight;
1884
+ private flushTimer;
1885
+ private readonly onPageHide;
1886
+ private hits;
1887
+ private misses;
1888
+ private evictedEntries;
1889
+ private evictedBytes;
1890
+ private reconciledEntries;
1891
+ constructor(opts: BlobCacheOptions);
1892
+ /** Coalesce manifest write-back. Metadata only, so losing the tail costs an
1893
+ * LRU timestamp that reconcile reseeds from the file mtime. */
1894
+ private scheduleFlush;
1895
+ /**
1896
+ * Resolve the bytes for `key`, filling L1 on the way when `persist` is on.
1897
+ * Returns null when the file does not exist remotely and is not cached.
1898
+ */
1899
+ read(key: BlobKey, options?: BlobReadOptions): Promise<Blob | null>;
1900
+ /**
1901
+ * An object URL for `key`, refcounted. Callers MUST `release()`; the URL is
1902
+ * revoked once the last holder lets go and it falls out of the hot window.
1903
+ */
1904
+ acquireUrl(key: BlobKey, options?: BlobReadOptions): Promise<BlobUrlLease | null>;
1905
+ private lease;
1906
+ private releaseUrl;
1907
+ private revokeUrl;
1908
+ /** L1 lookup with the size check that catches torn and cross-tab writes. */
1909
+ private readLocal;
1910
+ /** True when the remote agrees with the cached size, or cannot be reached. */
1911
+ private headMatches;
1912
+ private fetchDeduped;
1913
+ private persist;
1914
+ private writeThrough;
1915
+ /** Forget one path everywhere. Called on `bucket.put()`/`bucket.delete()`. */
1916
+ invalidate(key: BlobKey): Promise<void>;
1917
+ private dropLocal;
1918
+ setPinned(key: BlobKey, pinned: boolean): void;
1919
+ /**
1920
+ * Bring total bytes under budget by dropping the least recently used
1921
+ * entries. Pinned entries and anything with a live object URL are skipped —
1922
+ * evicting bytes that a mounted `<img>` is displaying would blank it.
1923
+ */
1924
+ private enforceBudget;
1925
+ /** Evict LRU-first until at or below `target`. Returns the resulting total. */
1926
+ private evictTo;
1927
+ /**
1928
+ * Rebuild the manifest from what is actually on disk. OPFS wins on existence
1929
+ * in both directions: files with no row get a row (seeded from mtime), rows
1930
+ * are only loaded for files that exist, and torn `.part-` writes are swept by
1931
+ * the walk itself.
1932
+ *
1933
+ * Rows whose file vanished outside our control (a browser origin eviction)
1934
+ * are left in `_00_blob`. They are inert — `load()` only ever asks for ids it
1935
+ * found on disk — and are overwritten if that path is cached again.
1936
+ */
1937
+ reconcile(): Promise<void>;
1938
+ /** Warm the cache for offline use. Skips anything already cached. */
1939
+ prefetch(keys: BlobKey[]): Promise<void>;
1940
+ /** Bind to the boot bucket and hydrate the manifest from disk. Separate from
1941
+ * {@link setNamespace} because boot must reconcile even when the namespace
1942
+ * it lands on is the one the store was constructed with. */
1943
+ start(namespace: string): Promise<void>;
1944
+ /** Repoint at another local bucket. The bytes of the old one stay on disk so
1945
+ * switching back (or signing back in) is still warm. */
1946
+ setNamespace(namespace: string): Promise<void>;
1947
+ setMaxBytes(maxBytes: number): void;
1948
+ /** Delete every cached byte in the current namespace. */
1949
+ clear(): Promise<void>;
1950
+ flush(): Promise<void>;
1951
+ /** Flush metadata and drop every object URL. Must run before the local store
1952
+ * closes — the flush writes through it. */
1953
+ close(): Promise<void>;
1954
+ stats(): BlobCacheStats;
1955
+ }
1956
+ //#endregion
1715
1957
  //#region src/sp00ky.d.ts
1958
+ /** Coerce whatever the `.get()` RPC hands back into a Blob. */
1959
+ declare function bucketContentToBlob(content: unknown): Blob | null;
1716
1960
  declare class BucketHandle {
1717
1961
  private bucketName;
1718
1962
  private remote;
1719
- constructor(bucketName: string, remote: RemoteDatabaseService);
1963
+ /** Absent on the raw handle the cache itself reads through. */
1964
+ private blobs?;
1965
+ constructor(bucketName: string, remote: RemoteDatabaseService, /** Absent on the raw handle the cache itself reads through. */
1966
+ blobs?: (BlobCache | null) | undefined);
1720
1967
  put(path: string, content: string | Uint8Array | Blob): Promise<void>;
1721
1968
  get(path: string): Promise<unknown>;
1969
+ /**
1970
+ * Read through the local blob cache: OPFS first, the bucket second. Unlike
1971
+ * {@link get} this survives a reload and works offline. Returns null when the
1972
+ * file exists in neither place.
1973
+ */
1974
+ read(path: string, options?: BlobReadOptions): Promise<Blob | null>;
1975
+ /**
1976
+ * A refcounted object URL for `path`, suitable for `<img src>`. The caller
1977
+ * MUST call `release()` when the URL goes off screen. Returns null when the
1978
+ * file does not exist, or when object URLs are unavailable (non-browser).
1979
+ */
1980
+ url(path: string, options?: BlobReadOptions): Promise<BlobUrlLease | null>;
1981
+ /** Exempt `path` from pressure eviction. Pinned bytes never expire. */
1982
+ pin(path: string): void;
1983
+ unpin(path: string): void;
1984
+ /** Drop `path` from the local cache without touching the remote file. */
1985
+ evict(path: string): Promise<void>;
1986
+ /** Warm the cache for offline use. Already-cached paths are skipped. */
1987
+ prefetch(paths: string[]): Promise<void>;
1722
1988
  delete(path: string): Promise<void>;
1723
1989
  exists(path: string): Promise<boolean>;
1724
1990
  head(path: string): Promise<Record<string, unknown>>;
@@ -1730,6 +1996,7 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1730
1996
  private config;
1731
1997
  private local;
1732
1998
  private remote;
1999
+ private blobs;
1733
2000
  private connectionSupervisor;
1734
2001
  private persistenceClient;
1735
2002
  private migrator;
@@ -1909,6 +2176,11 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1909
2176
  reportFrontendTiming(queryHash: string, ms: number): void;
1910
2177
  run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
1911
2178
  bucket<B extends BucketNames<S>>(name: B): BucketHandle;
2179
+ /** Cache-free handle. The blob cache reads the remote through this, so a
2180
+ * cache miss can't loop back into the cache. */
2181
+ private rawBucket;
2182
+ /** Blob cache counters for DevTools. */
2183
+ getBlobCacheStats(): BlobCacheStats;
1912
2184
  create(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
1913
2185
  update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions): Promise<{
1914
2186
  [x: string]: /*elided*/any;
@@ -1940,4 +2212,4 @@ declare function textToHtml(text: string): string;
1940
2212
  */
1941
2213
 
1942
2214
  //#endregion
1943
- export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
2215
+ export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, BucketHandle, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };