@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import type { Logger } from '../logger/index';
|
|
2
|
+
import type { BlobKey, BlobStore } from './blob-store';
|
|
3
|
+
import { BlobKeyError, blobKeyId } from './blob-store';
|
|
4
|
+
import type { BlobEntry } from './blob-manifest';
|
|
5
|
+
import { BlobManifest } from './blob-manifest';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Three-layer cache for bucket files.
|
|
9
|
+
*
|
|
10
|
+
* L0 object URLs, refcounted, per tab (this class)
|
|
11
|
+
* L1 bytes in OPFS, durable (BlobStore)
|
|
12
|
+
* L2 the remote bucket over the sync WS (injected `fetchRemote`)
|
|
13
|
+
*
|
|
14
|
+
* Nothing is ever dropped because it got old. A TTL would silently break the
|
|
15
|
+
* offline case this cache exists to serve: the row referencing an image is in
|
|
16
|
+
* the local cache indefinitely, so the image has to be too. Bytes only ever go
|
|
17
|
+
* away for one of four reasons:
|
|
18
|
+
*
|
|
19
|
+
* 1. the app invalidated them — `bucket.put()`/`bucket.delete()` on that path;
|
|
20
|
+
* 2. boot reconcile found no file behind the row (or a torn `.part-` write);
|
|
21
|
+
* 3. a read found a size that disagrees with the manifest;
|
|
22
|
+
* 4. the cache is over budget, and this is the least recently used unpinned
|
|
23
|
+
* entry that nothing is currently rendering.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Evict down to this fraction of the budget, so eviction is not per-write. */
|
|
27
|
+
const LOW_WATER = 0.8;
|
|
28
|
+
/** Object URLs kept alive at zero references, so list scrolling doesn't thrash. */
|
|
29
|
+
const HOT_URL_LIMIT = 32;
|
|
30
|
+
/** Parallel remote reads during `prefetch`. Shares the sync socket's queue. */
|
|
31
|
+
const PREFETCH_CONCURRENCY = 3;
|
|
32
|
+
/** Debounce on manifest write-back. Every cache hit moves `lastAccess`, so
|
|
33
|
+
* flushing eagerly would turn rendering a list of avatars into a write storm. */
|
|
34
|
+
const FLUSH_DEBOUNCE_MS = 2000;
|
|
35
|
+
|
|
36
|
+
export interface BlobUrlLease {
|
|
37
|
+
url: string;
|
|
38
|
+
release(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BlobReadOptions {
|
|
42
|
+
/** Write through to L1 on a miss. Default true. */
|
|
43
|
+
persist?: boolean;
|
|
44
|
+
/** Mark the entry exempt from pressure eviction. */
|
|
45
|
+
pin?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Default `'never'`: a bucket path is treated as immutable, which is how the
|
|
48
|
+
* client writes them (`crypto.randomUUID() + ext`). `'head'` spends a remote
|
|
49
|
+
* `head()` to compare sizes before trusting L1.
|
|
50
|
+
*/
|
|
51
|
+
revalidate?: 'never' | 'head';
|
|
52
|
+
/** Skip L0/L1 entirely and refill from remote. Backs `refetch()`. */
|
|
53
|
+
reload?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BlobCacheStats {
|
|
57
|
+
entries: number;
|
|
58
|
+
totalBytes: number;
|
|
59
|
+
budgetBytes: number;
|
|
60
|
+
pinnedBytes: number;
|
|
61
|
+
evictedEntries: number;
|
|
62
|
+
evictedBytes: number;
|
|
63
|
+
reconciledEntries: number;
|
|
64
|
+
hits: number;
|
|
65
|
+
misses: number;
|
|
66
|
+
persistent: boolean;
|
|
67
|
+
/** True when pinned bytes alone exceed the budget: new entries stop being
|
|
68
|
+
* written rather than pinned ones being thrown away. */
|
|
69
|
+
persistPaused: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface BlobCacheOptions {
|
|
73
|
+
store: BlobStore;
|
|
74
|
+
manifest: BlobManifest;
|
|
75
|
+
/** L2 read. Resolves to null when the file does not exist remotely. */
|
|
76
|
+
fetchRemote(key: BlobKey): Promise<Blob | null>;
|
|
77
|
+
/** L2 metadata, for `revalidate: 'head'`. */
|
|
78
|
+
headRemote?(key: BlobKey): Promise<Record<string, unknown> | null>;
|
|
79
|
+
logger: Logger;
|
|
80
|
+
maxBytes: number;
|
|
81
|
+
now?: () => number;
|
|
82
|
+
/** Injected so the URL layer is exercisable off a DOM (node tests). */
|
|
83
|
+
urls?: { create(blob: Blob): string; revoke(url: string): void };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface UrlEntry {
|
|
87
|
+
url: string;
|
|
88
|
+
refCount: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function defaultUrlFactory(): BlobCacheOptions['urls'] {
|
|
92
|
+
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return undefined;
|
|
93
|
+
return {
|
|
94
|
+
create: (blob) => URL.createObjectURL(blob),
|
|
95
|
+
revoke: (url) => URL.revokeObjectURL(url),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isQuotaError(err: unknown): boolean {
|
|
100
|
+
return (
|
|
101
|
+
err instanceof Error &&
|
|
102
|
+
(err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_FILE_NO_DEVICE_SPACE')
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class BlobCache {
|
|
107
|
+
private readonly store: BlobStore;
|
|
108
|
+
private readonly manifest: BlobManifest;
|
|
109
|
+
private readonly fetchRemote: (key: BlobKey) => Promise<Blob | null>;
|
|
110
|
+
private readonly headRemote?: (key: BlobKey) => Promise<Record<string, unknown> | null>;
|
|
111
|
+
private readonly logger: Logger;
|
|
112
|
+
private readonly now: () => number;
|
|
113
|
+
private readonly urlFactory: BlobCacheOptions['urls'];
|
|
114
|
+
|
|
115
|
+
private maxBytes: number;
|
|
116
|
+
private persistPaused = false;
|
|
117
|
+
/** Set after a quota failure survives one forced eviction. */
|
|
118
|
+
private persistDisabled = false;
|
|
119
|
+
|
|
120
|
+
private readonly urls = new Map<string, UrlEntry>();
|
|
121
|
+
/** Ids at zero references, oldest first — the hot-URL window. */
|
|
122
|
+
private idleUrls: string[] = [];
|
|
123
|
+
private readonly inflight = new Map<string, Promise<Blob | null>>();
|
|
124
|
+
|
|
125
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
126
|
+
private readonly onPageHide = () => {
|
|
127
|
+
void this.manifest.flush();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolves once the manifest has been reconciled against disk. Reads await
|
|
132
|
+
* it, so `start()` does NOT have to be awaited on the boot path — blocking
|
|
133
|
+
* boot on an OPFS directory walk delayed the WebSocket connect (and with it
|
|
134
|
+
* the connection supervisor) for no benefit.
|
|
135
|
+
*/
|
|
136
|
+
private ready: Promise<void> = Promise.resolve();
|
|
137
|
+
|
|
138
|
+
private hits = 0;
|
|
139
|
+
private misses = 0;
|
|
140
|
+
private evictedEntries = 0;
|
|
141
|
+
private evictedBytes = 0;
|
|
142
|
+
private reconciledEntries = 0;
|
|
143
|
+
|
|
144
|
+
constructor(opts: BlobCacheOptions) {
|
|
145
|
+
this.store = opts.store;
|
|
146
|
+
this.manifest = opts.manifest;
|
|
147
|
+
this.fetchRemote = opts.fetchRemote;
|
|
148
|
+
this.headRemote = opts.headRemote;
|
|
149
|
+
this.logger = opts.logger.child({ service: 'BlobCache' });
|
|
150
|
+
this.maxBytes = opts.maxBytes;
|
|
151
|
+
this.now = opts.now ?? (() => Date.now());
|
|
152
|
+
this.urlFactory = opts.urls ?? defaultUrlFactory();
|
|
153
|
+
// A tab that is closed or bfcached never runs the debounce, so the last
|
|
154
|
+
// window of access times would be lost — and `lastAccess` is what eviction
|
|
155
|
+
// orders by. pagehide is the one lifecycle event that reliably fires.
|
|
156
|
+
if (typeof addEventListener === 'function') addEventListener('pagehide', this.onPageHide);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Coalesce manifest write-back. Metadata only, so losing the tail costs an
|
|
160
|
+
* LRU timestamp that reconcile reseeds from the file mtime. */
|
|
161
|
+
private scheduleFlush(): void {
|
|
162
|
+
if (this.flushTimer) return;
|
|
163
|
+
this.flushTimer = setTimeout(() => {
|
|
164
|
+
this.flushTimer = null;
|
|
165
|
+
void this.manifest.flush();
|
|
166
|
+
}, FLUSH_DEBOUNCE_MS);
|
|
167
|
+
// Never hold a node process (or a test run) open on a metadata write.
|
|
168
|
+
(this.flushTimer as { unref?: () => void }).unref?.();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- Reads -------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the bytes for `key`, filling L1 on the way when `persist` is on.
|
|
175
|
+
* Returns null when the file does not exist remotely and is not cached.
|
|
176
|
+
*/
|
|
177
|
+
async read(key: BlobKey, options: BlobReadOptions = {}): Promise<Blob | null> {
|
|
178
|
+
// Boot fires `start()` without awaiting it; a read that lands first must
|
|
179
|
+
// not race the reconcile, or it would refetch a file already on disk and
|
|
180
|
+
// then overwrite the row reconcile is about to rebuild.
|
|
181
|
+
await this.ready;
|
|
182
|
+
const id = blobKeyId(key);
|
|
183
|
+
const persist = options.persist !== false && !this.persistDisabled;
|
|
184
|
+
|
|
185
|
+
if (!options.reload) {
|
|
186
|
+
const cached = await this.readLocal(key, id, options);
|
|
187
|
+
if (cached) {
|
|
188
|
+
this.hits++;
|
|
189
|
+
if (options.pin) this.manifest.setPinned(id, true);
|
|
190
|
+
return cached;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.misses++;
|
|
195
|
+
const bytes = await this.fetchDeduped(key, id);
|
|
196
|
+
if (!bytes) {
|
|
197
|
+
// Gone remotely (or unreachable with nothing cached): drop any stale row
|
|
198
|
+
// so the next boot doesn't resurrect a file that isn't there.
|
|
199
|
+
await this.dropLocal(key, id);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
if (persist) await this.persist(key, id, bytes, options.pin === true);
|
|
203
|
+
return bytes;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* An object URL for `key`, refcounted. Callers MUST `release()`; the URL is
|
|
208
|
+
* revoked once the last holder lets go and it falls out of the hot window.
|
|
209
|
+
*/
|
|
210
|
+
async acquireUrl(key: BlobKey, options: BlobReadOptions = {}): Promise<BlobUrlLease | null> {
|
|
211
|
+
if (!this.urlFactory) return null;
|
|
212
|
+
const id = blobKeyId(key);
|
|
213
|
+
|
|
214
|
+
if (options.reload) this.revokeUrl(id);
|
|
215
|
+
else {
|
|
216
|
+
const live = this.urls.get(id);
|
|
217
|
+
if (live) return this.lease(id, live);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const blob = await this.read(key, options);
|
|
221
|
+
if (!blob) return null;
|
|
222
|
+
|
|
223
|
+
// A concurrent caller may have minted one while we awaited.
|
|
224
|
+
const existing = this.urls.get(id);
|
|
225
|
+
if (existing) return this.lease(id, existing);
|
|
226
|
+
|
|
227
|
+
const entry: UrlEntry = { url: this.urlFactory.create(blob), refCount: 0 };
|
|
228
|
+
this.urls.set(id, entry);
|
|
229
|
+
return this.lease(id, entry);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private lease(id: string, entry: UrlEntry): BlobUrlLease {
|
|
233
|
+
entry.refCount++;
|
|
234
|
+
const idle = this.idleUrls.indexOf(id);
|
|
235
|
+
if (idle !== -1) this.idleUrls.splice(idle, 1);
|
|
236
|
+
let released = false;
|
|
237
|
+
return {
|
|
238
|
+
url: entry.url,
|
|
239
|
+
release: () => {
|
|
240
|
+
if (released) return;
|
|
241
|
+
released = true;
|
|
242
|
+
this.releaseUrl(id);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private releaseUrl(id: string): void {
|
|
248
|
+
const entry = this.urls.get(id);
|
|
249
|
+
if (!entry) return;
|
|
250
|
+
entry.refCount--;
|
|
251
|
+
if (entry.refCount > 0) return;
|
|
252
|
+
// Keep it warm: re-minting from OPFS is cheap but not free, and a scrolling
|
|
253
|
+
// list unmounts and remounts the same images constantly.
|
|
254
|
+
this.idleUrls.push(id);
|
|
255
|
+
while (this.idleUrls.length > HOT_URL_LIMIT) {
|
|
256
|
+
const evicted = this.idleUrls.shift()!;
|
|
257
|
+
const stale = this.urls.get(evicted);
|
|
258
|
+
if (stale && stale.refCount <= 0) this.revokeUrl(evicted);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private revokeUrl(id: string): void {
|
|
263
|
+
const entry = this.urls.get(id);
|
|
264
|
+
if (!entry) return;
|
|
265
|
+
this.urls.delete(id);
|
|
266
|
+
const idle = this.idleUrls.indexOf(id);
|
|
267
|
+
if (idle !== -1) this.idleUrls.splice(idle, 1);
|
|
268
|
+
this.urlFactory?.revoke(entry.url);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** L1 lookup with the size check that catches torn and cross-tab writes. */
|
|
272
|
+
private async readLocal(key: BlobKey, id: string, options: BlobReadOptions): Promise<Blob | null> {
|
|
273
|
+
let blob: Blob | null;
|
|
274
|
+
try {
|
|
275
|
+
blob = await this.store.read(key);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err instanceof BlobKeyError) return null;
|
|
278
|
+
this.logger.warn({ err, id }, 'blob read from local store failed');
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const entry = this.manifest.getById(id);
|
|
282
|
+
if (!blob) {
|
|
283
|
+
if (entry) this.manifest.remove(id);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
if (entry && entry.size !== blob.size) {
|
|
287
|
+
// Half-written, or another tab overwrote the path. Disk is not trusted
|
|
288
|
+
// over the manifest here: refill from remote.
|
|
289
|
+
await this.dropLocal(key, id);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (options.revalidate === 'head' && !(await this.headMatches(key, blob.size))) {
|
|
293
|
+
await this.dropLocal(key, id);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
if (entry) {
|
|
297
|
+
this.manifest.touch(id, this.now());
|
|
298
|
+
} else {
|
|
299
|
+
// File on disk with no row — a manifest that was wiped under us. Adopt it
|
|
300
|
+
// rather than re-downloading; reconcile does the same thing at boot.
|
|
301
|
+
this.manifest.put({
|
|
302
|
+
id,
|
|
303
|
+
bucket: key.bucket,
|
|
304
|
+
path: key.path,
|
|
305
|
+
size: blob.size,
|
|
306
|
+
contentType: blob.type ?? '',
|
|
307
|
+
createdAt: this.now(),
|
|
308
|
+
lastAccess: this.now(),
|
|
309
|
+
hits: 1,
|
|
310
|
+
pinned: false,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
this.scheduleFlush();
|
|
314
|
+
return blob;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** True when the remote agrees with the cached size, or cannot be reached. */
|
|
318
|
+
private async headMatches(key: BlobKey, size: number): Promise<boolean> {
|
|
319
|
+
if (!this.headRemote) return true;
|
|
320
|
+
try {
|
|
321
|
+
const head = await this.headRemote(key);
|
|
322
|
+
const remoteSize = Number(head?.size);
|
|
323
|
+
return !Number.isFinite(remoteSize) || remoteSize === size;
|
|
324
|
+
} catch {
|
|
325
|
+
// Offline revalidation must not invalidate a good cache entry.
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private fetchDeduped(key: BlobKey, id: string): Promise<Blob | null> {
|
|
331
|
+
const running = this.inflight.get(id);
|
|
332
|
+
if (running) return running;
|
|
333
|
+
const promise = this.fetchRemote(key).finally(() => this.inflight.delete(id));
|
|
334
|
+
this.inflight.set(id, promise);
|
|
335
|
+
return promise;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- Writes ------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
private async persist(key: BlobKey, id: string, bytes: Blob, pin: boolean): Promise<void> {
|
|
341
|
+
// Deliberately NOT gated on `store.persistent`: the memory store still
|
|
342
|
+
// dedupes byte reads across components for the life of the tab, which is
|
|
343
|
+
// the promised degradation. `persistent` is a durability report, not a
|
|
344
|
+
// write permission.
|
|
345
|
+
if (this.persistPaused) return;
|
|
346
|
+
try {
|
|
347
|
+
await this.writeThrough(key, id, bytes, pin);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (!isQuotaError(err)) {
|
|
350
|
+
if (!(err instanceof BlobKeyError)) this.logger.warn({ err, id }, 'blob write failed');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Out of space. Make room aggressively, then try exactly once more.
|
|
354
|
+
await this.evictTo(Math.floor(this.maxBytes * 0.5));
|
|
355
|
+
try {
|
|
356
|
+
await this.writeThrough(key, id, bytes, pin);
|
|
357
|
+
} catch (retryErr) {
|
|
358
|
+
this.persistDisabled = true;
|
|
359
|
+
this.logger.warn({ err: retryErr }, 'blob cache disabled: storage quota exhausted');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async writeThrough(key: BlobKey, id: string, bytes: Blob, pin: boolean): Promise<void> {
|
|
365
|
+
const size = await this.store.write(key, bytes);
|
|
366
|
+
const now = this.now();
|
|
367
|
+
const previous = this.manifest.getById(id);
|
|
368
|
+
this.manifest.put({
|
|
369
|
+
id,
|
|
370
|
+
bucket: key.bucket,
|
|
371
|
+
path: key.path,
|
|
372
|
+
size,
|
|
373
|
+
contentType: bytes.type ?? '',
|
|
374
|
+
createdAt: previous?.createdAt ?? now,
|
|
375
|
+
lastAccess: now,
|
|
376
|
+
hits: (previous?.hits ?? 0) + 1,
|
|
377
|
+
pinned: pin || previous?.pinned === true,
|
|
378
|
+
});
|
|
379
|
+
this.scheduleFlush();
|
|
380
|
+
await this.enforceBudget();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---- Invalidation and eviction ----------------------------------------
|
|
384
|
+
|
|
385
|
+
/** Forget one path everywhere. Called on `bucket.put()`/`bucket.delete()`. */
|
|
386
|
+
async invalidate(key: BlobKey): Promise<void> {
|
|
387
|
+
await this.dropLocal(key, blobKeyId(key));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async dropLocal(key: BlobKey, id: string): Promise<void> {
|
|
391
|
+
this.revokeUrl(id);
|
|
392
|
+
this.manifest.remove(id);
|
|
393
|
+
this.scheduleFlush();
|
|
394
|
+
try {
|
|
395
|
+
await this.store.remove(key);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
if (!(err instanceof BlobKeyError)) this.logger.warn({ err, id }, 'blob delete failed');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
setPinned(key: BlobKey, pinned: boolean): void {
|
|
402
|
+
if (this.manifest.setPinned(blobKeyId(key), pinned)) this.scheduleFlush();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Bring total bytes under budget by dropping the least recently used
|
|
407
|
+
* entries. Pinned entries and anything with a live object URL are skipped —
|
|
408
|
+
* evicting bytes that a mounted `<img>` is displaying would blank it.
|
|
409
|
+
*/
|
|
410
|
+
private async enforceBudget(): Promise<void> {
|
|
411
|
+
if (this.manifest.totalBytes() <= this.maxBytes) {
|
|
412
|
+
this.persistPaused = false;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const remaining = await this.evictTo(Math.floor(this.maxBytes * LOW_WATER));
|
|
416
|
+
if (remaining > this.maxBytes) {
|
|
417
|
+
// Everything left is pinned or on screen. Stop growing rather than
|
|
418
|
+
// throwing away bytes the app explicitly asked us to keep.
|
|
419
|
+
if (!this.persistPaused) {
|
|
420
|
+
this.logger.warn(
|
|
421
|
+
{ totalBytes: remaining, budgetBytes: this.maxBytes, pinnedBytes: this.manifest.pinnedBytes() },
|
|
422
|
+
'blob cache over budget with nothing evictable; pausing new writes'
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
this.persistPaused = true;
|
|
426
|
+
} else {
|
|
427
|
+
this.persistPaused = false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Evict LRU-first until at or below `target`. Returns the resulting total. */
|
|
432
|
+
private async evictTo(target: number): Promise<number> {
|
|
433
|
+
let total = this.manifest.totalBytes();
|
|
434
|
+
if (total <= target) return total;
|
|
435
|
+
const candidates = this.manifest
|
|
436
|
+
.all()
|
|
437
|
+
.filter((e) => !e.pinned && (this.urls.get(e.id)?.refCount ?? 0) <= 0)
|
|
438
|
+
.sort((a, b) => a.lastAccess - b.lastAccess);
|
|
439
|
+
const dropped: string[] = [];
|
|
440
|
+
for (const entry of candidates) {
|
|
441
|
+
if (total <= target) break;
|
|
442
|
+
await this.dropLocal({ bucket: entry.bucket, path: entry.path }, entry.id);
|
|
443
|
+
total -= entry.size;
|
|
444
|
+
this.evictedEntries++;
|
|
445
|
+
this.evictedBytes += entry.size;
|
|
446
|
+
dropped.push(entry.id);
|
|
447
|
+
}
|
|
448
|
+
if (dropped.length > 0) {
|
|
449
|
+
this.logger.info(
|
|
450
|
+
{ count: dropped.length, totalBytes: total, budgetBytes: this.maxBytes },
|
|
451
|
+
'blob cache evicted least-recently-used entries'
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return total;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---- Lifecycle ---------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Rebuild the manifest from what is actually on disk. OPFS wins on existence
|
|
461
|
+
* in both directions: files with no row get a row (seeded from mtime), rows
|
|
462
|
+
* are only loaded for files that exist, and torn `.part-` writes are swept by
|
|
463
|
+
* the walk itself.
|
|
464
|
+
*
|
|
465
|
+
* Rows whose file vanished outside our control (a browser origin eviction)
|
|
466
|
+
* are left in `_00_blob`. They are inert — `load()` only ever asks for ids it
|
|
467
|
+
* found on disk — and are overwritten if that path is cached again.
|
|
468
|
+
*/
|
|
469
|
+
async reconcile(): Promise<void> {
|
|
470
|
+
let stats;
|
|
471
|
+
try {
|
|
472
|
+
stats = await this.store.list();
|
|
473
|
+
} catch (err) {
|
|
474
|
+
this.logger.warn({ err }, 'blob cache reconcile failed to list local store');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
await this.manifest.load(stats.map((s) => blobKeyId(s.key)));
|
|
478
|
+
let rebuilt = 0;
|
|
479
|
+
for (const stat of stats) {
|
|
480
|
+
const id = blobKeyId(stat.key);
|
|
481
|
+
const existing = this.manifest.getById(id);
|
|
482
|
+
if (!existing) {
|
|
483
|
+
this.manifest.put({
|
|
484
|
+
id,
|
|
485
|
+
bucket: stat.key.bucket,
|
|
486
|
+
path: stat.key.path,
|
|
487
|
+
size: stat.size,
|
|
488
|
+
contentType: '',
|
|
489
|
+
createdAt: stat.mtime,
|
|
490
|
+
lastAccess: stat.mtime,
|
|
491
|
+
hits: 0,
|
|
492
|
+
pinned: false,
|
|
493
|
+
});
|
|
494
|
+
rebuilt++;
|
|
495
|
+
} else if (existing.size !== stat.size) {
|
|
496
|
+
this.manifest.put({ ...existing, size: stat.size });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
this.reconciledEntries = stats.length;
|
|
500
|
+
if (rebuilt > 0) {
|
|
501
|
+
this.logger.info({ rebuilt, entries: stats.length }, 'blob cache rebuilt manifest rows from disk');
|
|
502
|
+
}
|
|
503
|
+
await this.enforceBudget();
|
|
504
|
+
await this.manifest.flush();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Warm the cache for offline use. Skips anything already cached. */
|
|
508
|
+
async prefetch(keys: BlobKey[]): Promise<void> {
|
|
509
|
+
const queue = keys.filter((key) => !this.manifest.get(key));
|
|
510
|
+
let cursor = 0;
|
|
511
|
+
const worker = async (): Promise<void> => {
|
|
512
|
+
while (cursor < queue.length) {
|
|
513
|
+
const key = queue[cursor++]!;
|
|
514
|
+
try {
|
|
515
|
+
await this.read(key);
|
|
516
|
+
} catch (err) {
|
|
517
|
+
this.logger.warn({ err, id: blobKeyId(key) }, 'blob prefetch failed');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
await Promise.all(Array.from({ length: Math.min(PREFETCH_CONCURRENCY, queue.length) }, worker));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Bind to the boot bucket and hydrate the manifest from disk. Separate from
|
|
525
|
+
* {@link setNamespace} because boot must reconcile even when the namespace
|
|
526
|
+
* it lands on is the one the store was constructed with. */
|
|
527
|
+
async start(namespace: string): Promise<void> {
|
|
528
|
+
this.store.setNamespace(namespace);
|
|
529
|
+
// Publish the in-flight reconcile so reads issued before it finishes queue
|
|
530
|
+
// behind it instead of racing it. Swallow here: `reconcile()` already logs,
|
|
531
|
+
// and an unhandled rejection on a fire-and-forget boot call must not
|
|
532
|
+
// surface as a global error.
|
|
533
|
+
this.ready = this.reconcile().catch(() => {});
|
|
534
|
+
await this.ready;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Repoint at another local bucket. The bytes of the old one stay on disk so
|
|
538
|
+
* switching back (or signing back in) is still warm. */
|
|
539
|
+
async setNamespace(namespace: string): Promise<void> {
|
|
540
|
+
if (namespace === this.store.namespace) return;
|
|
541
|
+
await this.manifest.flush();
|
|
542
|
+
for (const id of [...this.urls.keys()]) this.revokeUrl(id);
|
|
543
|
+
this.inflight.clear();
|
|
544
|
+
this.manifest.reset();
|
|
545
|
+
this.store.setNamespace(namespace);
|
|
546
|
+
this.persistPaused = false;
|
|
547
|
+
this.ready = this.reconcile().catch(() => {});
|
|
548
|
+
await this.ready;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
setMaxBytes(maxBytes: number): void {
|
|
552
|
+
this.maxBytes = maxBytes;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Delete every cached byte in the current namespace. */
|
|
556
|
+
async clear(): Promise<void> {
|
|
557
|
+
for (const id of [...this.urls.keys()]) this.revokeUrl(id);
|
|
558
|
+
for (const entry of this.manifest.all()) this.manifest.remove(entry.id);
|
|
559
|
+
this.inflight.clear();
|
|
560
|
+
try {
|
|
561
|
+
await this.store.clear();
|
|
562
|
+
} catch (err) {
|
|
563
|
+
this.logger.warn({ err }, 'blob cache clear failed');
|
|
564
|
+
}
|
|
565
|
+
await this.manifest.flush();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async flush(): Promise<void> {
|
|
569
|
+
await this.manifest.flush();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Flush metadata and drop every object URL. Must run before the local store
|
|
573
|
+
* closes — the flush writes through it. */
|
|
574
|
+
async close(): Promise<void> {
|
|
575
|
+
if (this.flushTimer) {
|
|
576
|
+
clearTimeout(this.flushTimer);
|
|
577
|
+
this.flushTimer = null;
|
|
578
|
+
}
|
|
579
|
+
if (typeof removeEventListener === 'function') removeEventListener('pagehide', this.onPageHide);
|
|
580
|
+
await this.manifest.flush();
|
|
581
|
+
for (const id of [...this.urls.keys()]) this.revokeUrl(id);
|
|
582
|
+
this.inflight.clear();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
stats(): BlobCacheStats {
|
|
586
|
+
return {
|
|
587
|
+
entries: this.manifest.all().length,
|
|
588
|
+
totalBytes: this.manifest.totalBytes(),
|
|
589
|
+
budgetBytes: this.maxBytes,
|
|
590
|
+
pinnedBytes: this.manifest.pinnedBytes(),
|
|
591
|
+
evictedEntries: this.evictedEntries,
|
|
592
|
+
evictedBytes: this.evictedBytes,
|
|
593
|
+
reconciledEntries: this.reconciledEntries,
|
|
594
|
+
hits: this.hits,
|
|
595
|
+
misses: this.misses,
|
|
596
|
+
persistent: this.store.persistent && !this.persistDisabled,
|
|
597
|
+
persistPaused: this.persistPaused,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export type { BlobEntry };
|
|
603
|
+
export { BlobManifest };
|