diff --git a/packages/core/package.json b/packages/core/package.json index c6a3518..c5a6255 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,7 +43,7 @@ }, "idfkit": { "conformance": "conformance-2026.8", - "governance": "governance-2026.11" + "governance": "governance-2026.12" }, "dependencies": { "@idfkit/schemas": "0.0.0" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60bb660..e40a097 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,7 +59,7 @@ export { Severity, validateDocument, validateObject } from './validate/index.js' export type { ValidationError, ValidationResult } from './validate/index.js'; export { describeObjectType } from './introspect/describe.js'; -export type { FieldDescription, ObjectDescription, ProsePool } from './introspect/describe.js'; +export type { FieldDescription, ObjectDescription } from './introspect/describe.js'; export { docsUrlForObject, @@ -70,4 +70,4 @@ export { export type { DocsUrl } from './docs-url/index.js'; export { Schema, SchemaBundle, httpSource } from '@idfkit/schemas'; -export type { BundleSource, SchemaDelta, SlimField, SlimType } from '@idfkit/schemas'; +export type { BundleSource, ProsePool, SchemaDelta, SlimField, SlimType } from '@idfkit/schemas'; diff --git a/packages/core/src/introspect/describe.ts b/packages/core/src/introspect/describe.ts index 43f97a9..8c44c02 100644 --- a/packages/core/src/introspect/describe.ts +++ b/packages/core/src/introspect/describe.ts @@ -1,4 +1,4 @@ -import type { Schema, SlimField, SlimType } from '@idfkit/schemas'; +import type { ProsePool, Schema, SlimField, SlimType } from '@idfkit/schemas'; /** * Description of a single field in an EnergyPlus object type. @@ -105,18 +105,6 @@ export interface ObjectDescription { * Python raises `UnknownObjectTypeError`, which has no registered TypeScript * counterpart and so must not become a new exported class. */ -/** - * The schema's explanatory prose, loaded on demand. - * - * A plain array of strings, indexed by `SlimType.m` and `SlimField.n`. It is - * passed in rather than reached for, and that is deliberate: reading it is - * asynchronous, `describeObjectType` is synchronous, and making the function - * async to fetch a file most callers do not want would be a breaking change - * serving the minority. Loading it is the caller's step, and its cost is - * visible at the call site instead of hidden inside a description. - */ -export type ProsePool = readonly string[]; - export function describeObjectType( schema: Schema, objType: string, diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index dc56da3..9b8910e 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -1,5 +1,5 @@ import { BlobStore, Schema } from './schema.js'; -import type { BundleIndex, Manifest, SlimType } from './types.js'; +import type { BundleIndex, Manifest, ProsePool, SlimType } from './types.js'; export { BlobStore, Schema } from './schema.js'; export type { SchemaDelta } from './schema.js'; @@ -7,6 +7,7 @@ export type { BundleIndex, FieldKind, Manifest, + ProsePool, SlimExtensible, SlimField, SlimType, @@ -82,6 +83,9 @@ export class SchemaBundle { #schemas = new Map(); /** In-flight loads, so concurrent callers share one request. */ #pending = new Map>(); + #prose: ProsePool | undefined; + /** In-flight prose read, on the same terms as `#pending`. */ + #proseInFlight: Promise | undefined; constructor(source: BundleSource) { this.#source = source; @@ -122,6 +126,56 @@ export class SchemaBundle { return this.#schemas.get(version); } + /** + * Load the explanatory prose this bundle's manifests index into. + * + * One pool serves every version: `build.mjs` deduplicates the memos and notes + * of all seventeen schemas into a single array and the manifests carry indices + * into it, so there is no version to pass here. + * + * It is a separate call rather than part of `load` because the two are wanted + * at different moments by different callers. Parsing a model needs the records + * and never needs a sentence of English; describing a type for a reader needs + * both. Keeping the fetch separate is what lets the parse path stay unaware the + * pool exists, which `check-bundle-purity.mjs` asserts by building a minimal + * read-and-write graph and failing on any input under `data/`. + * + * Repeat calls return the same array; concurrent calls share one read. + */ + async loadProse(): Promise { + if (this.#prose !== undefined) return this.#prose; + + const inFlight = this.#proseInFlight; + if (inFlight !== undefined) return inFlight; + + const promise = this.#loadProse().finally(() => { + this.#proseInFlight = undefined; + }); + this.#proseInFlight = promise; + return promise; + } + + /** + * The prose pool if it has been loaded, or undefined. Synchronous by design. + * + * The counterpart of `loaded`, and it exists for the same reason. Everything + * that reads prose is synchronous: `describeObjectType` takes a pool rather + * than fetching one, and every answer in `@idfkit/language` is a pure function + * so an editor server can answer a cursor without holding a thread. A consumer + * calls `loadProse` once when a document arrives and reads this on the request + * path. Without the pair, a caller would either await inside a path it keeps + * synchronous or build a second cache beside the one this class already has. + */ + prose(): ProsePool | undefined { + return this.#prose; + } + + async #loadProse(): Promise { + const pool = (await this.#source.read('docs.json')) as ProsePool; + this.#prose = pool; + return pool; + } + async #load(version: string): Promise { const index = await this.#loadIndex(); const fileName = index.manifests[version]; diff --git a/packages/schemas/src/types.ts b/packages/schemas/src/types.ts index 4258e5c..9cd7dc1 100644 --- a/packages/schemas/src/types.ts +++ b/packages/schemas/src/types.ts @@ -160,3 +160,16 @@ export interface BundleIndex { /** Per-version manifest file names, keyed by version string. */ manifests: Record; } + +/** + * The schema's explanatory prose, deduplicated across every bundled version. + * + * A plain array of strings, indexed by `SlimType.m` and `SlimField.n`. It lives + * here rather than beside the code that reads it because THE INDICES ARE ONLY + * MEANINGFUL AGAINST THE MANIFESTS BUILT IN THE SAME RUN: `build.mjs` writes + * `docs.json` and the manifests together, and a pool paired with manifests from + * a different build resolves every sentence to the wrong one, silently. Keeping + * the type and the loader on `SchemaBundle` is what makes that pairing hard to + * get wrong. + */ +export type ProsePool = readonly string[]; diff --git a/packages/schemas/tests/bundle.test.ts b/packages/schemas/tests/bundle.test.ts index a549dbb..d4bed77 100644 --- a/packages/schemas/tests/bundle.test.ts +++ b/packages/schemas/tests/bundle.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { httpSource } from '@idfkit/schemas'; +import { httpSource, SchemaBundle } from '@idfkit/schemas'; import { localBundle, nodeSource } from '@idfkit/schemas/node'; const bundle = localBundle(); @@ -331,3 +331,66 @@ describe('field order and accepted values in the bundle', () => { expect(flagged.every((f) => !(f.e ?? []).includes(''))).toBe(true); }); }); + +/** + * Reaching the pool through the bundle that indexes into it. + * + * The pool is only meaningful against manifests built in the same run, because + * `build.mjs` writes `docs.json` and the manifests together and the indices point + * into that exact array. Putting the loader on `SchemaBundle` is what keeps the + * two together: a consumer cannot pair this pool with somebody else's manifests + * without going out of its way. + */ +describe('SchemaBundle prose', () => { + it('loads the pool and then serves it synchronously', async () => { + const own = localBundle(); + + // Nothing before the load, and no throw for asking. + expect(own.prose()).toBeUndefined(); + + const loaded = await own.loadProse(); + expect(Array.isArray(loaded)).toBe(true); + expect(loaded.length).toBeGreaterThan(4000); + + // The synchronous accessor is the point: every reader of prose is sync, so a + // consumer loads once when a document arrives and reads on the request path. + expect(own.prose()).toBe(loaded); + }); + + it('returns the same array on repeat calls and shares one read', async () => { + const own = localBundle(); + + // Concurrent callers must not each fetch. Both promises resolve to one array. + const [a, b] = await Promise.all([own.loadProse(), own.loadProse()]); + expect(a).toBe(b); + expect(await own.loadProse()).toBe(a); + }); + + it('reads docs.json and nothing else', async () => { + const read = vi.fn(async (name: string) => nodeSource().read(name)); + const own = new SchemaBundle({ read }); + + await own.loadProse(); + + // Not index.json, not types.json, not a manifest: the pool is one file and + // loading it must not drag the parse path's inputs in behind it. + expect(read.mock.calls.map(([name]) => name)).toEqual(['docs.json']); + }); + + it('indexes what the manifests point at', async () => { + const own = localBundle(); + const schema = await own.load('26.1.0'); + const pool = await own.loadProse(); + + // A pool that is present but wrongly indexed produces prose that looks + // plausible and belongs to another field, so this checks the join rather + // than the array. + const type = schema.require('BuildingSurface:Detailed'); + expect(type.m).toBeDefined(); + expect(pool[type.m!]).toContain('heat transfer surfaces'); + + const field = schema.field('BuildingSurface:Detailed', 'construction_name'); + expect(field?.n).toBeDefined(); + expect(typeof pool[field!.n!]).toBe('string'); + }); +});