@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,227 @@
|
|
|
1
|
+
import { RecordId } from 'surrealdb';
|
|
2
|
+
import type { LocalStore, Row } from '../database/cache-engine';
|
|
3
|
+
import type { BlobKey } from './blob-store';
|
|
4
|
+
import { blobKeyId } from './blob-store';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Metadata index for the blob cache, persisted in the `_00_blob` table of the
|
|
8
|
+
* local engine.
|
|
9
|
+
*
|
|
10
|
+
* The manifest is deliberately NOT the source of truth for existence — OPFS is.
|
|
11
|
+
* Several existing recovery paths destroy the local store while leaving OPFS
|
|
12
|
+
* intact (the SQLite leader's wipe-on-pool-open, the memory fallback when OPFS
|
|
13
|
+
* refuses to open for SQLite, IndexedDB corruption recovery on the SurrealDB
|
|
14
|
+
* engine). Treating the manifest as an index that `reconcile()` can rebuild
|
|
15
|
+
* from a directory walk means those paths cost metadata, not the offline cache.
|
|
16
|
+
*
|
|
17
|
+
* Runtime reads hit the in-memory map; writes are batched. `lastAccess` changes
|
|
18
|
+
* on every single cache hit, so flushing each one would turn an image render
|
|
19
|
+
* into a local-DB write.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const BLOB_TABLE = '_00_blob';
|
|
23
|
+
|
|
24
|
+
export interface BlobEntry {
|
|
25
|
+
/** `${bucket}/${path}` — also the `_00_blob` row id. */
|
|
26
|
+
id: string;
|
|
27
|
+
bucket: string;
|
|
28
|
+
path: string;
|
|
29
|
+
size: number;
|
|
30
|
+
contentType: string;
|
|
31
|
+
createdAt: number;
|
|
32
|
+
lastAccess: number;
|
|
33
|
+
hits: number;
|
|
34
|
+
/** Exempt from pressure eviction. Never expires on its own. */
|
|
35
|
+
pinned: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function entryKey(entry: BlobEntry): BlobKey {
|
|
39
|
+
return { bucket: entry.bucket, path: entry.path };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function rowToEntry(id: string, row: Row): BlobEntry | null {
|
|
43
|
+
const bucket = typeof row.bucket === 'string' ? row.bucket : '';
|
|
44
|
+
const path = typeof row.path === 'string' ? row.path : '';
|
|
45
|
+
if (!bucket || !path) return null;
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
bucket,
|
|
49
|
+
path,
|
|
50
|
+
size: Number(row.size) || 0,
|
|
51
|
+
contentType: typeof row.contentType === 'string' ? row.contentType : '',
|
|
52
|
+
createdAt: Number(row.createdAt) || 0,
|
|
53
|
+
lastAccess: Number(row.lastAccess) || 0,
|
|
54
|
+
hits: Number(row.hits) || 0,
|
|
55
|
+
pinned: row.pinned === true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function entryToRow(entry: BlobEntry): Row {
|
|
60
|
+
return {
|
|
61
|
+
bucket: entry.bucket,
|
|
62
|
+
path: entry.path,
|
|
63
|
+
size: entry.size,
|
|
64
|
+
contentType: entry.contentType,
|
|
65
|
+
createdAt: entry.createdAt,
|
|
66
|
+
lastAccess: entry.lastAccess,
|
|
67
|
+
hits: entry.hits,
|
|
68
|
+
pinned: entry.pinned,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class BlobManifest {
|
|
73
|
+
private entries = new Map<string, BlobEntry>();
|
|
74
|
+
/** Ids whose in-memory state has not been written back yet. */
|
|
75
|
+
private dirty = new Set<string>();
|
|
76
|
+
private removed = new Set<string>();
|
|
77
|
+
private flushing: Promise<void> | null = null;
|
|
78
|
+
|
|
79
|
+
constructor(private local: LocalStore) {}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Hydrate from the rows matching `keys`. Ids come from the OPFS listing, so
|
|
83
|
+
* this never needs a full-table scan (and therefore never needs a QueryPlan).
|
|
84
|
+
* Any read failure yields an empty manifest: reconcile then rebuilds every
|
|
85
|
+
* row from disk, which is exactly the desired degradation.
|
|
86
|
+
*/
|
|
87
|
+
async load(ids: string[]): Promise<void> {
|
|
88
|
+
this.entries.clear();
|
|
89
|
+
this.dirty.clear();
|
|
90
|
+
this.removed.clear();
|
|
91
|
+
if (ids.length === 0) return;
|
|
92
|
+
let rows: Row[] = [];
|
|
93
|
+
try {
|
|
94
|
+
rows = await this.local.selectByIds(
|
|
95
|
+
BLOB_TABLE,
|
|
96
|
+
ids.map((id) => new RecordId(BLOB_TABLE, id))
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
const id = readRowId(row);
|
|
103
|
+
if (!id) continue;
|
|
104
|
+
const entry = rowToEntry(id, row);
|
|
105
|
+
if (entry) this.entries.set(id, entry);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get(key: BlobKey): BlobEntry | undefined {
|
|
110
|
+
return this.entries.get(blobKeyId(key));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getById(id: string): BlobEntry | undefined {
|
|
114
|
+
return this.entries.get(id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
all(): BlobEntry[] {
|
|
118
|
+
return [...this.entries.values()];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
totalBytes(): number {
|
|
122
|
+
let total = 0;
|
|
123
|
+
for (const entry of this.entries.values()) total += entry.size;
|
|
124
|
+
return total;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pinnedBytes(): number {
|
|
128
|
+
let total = 0;
|
|
129
|
+
for (const entry of this.entries.values()) if (entry.pinned) total += entry.size;
|
|
130
|
+
return total;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
put(entry: BlobEntry): void {
|
|
134
|
+
this.entries.set(entry.id, entry);
|
|
135
|
+
this.removed.delete(entry.id);
|
|
136
|
+
this.dirty.add(entry.id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
touch(id: string, now: number): void {
|
|
140
|
+
const entry = this.entries.get(id);
|
|
141
|
+
if (!entry) return;
|
|
142
|
+
entry.lastAccess = now;
|
|
143
|
+
entry.hits += 1;
|
|
144
|
+
this.dirty.add(id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setPinned(id: string, pinned: boolean): boolean {
|
|
148
|
+
const entry = this.entries.get(id);
|
|
149
|
+
if (!entry || entry.pinned === pinned) return false;
|
|
150
|
+
entry.pinned = pinned;
|
|
151
|
+
this.dirty.add(id);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
remove(id: string): void {
|
|
156
|
+
if (!this.entries.delete(id)) return;
|
|
157
|
+
this.dirty.delete(id);
|
|
158
|
+
this.removed.add(id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Forget everything without scheduling deletes — for a bucket switch, where
|
|
162
|
+
* the rows belong to the store we are leaving and must stay put. */
|
|
163
|
+
reset(): void {
|
|
164
|
+
this.entries.clear();
|
|
165
|
+
this.dirty.clear();
|
|
166
|
+
this.removed.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
hasPendingWrites(): boolean {
|
|
170
|
+
return this.dirty.size > 0 || this.removed.size > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Write back pending changes. Serialized: a second concurrent flush awaits
|
|
175
|
+
* the first rather than racing it into the same rows. Failures are swallowed
|
|
176
|
+
* on purpose — a lost metadata write costs an LRU timestamp, and the entry is
|
|
177
|
+
* rebuilt from disk on the next reconcile.
|
|
178
|
+
*/
|
|
179
|
+
async flush(): Promise<void> {
|
|
180
|
+
if (this.flushing) return this.flushing;
|
|
181
|
+
if (!this.hasPendingWrites()) return;
|
|
182
|
+
const run = this.doFlush();
|
|
183
|
+
this.flushing = run;
|
|
184
|
+
try {
|
|
185
|
+
await run;
|
|
186
|
+
} finally {
|
|
187
|
+
this.flushing = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async doFlush(): Promise<void> {
|
|
192
|
+
const dirty = [...this.dirty];
|
|
193
|
+
const removed = [...this.removed];
|
|
194
|
+
this.dirty.clear();
|
|
195
|
+
this.removed.clear();
|
|
196
|
+
for (const id of dirty) {
|
|
197
|
+
const entry = this.entries.get(id);
|
|
198
|
+
if (!entry) continue;
|
|
199
|
+
try {
|
|
200
|
+
// A RecordId, never a bare string: the SurrealDB engine binds the id
|
|
201
|
+
// verbatim and `UPSERT <string>` is an InternalError, so a string id
|
|
202
|
+
// silently never lands (see the same note on `_00_preload`).
|
|
203
|
+
await this.local.upsert(BLOB_TABLE, new RecordId(BLOB_TABLE, id), entryToRow(entry), 'replace');
|
|
204
|
+
} catch {
|
|
205
|
+
/* metadata only — rebuilt from disk on the next reconcile */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const id of removed) {
|
|
209
|
+
try {
|
|
210
|
+
await this.local.delete(BLOB_TABLE, new RecordId(BLOB_TABLE, id));
|
|
211
|
+
} catch {
|
|
212
|
+
/* a surviving row with no file is dropped by the next reconcile */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Row ids come back as a `RecordId` (SurrealDB) or a `table:id` string (SQLite). */
|
|
219
|
+
function readRowId(row: Row): string | null {
|
|
220
|
+
const raw = row.id;
|
|
221
|
+
if (raw instanceof RecordId) return String(raw.id);
|
|
222
|
+
if (typeof raw === 'string') {
|
|
223
|
+
const prefix = `${BLOB_TABLE}:`;
|
|
224
|
+
return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
BlobKeyError,
|
|
4
|
+
MemoryBlobStore,
|
|
5
|
+
blobKeyId,
|
|
6
|
+
decodeSegment,
|
|
7
|
+
encodeSegment,
|
|
8
|
+
keySegments,
|
|
9
|
+
pathSegments,
|
|
10
|
+
} from './blob-store';
|
|
11
|
+
|
|
12
|
+
describe('key encoding', () => {
|
|
13
|
+
it('round-trips segments that are illegal or ambiguous in a filename', () => {
|
|
14
|
+
for (const raw of ['avatar.png', 'a/b', 'hello world', 'ünïcode.jpg', '..', '%2F', "quote'"]) {
|
|
15
|
+
expect(decodeSegment(encodeSegment(raw))).toBe(raw);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('never emits a literal dot', () => {
|
|
20
|
+
// The `.part-` torn-write marker is only unambiguous because committed
|
|
21
|
+
// names cannot contain one. If this stops holding, a real file named
|
|
22
|
+
// `x.part-1` gets swept as a half-written file on the next boot.
|
|
23
|
+
for (const raw of ['avatar.png', 'a.part-1', '...', 'x.tar.gz']) {
|
|
24
|
+
expect(encodeSegment(raw)).not.toContain('.');
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a segment too long for the filesystem', () => {
|
|
29
|
+
expect(() => encodeSegment('x'.repeat(201))).toThrow(BlobKeyError);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('strips traversal and empty segments so a crafted path cannot escape', () => {
|
|
33
|
+
expect(pathSegments('../../etc/passwd')).toEqual(['etc', 'passwd']);
|
|
34
|
+
expect(pathSegments('a//b/./c')).toEqual(['a', 'b', 'c']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('maps a key to a bucket directory plus the path chain', () => {
|
|
38
|
+
expect(keySegments({ bucket: 'files', path: 'a/b/c.png' })).toEqual([
|
|
39
|
+
'files',
|
|
40
|
+
'a',
|
|
41
|
+
'b',
|
|
42
|
+
'c%2Epng',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects a key with no filename', () => {
|
|
47
|
+
expect(() => keySegments({ bucket: 'files', path: '/' })).toThrow(BlobKeyError);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('normalizes the manifest row id', () => {
|
|
51
|
+
expect(blobKeyId({ bucket: 'files', path: '/a//b.png' })).toBe('files/a/b.png');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('MemoryBlobStore', () => {
|
|
56
|
+
it('round-trips bytes and lists them back under the right key', async () => {
|
|
57
|
+
const store = new MemoryBlobStore('user-1');
|
|
58
|
+
const key = { bucket: 'files', path: 'nested/dir/photo.jpg' };
|
|
59
|
+
|
|
60
|
+
await store.write(key, new Blob(['hello']));
|
|
61
|
+
|
|
62
|
+
expect(await (await store.read(key))!.text()).toBe('hello');
|
|
63
|
+
expect(await store.list()).toEqual([{ key, size: 5, mtime: expect.any(Number) }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('isolates namespaces', async () => {
|
|
67
|
+
const store = new MemoryBlobStore('user-1');
|
|
68
|
+
const key = { bucket: 'files', path: 'photo.jpg' };
|
|
69
|
+
await store.write(key, new Blob(['hello']));
|
|
70
|
+
|
|
71
|
+
store.setNamespace('user-2');
|
|
72
|
+
expect(await store.read(key)).toBeNull();
|
|
73
|
+
|
|
74
|
+
store.setNamespace('user-1');
|
|
75
|
+
expect(await store.read(key)).not.toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte storage for cached bucket files.
|
|
3
|
+
*
|
|
4
|
+
* The default implementation is OPFS. Bucket files never arrive over HTTP in
|
|
5
|
+
* this client — `BucketHandle.get()` is a SurrealQL RPC on the sync socket — so
|
|
6
|
+
* neither the browser's HTTP cache nor the Cache API can hold them. We persist
|
|
7
|
+
* the bytes ourselves, and OPFS is the cheapest place to put them: a read is
|
|
8
|
+
* `getFile()` → a disk-backed lazy `File` that `URL.createObjectURL` can serve
|
|
9
|
+
* without ever moving the bytes through the JS heap.
|
|
10
|
+
*
|
|
11
|
+
* Layout is real nested directories rather than one hashed filename:
|
|
12
|
+
*
|
|
13
|
+
* sp00ky-blobs/<namespace>/<bucket>/<...path segments>
|
|
14
|
+
*
|
|
15
|
+
* That costs a `getDirectoryHandle` per segment on write, and buys the property
|
|
16
|
+
* the whole orphan story rests on: the full `(bucket, path)` key is recoverable
|
|
17
|
+
* from a directory walk alone. The `_00_blob` manifest can therefore be wiped
|
|
18
|
+
* (memory fallback, SQLite pool wipe, IndexedDB corruption recovery) and be
|
|
19
|
+
* rebuilt from disk instead of taking the cached bytes down with it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Identifies one cached file: the bucket it lives in and its path within. */
|
|
23
|
+
export interface BlobKey {
|
|
24
|
+
bucket: string;
|
|
25
|
+
path: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What a directory walk can tell us about a stored file, with no manifest. */
|
|
29
|
+
export interface BlobStat {
|
|
30
|
+
key: BlobKey;
|
|
31
|
+
size: number;
|
|
32
|
+
/** File mtime. Seeds `lastAccess` when a manifest row has to be rebuilt. */
|
|
33
|
+
mtime: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BlobStore {
|
|
37
|
+
/** False for {@link MemoryBlobStore} and for OPFS-less environments: the
|
|
38
|
+
* cache still dedupes and serves within a tab, but nothing survives reload. */
|
|
39
|
+
readonly persistent: boolean;
|
|
40
|
+
/** Namespace (the local bucketId) all keys are resolved under. */
|
|
41
|
+
readonly namespace: string;
|
|
42
|
+
|
|
43
|
+
read(key: BlobKey): Promise<Blob | null>;
|
|
44
|
+
/** Returns the number of bytes written. Throws on quota exhaustion. */
|
|
45
|
+
write(key: BlobKey, bytes: Blob): Promise<number>;
|
|
46
|
+
remove(key: BlobKey): Promise<void>;
|
|
47
|
+
/** Every committed file under the current namespace. Sweeps torn writes. */
|
|
48
|
+
list(): Promise<BlobStat[]>;
|
|
49
|
+
/** Drop the whole namespace (sign-out with `clearOnSignOut`, or a reset). */
|
|
50
|
+
clear(): Promise<void>;
|
|
51
|
+
/** Point at another namespace. Does not touch the bytes of the old one. */
|
|
52
|
+
setNamespace(namespace: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const BLOB_ROOT_DIR = 'sp00ky-blobs';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Marks a half-written file. Committed names can never contain a literal `.`
|
|
59
|
+
* (see {@link encodeSegment}), so this suffix is unambiguous — anything wearing
|
|
60
|
+
* it at walk time is a write that died before commit, and is swept.
|
|
61
|
+
*/
|
|
62
|
+
const PART_MARKER = '.part-';
|
|
63
|
+
|
|
64
|
+
/** OPFS names are capped around 255 bytes; stay well clear. */
|
|
65
|
+
const MAX_SEGMENT_LENGTH = 200;
|
|
66
|
+
/** Walk guards, mirroring `walkOpfs` in the DevTools storage report. */
|
|
67
|
+
const MAX_WALK_DEPTH = 12;
|
|
68
|
+
const MAX_WALK_ENTRIES = 5000;
|
|
69
|
+
|
|
70
|
+
/** A path segment we refuse to store — the caller degrades to no persistence. */
|
|
71
|
+
export class BlobKeyError extends Error {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = 'BlobKeyError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Percent-encode a path segment, and escape `.` on top of that. Escaping the
|
|
80
|
+
* dot is what makes {@link PART_MARKER} safe: a real file called `x.part-1`
|
|
81
|
+
* would otherwise be swept as a torn write on the next boot.
|
|
82
|
+
*/
|
|
83
|
+
export function encodeSegment(segment: string): string {
|
|
84
|
+
const encoded = encodeURIComponent(segment).replace(/\./g, '%2E');
|
|
85
|
+
if (encoded.length > MAX_SEGMENT_LENGTH) {
|
|
86
|
+
throw new BlobKeyError(`path segment too long to store (${encoded.length} > ${MAX_SEGMENT_LENGTH})`);
|
|
87
|
+
}
|
|
88
|
+
return encoded;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function decodeSegment(segment: string): string {
|
|
92
|
+
return decodeURIComponent(segment);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Path segments, `..`/`.`/empty stripped so a crafted path can't escape the root. */
|
|
96
|
+
export function pathSegments(path: string): string[] {
|
|
97
|
+
return path.split('/').filter((s) => s.length > 0 && s !== '.' && s !== '..');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `<bucket>/<...path>` — the directory chain plus filename for a key. */
|
|
101
|
+
export function keySegments(key: BlobKey): string[] {
|
|
102
|
+
const parts = pathSegments(key.path);
|
|
103
|
+
if (parts.length === 0) throw new BlobKeyError(`empty file path for bucket "${key.bucket}"`);
|
|
104
|
+
return [key.bucket, ...parts].map(encodeSegment);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Stable manifest row id. Mirrors the on-disk layout, decoded. */
|
|
108
|
+
export function blobKeyId(key: BlobKey): string {
|
|
109
|
+
return `${key.bucket}/${pathSegments(key.path).join('/')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether OPFS is usable for read AND write in this environment. Writes need
|
|
113
|
+
* `createWritable`, which arrived late in Safari; without it we run
|
|
114
|
+
* memory-only rather than dragging in a sync-access-handle worker. */
|
|
115
|
+
export function opfsWritableSupported(): boolean {
|
|
116
|
+
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return false;
|
|
117
|
+
const proto = (globalThis as { FileSystemFileHandle?: { prototype?: unknown } }).FileSystemFileHandle
|
|
118
|
+
?.prototype as { createWritable?: unknown } | undefined;
|
|
119
|
+
return typeof proto?.createWritable === 'function';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function supportsHandleMove(): boolean {
|
|
123
|
+
const proto = (globalThis as { FileSystemFileHandle?: { prototype?: unknown } }).FileSystemFileHandle
|
|
124
|
+
?.prototype as { move?: unknown } | undefined;
|
|
125
|
+
return typeof proto?.move === 'function';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Minimal structural view of the OPFS handles we touch. */
|
|
129
|
+
interface DirHandle {
|
|
130
|
+
kind: 'directory';
|
|
131
|
+
name: string;
|
|
132
|
+
getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise<DirHandle>;
|
|
133
|
+
getFileHandle(name: string, opts?: { create?: boolean }): Promise<FileHandle>;
|
|
134
|
+
removeEntry(name: string, opts?: { recursive?: boolean }): Promise<void>;
|
|
135
|
+
entries(): AsyncIterableIterator<[string, DirHandle | FileHandle]>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface FileHandle {
|
|
139
|
+
kind: 'file';
|
|
140
|
+
name: string;
|
|
141
|
+
getFile(): Promise<File>;
|
|
142
|
+
createWritable(): Promise<{ write(data: Blob): Promise<void>; close(): Promise<void>; abort?(): Promise<void> }>;
|
|
143
|
+
move?(name: string): Promise<void>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isNotFound(err: unknown): boolean {
|
|
147
|
+
return err instanceof Error && err.name === 'NotFoundError';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** OPFS-backed byte storage. All calls are async — nothing blocks the main thread. */
|
|
151
|
+
export class OpfsBlobStore implements BlobStore {
|
|
152
|
+
readonly persistent = true;
|
|
153
|
+
private ns: string;
|
|
154
|
+
private readonly useTempCommit = supportsHandleMove();
|
|
155
|
+
private tempCounter = 0;
|
|
156
|
+
private readonly tempToken =
|
|
157
|
+
typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID().slice(0, 8) : 'tmp';
|
|
158
|
+
|
|
159
|
+
constructor(namespace: string) {
|
|
160
|
+
this.ns = namespace;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get namespace(): string {
|
|
164
|
+
return this.ns;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
setNamespace(namespace: string): void {
|
|
168
|
+
this.ns = namespace;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async root(create: boolean): Promise<DirHandle | null> {
|
|
172
|
+
const opfs = (await navigator.storage.getDirectory()) as unknown as DirHandle;
|
|
173
|
+
return this.resolveDir(opfs, [BLOB_ROOT_DIR, encodeSegment(this.ns)], create);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async resolveDir(from: DirHandle, segments: string[], create: boolean): Promise<DirHandle | null> {
|
|
177
|
+
let dir = from;
|
|
178
|
+
for (const segment of segments) {
|
|
179
|
+
try {
|
|
180
|
+
dir = await dir.getDirectoryHandle(segment, { create });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (!create && isNotFound(err)) return null;
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return dir;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async read(key: BlobKey): Promise<Blob | null> {
|
|
190
|
+
const segments = keySegments(key);
|
|
191
|
+
const root = await this.root(false);
|
|
192
|
+
if (!root) return null;
|
|
193
|
+
const dir = await this.resolveDir(root, segments.slice(0, -1), false);
|
|
194
|
+
if (!dir) return null;
|
|
195
|
+
try {
|
|
196
|
+
const handle = await dir.getFileHandle(segments[segments.length - 1]!);
|
|
197
|
+
return await handle.getFile();
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (isNotFound(err)) return null;
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async write(key: BlobKey, bytes: Blob): Promise<number> {
|
|
205
|
+
const segments = keySegments(key);
|
|
206
|
+
const root = await this.root(true);
|
|
207
|
+
if (!root) throw new Error('OPFS root unavailable');
|
|
208
|
+
const dir = (await this.resolveDir(root, segments.slice(0, -1), true))!;
|
|
209
|
+
const name = segments[segments.length - 1]!;
|
|
210
|
+
|
|
211
|
+
// With `move()` we write to a temp name and commit by rename, so a crash
|
|
212
|
+
// mid-write leaves a sweepable `.part-` file rather than a truncated real
|
|
213
|
+
// one. Without it (Firefox), write in place: a torn file is caught on read
|
|
214
|
+
// by the manifest size check, which we need anyway for cross-tab races.
|
|
215
|
+
const target = this.useTempCommit ? `${name}${PART_MARKER}${this.tempToken}-${this.tempCounter++}` : name;
|
|
216
|
+
const handle = await dir.getFileHandle(target, { create: true });
|
|
217
|
+
const writable = await handle.createWritable();
|
|
218
|
+
try {
|
|
219
|
+
await writable.write(bytes);
|
|
220
|
+
await writable.close();
|
|
221
|
+
} catch (err) {
|
|
222
|
+
await writable.abort?.().catch(() => {});
|
|
223
|
+
await dir.removeEntry(target).catch(() => {});
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
if (this.useTempCommit) await handle.move!(name);
|
|
227
|
+
return bytes.size;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async remove(key: BlobKey): Promise<void> {
|
|
231
|
+
const segments = keySegments(key);
|
|
232
|
+
const root = await this.root(false);
|
|
233
|
+
if (!root) return;
|
|
234
|
+
const dir = await this.resolveDir(root, segments.slice(0, -1), false);
|
|
235
|
+
if (!dir) return;
|
|
236
|
+
try {
|
|
237
|
+
await dir.removeEntry(segments[segments.length - 1]!);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (!isNotFound(err)) throw err;
|
|
240
|
+
}
|
|
241
|
+
// Directories are left behind deliberately: pruning them would race a
|
|
242
|
+
// concurrent write into the same folder for no measurable space saving.
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async list(): Promise<BlobStat[]> {
|
|
246
|
+
const root = await this.root(false);
|
|
247
|
+
if (!root) return [];
|
|
248
|
+
const out: BlobStat[] = [];
|
|
249
|
+
await this.walk(root, [], out, 0);
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async walk(dir: DirHandle, trail: string[], out: BlobStat[], depth: number): Promise<void> {
|
|
254
|
+
if (depth > MAX_WALK_DEPTH || out.length >= MAX_WALK_ENTRIES) return;
|
|
255
|
+
for await (const [name, handle] of dir.entries()) {
|
|
256
|
+
if (out.length >= MAX_WALK_ENTRIES) return;
|
|
257
|
+
if (handle.kind === 'directory') {
|
|
258
|
+
await this.walk(handle as DirHandle, [...trail, name], out, depth + 1);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Torn write from a process that died between create and commit.
|
|
262
|
+
if (name.includes(PART_MARKER)) {
|
|
263
|
+
await dir.removeEntry(name).catch(() => {});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
// A file directly under the namespace root has no bucket segment.
|
|
267
|
+
if (trail.length === 0) continue;
|
|
268
|
+
try {
|
|
269
|
+
const file = await (handle as FileHandle).getFile();
|
|
270
|
+
out.push({
|
|
271
|
+
key: {
|
|
272
|
+
bucket: decodeSegment(trail[0]!),
|
|
273
|
+
path: [...trail.slice(1), name].map(decodeSegment).join('/'),
|
|
274
|
+
},
|
|
275
|
+
size: file.size,
|
|
276
|
+
mtime: file.lastModified,
|
|
277
|
+
});
|
|
278
|
+
} catch {
|
|
279
|
+
// Locked or vanished mid-walk: skip. Reconcile is best-effort.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async clear(): Promise<void> {
|
|
285
|
+
const opfs = (await navigator.storage.getDirectory()) as unknown as DirHandle;
|
|
286
|
+
const root = await this.resolveDir(opfs, [BLOB_ROOT_DIR], false);
|
|
287
|
+
if (!root) return;
|
|
288
|
+
try {
|
|
289
|
+
await root.removeEntry(encodeSegment(this.ns), { recursive: true });
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (!isNotFound(err)) throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* In-memory byte store. Used when OPFS is unavailable (the cache still dedupes
|
|
298
|
+
* and serves within the tab, matching the pre-existing behaviour) and as the
|
|
299
|
+
* test double for {@link BlobCache} — `blob-cache.test.ts` runs in node.
|
|
300
|
+
*/
|
|
301
|
+
export class MemoryBlobStore implements BlobStore {
|
|
302
|
+
readonly persistent = false;
|
|
303
|
+
private ns: string;
|
|
304
|
+
private readonly files = new Map<string, Map<string, { blob: Blob; mtime: number }>>();
|
|
305
|
+
private clock = 0;
|
|
306
|
+
|
|
307
|
+
constructor(namespace = 'anon') {
|
|
308
|
+
this.ns = namespace;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
get namespace(): string {
|
|
312
|
+
return this.ns;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
setNamespace(namespace: string): void {
|
|
316
|
+
this.ns = namespace;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private bucketFiles(): Map<string, { blob: Blob; mtime: number }> {
|
|
320
|
+
let ns = this.files.get(this.ns);
|
|
321
|
+
if (!ns) {
|
|
322
|
+
ns = new Map();
|
|
323
|
+
this.files.set(this.ns, ns);
|
|
324
|
+
}
|
|
325
|
+
return ns;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async read(key: BlobKey): Promise<Blob | null> {
|
|
329
|
+
keySegments(key);
|
|
330
|
+
return this.bucketFiles().get(blobKeyId(key))?.blob ?? null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async write(key: BlobKey, bytes: Blob): Promise<number> {
|
|
334
|
+
keySegments(key);
|
|
335
|
+
this.bucketFiles().set(blobKeyId(key), { blob: bytes, mtime: ++this.clock });
|
|
336
|
+
return bytes.size;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async remove(key: BlobKey): Promise<void> {
|
|
340
|
+
this.bucketFiles().delete(blobKeyId(key));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async list(): Promise<BlobStat[]> {
|
|
344
|
+
const out: BlobStat[] = [];
|
|
345
|
+
for (const [id, entry] of this.bucketFiles()) {
|
|
346
|
+
const slash = id.indexOf('/');
|
|
347
|
+
out.push({
|
|
348
|
+
key: { bucket: id.slice(0, slash), path: id.slice(slash + 1) },
|
|
349
|
+
size: entry.blob.size,
|
|
350
|
+
mtime: entry.mtime,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async clear(): Promise<void> {
|
|
357
|
+
this.files.delete(this.ns);
|
|
358
|
+
}
|
|
359
|
+
}
|