From eaf777ca0ee871423589af9fdb392bc2a5835d3b Mon Sep 17 00:00:00 2001 From: waple0820 Date: Tue, 14 Jul 2026 16:59:30 +0800 Subject: [PATCH] fix: refresh retained artifact health safely --- app.js | 75 +++++++++++++++++++++++++++------- channel-store.js | 27 +++++++++++- tests/channel-store-smoke.html | 15 +++++++ validator.js | 5 ++- 4 files changed, 105 insertions(+), 17 deletions(-) diff --git a/app.js b/app.js index 3bb7817..748e2a8 100644 --- a/app.js +++ b/app.js @@ -6,7 +6,12 @@ const MAX_IMPORT_BYTES = 5 * 1024 * 1024; const MAX_SEARCH_TEXT = 250000; const AGENT_BRIDGE_URL = 'http://127.0.0.1:4175'; const CHANNEL_API_URL = '/api/channels'; -const BUILTIN_CONTENT_VERSION = 2; +const BUILTIN_CONTENT_VERSION = 3; +const MANAGED_WELCOME_REVISION_IDS = new Set([ + // Official v1 Welcome shipped by Helm. User-edited copies have a different + // content-addressed Revision ID and are never replaced by this migration. + 'sha256:b64dad9e6fe8ed89a2738621bbecf5c2c1686460b6b49a2a789c1abce51303af' +]); const channelRepository = globalThis.HelmChannelStore?.defaultRepository; const templates = [ @@ -91,9 +96,9 @@ function knownProjects() { }); } -async function getAll() { +async function getAll(options = {}) { if (!channelRepository) throw new Error('Helm Channels repository is unavailable.'); - const records = await channelRepository.listDocuments(); + const records = await channelRepository.listDocuments(options); return Promise.all(records.filter(Boolean).map(async (record) => ({ ...record, revisions: await channelRepository.listRevisions(record.id) }))); } @@ -113,12 +118,12 @@ async function loadRequiredLineageSources() { } } -async function saveDocument(artifact) { +async function saveDocument(artifact, options = {}) { const existing = await channelRepository.getArtifact(artifact.id); const result = await channelRepository.createOrRevise(artifact, { artifactId: artifact.id, expectedCurrentRevisionId: existing?.currentRevisionId, - updateCatalog: Boolean(existing) + updateCatalog: Boolean(existing) && options.updateCatalog !== false }); return { ...result.document, revisions: await channelRepository.listRevisions(artifact.id) }; } @@ -219,6 +224,46 @@ function inspectHtml(html) { return { valid: false, score: 0, manifest, extractedText: safeText(content?.textContent).replace(/\s+/g, ' '), issues: [{ severity: 'warning', code: 'validator-unavailable', message: 'Contract inspection was unavailable in this browser.' }] }; } +function derivedRevisionData(html, identity = {}) { + const inspection = inspectHtml(html); + const validatorVersion = Number(globalThis.HelmValidator?.VALIDATOR_VERSION); + const issues = Array.isArray(inspection.issues) ? [...inspection.issues] : []; + const sourceDocumentId = safeText(identity.sourceDocumentId, safeText(inspection.manifest?.id)); + if (identity.identityState === 'catalog-copy' && sourceDocumentId && !issues.some((issue) => issue.code === 'catalog-copy-identity')) { + issues.unshift({ severity: 'warning', code: 'catalog-copy-identity', message: `Source manifest ID “${sourceDocumentId}” already exists here. This is an explicit catalog copy with library ID “${safeText(identity.id)}”; its original HTML was not renamed.` }); + } + return { + contentText: safeText(inspection.extractedText).slice(0, MAX_SEARCH_TEXT), + validation: { + valid: Boolean(inspection.valid), + hasManifest: Boolean(inspection.manifest), + score: Number.isFinite(inspection.score) ? inspection.score : 0, + issues + }, + ...(Number.isInteger(validatorVersion) && validatorVersion > 0 ? { derivedVersion: validatorVersion } : {}) + }; +} + +function isManagedWelcome(artifact) { + return artifact?.id === 'welcome-to-helm' && MANAGED_WELCOME_REVISION_IDS.has(artifact.currentRevisionId || `sha256:${artifact.contentHash}`); +} + +async function refreshStoredValidation(records) { + const validatorVersion = Number(globalThis.HelmValidator?.VALIDATOR_VERSION); + if (typeof globalThis.HelmValidator?.validate !== 'function' || !Number.isInteger(validatorVersion) || validatorVersion < 1) { + console.error('Helm validator is unavailable; stored Revision health was left unchanged.'); + return getAll(); + } + for (const artifact of records) { + for (const revision of artifact.revisions || []) { + if (revision.derivedVersion === validatorVersion) continue; + const derived = derivedRevisionData(revision.html, artifact); + await channelRepository.updateRevisionDerivedData(artifact.id, revision.id, derived); + } + } + return getAll(); +} + function preferredId(value, title) { return typeof value === 'string' && /^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$/.test(value) ? value : slug(title); } @@ -257,11 +302,8 @@ function enrichArtifact(record, options = {}) { const project = normaliseProject(input.project || manifest.project, inferredProject); const core = new Set(['id', 'title', 'type', 'tags', 'summary', 'source', 'project', 'createdAt', 'updatedAt', 'html', 'contentText', 'validation', 'sourceDocumentId', 'identityState']); const extensions = Object.fromEntries(Object.entries(input).filter(([key]) => !core.has(key))); - const issues = Array.isArray(inspection.issues) ? [...inspection.issues] : []; - if (identityState === 'catalog-copy' && !issues.some((issue) => issue.code === 'catalog-copy-identity')) { - issues.unshift({ severity: 'warning', code: 'catalog-copy-identity', message: `Source manifest ID “${sourceDocumentId}” already exists here. This is an explicit catalog copy with library ID “${id}”; its original HTML was not renamed.` }); - } - return { ...extensions, id, sourceDocumentId: sourceDocumentId || null, identityState, title, type, tags, summary, source, project, createdAt, updatedAt, html: input.html, contentText: safeText(inspection.extractedText).slice(0, MAX_SEARCH_TEXT), validation: { valid: Boolean(inspection.valid), hasManifest: Boolean(inspection.manifest), score: Number.isFinite(inspection.score) ? inspection.score : 0, issues } }; + const derived = derivedRevisionData(input.html, { id, sourceDocumentId, identityState }); + return { ...extensions, id, sourceDocumentId: sourceDocumentId || null, identityState, title, type, tags, summary, source, project, createdAt, updatedAt, html: input.html, ...derived }; } async function initialise() { @@ -269,7 +311,8 @@ async function initialise() { if (!channelRepository) throw new Error('Helm Channels repository is unavailable.'); await channelRepository.open(); setAppearance(await getSetting('appearanceMode')); - const stored = await getAll(); + let stored = await getAll({ includeArchived: true }); + stored = await refreshStoredValidation(stored); const initialized = await getSetting('libraryInitialized'); if (!initialized && !stored.length) { const seeded = seedDocuments.map((artifact) => ({ ...artifact, html: seedHtml(artifact) })); @@ -279,10 +322,10 @@ async function initialise() { } const builtinVersion = Number(await getSetting('builtinContentVersion') || 0); const welcome = documents.find((artifact) => artifact.id === 'welcome-to-helm'); - if (builtinVersion < BUILTIN_CONTENT_VERSION && welcome?.source === 'Helm' && welcome.html.includes('Template visual · replace before handoff')) { + if (builtinVersion < BUILTIN_CONTENT_VERSION && isManagedWelcome(welcome)) { const definition = seedDocuments.find((artifact) => artifact.id === 'welcome-to-helm'); const upgraded = enrichArtifact({ ...definition, html: seedHtml(definition) }, { preserveId: true, takenIds: new Set(documents.map((artifact) => artifact.id)) }); - const saved = await saveDocument(upgraded); + const saved = await saveDocument(upgraded, { updateCatalog: false }); documents = documents.map((artifact) => artifact.id === saved.id ? saved : artifact); } await setSetting('builtinContentVersion', BUILTIN_CONTENT_VERSION); @@ -892,11 +935,13 @@ async function restoreImportedArtifact(artifact) { let expectedCurrentRevisionId; for (const revision of history) { const parent = revision.parent && (await channelRepository.getRevision(revision.parent.artifactId, revision.parent.revisionId)) ? revision.parent : undefined; + const derived = typeof globalThis.HelmValidator?.validate === 'function' + ? derivedRevisionData(revision.html, artifact) + : { contentText: revision.contentText || '', validation: revision.validation || null, ...(revision.derivedVersion ? { derivedVersion: revision.derivedVersion } : {}) }; const result = await channelRepository.createOrRevise({ ...catalog, html: revision.html, - contentText: revision.contentText || '', - validation: revision.validation || null, + ...derived, authoredAt: revision.authoredAt, author: revision.author, updatedAt: revision.authoredAt || revision.createdAt || artifact.updatedAt diff --git a/channel-store.js b/channel-store.js index ff219d9..bcabfa9 100644 --- a/channel-store.js +++ b/channel-store.js @@ -25,7 +25,7 @@ ]); const REVISION_INPUT_FIELDS = new Set([ ...ARTIFACT_FIELDS, 'html', 'contentText', 'validation', 'share', 'revisionId', - 'contentHash', 'parent', 'authoredAt', 'author' + 'contentHash', 'parent', 'authoredAt', 'author', 'derivedVersion' ]); class ChannelStoreError extends Error { @@ -183,6 +183,7 @@ html: input.html, contentText: typeof input.contentText === 'string' ? input.contentText : '', validation: input.validation === undefined ? null : cloneJson(input.validation, 'validation'), + derivedVersion: Number.isInteger(input.derivedVersion) && input.derivedVersion > 0 ? input.derivedVersion : null, sourceManifestId: safeString(input.sourceDocumentId) || null, share: options.share === undefined || options.share === null ? null : cloneJson(options.share, 'share') }; @@ -195,6 +196,7 @@ html: revision.html, contentText: revision.contentText || '', validation: revision.validation, + derivedVersion: revision.derivedVersion || null, share: revision.share, revisionId: revision.id, contentHash: revision.contentHash, @@ -485,6 +487,28 @@ return getDocument(id); } + async function updateRevisionDerivedData(artifactId, revisionId, patch) { + await open(); + if (!isPlainObject(patch)) throw new TypeError('Revision derived-data patch must be an object.'); + const allowed = new Set(['contentText', 'validation', 'derivedVersion']); + const forbidden = Object.keys(patch).filter((key) => !allowed.has(key)); + if (forbidden.length) throw new ChannelStoreError('invalid-revision-derived-patch', `Revision derived fields cannot update: ${forbidden.join(', ')}.`); + const db = await database(); + const tx = db.transaction(REVISION_STORE, 'readwrite'); + const store = tx.objectStore(REVISION_STORE); + const revision = await requestResult(store.get([artifactId, revisionId])); + if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${artifactId}/${revisionId} does not exist.`); } + const updated = { + ...revision, + ...(patch.contentText !== undefined ? { contentText: typeof patch.contentText === 'string' ? patch.contentText : '' } : {}), + ...(patch.validation !== undefined ? { validation: patch.validation === null ? null : cloneJson(patch.validation, 'validation') } : {}), + ...(patch.derivedVersion !== undefined ? { derivedVersion: Number.isInteger(patch.derivedVersion) && patch.derivedVersion > 0 ? patch.derivedVersion : null } : {}) + }; + store.put(updated); + await transactionDone(tx); + return updated; + } + async function setStatus(id, status, options = {}) { await open(); if (!STATUSES.has(status)) throw new TypeError(`Unknown artifact status: ${status}.`); @@ -625,6 +649,7 @@ listDocuments, createOrRevise, updateCatalog, + updateRevisionDerivedData, setStatus, setRevisionShare, revokePublication, diff --git a/tests/channel-store-smoke.html b/tests/channel-store-smoke.html index b115132..17a31f2 100644 --- a/tests/channel-store-smoke.html +++ b/tests/channel-store-smoke.html @@ -72,6 +72,21 @@

Helm Channels store smoke test

revisions = await store.listRevisions('artifact-one'); assert(revisions.length === 2 && revisions[0].html === originalHtml, 'catalog update creates no revision'); + const immutableBeforeRefresh = { html: revisions[0].html, contentHash: revisions[0].contentHash, parent: revisions[0].parent, share: revisions[0].share }; + const artifactBeforeRefresh = await store.getArtifact('artifact-one'); + const derived = await store.updateRevisionDerivedData('artifact-one', revisions[0].id, { contentText: 'refreshed search text', validation: { valid: false, score: 96, issues: [{ code: 'new-check', severity: 'warning' }] }, derivedVersion: 2 }); + assert(derived.html === immutableBeforeRefresh.html && derived.contentHash === immutableBeforeRefresh.contentHash && JSON.stringify(derived.parent) === JSON.stringify(immutableBeforeRefresh.parent) && JSON.stringify(derived.share) === JSON.stringify(immutableBeforeRefresh.share) && derived.contentText === 'refreshed search text' && derived.validation.score === 96 && derived.derivedVersion === 2, 'derived validation refresh preserves immutable Revision identity, lineage, and share'); + const artifactAfterRefresh = await store.getArtifact('artifact-one'); + assert(artifactAfterRefresh.status === artifactBeforeRefresh.status && artifactAfterRefresh.currentRevisionId === artifactBeforeRefresh.currentRevisionId && artifactAfterRefresh.publishedRevisionId === artifactBeforeRefresh.publishedRevisionId, 'derived validation refresh cannot move Channel workflow pointers'); + const childBeforeRefresh = await store.getRevision('artifact-one', second.revision.id); + const childDerived = await store.updateRevisionDerivedData('artifact-one', second.revision.id, { contentText: 'refreshed child text', validation: { valid: true, score: 100 }, derivedVersion: 2 }); + assert(childDerived.parent.artifactId === childBeforeRefresh.parent.artifactId && childDerived.parent.revisionId === childBeforeRefresh.parent.revisionId, 'derived validation refresh preserves non-empty Revision lineage'); + assert((await store.listRevisions('artifact-one')).length === 2, 'derived validation refresh creates no revision'); + let rejectedDerivedPatch = false; + try { await store.updateRevisionDerivedData('artifact-one', revisions[0].id, { html: 'mutated' }); } + catch (error) { rejectedDerivedPatch = error.code === 'invalid-revision-derived-patch'; } + assert(rejectedDerivedPatch && (await store.getRevision('artifact-one', revisions[0].id)).html === originalHtml, 'derived validation API rejects immutable revision fields'); + let conflict = false; try { await store.createOrRevise({ ...legacy, html: 'conflict' }, { expectedCurrentRevisionId: revisions[0].id }); } catch (error) { conflict = error.code === 'head-conflict'; } diff --git a/validator.js b/validator.js index 0638761..ee33183 100644 --- a/validator.js +++ b/validator.js @@ -13,6 +13,9 @@ 'use strict'; const SCHEMA_VERSION = 'HDOC/1.0'; + // Increment when validation semantics change so stored revisions can refresh + // their derived health report without changing immutable HTML bytes. + const VALIDATOR_VERSION = 2; const DOCUMENT_TYPES = new Set(['report', 'brief', 'reference', 'dashboard', 'note']); const UTC_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; @@ -423,5 +426,5 @@ }; } - return Object.freeze({ SCHEMA_VERSION, validate }); + return Object.freeze({ SCHEMA_VERSION, VALIDATOR_VERSION, validate }); }));