Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 60 additions & 15 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) })));
}

Expand All @@ -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) };
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -257,19 +302,17 @@ 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() {
try {
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) }));
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion channel-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')
};
Expand All @@ -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,
Expand Down Expand Up @@ -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}.`);
Expand Down Expand Up @@ -625,6 +649,7 @@
listDocuments,
createOrRevise,
updateCatalog,
updateRevisionDerivedData,
setStatus,
setRevisionShare,
revokePublication,
Expand Down
15 changes: 15 additions & 0 deletions tests/channel-store-smoke.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ <h1>Helm Channels store smoke test</h1>
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: '<html>mutated</html>' }); }
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: '<html>conflict</html>' }, { expectedCurrentRevisionId: revisions[0].id }); }
catch (error) { conflict = error.code === 'head-conflict'; }
Expand Down
5 changes: 4 additions & 1 deletion validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;
Expand Down Expand Up @@ -423,5 +426,5 @@
};
}

return Object.freeze({ SCHEMA_VERSION, validate });
return Object.freeze({ SCHEMA_VERSION, VALIDATOR_VERSION, validate });
}));
Loading