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
5 changes: 4 additions & 1 deletion astro.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export default defineConfig({
// context package that setup:assets deliberately left missing after a 404 from R2, instead of failing the
// whole build over one object. CI and local builds never set this flag and stay strict.
const allowMissing = process.env.CSSEARTH_ALLOW_MISSING_ASSETS === '1';
const { availability, failures } = await prepareContextAvailability({ strict: command === 'build' && !allowMissing });
// An asset-origin build deliberately leaves public/scenes absent. Its tracked manifest is the local,
// content-addressed contract for previews already published to R2; all prepared package bytes stay strict.
const { availability, failures } = await prepareContextAvailability({ strict: command === 'build' && !allowMissing,
publicAssets: assetOrigin() ? 'manifest' : 'local' });
updateConfig({ vite: { define: { __CSSEARTH_CONTEXT_AVAILABILITY__: JSON.stringify(availability) } } });
if (failures.length) logger.warn(`Some 3D views are unavailable in this installation:\n${failures.join('\n')}\nPrepare their packages and restart the server to enable them.`);
},
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/context-package.mts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export async function writeContextPackage(root: string, id: string) {
const files: [string, string | Uint8Array][] = [
[`${directory}/object.json`, JSON.stringify(descriptor)], [`${directory}/prepared/lenses.json`, bankBytes],
[`${directory}/prepared/provenance.json`, JSON.stringify(provenance)], [`${directory}/prepared/presentation.json`, JSON.stringify(presentation)],
[`${directory}/runtime-assets.json`, JSON.stringify({ schema: `css${id}-runtime-assets@1`, resourceRoot: 'prepared',
assets: [{ filename: 'preview.webp', location: 'public', bytes: image.length, sha256: digest }] })],
[`${directory}/source/presentation.json`, JSON.stringify({ schema: 'cssearth-volume-presentation-source@1', objectId: id,
name: `${id} fixture`, defaultLens: 'optical', lenses: [{ id: 'optical' }] })],
[`${directory}/prepared/slice.webp`, image], [`public${preview}`, image],
];
for (const [path, bytes] of files) { const file = resolve(root, path); await mkdir(dirname(file), { recursive: true }); await writeFile(file, bytes); }
Expand Down
23 changes: 19 additions & 4 deletions tools/prepare-context-availability.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import type { ContextAvailability } from '../src/platform/context-availability.m
import { parsePreparedVolumePresentation } from '../site/volume-presentation.mts';
import { readContextObjects } from './prepare-catalog.mts';
import { hasErrorCode } from './source-values.mts';
import { requireRuntimeAssetManifest } from '../src/platform/runtime-asset-closure.mts';

const root = resolve(import.meta.dirname, '..');
type PublicAssetAvailability = 'local' | 'manifest';

/** Verify complete volume packages once before serving; no source processing or downloads. */
export async function inspectContextAvailability(projectRoot = root): Promise<ContextAvailability> {
export async function inspectContextAvailability(projectRoot = root, { publicAssets = 'local' }: {
publicAssets?: PublicAssetAvailability;
} = {}): Promise<ContextAvailability> {
const contexts = await readContextObjects(resolve(projectRoot, 'src/objects'));
const entries = await Promise.all(contexts.filter(object => object.type === 'volume-lens-bank').map(async ({ id }) => {
const directory = resolve(projectRoot, 'src/objects', id);
Expand Down Expand Up @@ -50,11 +54,20 @@ export async function inspectContextAvailability(projectRoot = root): Promise<Co
const bankPin = outputs.find(output => output.url === bankUrl);
if (!bankPin || bankPin.sha256 !== descriptor.prepared!.sha256) throw new TypeError(`Unbound prepared bank: ${bankUrl}.`);
await verify(projectRoot, bankUrl, bankPin);
const published = publicAssets === 'manifest'
? requireRuntimeAssetManifest(id, JSON.parse((await read(directory, 'runtime-assets.json')).toString()))
: null;
for (const lens of presentation.controls) for (const url of new Set([lens.thumbnailUrl, lens.texture?.url])) {
if (!url?.startsWith(`/scenes/${id}/`)) throw new TypeError(`Invalid dataset preview URL: ${url}.`);
const pin = outputs.find(output => output.url === url);
if (!pin) throw new TypeError(`Unpinned dataset preview: ${url}.`);
await verify(resolve(projectRoot, 'public'), url.slice(1), pin);
if (published) {
const filename = url.slice(`/scenes/${id}/`.length);
const asset = published.assets.find(candidate => candidate.filename === filename &&
(published.resourceRoot !== 'prepared' || candidate.location === 'public'));
if (!asset || asset.sha256 !== pin.sha256 || asset.bytes !== pin.bytes)
throw new TypeError(`Unpublished dataset preview: ${url}.`);
} else await verify(resolve(projectRoot, 'public'), url.slice(1), pin);
}
return [id, { available: true }] as const;
} catch (error) {
Expand All @@ -64,8 +77,10 @@ export async function inspectContextAvailability(projectRoot = root): Promise<Co
return Object.fromEntries(entries);
}

export async function prepareContextAvailability({ projectRoot = root, strict = false } = {}) {
const availability = await inspectContextAvailability(projectRoot);
export async function prepareContextAvailability({ projectRoot = root, strict = false, publicAssets = 'local' }: {
projectRoot?: string; strict?: boolean; publicAssets?: PublicAssetAvailability;
} = {}) {
const availability = await inspectContextAvailability(projectRoot, { publicAssets });
const failures = Object.entries(availability).flatMap(([id, state]) => state.available ? [] : [`${id}: ${state.reason}`]);
if (strict && failures.length) throw new Error(`Prepared context packages unavailable:\n${failures.join('\n')}`);
return { availability, failures };
Expand Down
26 changes: 26 additions & 0 deletions tools/prepare-context-availability.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { inspectContextAvailability, prepareContextAvailability } from './prepare-context-availability.mts';
import { parseContextAvailability } from '../src/platform/context-availability.mts';
import { writeContextPackage } from '../tests/fixtures/context-package.mts';
import { readPreparedVolumeProvenance } from './prepare-volume-provenance.mts';

test('a missing bank isolates one object; restoring it admits the complete package on the next startup', async t => {
const root = await mkdtemp(resolve(tmpdir(), 'cssearth-availability-')); t.after(() => rm(root, { recursive: true, force: true }));
Expand Down Expand Up @@ -53,3 +54,28 @@
assert.throws(() => parseContextAvailability({ helix: { available: 'true' } }));
assert.throws(() => parseContextAvailability({ helix: { available: false } }));
});

test('an asset-origin build verifies a missing local preview against its published manifest', async t => {
const root = await mkdtemp(resolve(tmpdir(), 'cssearth-availability-')); t.after(() => rm(root, { recursive: true, force: true }));
const f = await writeContextPackage(root, 'helix');
await rm(resolve(root, 'public/scenes/helix/preview.webp'));
assert.equal((await inspectContextAvailability(root)).helix.available, false);
assert.deepEqual(await inspectContextAvailability(root, { publicAssets: 'manifest' }), { helix: { available: true } });
const manifestPath = resolve(f.directory, 'runtime-assets.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
manifest.assets[0].sha256 = '0'.repeat(64);
await writeFile(manifestPath, JSON.stringify(manifest));
assert.match((await inspectContextAvailability(root, { publicAssets: 'manifest' })).helix.reason ?? '', /Unpublished dataset preview/);

Check failure on line 68 in tools/prepare-context-availability.test.mts

View workflow job for this annotation

GitHub Actions / Typecheck test files

Property 'reason' does not exist on type '{ available: true; } | { available: false; reason: string; }'.
});

test('deploy catalogue input reads the installed prepared volume instead of regenerating it', async t => {
const root = await mkdtemp(resolve(tmpdir(), 'cssearth-prepared-volume-')); t.after(() => rm(root, { recursive: true, force: true }));
const fixture = await writeContextPackage(root, 'helix');
const volumes = await readPreparedVolumeProvenance({ root });
assert.equal(volumes.length, 1);
assert.equal(volumes[0]?.id, 'helix');
assert.equal(volumes[0]?.name, 'helix fixture');
assert.deepEqual(volumes[0]?.controls.map(control => control.id), ['optical']);
assert.deepEqual(volumes[0]?.provenance, fixture.provenance);
assert.deepEqual(volumes[0]?.outputs, []);
});
8 changes: 6 additions & 2 deletions tools/prepare-facilities.mts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type { ProvenanceDocument } from '../src/platform/object-provenance.mts';
import { writePreparedSet } from './write-prepared-set.mts';
import { restoreFactsheetEvidence } from './restore-factsheet-evidence.mts';
import type { FactsheetSourceTransport } from './restore-factsheet-evidence.mts';
import { prepareVolumeProvenance, volumeProvenanceCompilerClosure } from './prepare-volume-provenance.mts';
import { prepareVolumeProvenance, readPreparedVolumeProvenance, volumeProvenanceCompilerClosure } from './prepare-volume-provenance.mts';
import { RUNTIME_ASSET_ORIGIN } from './source-mirror.mts';
export const explorationCompilerClosure = [
'tools/prepare-facilities.mts', 'tools/spatial-source-citations.mts', 'packages/catalog/src/spatial.ts', 'packages/catalog/src/spatial-relations.ts', 'packages/catalog/src/clusters.ts', 'src/platform/exploration-catalog.mts', 'src/platform/exploration-contributions.mts',
Expand Down Expand Up @@ -139,7 +139,11 @@ export async function prepareFacilities({ root = resolve(import.meta.dirname, '.
inventory.push(...sourceInventory(manifest, `${base}/source/manifest.json`, sources, new Set(document.sources.map(source => source.path))));
objects.push({ id: object.id, name: object.name, route: object.route, base, controls: lenses, provenance: document });
}
const volumes = [...await prepareVolumeProvenance({ root, input, mirrorOrigin }), ...await prepareContextProvenance({ root, input })];
// Deploys consume the exact prepared package restored from R2. Authoring preparation still rebuilds provenance
// and previews from their sources, but catalog-only publication must never invent a second package identity.
const volumes = publish === 'catalogues'
? await readPreparedVolumeProvenance({ root, input })
: [...await prepareVolumeProvenance({ root, input, mirrorOrigin }), ...await prepareContextProvenance({ root, input })];
for (const volume of volumes) {
const document = validateObjectProvenance(volume.provenance, volume.id);
const manifestPath = `${sourcePath(volume.base)}/${sourcePath(document.manifest.path)}`;
Expand Down
43 changes: 43 additions & 0 deletions tools/prepare-volume-provenance.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { basename, dirname, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';
import { parseObjectDescriptor } from '@cssearth/objects';
import type { Lens } from '../site/planet-shell-types.ts';
import { validateDatasetText } from '../site/dataset-content.mts';
import { parsePreparedVolumePresentation } from '../site/volume-presentation.mts';
import { parseCapture } from '../src/platform/exploration-catalog.mts';
import { validateObjectProvenance } from '../src/platform/object-provenance.mts';
import type { ProvenanceDocument, ProvenanceSource, ProvenanceJson } from '../src/platform/object-provenance.mts';
Expand Down Expand Up @@ -135,6 +137,47 @@ async function hostedDatasets(root: string, base: string, objectId: string, lens
for (const lensId of lensIds) if (!datasets[lensId]) throw new TypeError(`No dataset of ${hostId} shows ${objectId}/${lensId}.`);
return { objectId: hostId, name: sourceText(content.displayName), route: `/${hostId}/`, datasets };
}

/** Read the prepared package that setup:assets installed. Deploy catalogue compilation must bind to these
* R2-backed bytes; rebuilding provenance from authoring inputs can describe a different package. */
export async function readPreparedVolumeProvenance({ root = process.cwd(), input = path => readFile(resolve(root, path)) }: {
root?: string; input?: (path: string) => Promise<Buffer>;
} = {}): Promise<PreparedVolumeProvenance[]> {
const results: PreparedVolumeProvenance[] = [];
const folders = await readdir(resolve(root, 'src/objects'), { withFileTypes: true });
for (const folder of folders.filter(folder => folder.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
const id = folder.name, base = `src/objects/${id}`, sourcePresentationPath = `${base}/source/presentation.json`;
const exists = await readFile(resolve(root, sourcePresentationPath)).then(() => true, (error: unknown) => {
if (hasErrorCode(error, 'ENOENT')) return false;
throw error;
});
if (!exists) continue;
const sourcePresentation = sourceObject(json(await input(sourcePresentationPath)));
if (sourcePresentation.schema === 'cssearth-volume-presentation-source@1') {
if (sourcePresentation.objectId !== id) throw new TypeError(`Mismatched volume presentation object: ${id}.`);
const descriptor = parseObjectDescriptor(json(await input(`${base}/object.json`)));
if (descriptor.id !== id || !descriptor.prepared || !['volume-lens-bank', 'image-layer-bank'].includes(descriptor.type))
throw new TypeError(`Invalid prepared volume descriptor: ${id}.`);
const provenance = validateObjectProvenance(json(await input(`${base}/prepared/provenance.json`)), id);
const defaultLens = sourceId(sourcePresentation.defaultLens);
const lensIds = sourceArray(sourcePresentation.lenses, raw => sourceId(sourceObject(raw).id));
const prepared = parsePreparedVolumePresentation(json(await input(`${base}/prepared/presentation.json`)),
{ id, defaultLens, lenses: lensIds.map(lensId => ({ id: lensId })) }, provenance);
const bankUrl = `${base}/${descriptor.prepared!.url}`;
const bankPin = provenance.products.flatMap(product => product.outputs).find(output => output.url === bankUrl);
if (!bankPin || bankPin.sha256 !== descriptor.prepared!.sha256) throw new TypeError(`Unbound prepared bank: ${bankUrl}.`);
const hostedBy = await hostedDatasets(root, base, id, prepared.controls.map(control => control.id), input);
results.push({ id, name: sourceText(sourcePresentation.name), route: hostedBy?.route ?? `/sun/?focus=${id}`, base,
controls: prepared.controls, defaultLens: prepared.defaultLens, provenance, outputs: [], ...(hostedBy ? { hostedBy } : {}) });
continue;
}
if (sourcePresentation.provenance === undefined) continue;
const preparedPresentation = sourceObject(json(await input(`${base}/prepared/presentation.json`)));
const provenance = validateObjectProvenance(json(await input(`${base}/prepared/provenance.json`)), id);
results.push({ id, name: sourceText(preparedPresentation.name), route: '/sun/', base, controls: [], defaultLens: '', provenance, outputs: [] });
}
return results;
}
interface Options {
root?: string;
/** Repository-relative, tracked compiler inputs only. Downloads never enter source closure. */
Expand Down
Loading