Files
openmaic/OpenMAIC/packages/@openmaic/storage/test/asset-store.test.ts
2026-08-16 14:58:47 +08:00

1225 lines
45 KiB
TypeScript

import { IDBFactory } from 'fake-indexeddb';
import { describe, expect, test, vi } from 'vitest';
import type { AssetRef } from '@openmaic/dsl';
import * as storageExports from '../src/index.js';
import { BrowserAssetStore, newAssetId, toAssetId } from '../src/index.js';
import { contentHashOf, type ContentHash } from '../src/asset/blob.js';
import { ASSET_ID_PREFIX, __setAssetIdFactoryForTesting } from '../src/asset/id.js';
import { blobForObjectUrl, objectUrlCount } from './setup.js';
import { runAssetStoreContract } from './asset-contract.js';
const readObjectUrl = async (url: string): Promise<Uint8Array> => {
const b = blobForObjectUrl(url);
if (!b) throw new Error(`no blob registered for object URL ${url}`);
return new Uint8Array(await b.arrayBuffer());
};
runAssetStoreContract(
'BrowserAssetStore',
{
makeStore: () =>
new BrowserAssetStore({ indexedDB: new IDBFactory(), dbName: 'test-asset-pool' }),
withAllocator: async (allocator, run) => {
__setAssetIdFactoryForTesting(allocator);
try {
return await run();
} finally {
__setAssetIdFactoryForTesting(null);
}
},
},
readObjectUrl,
);
// ---------------------------------------------------------------------------
// Internal view: assertions the outward contract deliberately cannot make,
// because "the bytes are stored once" and "the bytes were reclaimed" are
// exactly the facts the outward API must not disclose. They are still the
// design's load-bearing claims, so they are checked here, against the rows.
// ---------------------------------------------------------------------------
function openRaw(idb: IDBFactory, dbName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = idb.open(dbName, 1);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function createRaw(
idb: IDBFactory,
dbName: string,
upgrade: (db: IDBDatabase) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const req = idb.open(dbName, 1);
req.onupgradeneeded = () => upgrade(req.result);
req.onsuccess = () => {
req.result.close();
resolve();
};
req.onerror = () => reject(req.error);
});
}
function deleteRaw(idb: IDBFactory, dbName: string): Promise<void> {
return new Promise((resolve, reject) => {
const req = idb.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function rowCount(idb: IDBFactory, dbName: string, store: string): Promise<number> {
const db = await openRaw(idb, dbName);
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readonly').objectStore(store).count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function entryRow(idb: IDBFactory, dbName: string, id: string): Promise<unknown> {
const db = await openRaw(idb, dbName);
return new Promise((resolve, reject) => {
const req = db.transaction('assets', 'readonly').objectStore('assets').get(id);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function rowKeys(idb: IDBFactory, dbName: string, store: string): Promise<IDBValidKey[]> {
const db = await openRaw(idb, dbName);
return new Promise((resolve, reject) => {
const req = db.transaction(store, 'readonly').objectStore(store).getAllKeys();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dropBlobRows(idb: IDBFactory, dbName: string): Promise<void> {
const db = await openRaw(idb, dbName);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('blobs', 'readwrite');
tx.objectStore('blobs').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
interface Pool {
idb: IDBFactory;
dbName: string;
store: BrowserAssetStore;
/** A second instance over the same database — no shared in-memory URL cache. */
cold: () => BrowserAssetStore;
assets: () => Promise<number>;
blobs: () => Promise<number>;
}
function makePool(dbName: string): Pool {
const idb = new IDBFactory();
return {
idb,
dbName,
store: new BrowserAssetStore({ indexedDB: idb, dbName }),
cold: () => new BrowserAssetStore({ indexedDB: idb, dbName }),
assets: () => rowCount(idb, dbName, 'assets'),
blobs: () => rowCount(idb, dbName, 'blobs'),
};
}
const blob = (s: string, type = 'text/plain'): Blob => new Blob([s], { type });
const bytes = (s: string): Uint8Array => new TextEncoder().encode(s);
function cacheBookkeeping(store: BrowserAssetStore): {
entries: number;
retiredRefs: number;
retiredUrls: number;
} {
const cache = (
store as unknown as {
urls: {
entries: Map<string, unknown>;
retired: Map<string, unknown[]>;
};
}
).urls;
return {
entries: cache.entries.size,
retiredRefs: cache.retired.size,
retiredUrls: [...cache.retired.values()].reduce((total, urls) => total + urls.length, 0),
};
}
async function macrotaskHops(count = 3): Promise<void> {
for (let hop = 0; hop < count; hop += 1) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
interface ReadGate {
started: Promise<void>;
release: () => void;
}
async function interceptBlobReads(
idb: IDBFactory,
dbName: string,
): Promise<{
next: () => ReadGate;
restore: () => void;
}> {
const db = await openRaw(idb, dbName);
const transaction = db.transaction('assets', 'readonly');
const prototype = Object.getPrototypeOf(transaction.objectStore('assets')) as IDBObjectStore;
db.close();
const originalGet = prototype.get;
const gates: Array<{
started: () => void;
wait: Promise<void>;
}> = [];
prototype.get = function interceptedGet(
this: IDBObjectStore,
query: IDBValidKey | IDBKeyRange,
): IDBRequest<unknown> {
const request = originalGet.call(this, query);
const gate = this.name === 'blobs' ? gates.shift() : undefined;
if (!gate) return request;
const wrapped = new Proxy(request, {
get(target, property) {
return Reflect.get(target, property, target);
},
set(target, property, value) {
if (property === 'onsuccess') {
target.onsuccess = (event) => {
gate.started();
void gate.wait.then(() => {
(value as ((event: Event) => unknown) | null)?.call(wrapped, event);
});
};
return true;
}
return Reflect.set(target, property, value, target);
},
});
return wrapped;
};
return {
next: () => {
let markStarted!: () => void;
let release!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const wait = new Promise<void>((resolve) => {
release = resolve;
});
gates.push({ started: markStarted, wait });
return { started, release };
},
restore: () => {
prototype.get = originalGet;
},
};
}
describe('BrowserAssetStore de-duplicates bytes beneath allocated ids', () => {
test('N ids over identical bytes keep N registry rows and one blob row', async () => {
const pool = makePool('dedup-pool');
const ids = await Promise.all([
pool.store.put(blob('dup me')),
pool.store.put(blob('dup me')),
pool.store.put(blob('dup me')),
]);
expect(new Set(ids).size).toBe(3);
expect(await pool.assets()).toBe(3);
expect(await pool.blobs()).toBe(1);
});
test('distinct bytes keep distinct blob rows', async () => {
const pool = makePool('distinct-pool');
await pool.store.put(blob('a'));
await pool.store.put(blob('b'));
expect(await pool.blobs()).toBe(2);
});
});
describe('BrowserAssetStore reclaims bytes at the last reference', () => {
test('remove keeps an issued snapshot alive while the next resolve is a miss', async () => {
const pool = makePool('remove-snapshot');
const id = await pool.store.put(blob('shared database'));
const url = await pool.store.resolve(id);
expect(url).not.toBeNull();
expect(blobForObjectUrl(url!)).toBeDefined();
await pool.store.remove(id);
expect(await pool.store.resolve(id)).toBeNull();
await macrotaskHops();
expect(await readObjectUrl(url!)).toEqual(bytes('shared database'));
});
test('release revokes only this instance URL and leaves the registry untouched', async () => {
const pool = makePool('consumer-release');
const id = await pool.store.put(blob('displayed bytes'));
const url = await pool.store.resolve(id);
expect(blobForObjectUrl(url!)).toBeDefined();
await pool.store.release(id);
await pool.store.release(id);
await expect(pool.store.release('never allocated')).resolves.toBeUndefined();
expect(blobForObjectUrl(url!)).toBeUndefined();
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
const nextUrl = await pool.store.resolve(id);
expect(nextUrl).not.toBe(url);
expect(await readObjectUrl(nextUrl!)).toEqual(bytes('displayed bytes'));
});
test('removing one of two ids drops the row but keeps the bytes', async () => {
const pool = makePool('reclaim-partial');
const a = await pool.store.put(blob('two owners'));
const b = await pool.store.put(blob('two owners'));
await pool.store.remove(a);
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
// Read through a cold instance so no cached object URL can mask a
// reclamation that should not have happened.
const url = await pool.cold().resolve(b);
expect(url).not.toBeNull();
expect(await readObjectUrl(url!)).toEqual(bytes('two owners'));
});
test('removing the last id reclaims the bytes', async () => {
const pool = makePool('reclaim-last');
const a = await pool.store.put(blob('sole owner'));
const b = await pool.store.put(blob('sole owner'));
await pool.store.remove(a);
await pool.store.remove(b);
expect(await pool.assets()).toBe(0);
expect(await pool.blobs()).toBe(0);
});
test('removing one asset does not reclaim an unrelated assets bytes', async () => {
const pool = makePool('reclaim-unrelated');
const a = await pool.store.put(blob('doomed'));
await pool.store.put(blob('bystander'));
await pool.store.remove(a);
expect(await pool.blobs()).toBe(1);
});
test('a repeated remove reclaims nothing twice', async () => {
const pool = makePool('reclaim-idempotent');
const a = await pool.store.put(blob('once'));
const b = await pool.store.put(blob('twice'));
await pool.store.remove(a);
await pool.store.remove(a);
expect(await pool.blobs()).toBe(1);
expect(await pool.store.resolve(b)).not.toBeNull();
});
test('a concurrent put of the same bytes is never orphaned by the last remove', async () => {
const pool = makePool('reclaim-race');
const first = await pool.store.put(blob('contended bytes'));
// The removal of the only reference races a put that adopts the same bytes.
// Whichever transaction commits first, the surviving id must resolve.
const [second] = await Promise.all([
pool.store.put(blob('contended bytes')),
pool.store.remove(first),
]);
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
const url = await pool.cold().resolve(second);
expect(url).not.toBeNull();
expect(await readObjectUrl(url!)).toEqual(bytes('contended bytes'));
});
test('an entry whose bytes are already gone resolves to null and removes cleanly', async () => {
const pool = makePool('dangling-entry');
const id = await pool.store.put(blob('orphan'));
await dropBlobRows(pool.idb, pool.dbName);
expect(await pool.cold().resolve(id)).toBeNull();
await expect(pool.store.remove(id)).resolves.toBeUndefined();
expect(await pool.assets()).toBe(0);
});
});
describe('BrowserAssetStore replaces bytes behind stable ids', () => {
test('keeps the id stable and old snapshot alive while resolving the new bytes', async () => {
const pool = makePool('replace-stable');
const id = await pool.store.put(blob('before'));
const oldUrl = await pool.store.resolve(id);
await expect(pool.store.replace(id, blob('after'))).resolves.toBeUndefined();
expect(await readObjectUrl(oldUrl!)).toEqual(bytes('before'));
const newUrl = await pool.store.resolve(id);
expect(newUrl).not.toBe(oldUrl);
expect(await readObjectUrl(newUrl!)).toEqual(bytes('after'));
await macrotaskHops();
expect(await readObjectUrl(oldUrl!)).toEqual(bytes('before'));
});
test('reclaims the old blob when the replaced id was its last reference', async () => {
const pool = makePool('replace-reclaim-last');
const id = await pool.store.put(blob('old bytes'));
const [oldHash] = await rowKeys(pool.idb, pool.dbName, 'blobs');
await pool.store.replace(id, blob('new bytes'));
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
expect(await rowKeys(pool.idb, pool.dbName, 'blobs')).not.toContain(oldHash);
});
test('keeps the old blob while a sibling id still references it', async () => {
const pool = makePool('replace-reclaim-shared');
const replaced = await pool.store.put(blob('shared old bytes'));
const sibling = await pool.store.put(blob('shared old bytes'));
const [oldHash] = await rowKeys(pool.idb, pool.dbName, 'blobs');
await pool.store.replace(replaced, blob('new bytes'));
expect(await pool.blobs()).toBe(2);
expect(await rowKeys(pool.idb, pool.dbName, 'blobs')).toContain(oldHash);
const siblingUrl = await pool.cold().resolve(sibling);
expect(await readObjectUrl(siblingUrl!)).toEqual(bytes('shared old bytes'));
});
test('deduplicates replacement bytes already in the pool without changing its outcome', async () => {
const pool = makePool('replace-dedup-hit');
const replaced = await pool.store.put(blob('old bytes'));
const existing = await pool.store.put(blob('target bytes'));
const result = await pool.store.replace(replaced, blob('target bytes'));
expect(result).toBeUndefined();
expect(await pool.assets()).toBe(2);
expect(await pool.blobs()).toBe(1);
for (const id of [replaced, existing]) {
const url = await pool.cold().resolve(id);
expect(await readObjectUrl(url!)).toEqual(bytes('target bytes'));
}
});
test('replacing with the same bytes keeps one entry, one blob, and a resolvable id', async () => {
const pool = makePool('replace-same-bytes');
const id = await pool.store.put(blob('unchanged bytes'));
await pool.store.replace(id, blob('unchanged bytes'));
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
const url = await pool.store.resolve(id);
expect(url).not.toBeNull();
expect(await readObjectUrl(url!)).toEqual(bytes('unchanged bytes'));
});
test('rejects replacing an unknown id', async () => {
const pool = makePool('replace-unknown');
await expect(pool.store.replace(toAssetId('not allocated'), blob('bytes'))).rejects.toThrow(
/unknown asset id/i,
);
expect(await pool.assets()).toBe(0);
expect(await pool.blobs()).toBe(0);
});
test('keeps metadata when omitted and keeps MIME coherent with replacement input', async () => {
const pool = makePool('replace-meta');
const id = await pool.store.put(blob('one', 'image/png'), {
prompt: 'first prompt',
nested: { seed: 1 },
});
await pool.store.replace(id, blob('two', 'image/webp'));
let row = (await entryRow(pool.idb, pool.dbName, id)) as {
meta: unknown;
mime: string;
};
expect(row.meta).toEqual({
prompt: 'first prompt',
nested: { seed: 1 },
});
expect(row.mime).toBe('image/webp');
const updated = { contentType: 'image/jpeg', prompt: 'second prompt' };
await pool.store.replace(id, blob('three', 'image/webp'), updated);
row = (await entryRow(pool.idb, pool.dbName, id)) as { meta: unknown; mime: string };
expect(row.meta).toEqual(updated);
expect(row.mime).toBe('image/jpeg');
});
test('an untyped replacement inherits old MIME when metadata is omitted', async () => {
const pool = makePool('replace-untyped-mime');
const id = await pool.store.put(blob('one', 'image/png'), {
contentType: 'image/png',
prompt: 'kept',
});
await pool.store.replace(id, new Blob(['two']));
const row = (await entryRow(pool.idb, pool.dbName, id)) as {
meta: { contentType?: string; prompt?: string };
mime: string;
};
expect(row.meta).toEqual({ contentType: 'image/png', prompt: 'kept' });
expect(row.mime).toBe('image/png');
expect(blobForObjectUrl((await pool.store.resolve(id))!)?.type).toBe('image/png');
});
test('a typed replacement updates MIME when metadata is omitted', async () => {
const pool = makePool('replace-typed-mime');
const id = await pool.store.put(blob('one', 'image/png'), { prompt: 'kept' });
await pool.store.replace(id, blob('two', 'image/webp'));
const row = (await entryRow(pool.idb, pool.dbName, id)) as {
meta: { prompt?: string };
mime: string;
};
expect(row.meta).toEqual({ prompt: 'kept' });
expect(row.mime).toBe('image/webp');
});
test('replacement metadata contentType is also the stored MIME', async () => {
const pool = makePool('replace-meta-mime');
const id = await pool.store.put(blob('one', 'image/png'));
await pool.store.replace(id, blob('two', 'image/webp'), {
contentType: 'image/jpeg',
prompt: 'new',
});
const row = (await entryRow(pool.idb, pool.dbName, id)) as {
meta: { contentType?: string };
mime: string;
};
expect(row.meta.contentType).toBe('image/jpeg');
expect(row.mime).toBe(row.meta.contentType);
});
test('a concurrent replace and remove leaves no orphan or dangling row', async () => {
const pool = makePool('replace-remove-race');
const id = await pool.store.put(blob('before race'));
const [replaceResult, removeResult] = await Promise.allSettled([
pool.store.replace(id, blob('after race')),
pool.store.remove(id),
]);
expect(removeResult.status).toBe('fulfilled');
if (replaceResult.status === 'rejected') {
expect(replaceResult.reason).toMatchObject({ message: expect.stringMatching(/unknown/i) });
}
expect(await pool.assets()).toBe(0);
expect(await pool.blobs()).toBe(0);
expect(await pool.cold().resolve(id)).toBeNull();
});
});
describe('BrowserAssetStore registry rows', () => {
test('carry contentHash, mime and meta, and no principal', async () => {
const pool = makePool('row-shape');
const id = await pool.store.put(blob('shaped', 'image/png'), { contentType: 'image/png' });
const row = await entryRow(pool.idb, pool.dbName, id);
expect(Object.keys(row as object).sort()).toEqual(['contentHash', 'meta', 'mime']);
// A principal is derived server-side from an authenticated session; the
// browser has none to derive, so the browser registry models none.
expect(row).not.toHaveProperty('principal');
});
test('round-trip meta verbatim, including nested provenance', async () => {
const pool = makePool('meta-fidelity');
const meta = {
contentType: 'image/png',
prompt: 'a cat on a windowsill',
model: 'some-image-model',
dimensions: { width: 1024, height: 768 },
tags: ['generated', 'slide-3'],
seed: 42,
nested: { deep: { value: null } },
};
const id = await pool.store.put(blob('pixels'), meta);
const row = (await entryRow(pool.idb, pool.dbName, id)) as { meta: unknown };
expect(row.meta).toEqual(meta);
});
test('put derives MIME from the pre-await metadata snapshot', async () => {
const pool = makePool('meta-race-put');
let continueRead!: () => void;
const readGate = new Promise<void>((resolve) => {
continueRead = resolve;
});
const data = {
size: 5,
type: 'image/webp',
async arrayBuffer() {
await readGate;
return new Blob(['bytes']).arrayBuffer();
},
};
const meta = { contentType: 'image/png', prompt: 'original' };
const putting = pool.store.put(data, meta);
meta.contentType = 'image/jpeg';
meta.prompt = 'mutated';
continueRead();
const id = await putting;
const row = (await entryRow(pool.idb, pool.dbName, id)) as { meta: unknown; mime: string };
expect(row.meta).toEqual({ contentType: 'image/png', prompt: 'original' });
expect(row.mime).toBe('image/png');
});
test('replace derives MIME from the pre-await metadata snapshot', async () => {
const pool = makePool('meta-race-replace');
const id = await pool.store.put(blob('before', 'image/png'));
let continueRead!: () => void;
const readGate = new Promise<void>((resolve) => {
continueRead = resolve;
});
const data = {
size: 5,
type: 'image/webp',
async arrayBuffer() {
await readGate;
return new Blob(['after']).arrayBuffer();
},
};
const meta = { contentType: 'image/png', prompt: 'original' };
const replacing = pool.store.replace(id, data, meta);
meta.contentType = 'image/jpeg';
meta.prompt = 'mutated';
continueRead();
await replacing;
const row = (await entryRow(pool.idb, pool.dbName, id)) as { meta: unknown; mime: string };
expect(row.meta).toEqual({ contentType: 'image/png', prompt: 'original' });
expect(row.mime).toBe('image/png');
});
test('put and replace snapshot the caller-owned blob type before awaiting bytes', async () => {
const pool = makePool('blob-type-race');
let continuePut!: () => void;
const putGate = new Promise<void>((resolve) => {
continuePut = resolve;
});
const putData = {
size: 3,
type: 'image/png',
async arrayBuffer() {
await putGate;
return new Blob(['one']).arrayBuffer();
},
};
const putting = pool.store.put(putData);
putData.type = 'image/jpeg';
continuePut();
const id = await putting;
expect(blobForObjectUrl((await pool.store.resolve(id))!)?.type).toBe('image/png');
let continueReplace!: () => void;
const replaceGate = new Promise<void>((resolve) => {
continueReplace = resolve;
});
const replaceData = {
size: 3,
type: 'image/webp',
async arrayBuffer() {
await replaceGate;
return new Blob(['two']).arrayBuffer();
},
};
const replacing = pool.store.replace(id, replaceData);
replaceData.type = 'image/gif';
continueReplace();
await replacing;
expect(blobForObjectUrl((await pool.store.resolve(id))!)?.type).toBe('image/webp');
});
test('default meta to an empty object when the caller passes none', async () => {
const pool = makePool('meta-absent');
const id = await pool.store.put(blob('bare'));
const row = (await entryRow(pool.idb, pool.dbName, id)) as { meta: unknown };
expect(row.meta).toEqual({});
});
test('rejects non-cloneable metadata before put opens a write transaction', async () => {
const pool = makePool('meta-clone-put');
const seed = await pool.store.put(blob('schema seed'));
await pool.store.remove(seed);
const meta = { callback: () => undefined };
await expect(pool.store.put(blob('never written'), meta)).rejects.toThrow(
/metadata values must be structured-cloneable/i,
);
expect(await pool.assets()).toBe(0);
expect(await pool.blobs()).toBe(0);
});
test('rejects non-cloneable replacement metadata without changing stored rows', async () => {
const pool = makePool('meta-clone-replace');
const id = await pool.store.put(blob('kept'));
const meta = { callback: () => undefined };
await expect(pool.store.replace(id, blob('rejected'), meta)).rejects.toThrow(
/metadata values must be structured-cloneable/i,
);
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
const url = await pool.cold().resolve(id);
expect(await readObjectUrl(url!)).toEqual(bytes('kept'));
});
test('take mime from meta.contentType, falling back to the blob type', async () => {
const pool = makePool('mime-source');
const explicit = await pool.store.put(blob('x', 'text/plain'), { contentType: 'image/png' });
const implicit = await pool.store.put(blob('y', 'audio/mpeg'));
const untyped = await pool.store.put(new Blob(['z'], { type: '' }));
expect(blobForObjectUrl((await pool.store.resolve(explicit))!)?.type).toBe('image/png');
expect(blobForObjectUrl((await pool.store.resolve(implicit))!)?.type).toBe('audio/mpeg');
expect(blobForObjectUrl((await pool.store.resolve(untyped))!)?.type).toBe('');
});
test('give the same bytes independent mime per id', async () => {
const pool = makePool('mime-per-id');
const png = await pool.store.put(blob('same bytes'), { contentType: 'image/png' });
const jpeg = await pool.store.put(blob('same bytes'), { contentType: 'image/jpeg' });
expect(await pool.blobs()).toBe(1);
expect(blobForObjectUrl((await pool.store.resolve(png))!)?.type).toBe('image/png');
expect(blobForObjectUrl((await pool.store.resolve(jpeg))!)?.type).toBe('image/jpeg');
});
});
describe('BrowserAssetStore never surfaces a content hash', () => {
test('the allocated id is unrelated to the stored bytes', async () => {
const pool = makePool('hash-hiding');
const id = await pool.store.put(blob('secret bytes'));
const row = (await entryRow(pool.idb, pool.dbName, id)) as { contentHash: string };
expect(row.contentHash).toMatch(/^sha256-[0-9a-f]{64}$/);
expect(id).not.toContain(row.contentHash);
expect(id).not.toContain('sha256');
});
test('a content hash is not accepted as a reference', async () => {
const pool = makePool('hash-not-a-ref');
const id = await pool.store.put(blob('secret bytes'));
const { contentHash } = (await entryRow(pool.idb, pool.dbName, id)) as { contentHash: string };
// Knowing the hash buys nothing: it is a private storage key, not a handle.
expect(await pool.store.resolve(contentHash)).toBeNull();
await expect(pool.store.remove(contentHash)).resolves.toBeUndefined();
expect(await pool.store.resolve(id)).not.toBeNull();
});
});
describe('BrowserAssetStore cross-instance cache coherence', () => {
test('a hot instance observes another instance removing an entry', async () => {
const idb = new IDBFactory();
const dbName = 'cross-instance-remove';
const writer = new BrowserAssetStore({ indexedDB: idb, dbName });
const reader = new BrowserAssetStore({ indexedDB: idb, dbName });
const id = await writer.put(blob('removed elsewhere'));
const oldUrl = await reader.resolve(id);
expect(blobForObjectUrl(oldUrl!)).toBeDefined();
await writer.remove(id);
expect(await reader.resolve(id)).toBeNull();
expect(await readObjectUrl(oldUrl!)).toEqual(bytes('removed elsewhere'));
await Promise.all([reader.close(), writer.close()]);
});
test('a hot instance observes replacement bytes by content hash', async () => {
const idb = new IDBFactory();
const dbName = 'cross-instance-replace';
const writer = new BrowserAssetStore({ indexedDB: idb, dbName });
const reader = new BrowserAssetStore({ indexedDB: idb, dbName });
const id = await writer.put(blob('before'));
const oldUrl = await reader.resolve(id);
await writer.replace(id, blob('after'));
const newUrl = await reader.resolve(id);
expect(newUrl).not.toBe(oldUrl);
expect(await readObjectUrl(newUrl!)).toEqual(bytes('after'));
expect(await readObjectUrl(oldUrl!)).toEqual(bytes('before'));
await Promise.all([reader.close(), writer.close()]);
});
test('a hot instance observes same-byte replacement MIME changes', async () => {
const idb = new IDBFactory();
const dbName = 'cross-instance-replace-mime';
const writer = new BrowserAssetStore({ indexedDB: idb, dbName });
const reader = new BrowserAssetStore({ indexedDB: idb, dbName });
const id = await writer.put(blob('same bytes', 'image/png'));
const oldUrl = await reader.resolve(id);
expect(blobForObjectUrl(oldUrl!)?.type).toBe('image/png');
await writer.replace(id, blob('same bytes', 'image/webp'));
const newUrl = await reader.resolve(id);
expect(newUrl).not.toBe(oldUrl);
expect(blobForObjectUrl(oldUrl!)?.type).toBe('image/png');
expect(blobForObjectUrl(newUrl!)?.type).toBe('image/webp');
await Promise.all([reader.close(), writer.close()]);
});
});
test('resolve racing replace returns a snapshot that stays alive across macrotasks', async () => {
const pool = makePool('resolve-replace-snapshot');
const id = await pool.store.put(blob('version-0'));
let previous = 'version-0';
for (let i = 1; i <= 10; i += 1) {
const next = `version-${i}`;
const [url] = await Promise.all([pool.store.resolve(id), pool.store.replace(id, blob(next))]);
expect(url).not.toBeNull();
expect(blobForObjectUrl(url!)).toBeDefined();
const observed = new TextDecoder().decode(await readObjectUrl(url!));
expect([previous, next]).toContain(observed);
await macrotaskHops();
expect(blobForObjectUrl(url!)).toBeDefined();
previous = next;
}
await pool.store.release(id);
expect(objectUrlCount()).toBe(0);
await pool.store.close();
});
test('double replace during an in-flight resolve cannot revoke its returned snapshot', async () => {
const idb = new IDBFactory();
const dbName = 'double-replace-snapshot';
const writer = new BrowserAssetStore({ indexedDB: idb, dbName });
const reader = new BrowserAssetStore({ indexedDB: idb, dbName });
const id = await writer.put(blob('version-0'));
const reads = await interceptBlobReads(idb, dbName);
const gate = reads.next();
try {
const resolving = reader.resolve(id);
await gate.started;
await writer.replace(id, blob('version-1'));
await writer.replace(id, blob('version-2'));
gate.release();
const url = await resolving;
expect(url).not.toBeNull();
await macrotaskHops();
expect(await readObjectUrl(url!)).toEqual(bytes('version-0'));
await Promise.all([reader.close(), writer.close()]);
} finally {
gate.release();
reads.restore();
}
});
describe('BrowserAssetStore lifecycle', () => {
test('close revokes current, retired, and late-settling snapshots', async () => {
const idb = new IDBFactory();
const dbName = 'close-pending-mint';
const store = new BrowserAssetStore({ indexedDB: idb, dbName });
const retiredId = await store.put(blob('retired-before'));
const retiredUrl = await store.resolve(retiredId);
await store.replace(retiredId, blob('retired-after'));
const currentUrl = await store.resolve(retiredId);
const pendingId = await store.put(blob('pending'));
const reads = await interceptBlobReads(idb, dbName);
const gate = reads.next();
try {
const resolving = store.resolve(pendingId);
await gate.started;
const closing = store.close();
gate.release();
const lateUrl = await resolving;
expect(lateUrl).not.toBeNull();
await closing;
expect(blobForObjectUrl(retiredUrl!)).toBeUndefined();
expect(blobForObjectUrl(currentUrl!)).toBeUndefined();
expect(blobForObjectUrl(lateUrl!)).toBeUndefined();
expect(objectUrlCount()).toBe(0);
} finally {
gate.release();
reads.restore();
}
});
test('double-close is safe and every later operation fails loudly', async () => {
const store = new BrowserAssetStore({
indexedDB: new IDBFactory(),
dbName: 'double-close',
});
const id = await store.put(blob('closed'));
await expect(store.close()).resolves.toBeUndefined();
await expect(store.close()).resolves.toBeUndefined();
await expect(store.put(blob('x'))).rejects.toThrow(/store is closed/i);
await expect(store.resolve(id)).rejects.toThrow(/store is closed/i);
await expect(store.replace(id, blob('x'))).rejects.toThrow(/store is closed/i);
await expect(store.remove(id)).rejects.toThrow(/store is closed/i);
await expect(store.release(id)).rejects.toThrow(/store is closed/i);
});
test('close closes an opened IndexedDB connection', async () => {
const idb = new IDBFactory();
const store = new BrowserAssetStore({ indexedDB: idb, dbName: 'close-idb' });
await store.put(blob('open the database'));
const probe = await openRaw(idb, 'close-idb');
const prototype = Object.getPrototypeOf(probe) as IDBDatabase;
const close = vi.spyOn(prototype, 'close');
try {
await store.close();
expect(close).toHaveBeenCalledTimes(1);
} finally {
close.mockRestore();
probe.close();
}
});
});
describe('BrowserAssetStore snapshot retention', () => {
test('release revokes current and retired snapshots for one ref only', async () => {
const pool = makePool('release-all-snapshots');
const released = await pool.store.put(blob('released-0'));
const untouched = await pool.store.put(blob('untouched'));
const releasedUrls = [(await pool.store.resolve(released))!];
const untouchedUrl = await pool.store.resolve(untouched);
for (let version = 1; version <= 2; version += 1) {
await pool.store.replace(released, blob(`released-${version}`));
releasedUrls.push((await pool.store.resolve(released))!);
}
expect(objectUrlCount()).toBe(4);
await pool.store.release(released);
for (const url of releasedUrls) expect(blobForObjectUrl(url)).toBeUndefined();
expect(await readObjectUrl(untouchedUrl!)).toEqual(bytes('untouched'));
expect(objectUrlCount()).toBe(1);
await pool.store.close();
});
test('no-op releases do not retain caller-controlled refs', async () => {
const store = new BrowserAssetStore({
indexedDB: new IDBFactory(),
dbName: 'release-bookkeeping',
});
await Promise.all(
Array.from({ length: 10_000 }, (_, index) => store.release(`never-allocated-${index}`)),
);
expect(cacheBookkeeping(store)).toEqual({
entries: 0,
retiredRefs: 0,
retiredUrls: 0,
});
await store.close();
});
test('N replaces and resolves retain N snapshots until release', async () => {
const store = new BrowserAssetStore({
indexedDB: new IDBFactory(),
dbName: 'snapshot-retention-bound',
});
const id = await store.put(blob('version-0'));
await store.resolve(id);
const replacements = 8;
for (let version = 1; version <= replacements; version += 1) {
await store.replace(id, blob(`version-${version}`));
await store.resolve(id);
}
expect(cacheBookkeeping(store)).toEqual({
entries: 1,
retiredRefs: 1,
retiredUrls: replacements,
});
expect(objectUrlCount()).toBe(replacements + 1);
await store.release(id);
expect(cacheBookkeeping(store)).toEqual({
entries: 0,
retiredRefs: 0,
retiredUrls: 0,
});
expect(objectUrlCount()).toBe(0);
await store.close();
});
});
test('plain asset refs are not assignable to branded content hashes', () => {
const contentHashFromAssetRef = (ref: AssetRef): ContentHash => {
// @ts-expect-error caller-visible asset refs lack the internal content-hash brand.
return ref;
};
expect(typeof contentHashFromAssetRef).toBe('function');
});
test('content hashing fails clearly when crypto.subtle is unavailable', async () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
const crypto = globalThis.crypto;
Object.defineProperty(globalThis, 'crypto', {
value: { getRandomValues: crypto.getRandomValues.bind(crypto), subtle: undefined },
configurable: true,
});
try {
await expect(contentHashOf(new Blob(['bytes']))).rejects.toThrow(
/crypto\.subtle.*secure context/i,
);
} finally {
if (descriptor) Object.defineProperty(globalThis, 'crypto', descriptor);
else Reflect.deleteProperty(globalThis, 'crypto');
}
});
test('the package entry does not expose internal asset-layer symbols', () => {
for (const name of [
'ASSET_ID_PREFIX',
'__setAssetIdFactoryForTesting',
'ContentHash',
'BlobStore',
'BrowserAssetProvider',
'S3AssetByteStore',
]) {
expect(storageExports).not.toHaveProperty(name);
}
});
test('BrowserAssetStore mints an object URL only after its read transaction commits', async () => {
const pool = makePool('mint-after-commit');
const id = await pool.store.put(blob('transaction ordering'));
const db = await openRaw(pool.idb, pool.dbName);
const proto = Object.getPrototypeOf(db) as IDBDatabase;
const originalTransaction = proto.transaction;
let activeReadonlyTransactions = 0;
const activeCountsAtMint: number[] = [];
proto.transaction = function patched(
this: IDBDatabase,
storeNames: string | string[],
mode?: IDBTransactionMode,
options?: IDBTransactionOptions,
): IDBTransaction {
const tx = originalTransaction.call(this, storeNames, mode, options);
if ((mode ?? 'readonly') === 'readonly') {
activeReadonlyTransactions += 1;
const finished = () => {
activeReadonlyTransactions -= 1;
};
tx.addEventListener('complete', finished, { once: true });
tx.addEventListener('abort', finished, { once: true });
}
return tx;
} as IDBDatabase['transaction'];
const originalCreateObjectURL = URL.createObjectURL;
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockImplementation((value) => {
activeCountsAtMint.push(activeReadonlyTransactions);
return originalCreateObjectURL(value);
});
try {
expect(await pool.store.resolve(id)).not.toBeNull();
expect(activeCountsAtMint).toEqual([0]);
} finally {
createObjectURL.mockRestore();
proto.transaction = originalTransaction;
}
});
describe('allocated asset ids', () => {
test('carry the type prefix and a fixed-width body', async () => {
const id = newAssetId();
expect(id.startsWith(ASSET_ID_PREFIX)).toBe(true);
// 128 bits in a 32-symbol alphabet: 26 symbols.
expect(id).toMatch(/^ast_[0-9abcdefghjkmnpqrstvwxyz]{26}$/);
});
test('do not repeat', () => {
const minted = new Set(Array.from({ length: 2000 }, () => newAssetId()));
expect(minted.size).toBe(2000);
});
test('fail clearly when crypto.getRandomValues is unavailable', () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
const crypto = globalThis.crypto;
Object.defineProperty(globalThis, 'crypto', {
value: { subtle: crypto.subtle },
configurable: true,
});
try {
expect(() => newAssetId()).toThrow(/crypto\.getRandomValues.*secure context/i);
} finally {
if (descriptor) Object.defineProperty(globalThis, 'crypto', descriptor);
else Reflect.deleteProperty(globalThis, 'crypto');
}
});
test('are the same width regardless of what they end up naming', async () => {
const pool = makePool('id-width');
const small = await pool.store.put(blob('x'));
const large = await pool.store.put(blob('y'.repeat(100_000)));
expect(small.length).toBe(large.length);
});
test('toAssetId brands without validating', async () => {
const pool = makePool('id-brand');
// Deliberately not an id this store issued: branding is a compile-time act,
// so the runtime answer is a miss rather than a rejection.
expect(await pool.store.resolve(toAssetId('not an id at all'))).toBeNull();
});
});
describe('BrowserAssetStore failure handling', () => {
const incompatibleSchemaMessage = (dbName: string): string =>
`IndexedDB database "${dbName}" has an incompatible asset schema. ` +
'It may have been created by the 0.1.x content-addressed BrowserAssetProvider or another incompatible version. ' +
'Use a fresh dbName, or wait for or request the explicit one-time import helper.';
test('every data operation rejects a legacy provider database with actionable guidance', async () => {
const idb = new IDBFactory();
const dbName = 'legacy-provider-db';
await createRaw(idb, dbName, (db) => db.createObjectStore('assets'));
const store = new BrowserAssetStore({ indexedDB: idb, dbName });
const expected = incompatibleSchemaMessage(dbName);
const operations = [
() => store.put(blob('new bytes')),
() => store.resolve('legacy-ref'),
() => store.remove('legacy-ref'),
() => store.replace(toAssetId('legacy-ref'), blob('replacement')),
() => store.release('legacy-ref'),
];
for (const operation of operations) {
await expect(operation()).rejects.toMatchObject({ name: 'Error', message: expected });
}
});
test('a fresh database still works end to end after schema validation', async () => {
const store = new BrowserAssetStore({
indexedDB: new IDBFactory(),
dbName: 'schema-validation-fresh-pool',
});
const id = await store.put(blob('fresh bytes'));
const url = await store.resolve(id);
expect(url).not.toBeNull();
expect(await readObjectUrl(url!)).toEqual(bytes('fresh bytes'));
await store.replace(id, blob('replacement bytes'));
expect(await readObjectUrl((await store.resolve(id))!)).toEqual(bytes('replacement bytes'));
await store.remove(id);
expect(await store.resolve(id)).toBeNull();
});
test('does not memoize a schema rejection and retries after the same database is corrected', async () => {
const idb = new IDBFactory();
const dbName = 'corrected-schema-pool';
await createRaw(idb, dbName, (db) => db.createObjectStore('assets'));
const store = new BrowserAssetStore({ indexedDB: idb, dbName });
await expect(store.resolve('legacy-ref')).rejects.toMatchObject({
message: incompatibleSchemaMessage(dbName),
});
await deleteRaw(idb, dbName);
await createRaw(idb, dbName, (db) => {
db.createObjectStore('blobs');
db.createObjectStore('assets').createIndex('by-content-hash', 'contentHash');
});
const id = await store.put(blob('retry bytes'));
expect(await store.resolve(id)).not.toBeNull();
});
test('an allocated-id collision aborts without overwriting or orphaning bytes', async () => {
const pool = makePool('id-collision');
const random = vi
.spyOn(globalThis.crypto, 'getRandomValues')
.mockImplementation(<T extends ArrayBufferView | null>(array: T): T => {
if (array) new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(7);
return array;
});
try {
const first = await pool.store.put(blob('kept bytes'));
await expect(pool.store.put(blob('rejected bytes'))).rejects.toThrow();
const url = await pool.store.resolve(first);
expect(url).not.toBeNull();
expect(await readObjectUrl(url!)).toEqual(bytes('kept bytes'));
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
} finally {
random.mockRestore();
}
});
test('recovers after a transient open failure instead of replaying it', async () => {
const real = new IDBFactory();
const seeded = new BrowserAssetStore({ indexedDB: real, dbName: 'flaky-pool' });
const id = await seeded.put(blob('seed'));
let failNextOpen = true;
const flaky = {
open: (name: string, version?: number) => {
if (failNextOpen) {
failNextOpen = false;
throw new Error('transient open failure');
}
return real.open(name, version);
},
deleteDatabase: real.deleteDatabase.bind(real),
cmp: real.cmp.bind(real),
databases: real.databases?.bind(real),
} as unknown as IDBFactory;
const store = new BrowserAssetStore({ indexedDB: flaky, dbName: 'flaky-pool' });
await expect(store.resolve(id)).rejects.toThrow();
expect(await store.resolve(id)).not.toBeNull();
});
// A write whose requests all SUCCEED and whose transaction then dies before
// commit — what an over-quota commit does — must be reported as a failure and
// leave nothing behind. Reporting request success as durability would claim
// something the store never gave, so `put` resolves on commit, not on the
// last request. The abort here is triggered from the success handler of the
// final write, so every request the store awaits has already succeeded.
test('a write whose requests succeed but whose transaction aborts still rejects', async () => {
const pool = makePool('abort-pool');
await pool.store.put(blob('before the failure'));
const db = await openRaw(pool.idb, pool.dbName);
const proto = Object.getPrototypeOf(
db.transaction('blobs', 'readonly').objectStore('blobs'),
) as IDBObjectStore;
const originalAdd = proto.add;
proto.add = function patched(this: IDBObjectStore, value: unknown, key?: IDBValidKey) {
const req = originalAdd.call(this, value, key);
// Only the registry write (keyed by the allocated id) arms the abort, so
// the blob write ahead of it completes normally and the store sees a
// fully successful sequence of requests.
if (typeof key === 'string' && key.startsWith(ASSET_ID_PREFIX)) {
const tx = this.transaction;
req.addEventListener('success', () => tx.abort());
}
return req;
};
try {
await expect(pool.store.put(blob('doomed write'))).rejects.toThrow();
} finally {
proto.add = originalAdd;
}
expect(await pool.assets()).toBe(1);
expect(await pool.blobs()).toBe(1);
});
});