feat(ms-ai-architect): playground v3 state module (Proxy + EventTarget + IDB persistence)
Step 2/17 av Playground v3-leveransen.
State-skjelett:
- StateBus extends EventTarget (sharedBus + projectBus)
- Dyp Proxy med set/deleteProperty-traps som batcher dispatchEvent via
queueMicrotask (N synkrone mutasjoner -> én change-event per tick)
- Path tracking: subscribers får detail.paths for å filtrere relevante grener
- INITIAL_STATE med shared.{organization,technology,security,architecture,
business} + projects[] + activeProjectId/Surface + preferences.theme
Persistens:
- IDB primær: én DB ('ms-ai-architect-playground-v1') med 3 stores
(shared, projects, meta). Promise-wrapper rundt indexedDB.open.
- Synkrone migrasjoner i onupgradeneeded med oldVersion-guards (callback-stil
cursor — async cursor-iterasjon er forbudt per w3c/IndexedDB#282)
- db.onversionchange = () => db.close() defensivt på alle koblinger
- localStorage-fallback ved IDB-feil (Safari private mode, kvote): rå JSON
i STATE_KEY, warn ved >4.5 MB nær 5 MiB cap
- Throttled writer: debounce 300 ms etter siste mutasjon
Bootstrap:
- Auto-kjørt på slutten av <body> (DOM allerede parsed)
- window.__store + window.__persistence eksponert for Verify-asserts
Verify-asserts (i nettleser-konsoll på file://-åpnet HTML):
typeof window.__store !== 'undefined' && window.__store.state.schemaVersion === 1
Plan: .claude/projects/2026-05-03-playground-v3-architecture/plan.md (Step 2)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
63746df184
commit
483dad8049
1 changed files with 306 additions and 0 deletions
|
|
@ -49,6 +49,312 @@
|
|||
// __buildCommand, __buildEnvelope, __handlePasteImport.
|
||||
window.__STATE_KEY = STATE_KEY;
|
||||
window.__SCHEMA_VERSION = SCHEMA_VERSION;
|
||||
|
||||
// ============================================================
|
||||
// STATE MODULE (Step 2)
|
||||
// ============================================================
|
||||
//
|
||||
// Reactivity-skjelett: Proxy + EventTarget. set-trap batcher dispatchEvent
|
||||
// via queueMicrotask, så N synkrone mutasjoner gir bare én 'change'-event
|
||||
// per mikrotask-tick. Bruker dyp wrap (Proxy rekursivt på objekt-properties)
|
||||
// så nestede oppdateringer (state.shared.organization.name = ...) fanges.
|
||||
//
|
||||
// Persistens: IDB primær (~ubegrenset for 1-5 MB), localStorage fallback
|
||||
// (5 MiB cap). Open-DB pattern bruker Promise-wrapper og synkrone
|
||||
// migrasjoner i onupgradeneeded — async cursor-iterasjon er forbudt
|
||||
// (ref w3c/IndexedDB#282: korrupsjons-risiko ved async i upgrade-tx).
|
||||
//
|
||||
// Multi-tab: db.onversionchange = () => db.close() defensivt på alle
|
||||
// koblinger så en versjon-bump i en annen tab ikke stuck-blokkerer denne.
|
||||
|
||||
class StateBus extends EventTarget {}
|
||||
|
||||
const sharedBus = new StateBus();
|
||||
const projectBus = new StateBus();
|
||||
|
||||
// Initial state-form. Step 5+ utvider shared.* etter onboarding-skjema;
|
||||
// Step 7 utvider projects[]. preferences.theme settes i Step 13.
|
||||
const INITIAL_STATE = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
shared: {
|
||||
organization: {},
|
||||
technology: {},
|
||||
security: {},
|
||||
architecture: {},
|
||||
business: {}
|
||||
},
|
||||
projects: [],
|
||||
activeProjectId: null,
|
||||
activeSurface: 'home',
|
||||
preferences: { theme: 'dark' }
|
||||
};
|
||||
|
||||
// Microtask-batched event dispatcher. Mange synkrone set-traps i samme
|
||||
// tick → én 'change'-event neste mikrotask. Forhindrer N renders ved
|
||||
// batch-mutasjoner (f.eks. import-flow).
|
||||
function makeBatchedDispatcher(bus) {
|
||||
let pending = false;
|
||||
const changedPaths = new Set();
|
||||
return function dispatch(path) {
|
||||
changedPaths.add(path);
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
queueMicrotask(function () {
|
||||
pending = false;
|
||||
const paths = Array.from(changedPaths);
|
||||
changedPaths.clear();
|
||||
bus.dispatchEvent(new CustomEvent('change', { detail: { paths: paths } }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Dyp Proxy-wrap. Lazy: wrapper child-objekter ved første read, så cost
|
||||
// er bare betalt for grener brukeren faktisk berører. set-trap returnerer
|
||||
// boolean (Proxy spec-invariant) og dispatcher batched 'change'-event.
|
||||
// Path tracking gjør at subscribers kan filtrere på relevante grener.
|
||||
function deepProxy(target, dispatch, path) {
|
||||
path = path || '';
|
||||
const cache = new WeakMap();
|
||||
const handler = {
|
||||
get: function (obj, key) {
|
||||
const value = obj[key];
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
||||
if (cache.has(value)) return cache.get(value);
|
||||
const childPath = path ? path + '.' + String(key) : String(key);
|
||||
const wrapped = new Proxy(value, makeHandler(childPath));
|
||||
cache.set(value, wrapped);
|
||||
return wrapped;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
// Array-mutasjoner via push/splice trigger set på indekser; wrap likt.
|
||||
if (cache.has(value)) return cache.get(value);
|
||||
const childPath = path ? path + '.' + String(key) : String(key);
|
||||
const wrapped = new Proxy(value, makeHandler(childPath));
|
||||
cache.set(value, wrapped);
|
||||
return wrapped;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set: function (obj, key, value) {
|
||||
obj[key] = value;
|
||||
dispatch(path ? path + '.' + String(key) : String(key));
|
||||
return true;
|
||||
},
|
||||
deleteProperty: function (obj, key) {
|
||||
delete obj[key];
|
||||
dispatch(path ? path + '.' + String(key) : String(key));
|
||||
return true;
|
||||
}
|
||||
};
|
||||
function makeHandler(p) {
|
||||
return {
|
||||
get: function (o, k) { return new Proxy(target, handler).constructor === Proxy ? handler.get(o, k) : o[k]; },
|
||||
set: function (o, k, v) { o[k] = v; dispatch(p ? p + '.' + String(k) : String(k)); return true; },
|
||||
deleteProperty: function (o, k) { delete o[k]; dispatch(p ? p + '.' + String(k) : String(k)); return true; }
|
||||
};
|
||||
}
|
||||
return new Proxy(target, handler);
|
||||
}
|
||||
|
||||
function createStore(initial, bus) {
|
||||
const dispatch = makeBatchedDispatcher(bus);
|
||||
const proxied = deepProxy(initial, dispatch, '');
|
||||
return {
|
||||
state: proxied,
|
||||
raw: initial, // referanse til underliggende objekt — for serialisering
|
||||
subscribe: function (handler) { bus.addEventListener('change', handler); },
|
||||
unsubscribe: function (handler) { bus.removeEventListener('change', handler); }
|
||||
};
|
||||
}
|
||||
|
||||
// Throttled persistens: debounce 300 ms etter siste mutasjon, så bursts
|
||||
// (import-flow, batch-form-submit) committer bare én gang.
|
||||
function makeThrottledWriter(persist) {
|
||||
let timer = null;
|
||||
return function schedule() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
timer = null;
|
||||
persist().catch(function (err) {
|
||||
console.error('[playground v3] persist failed:', err);
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PERSISTENCE LAYER
|
||||
// ============================================================
|
||||
//
|
||||
// IDB primær. Én DB ('ms-ai-architect-playground-v1') med to object-stores:
|
||||
// - 'shared': nøkkel 'shared' → { organization, technology, ... }
|
||||
// - 'projects': nøkkel 'projectId' → project-objekt
|
||||
//
|
||||
// Migrasjoner i onupgradeneeded er SYNKRONE per spec — ingen await på
|
||||
// cursor.continue(); bruk callback-stil. async cursor-iterasjon i en
|
||||
// upgrade-tx kan korruptere DB (w3c/IndexedDB#282).
|
||||
|
||||
function openDB(name, version) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
reject(new Error('IndexedDB ikke tilgjengelig'));
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.open(name, version);
|
||||
req.onupgradeneeded = function (ev) {
|
||||
const db = req.result;
|
||||
const oldVersion = ev.oldVersion;
|
||||
// Synkrone migrasjoner — opprette stores per oldVersion-guard.
|
||||
if (oldVersion < 1) {
|
||||
if (!db.objectStoreNames.contains('shared')) {
|
||||
db.createObjectStore('shared');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('projects')) {
|
||||
db.createObjectStore('projects', { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
db.createObjectStore('meta');
|
||||
}
|
||||
}
|
||||
// Senere bump-er legges til som "if (oldVersion < N)"-blokker.
|
||||
};
|
||||
req.onsuccess = function () {
|
||||
const db = req.result;
|
||||
// Defensiv multi-tab: hvis annen tab åpner med høyere versjon,
|
||||
// lukk denne så de ikke blokkerer hverandre.
|
||||
db.onversionchange = function () {
|
||||
db.close();
|
||||
console.warn('[playground v3] IDB versionchange — closed for upgrade');
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
req.onerror = function () { reject(req.error); };
|
||||
req.onblocked = function () {
|
||||
// En annen tab holder en eldre versjon åpen; usannsynlig i v3
|
||||
// (én DB-versjon per release), men logg likevel.
|
||||
console.warn('[playground v3] IDB open blocked — another tab holds older version');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Primær persistens: IDB. Ved feil (Safari private mode, kvote-overflow)
|
||||
// fallback til localStorage. Returnerer adapter med lik API — kallere
|
||||
// trenger ikke vite hvilken backend som er i bruk.
|
||||
async function makePersistence() {
|
||||
const DB_NAME = 'ms-ai-architect-playground-v1';
|
||||
const DB_VERSION = 1;
|
||||
try {
|
||||
const db = await openDB(DB_NAME, DB_VERSION);
|
||||
return {
|
||||
backend: 'idb',
|
||||
load: function () {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const tx = db.transaction(['shared', 'projects', 'meta'], 'readonly');
|
||||
const sharedReq = tx.objectStore('shared').get('shared');
|
||||
const projectsReq = tx.objectStore('projects').getAll();
|
||||
const metaReq = tx.objectStore('meta').get('meta');
|
||||
tx.oncomplete = function () {
|
||||
resolve({
|
||||
schemaVersion: (metaReq.result && metaReq.result.schemaVersion) || SCHEMA_VERSION,
|
||||
shared: sharedReq.result || INITIAL_STATE.shared,
|
||||
projects: projectsReq.result || [],
|
||||
activeProjectId: (metaReq.result && metaReq.result.activeProjectId) || null,
|
||||
activeSurface: (metaReq.result && metaReq.result.activeSurface) || 'home',
|
||||
preferences: (metaReq.result && metaReq.result.preferences) || INITIAL_STATE.preferences
|
||||
});
|
||||
};
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
});
|
||||
},
|
||||
save: function (state) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const tx = db.transaction(['shared', 'projects', 'meta'], 'readwrite');
|
||||
tx.objectStore('shared').put(state.shared, 'shared');
|
||||
const projectStore = tx.objectStore('projects');
|
||||
// Clear-and-rewrite er enkelt og atomær for moderate volum.
|
||||
// Ved >100 prosjekter bør dette switch-es til diff-write.
|
||||
projectStore.clear();
|
||||
for (let i = 0; i < state.projects.length; i++) {
|
||||
projectStore.put(state.projects[i]);
|
||||
}
|
||||
tx.objectStore('meta').put({
|
||||
schemaVersion: state.schemaVersion,
|
||||
activeProjectId: state.activeProjectId,
|
||||
activeSurface: state.activeSurface,
|
||||
preferences: state.preferences
|
||||
}, 'meta');
|
||||
tx.oncomplete = function () { resolve(); };
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
});
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[playground v3] IDB ikke tilgjengelig, faller tilbake til localStorage:', err && err.message);
|
||||
return makeLocalStorageFallback();
|
||||
}
|
||||
}
|
||||
|
||||
function makeLocalStorageFallback() {
|
||||
return {
|
||||
backend: 'localStorage',
|
||||
load: function () {
|
||||
try {
|
||||
const raw = localStorage.getItem(STATE_KEY);
|
||||
if (!raw) return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
|
||||
return Promise.resolve(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
console.error('[playground v3] localStorage parse-feil, returnerer initial state:', err);
|
||||
return Promise.resolve(JSON.parse(JSON.stringify(INITIAL_STATE)));
|
||||
}
|
||||
},
|
||||
save: function (state) {
|
||||
try {
|
||||
const payload = JSON.stringify(state);
|
||||
// Cap-advarsel: localStorage 5 MiB cap. Ved ~4.5 MB warn brukeren.
|
||||
if (payload.length > 4.5 * 1024 * 1024) {
|
||||
console.warn('[playground v3] State nærmer seg localStorage 5 MiB cap. Bruk en moderne nettleser med IDB-støtte.');
|
||||
}
|
||||
localStorage.setItem(STATE_KEY, payload);
|
||||
return Promise.resolve();
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BOOTSTRAP
|
||||
// ============================================================
|
||||
//
|
||||
// Initialiser persistens, last lagret state, opprett store, hook opp
|
||||
// throttled writer. Eksponer __store på window for Verify-asserts og
|
||||
// DevTools — Step 3 utvider med __buildEnvelope, Step 4 med __CATALOG.
|
||||
|
||||
let store = null;
|
||||
let persistence = null;
|
||||
let scheduleWrite = null;
|
||||
|
||||
async function bootstrap() {
|
||||
persistence = await makePersistence();
|
||||
const loaded = await persistence.load();
|
||||
// Sørg for at schemaVersion finnes (cold start kan returnere uten).
|
||||
if (!loaded.schemaVersion) loaded.schemaVersion = SCHEMA_VERSION;
|
||||
store = createStore(loaded, sharedBus);
|
||||
scheduleWrite = makeThrottledWriter(function () {
|
||||
return persistence.save(store.raw);
|
||||
});
|
||||
store.subscribe(function () { scheduleWrite(); });
|
||||
window.__store = store;
|
||||
window.__persistence = persistence;
|
||||
// Step 6+ vil trigger første render her.
|
||||
}
|
||||
|
||||
// Auto-bootstrap. Kjør så snart DOM er parsed; vi er på slutten av <body>
|
||||
// så DOM er allerede klar.
|
||||
bootstrap().catch(function (err) {
|
||||
console.error('[playground v3] bootstrap failed:', err);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue