From 483dad8049ee375374aee816acc274eb1d4187c7 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 3 May 2026 17:57:48 +0200 Subject: [PATCH] feat(ms-ai-architect): playground v3 state module (Proxy + EventTarget + IDB persistence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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 --- .../playground/ms-ai-architect-v3.html | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html b/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html index 43b203c..c4ba165 100644 --- a/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html +++ b/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html @@ -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 + // så DOM er allerede klar. + bootstrap().catch(function (err) { + console.error('[playground v3] bootstrap failed:', err); + }); })();