From e55c3ddff23fdaa234d0bced092cd9a687b8a81b Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 5 Apr 2026 09:59:41 -0500 Subject: [PATCH 1/3] feat: idb infrastructure. --- docs/build-and-deploy.md | 1 + docs/dual-build-gh-pages-strategy.md | 93 +++++++ src/bootstrap.js | 1 + src/modules/cdn.js | 6 + src/modules/preview-entry-resolver.js | 36 +++ src/modules/preview-workspace-graph.js | 90 +++++++ src/modules/render-runtime.js | 11 +- src/modules/workspace-storage.js | 326 +++++++++++++++++++++++++ 8 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 docs/dual-build-gh-pages-strategy.md create mode 100644 src/modules/preview-entry-resolver.js create mode 100644 src/modules/preview-workspace-graph.js create mode 100644 src/modules/workspace-storage.js diff --git a/docs/build-and-deploy.md b/docs/build-and-deploy.md index 8910a0f..1399a9f 100644 --- a/docs/build-and-deploy.md +++ b/docs/build-and-deploy.md @@ -114,6 +114,7 @@ This command forces `KNIGHTED_PRIMARY_CDN=esm` and runs `npm run build` first, t Related docs: - `docs/code-mirror.md` for CodeMirror CDN integration rules, fallback behavior, and validation checklist. +- `docs/dual-build-gh-pages-strategy.md` for the clean two-URL stable and next deployment model during UI migration. - `src/modules/cdn.js` is the source of truth for CDN-managed runtime libraries (including fallback candidates). Add/update CDN specs there instead of hardcoding module URLs inside feature modules. diff --git a/docs/dual-build-gh-pages-strategy.md b/docs/dual-build-gh-pages-strategy.md new file mode 100644 index 0000000..bb8ce4d --- /dev/null +++ b/docs/dual-build-gh-pages-strategy.md @@ -0,0 +1,93 @@ +# Dual Build GitHub Pages Strategy + +## Purpose + +Document a clean migration strategy for delivering both stable and overhaul UI versions from one repository without adding runtime feature flags to application code. + +## Core Idea + +Build two versions of the site during deployment and publish both under one GitHub Pages branch. + +- Stable site at root path: /index.html +- Overhaul site at next path: /next/index.html + +The URL path acts as the switch. + +- Stable: /develop/ +- Overhaul: /develop/next/ + +## Why This Approach + +1. Keeps runtime code clean. +2. Avoids pervasive if version checks in app modules. +3. Allows side-by-side validation of stable and next UX. +4. Reduces long-term cleanup work versus in-app toggles. + +## Deployment Layout + +Publish a combined artifact to the GitHub Pages branch with this shape: + +- /index.html and root assets from stable branch build +- /next/index.html and next assets from overhaul branch build + +## CI Workflow Design + +A deployment workflow builds both branches in one run and publishes one artifact. + +1. Checkout stable branch into an isolated worktree directory. +2. Install dependencies and build stable output. +3. Copy stable output into publish root. +4. Checkout overhaul branch into a second isolated worktree directory. +5. Install dependencies and build overhaul output. +6. Copy overhaul output into publish root under /next. +7. Deploy combined publish folder to GitHub Pages. + +## Operational Guidance + +1. Run both builds in isolated directories to prevent cross-branch contamination. +2. Keep Node and npm versions pinned consistently in CI. +3. Use workflow concurrency to cancel outdated deploy jobs. +4. Use relative asset URLs so content works under both root and /next paths. +5. Fail the deploy if either build fails. + +## Source Control Model + +- main branch represents stable production UX. +- overhaul branch represents next-generation UX. +- Deploy workflow may trigger on pushes to either branch, but each run should still build both branches for a consistent dual-output artifact. + +## Relationship To App Architecture Work + +This strategy complements the multi-tab and local workspace migration by separating rollout concerns from runtime logic. + +- Runtime implementation remains modular and focused on architecture. +- Deployment controls the exposure of stable versus next. + +## Tradeoffs + +Pros: + +1. Cleaner codebase during migration. +2. Lower risk of runtime toggle regressions. +3. Clear QA and stakeholder review URLs. + +Cons: + +1. Longer deploy times due to dual builds. +2. More CI configuration complexity. +3. Temporary branch coordination requirements. + +## Exit Plan + +After next UI is production-ready: + +1. Promote next code into main. +2. Remove dual-build deployment logic. +3. Publish only root output again. +4. Remove migration-only docs and branch conventions. + +## Suggested Follow-up + +1. Add a deploy workflow implementation doc with exact GitHub Actions YAML and permissions. +2. Add a release checklist for validating both URLs before each deploy. +3. Add ownership notes for stable and next branch review responsibilities. diff --git a/src/bootstrap.js b/src/bootstrap.js index 0f7e3ae..221e644 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -13,6 +13,7 @@ const preloadImportKeys = [ 'jsxReact', 'react', 'reactDomClient', + 'idb', ] const isImportMapPrimary = diff --git a/src/modules/cdn.js b/src/modules/cdn.js index 0815fc0..2bdd8bf 100644 --- a/src/modules/cdn.js +++ b/src/modules/cdn.js @@ -74,6 +74,12 @@ export const cdnImportSpecs = { esm: 'react-dom@19.2.4/client', jspmGa: 'npm:react-dom@19.2.4/client.js', }, + idb: { + importMap: 'idb', + esm: 'idb@8.0.3', + unpkg: 'idb@8.0.3/build/index.js?module', + jspmGa: 'npm:idb@8.0.3/build/index.js', + }, sass: { importMap: 'sass', esm: [ diff --git a/src/modules/preview-entry-resolver.js b/src/modules/preview-entry-resolver.js new file mode 100644 index 0000000..ad04a72 --- /dev/null +++ b/src/modules/preview-entry-resolver.js @@ -0,0 +1,36 @@ +const previewEntryNamePattern = /(?:^|\/)(?:app|main)\.[jt]sx?$/i + +const normalizeTabIdentity = tab => { + if (!tab || typeof tab !== 'object') { + return '' + } + + if (typeof tab.path === 'string' && tab.path.trim().length > 0) { + return tab.path.trim() + } + + if (typeof tab.name === 'string' && tab.name.trim().length > 0) { + return tab.name.trim() + } + + return '' +} + +export const isPreviewEntryTab = tab => + previewEntryNamePattern.test(normalizeTabIdentity(tab)) + +export const resolvePreviewEntryTab = tabs => { + if (!Array.isArray(tabs) || tabs.length === 0) { + return null + } + + return tabs.find(isPreviewEntryTab) ?? null +} + +export const canRenderPreview = ({ tabs, fallbackSource = '' } = {}) => { + if (Array.isArray(tabs) && tabs.length > 0) { + return Boolean(resolvePreviewEntryTab(tabs)) + } + + return typeof fallbackSource === 'string' && fallbackSource.trim().length > 0 +} diff --git a/src/modules/preview-workspace-graph.js b/src/modules/preview-workspace-graph.js new file mode 100644 index 0000000..45f8a02 --- /dev/null +++ b/src/modules/preview-workspace-graph.js @@ -0,0 +1,90 @@ +const normalizeImportSpecifier = value => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null + +const normalizeGraphEntry = entry => { + if (!entry || typeof entry !== 'object') { + return null + } + + if (typeof entry.tabId !== 'string' || entry.tabId.length === 0) { + return null + } + + const imports = Array.isArray(entry.imports) + ? entry.imports.map(normalizeImportSpecifier).filter(Boolean) + : [] + + return { + tabId: entry.tabId, + contentHash: typeof entry.contentHash === 'string' ? entry.contentHash : '', + imports, + lastUpdated: + typeof entry.lastUpdated === 'number' && Number.isFinite(entry.lastUpdated) + ? entry.lastUpdated + : Date.now(), + } +} + +export const createPreviewWorkspaceGraphCache = () => { + const byTabId = new Map() + + const upsert = entry => { + const normalized = normalizeGraphEntry(entry) + + if (!normalized) { + throw new TypeError('Graph entry is invalid.') + } + + byTabId.set(normalized.tabId, normalized) + return normalized + } + + const get = tabId => { + if (typeof tabId !== 'string' || tabId.length === 0) { + return null + } + + return byTabId.get(tabId) ?? null + } + + const getDependents = targetImportSpecifier => { + const normalizedSpecifier = normalizeImportSpecifier(targetImportSpecifier) + + if (!normalizedSpecifier) { + return [] + } + + const dependents = [] + + for (const entry of byTabId.values()) { + if (entry.imports.includes(normalizedSpecifier)) { + dependents.push(entry) + } + } + + return dependents + } + + const remove = tabId => { + if (typeof tabId !== 'string' || tabId.length === 0) { + return false + } + + return byTabId.delete(tabId) + } + + const clear = () => { + byTabId.clear() + } + + const list = () => [...byTabId.values()] + + return { + upsert, + get, + getDependents, + remove, + clear, + list, + } +} diff --git a/src/modules/render-runtime.js b/src/modules/render-runtime.js index db27dc8..e241fd6 100644 --- a/src/modules/render-runtime.js +++ b/src/modules/render-runtime.js @@ -3,6 +3,7 @@ import { getFunctionLikeDeclarationNames, hasFunctionLikeDeclarationNamed, } from './jsx-top-level-declarations.js' +import { canRenderPreview } from './preview-entry-resolver.js' import { ensureJsxTransformSource } from './jsx-transform-runtime.js' export const createRenderRuntimeController = ({ @@ -14,6 +15,7 @@ export const createRenderRuntimeController = ({ isAutoRenderEnabled = () => false, getCssSource, getJsxSource, + getWorkspaceTabs, getPreviewHost, setPreviewHost, applyPreviewBackgroundColor, @@ -866,7 +868,14 @@ export const createRenderRuntimeController = ({ } } - const hasComponentSource = () => getJsxSource().trim().length > 0 + const hasComponentSource = () => { + const tabs = typeof getWorkspaceTabs === 'function' ? getWorkspaceTabs() : undefined + + return canRenderPreview({ + tabs, + fallbackSource: getJsxSource(), + }) + } const clearPreview = () => { const target = getRenderTarget() diff --git a/src/modules/workspace-storage.js b/src/modules/workspace-storage.js new file mode 100644 index 0000000..6b67eb6 --- /dev/null +++ b/src/modules/workspace-storage.js @@ -0,0 +1,326 @@ +import { cdnImports, importFromCdnWithFallback } from './cdn.js' + +const workspaceDbName = 'knighted-develop-workspaces' +const workspaceDbVersion = 1 +const workspaceStoreName = 'prWorkspaces' + +const normalizeTabRecord = tab => { + if (!tab || typeof tab !== 'object') { + return null + } + + const tabId = + typeof tab.id === 'string' && tab.id.length > 0 + ? tab.id + : typeof tab.path === 'string' && tab.path.length > 0 + ? tab.path + : null + + if (!tabId) { + return null + } + + return { + id: tabId, + name: typeof tab.name === 'string' ? tab.name : tabId, + path: typeof tab.path === 'string' ? tab.path : '', + language: typeof tab.language === 'string' ? tab.language : 'plaintext', + isActive: Boolean(tab.isActive), + scroll: Number.isFinite(tab.scroll) ? Math.max(0, tab.scroll) : 0, + content: typeof tab.content === 'string' ? tab.content : '', + lastModified: Number.isFinite(tab.lastModified) ? tab.lastModified : Date.now(), + } +} + +const normalizeWorkspaceRecord = record => { + if (!record || typeof record !== 'object') { + throw new TypeError('Workspace record must be an object.') + } + + if (typeof record.id !== 'string' || record.id.length === 0) { + throw new TypeError('Workspace record id must be a non-empty string.') + } + + const normalizedTabs = Array.isArray(record.tabs) + ? record.tabs.map(normalizeTabRecord).filter(Boolean) + : [] + + return { + id: record.id, + repo: typeof record.repo === 'string' ? record.repo : '', + base: typeof record.base === 'string' ? record.base : '', + head: typeof record.head === 'string' ? record.head : '', + prNumber: + typeof record.prNumber === 'number' && Number.isFinite(record.prNumber) + ? record.prNumber + : null, + prTitle: typeof record.prTitle === 'string' ? record.prTitle : '', + tabs: normalizedTabs, + activeTabId: typeof record.activeTabId === 'string' ? record.activeTabId : null, + schemaVersion: + typeof record.schemaVersion === 'number' && Number.isFinite(record.schemaVersion) + ? record.schemaVersion + : workspaceDbVersion, + lastModified: + typeof record.lastModified === 'number' && Number.isFinite(record.lastModified) + ? record.lastModified + : Date.now(), + createdAt: + typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) + ? record.createdAt + : Date.now(), + } +} + +const loadIdbRuntime = async () => { + const loaded = await importFromCdnWithFallback(cdnImports.idb) + const { openDB } = loaded.module ?? {} + + if (typeof openDB !== 'function') { + throw new Error('idb module did not expose openDB().') + } + + return { openDB } +} + +const openWorkspaceDb = async ({ loadRuntime } = {}) => { + const runtime = loadRuntime ?? loadIdbRuntime + const { openDB } = await runtime() + + return openDB(workspaceDbName, workspaceDbVersion, { + upgrade(db) { + if (!db.objectStoreNames.contains(workspaceStoreName)) { + const store = db.createObjectStore(workspaceStoreName, { + keyPath: 'id', + }) + store.createIndex('byRepo', 'repo') + store.createIndex('byLastModified', 'lastModified') + } + }, + }) +} + +const byLastModifiedDesc = (a, b) => b.lastModified - a.lastModified + +const withLastModifiedNow = record => ({ + ...record, + lastModified: Date.now(), +}) + +export const createWorkspaceStorageAdapter = ({ loadRuntime } = {}) => { + let dbPromise = null + + const ensureDb = () => { + if (!dbPromise) { + dbPromise = openWorkspaceDb({ loadRuntime }) + } + + return dbPromise + } + + const getWorkspaceById = async id => { + if (typeof id !== 'string' || id.length === 0) { + return null + } + + const db = await ensureDb() + const record = await db.get(workspaceStoreName, id) + + if (!record) { + return null + } + + return normalizeWorkspaceRecord(record) + } + + const listWorkspaces = async ({ repo } = {}) => { + const db = await ensureDb() + const items = await db.getAll(workspaceStoreName) + + const normalized = items.map(normalizeWorkspaceRecord) + const filtered = + typeof repo === 'string' && repo.length > 0 + ? normalized.filter(item => item.repo === repo) + : normalized + + return filtered.sort(byLastModifiedDesc) + } + + const upsertWorkspace = async record => { + const normalized = withLastModifiedNow(normalizeWorkspaceRecord(record)) + const db = await ensureDb() + + await db.put(workspaceStoreName, normalized) + + return normalized + } + + const upsertTabContent = async ({ + workspaceId, + tabId, + content, + scroll, + isActive, + name, + path, + language, + }) => { + if (typeof workspaceId !== 'string' || workspaceId.length === 0) { + throw new TypeError('workspaceId must be a non-empty string.') + } + + if (typeof tabId !== 'string' || tabId.length === 0) { + throw new TypeError('tabId must be a non-empty string.') + } + + const workspace = (await getWorkspaceById(workspaceId)) ?? { + id: workspaceId, + tabs: [], + schemaVersion: workspaceDbVersion, + createdAt: Date.now(), + lastModified: Date.now(), + } + + const previousTabs = Array.isArray(workspace.tabs) ? workspace.tabs : [] + const nextTabs = [...previousTabs] + const existingIndex = nextTabs.findIndex(tab => tab.id === tabId) + const previous = existingIndex >= 0 ? nextTabs[existingIndex] : null + + const next = normalizeTabRecord({ + ...previous, + id: tabId, + name, + path, + language, + content, + scroll, + isActive, + lastModified: Date.now(), + }) + + if (!next) { + throw new Error('Unable to persist tab content because tab record is invalid.') + } + + if (existingIndex >= 0) { + nextTabs[existingIndex] = next + } else { + nextTabs.push(next) + } + + const shouldSwitchActive = typeof isActive === 'boolean' ? isActive : false + const nextActiveTabId = shouldSwitchActive + ? tabId + : typeof workspace.activeTabId === 'string' + ? workspace.activeTabId + : null + + return upsertWorkspace({ + ...workspace, + tabs: nextTabs, + activeTabId: nextActiveTabId, + }) + } + + const removeWorkspace = async id => { + if (typeof id !== 'string' || id.length === 0) { + return false + } + + const db = await ensureDb() + await db.delete(workspaceStoreName, id) + + return true + } + + const close = async () => { + if (!dbPromise) { + return + } + + const db = await dbPromise + db.close() + dbPromise = null + } + + return { + getWorkspaceById, + listWorkspaces, + upsertWorkspace, + upsertTabContent, + removeWorkspace, + close, + } +} + +export const createDebouncedWorkspaceSaver = ({ + save, + waitMs = 800, + now = () => Date.now(), + schedule = setTimeout, + cancel = clearTimeout, +} = {}) => { + if (typeof save !== 'function') { + throw new TypeError('save must be a function.') + } + + let timer = null + let pendingPayload = null + let inFlight = Promise.resolve() + let lastScheduledAt = 0 + + const flush = async () => { + if (!pendingPayload) { + return + } + + const payload = pendingPayload + pendingPayload = null + + inFlight = inFlight.then(() => save(payload)) + await inFlight + } + + const queue = payload => { + pendingPayload = payload + lastScheduledAt = now() + + if (timer) { + cancel(timer) + } + + timer = schedule(async () => { + timer = null + await flush() + }, waitMs) + } + + const flushNow = async payload => { + if (payload !== undefined) { + pendingPayload = payload + } + + if (timer) { + cancel(timer) + timer = null + } + + await flush() + } + + const dispose = () => { + if (timer) { + cancel(timer) + timer = null + } + + pendingPayload = null + } + + return { + queue, + flushNow, + dispose, + getLastScheduledAt: () => lastScheduledAt, + } +} From 74e24516ba3337c193ef9a6b27024c0dce8faeb8 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 5 Apr 2026 10:04:56 -0500 Subject: [PATCH 2/3] ci: run on pr to next. --- .github/workflows/ci.yml | 1 + .github/workflows/playwright.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95c34ee..078ecbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - next jobs: ci: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 3e4da99..9f4247e 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - main + - next types: - opened - synchronize From 18a370aa3b91c956c51eca1ff08a395608d74b9c Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 5 Apr 2026 10:17:53 -0500 Subject: [PATCH 3/3] refactor: address comments. --- src/modules/workspace-storage.js | 102 ++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 22 deletions(-) diff --git a/src/modules/workspace-storage.js b/src/modules/workspace-storage.js index 6b67eb6..0243f7d 100644 --- a/src/modules/workspace-storage.js +++ b/src/modules/workspace-storage.js @@ -3,6 +3,8 @@ import { cdnImports, importFromCdnWithFallback } from './cdn.js' const workspaceDbName = 'knighted-develop-workspaces' const workspaceDbVersion = 1 const workspaceStoreName = 'prWorkspaces' +const workspaceByRepoIndexName = 'byRepo' +const workspaceByLastModifiedIndexName = 'byLastModified' const normalizeTabRecord = tab => { if (!tab || typeof tab !== 'object') { @@ -93,8 +95,8 @@ const openWorkspaceDb = async ({ loadRuntime } = {}) => { const store = db.createObjectStore(workspaceStoreName, { keyPath: 'id', }) - store.createIndex('byRepo', 'repo') - store.createIndex('byLastModified', 'lastModified') + store.createIndex(workspaceByRepoIndexName, 'repo') + store.createIndex(workspaceByLastModifiedIndexName, 'lastModified') } }, }) @@ -112,7 +114,10 @@ export const createWorkspaceStorageAdapter = ({ loadRuntime } = {}) => { const ensureDb = () => { if (!dbPromise) { - dbPromise = openWorkspaceDb({ loadRuntime }) + dbPromise = openWorkspaceDb({ loadRuntime }).catch(error => { + dbPromise = null + throw error + }) } return dbPromise @@ -135,15 +140,24 @@ export const createWorkspaceStorageAdapter = ({ loadRuntime } = {}) => { const listWorkspaces = async ({ repo } = {}) => { const db = await ensureDb() - const items = await db.getAll(workspaceStoreName) + const hasRepoFilter = typeof repo === 'string' && repo.length > 0 - const normalized = items.map(normalizeWorkspaceRecord) - const filtered = - typeof repo === 'string' && repo.length > 0 - ? normalized.filter(item => item.repo === repo) - : normalized + if (hasRepoFilter) { + const byRepo = await db.getAllFromIndex( + workspaceStoreName, + workspaceByRepoIndexName, + repo, + ) - return filtered.sort(byLastModifiedDesc) + return byRepo.map(normalizeWorkspaceRecord).sort(byLastModifiedDesc) + } + + const byLastModified = await db.getAllFromIndex( + workspaceStoreName, + workspaceByLastModifiedIndexName, + ) + + return byLastModified.map(normalizeWorkspaceRecord).reverse() } const upsertWorkspace = async record => { @@ -186,17 +200,37 @@ export const createWorkspaceStorageAdapter = ({ loadRuntime } = {}) => { const existingIndex = nextTabs.findIndex(tab => tab.id === tabId) const previous = existingIndex >= 0 ? nextTabs[existingIndex] : null - const next = normalizeTabRecord({ - ...previous, + const nextTabRecord = { + ...(previous ?? {}), id: tabId, - name, - path, - language, - content, - scroll, - isActive, lastModified: Date.now(), - }) + } + + if (name !== undefined) { + nextTabRecord.name = name + } + + if (path !== undefined) { + nextTabRecord.path = path + } + + if (language !== undefined) { + nextTabRecord.language = language + } + + if (content !== undefined) { + nextTabRecord.content = content + } + + if (scroll !== undefined) { + nextTabRecord.scroll = scroll + } + + if (isActive !== undefined) { + nextTabRecord.isActive = isActive + } + + const next = normalizeTabRecord(nextTabRecord) if (!next) { throw new Error('Unable to persist tab content because tab record is invalid.') @@ -255,6 +289,7 @@ export const createWorkspaceStorageAdapter = ({ loadRuntime } = {}) => { export const createDebouncedWorkspaceSaver = ({ save, + onError, waitMs = 800, now = () => Date.now(), schedule = setTimeout, @@ -269,6 +304,18 @@ export const createDebouncedWorkspaceSaver = ({ let inFlight = Promise.resolve() let lastScheduledAt = 0 + const reportSaveError = ({ error, payload }) => { + if (typeof onError !== 'function') { + return + } + + try { + onError(error, payload) + } catch { + /* Swallow reporter failures so saving can continue. */ + } + } + const flush = async () => { if (!pendingPayload) { return @@ -277,8 +324,15 @@ export const createDebouncedWorkspaceSaver = ({ const payload = pendingPayload pendingPayload = null - inFlight = inFlight.then(() => save(payload)) - await inFlight + const continueChain = inFlight.catch(() => undefined) + inFlight = continueChain.then(() => save(payload)) + + try { + await inFlight + } catch (error) { + reportSaveError({ error, payload }) + throw error + } } const queue = payload => { @@ -291,7 +345,11 @@ export const createDebouncedWorkspaceSaver = ({ timer = schedule(async () => { timer = null - await flush() + try { + await flush() + } catch { + /* Background flush errors are surfaced through onError when provided. */ + } }, waitMs) }