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

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
@@ -119,8 +119,12 @@ declare class ConnectionSupervisor {
119
119
  private disposed;
120
120
  private heartbeatTimer;
121
121
  private heartbeatInFlight;
122
+ /** Consecutive failed probes. See {@link FAILURES_BEFORE_TEARDOWN}. */
123
+ private heartbeatFailures;
122
124
  private reviveTimer;
123
125
  private reviveAttempts;
126
+ /** Timestamp of the last wake-triggered probe, for rate limiting. */
127
+ private lastWakeProbeAt;
124
128
  private reviving;
125
129
  /**
126
130
  * Set while the browser reports itself offline. Retrying a socket against a
@@ -129,6 +133,23 @@ declare class ConnectionSupervisor {
129
133
  private suspended;
130
134
  private teardown;
131
135
  private static readonly REVIVE_BASE_MS;
136
+ /**
137
+ * How many consecutive heartbeat failures it takes to tear the socket down.
138
+ *
139
+ * The probe rides the same serialized queue as every other RPC (deliberately
140
+ * — see {@link beat}), which means it cannot distinguish a WEDGED queue from
141
+ * a merely BUSY one. A single slow window (a large sync burst, one heavy
142
+ * app query) used to be enough to force-close a perfectly healthy socket,
143
+ * and the resulting reconnect re-registered every active query about a
144
+ * second later. That self-inflicted teardown manufactured the very reconnect
145
+ * storms this class exists to survive. A genuinely dead socket still fails
146
+ * every probe, so it is torn down one interval later than before.
147
+ */
148
+ private static readonly FAILURES_BEFORE_TEARDOWN;
149
+ /** Retry delay after an inconclusive (first) heartbeat failure. */
150
+ private static readonly HEARTBEAT_RETRY_MS;
151
+ /** Floor between probes triggered by wake events (tab focus, pageshow). */
152
+ private static readonly WAKE_PROBE_MIN_INTERVAL_MS;
132
153
  constructor(remote: RemoteDatabaseService, logger: Logger$1, config?: Required<ReconnectConfig>);
133
154
  /** Latest observed transport state. */
134
155
  get connection(): ConnectionState;
@@ -1068,6 +1089,14 @@ declare class Sp00kySync<S extends SchemaStructure> {
1068
1089
  * initial connect. See {@link subscribeToReconnect}.
1069
1090
  */
1070
1091
  private needsResubscribe;
1092
+ /** When the last reconnect-driven full refetch ran, for burst coalescing. */
1093
+ private lastReconnectRefetchAt;
1094
+ /**
1095
+ * Minimum gap between reconnect-driven full refetches. Long enough to absorb
1096
+ * a flapping socket (the SDK reconnect ladder starts at 1s), short enough
1097
+ * that a genuine drop minutes later still refetches.
1098
+ */
1099
+ private static readonly RECONNECT_REFETCH_COOLDOWN_MS;
1071
1100
  events: SyncEventSystem;
1072
1101
  private currentUserId;
1073
1102
  private tabRole;
@@ -1712,13 +1741,286 @@ declare class AppReleaseModule<S extends SchemaStructure> {
1712
1741
  private applyRecords;
1713
1742
  }
1714
1743
  //#endregion
1744
+ //#region src/services/blobs/blob-store.d.ts
1745
+ /**
1746
+ * Byte storage for cached bucket files.
1747
+ *
1748
+ * The default implementation is OPFS. Bucket files never arrive over HTTP in
1749
+ * this client — `BucketHandle.get()` is a SurrealQL RPC on the sync socket — so
1750
+ * neither the browser's HTTP cache nor the Cache API can hold them. We persist
1751
+ * the bytes ourselves, and OPFS is the cheapest place to put them: a read is
1752
+ * `getFile()` → a disk-backed lazy `File` that `URL.createObjectURL` can serve
1753
+ * without ever moving the bytes through the JS heap.
1754
+ *
1755
+ * Layout is real nested directories rather than one hashed filename:
1756
+ *
1757
+ * sp00ky-blobs/<namespace>/<bucket>/<...path segments>
1758
+ *
1759
+ * That costs a `getDirectoryHandle` per segment on write, and buys the property
1760
+ * the whole orphan story rests on: the full `(bucket, path)` key is recoverable
1761
+ * from a directory walk alone. The `_00_blob` manifest can therefore be wiped
1762
+ * (memory fallback, SQLite pool wipe, IndexedDB corruption recovery) and be
1763
+ * rebuilt from disk instead of taking the cached bytes down with it.
1764
+ */
1765
+ /** Identifies one cached file: the bucket it lives in and its path within. */
1766
+ interface BlobKey {
1767
+ bucket: string;
1768
+ path: string;
1769
+ }
1770
+ /** What a directory walk can tell us about a stored file, with no manifest. */
1771
+ interface BlobStat {
1772
+ key: BlobKey;
1773
+ size: number;
1774
+ /** File mtime. Seeds `lastAccess` when a manifest row has to be rebuilt. */
1775
+ mtime: number;
1776
+ }
1777
+ interface BlobStore {
1778
+ /** False for {@link MemoryBlobStore} and for OPFS-less environments: the
1779
+ * cache still dedupes and serves within a tab, but nothing survives reload. */
1780
+ readonly persistent: boolean;
1781
+ /** Namespace (the local bucketId) all keys are resolved under. */
1782
+ readonly namespace: string;
1783
+ read(key: BlobKey): Promise<Blob | null>;
1784
+ /** Returns the number of bytes written. Throws on quota exhaustion. */
1785
+ write(key: BlobKey, bytes: Blob): Promise<number>;
1786
+ remove(key: BlobKey): Promise<void>;
1787
+ /** Every committed file under the current namespace. Sweeps torn writes. */
1788
+ list(): Promise<BlobStat[]>;
1789
+ /** Drop the whole namespace (sign-out with `clearOnSignOut`, or a reset). */
1790
+ clear(): Promise<void>;
1791
+ /** Point at another namespace. Does not touch the bytes of the old one. */
1792
+ setNamespace(namespace: string): void;
1793
+ }
1794
+ //#endregion
1795
+ //#region src/services/blobs/blob-manifest.d.ts
1796
+ interface BlobEntry {
1797
+ /** `${bucket}/${path}` — also the `_00_blob` row id. */
1798
+ id: string;
1799
+ bucket: string;
1800
+ path: string;
1801
+ size: number;
1802
+ contentType: string;
1803
+ createdAt: number;
1804
+ lastAccess: number;
1805
+ hits: number;
1806
+ /** Exempt from pressure eviction. Never expires on its own. */
1807
+ pinned: boolean;
1808
+ }
1809
+ declare class BlobManifest {
1810
+ private local;
1811
+ private entries;
1812
+ /** Ids whose in-memory state has not been written back yet. */
1813
+ private dirty;
1814
+ private removed;
1815
+ private flushing;
1816
+ constructor(local: LocalStore);
1817
+ /**
1818
+ * Hydrate from the rows matching `keys`. Ids come from the OPFS listing, so
1819
+ * this never needs a full-table scan (and therefore never needs a QueryPlan).
1820
+ * Any read failure yields an empty manifest: reconcile then rebuilds every
1821
+ * row from disk, which is exactly the desired degradation.
1822
+ */
1823
+ load(ids: string[]): Promise<void>;
1824
+ get(key: BlobKey): BlobEntry | undefined;
1825
+ getById(id: string): BlobEntry | undefined;
1826
+ all(): BlobEntry[];
1827
+ totalBytes(): number;
1828
+ pinnedBytes(): number;
1829
+ put(entry: BlobEntry): void;
1830
+ touch(id: string, now: number): void;
1831
+ setPinned(id: string, pinned: boolean): boolean;
1832
+ remove(id: string): void;
1833
+ /** Forget everything without scheduling deletes — for a bucket switch, where
1834
+ * the rows belong to the store we are leaving and must stay put. */
1835
+ reset(): void;
1836
+ hasPendingWrites(): boolean;
1837
+ /**
1838
+ * Write back pending changes. Serialized: a second concurrent flush awaits
1839
+ * the first rather than racing it into the same rows. Failures are swallowed
1840
+ * on purpose — a lost metadata write costs an LRU timestamp, and the entry is
1841
+ * rebuilt from disk on the next reconcile.
1842
+ */
1843
+ flush(): Promise<void>;
1844
+ private doFlush;
1845
+ }
1846
+ //#endregion
1847
+ //#region src/services/blobs/blob-cache.d.ts
1848
+ interface BlobUrlLease {
1849
+ url: string;
1850
+ release(): void;
1851
+ }
1852
+ interface BlobReadOptions {
1853
+ /** Write through to L1 on a miss. Default true. */
1854
+ persist?: boolean;
1855
+ /** Mark the entry exempt from pressure eviction. */
1856
+ pin?: boolean;
1857
+ /**
1858
+ * Default `'never'`: a bucket path is treated as immutable, which is how the
1859
+ * client writes them (`crypto.randomUUID() + ext`). `'head'` spends a remote
1860
+ * `head()` to compare sizes before trusting L1.
1861
+ */
1862
+ revalidate?: 'never' | 'head';
1863
+ /** Skip L0/L1 entirely and refill from remote. Backs `refetch()`. */
1864
+ reload?: boolean;
1865
+ }
1866
+ interface BlobCacheStats {
1867
+ entries: number;
1868
+ totalBytes: number;
1869
+ budgetBytes: number;
1870
+ pinnedBytes: number;
1871
+ evictedEntries: number;
1872
+ evictedBytes: number;
1873
+ reconciledEntries: number;
1874
+ hits: number;
1875
+ misses: number;
1876
+ persistent: boolean;
1877
+ /** True when pinned bytes alone exceed the budget: new entries stop being
1878
+ * written rather than pinned ones being thrown away. */
1879
+ persistPaused: boolean;
1880
+ }
1881
+ interface BlobCacheOptions {
1882
+ store: BlobStore;
1883
+ manifest: BlobManifest;
1884
+ /** L2 read. Resolves to null when the file does not exist remotely. */
1885
+ fetchRemote(key: BlobKey): Promise<Blob | null>;
1886
+ /** L2 metadata, for `revalidate: 'head'`. */
1887
+ headRemote?(key: BlobKey): Promise<Record<string, unknown> | null>;
1888
+ logger: Logger$1;
1889
+ maxBytes: number;
1890
+ now?: () => number;
1891
+ /** Injected so the URL layer is exercisable off a DOM (node tests). */
1892
+ urls?: {
1893
+ create(blob: Blob): string;
1894
+ revoke(url: string): void;
1895
+ };
1896
+ }
1897
+ declare class BlobCache {
1898
+ private readonly store;
1899
+ private readonly manifest;
1900
+ private readonly fetchRemote;
1901
+ private readonly headRemote?;
1902
+ private readonly logger;
1903
+ private readonly now;
1904
+ private readonly urlFactory;
1905
+ private maxBytes;
1906
+ private persistPaused;
1907
+ /** Set after a quota failure survives one forced eviction. */
1908
+ private persistDisabled;
1909
+ private readonly urls;
1910
+ /** Ids at zero references, oldest first — the hot-URL window. */
1911
+ private idleUrls;
1912
+ private readonly inflight;
1913
+ private flushTimer;
1914
+ private readonly onPageHide;
1915
+ /**
1916
+ * Resolves once the manifest has been reconciled against disk. Reads await
1917
+ * it, so `start()` does NOT have to be awaited on the boot path — blocking
1918
+ * boot on an OPFS directory walk delayed the WebSocket connect (and with it
1919
+ * the connection supervisor) for no benefit.
1920
+ */
1921
+ private ready;
1922
+ private hits;
1923
+ private misses;
1924
+ private evictedEntries;
1925
+ private evictedBytes;
1926
+ private reconciledEntries;
1927
+ constructor(opts: BlobCacheOptions);
1928
+ /** Coalesce manifest write-back. Metadata only, so losing the tail costs an
1929
+ * LRU timestamp that reconcile reseeds from the file mtime. */
1930
+ private scheduleFlush;
1931
+ /**
1932
+ * Resolve the bytes for `key`, filling L1 on the way when `persist` is on.
1933
+ * Returns null when the file does not exist remotely and is not cached.
1934
+ */
1935
+ read(key: BlobKey, options?: BlobReadOptions): Promise<Blob | null>;
1936
+ /**
1937
+ * An object URL for `key`, refcounted. Callers MUST `release()`; the URL is
1938
+ * revoked once the last holder lets go and it falls out of the hot window.
1939
+ */
1940
+ acquireUrl(key: BlobKey, options?: BlobReadOptions): Promise<BlobUrlLease | null>;
1941
+ private lease;
1942
+ private releaseUrl;
1943
+ private revokeUrl;
1944
+ /** L1 lookup with the size check that catches torn and cross-tab writes. */
1945
+ private readLocal;
1946
+ /** True when the remote agrees with the cached size, or cannot be reached. */
1947
+ private headMatches;
1948
+ private fetchDeduped;
1949
+ private persist;
1950
+ private writeThrough;
1951
+ /** Forget one path everywhere. Called on `bucket.put()`/`bucket.delete()`. */
1952
+ invalidate(key: BlobKey): Promise<void>;
1953
+ private dropLocal;
1954
+ setPinned(key: BlobKey, pinned: boolean): void;
1955
+ /**
1956
+ * Bring total bytes under budget by dropping the least recently used
1957
+ * entries. Pinned entries and anything with a live object URL are skipped —
1958
+ * evicting bytes that a mounted `<img>` is displaying would blank it.
1959
+ */
1960
+ private enforceBudget;
1961
+ /** Evict LRU-first until at or below `target`. Returns the resulting total. */
1962
+ private evictTo;
1963
+ /**
1964
+ * Rebuild the manifest from what is actually on disk. OPFS wins on existence
1965
+ * in both directions: files with no row get a row (seeded from mtime), rows
1966
+ * are only loaded for files that exist, and torn `.part-` writes are swept by
1967
+ * the walk itself.
1968
+ *
1969
+ * Rows whose file vanished outside our control (a browser origin eviction)
1970
+ * are left in `_00_blob`. They are inert — `load()` only ever asks for ids it
1971
+ * found on disk — and are overwritten if that path is cached again.
1972
+ */
1973
+ reconcile(): Promise<void>;
1974
+ /** Warm the cache for offline use. Skips anything already cached. */
1975
+ prefetch(keys: BlobKey[]): Promise<void>;
1976
+ /** Bind to the boot bucket and hydrate the manifest from disk. Separate from
1977
+ * {@link setNamespace} because boot must reconcile even when the namespace
1978
+ * it lands on is the one the store was constructed with. */
1979
+ start(namespace: string): Promise<void>;
1980
+ /** Repoint at another local bucket. The bytes of the old one stay on disk so
1981
+ * switching back (or signing back in) is still warm. */
1982
+ setNamespace(namespace: string): Promise<void>;
1983
+ setMaxBytes(maxBytes: number): void;
1984
+ /** Delete every cached byte in the current namespace. */
1985
+ clear(): Promise<void>;
1986
+ flush(): Promise<void>;
1987
+ /** Flush metadata and drop every object URL. Must run before the local store
1988
+ * closes — the flush writes through it. */
1989
+ close(): Promise<void>;
1990
+ stats(): BlobCacheStats;
1991
+ }
1992
+ //#endregion
1715
1993
  //#region src/sp00ky.d.ts
1994
+ /** Coerce whatever the `.get()` RPC hands back into a Blob. */
1995
+ declare function bucketContentToBlob(content: unknown): Blob | null;
1716
1996
  declare class BucketHandle {
1717
1997
  private bucketName;
1718
1998
  private remote;
1719
- constructor(bucketName: string, remote: RemoteDatabaseService);
1999
+ /** Absent on the raw handle the cache itself reads through. */
2000
+ private blobs?;
2001
+ constructor(bucketName: string, remote: RemoteDatabaseService, /** Absent on the raw handle the cache itself reads through. */
2002
+ blobs?: (BlobCache | null) | undefined);
1720
2003
  put(path: string, content: string | Uint8Array | Blob): Promise<void>;
1721
2004
  get(path: string): Promise<unknown>;
2005
+ /**
2006
+ * Read through the local blob cache: OPFS first, the bucket second. Unlike
2007
+ * {@link get} this survives a reload and works offline. Returns null when the
2008
+ * file exists in neither place.
2009
+ */
2010
+ read(path: string, options?: BlobReadOptions): Promise<Blob | null>;
2011
+ /**
2012
+ * A refcounted object URL for `path`, suitable for `<img src>`. The caller
2013
+ * MUST call `release()` when the URL goes off screen. Returns null when the
2014
+ * file does not exist, or when object URLs are unavailable (non-browser).
2015
+ */
2016
+ url(path: string, options?: BlobReadOptions): Promise<BlobUrlLease | null>;
2017
+ /** Exempt `path` from pressure eviction. Pinned bytes never expire. */
2018
+ pin(path: string): void;
2019
+ unpin(path: string): void;
2020
+ /** Drop `path` from the local cache without touching the remote file. */
2021
+ evict(path: string): Promise<void>;
2022
+ /** Warm the cache for offline use. Already-cached paths are skipped. */
2023
+ prefetch(paths: string[]): Promise<void>;
1722
2024
  delete(path: string): Promise<void>;
1723
2025
  exists(path: string): Promise<boolean>;
1724
2026
  head(path: string): Promise<Record<string, unknown>>;
@@ -1730,6 +2032,7 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1730
2032
  private config;
1731
2033
  private local;
1732
2034
  private remote;
2035
+ private blobs;
1733
2036
  private connectionSupervisor;
1734
2037
  private persistenceClient;
1735
2038
  private migrator;
@@ -1909,6 +2212,11 @@ declare class Sp00kyClient<S extends SchemaStructure> {
1909
2212
  reportFrontendTiming(queryHash: string, ms: number): void;
1910
2213
  run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
1911
2214
  bucket<B extends BucketNames<S>>(name: B): BucketHandle;
2215
+ /** Cache-free handle. The blob cache reads the remote through this, so a
2216
+ * cache miss can't loop back into the cache. */
2217
+ private rawBucket;
2218
+ /** Blob cache counters for DevTools. */
2219
+ getBlobCacheStats(): BlobCacheStats;
1912
2220
  create(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
1913
2221
  update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions): Promise<{
1914
2222
  [x: string]: /*elided*/any;
@@ -1940,4 +2248,4 @@ declare function textToHtml(text: string): string;
1940
2248
  */
1941
2249
 
1942
2250
  //#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 };
2251
+ 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 };