clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / src / pack-library.js
pack-library.js 4.9K
1import { CORE_PACK_ID, createCatalog } from './catalog.js';2import { cloneData } from './data/clone.js';34export const PACK_LIBRARY_SCHEMA = 1;5export const DEFAULT_PACK_LIBRARY_KEY = 'miau.pack-library.v1';6export const MAX_STORED_PACKS = 128;7export const MAX_STORED_CHARACTERS = 2 * 1024 * 1024;89function discoverStorage(storage) {10 if (storage !== undefined) return storage;11 try { return globalThis.localStorage; } catch { return null; }12}1314function snapshot(catalog) {15 return catalog.getPacks()16 .filter(pack => pack.id !== CORE_PACK_ID && pack.origin !== 'bundled')17 .map(pack => catalog.getPack(pack.id))18 .filter(Boolean);19}2021/**22 * Owns persistence for explicitly installed packs. The catalog remains the23 * runtime source of truth; storage contains only validated authored data and24 * never CommonJS source. A failing or unavailable Storage object degrades to25 * an in-memory library for the lifetime of this instance.26 */27export function createPackLibrary(options = {}) {28 const catalog = options.catalog || createCatalog();29 const storage = discoverStorage(options.storage);30 const storageKey = options.key || DEFAULT_PACK_LIBRARY_KEY;31 let memorySnapshot = [];32 let storageError = null;33 let hydrated = false;3435 function rememberError(error) {36 storageError = error instanceof Error ? error.message : String(error);37 }3839 function persist() {40 const packs = snapshot(catalog);41 memorySnapshot = cloneData(packs);42 if (!storage || typeof storage.setItem !== 'function') {43 storageError ||= 'localStorage is unavailable';44 return false;45 }46 try {47 const data = JSON.stringify({ schema: PACK_LIBRARY_SCHEMA, packs });48 if (packs.length > MAX_STORED_PACKS || data.length > MAX_STORED_CHARACTERS) throw new RangeError('pack library is too large for saved storage');49 // One setItem makes each catalog mutation a single storage commit.50 storage.setItem(storageKey, data);51 storageError = null;52 return true;53 } catch (error) {54 rememberError(error);55 return false;56 }57 }5859 function hydrate() {60 if (hydrated) return { loaded: memorySnapshot.length, errors: [] };61 hydrated = true;62 if (!storage || typeof storage.getItem !== 'function') {63 storageError ||= 'localStorage is unavailable';64 return { loaded: 0, errors: [storageError] };65 }66 let raw;67 try { raw = storage.getItem(storageKey); }68 catch (error) {69 rememberError(error);70 return { loaded: 0, errors: [storageError] };71 }72 if (!raw) return { loaded: 0, errors: [] };73 if (raw.length > MAX_STORED_CHARACTERS) return { loaded: 0, errors: ['stored pack library is too large'] };74 let data;75 try { data = JSON.parse(raw); }76 catch (error) {77 rememberError(error);78 return { loaded: 0, errors: ['stored pack library is not valid JSON'] };79 }80 if (!data || data.schema !== PACK_LIBRARY_SCHEMA || !Array.isArray(data.packs) || data.packs.length > MAX_STORED_PACKS) {81 const error = 'stored pack library has an unsupported schema';82 rememberError(error);83 return { loaded: 0, errors: [error] };84 }85 const errors = [];86 for (const [index, pack] of data.packs.entries()) {87 try {88 catalog.register(pack, { origin: 'local' });89 memorySnapshot.push(cloneData(pack));90 } catch (error) {91 errors.push(`packs[${index}]: ${error instanceof Error ? error.message : String(error)}`);92 }93 }94 return { loaded: memorySnapshot.length, errors };95 }9697 function installPack(pack, registerOptions = {}) {98 if (registerOptions.keepBoth && registerOptions.replace) throw new TypeError('Choose either Replace or Keep both.');99 const origin = registerOptions.origin || 'local';100 if (origin !== 'local' && origin !== 'url') throw new TypeError('Saved packs must come from a local file or a shared link.');101 let candidate = pack;102 if (registerOptions.keepBoth && catalog.getPack(pack?.manifest?.id)) {103 const original = pack.manifest.id;104 let suffix = 2;105 const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;106 while (catalog.getPack(forkId())) suffix++;107 candidate = cloneData(pack);108 candidate.manifest.id = forkId();109 candidate.manifest.forkOf = original;110 }111 const id = catalog.register(candidate, {112 origin,113 replace: registerOptions.replace === true,114 });115 persist();116 return id;117 }118119 function removePack(id) {120 const removed = catalog.removePack(id);121 if (removed) persist();122 return removed;123 }124125 hydrate();126 return Object.freeze({127 catalog,128 hydrate,129 installPack,130 removePack,131 getPacks: () => catalog.getPacks(),132 getPack: id => catalog.getPack(id),133 get persistedPacks() { return cloneData(memorySnapshot); },134 get persistence() {135 return { mode: storageError ? 'memory' : 'storage', error: storageError };136 },137 });138}139140export const createPersistentPackLibrary = createPackLibrary;