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
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
},
"idfkit": {
"conformance": "conformance-2026.8",
"governance": "governance-2026.11"
"governance": "governance-2026.12"
},
"dependencies": {
"@idfkit/schemas": "0.0.0"
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
14 changes: 1 addition & 13 deletions packages/core/src/introspect/describe.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 55 additions & 1 deletion packages/schemas/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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';
export type {
BundleIndex,
FieldKind,
Manifest,
ProsePool,
SlimExtensible,
SlimField,
SlimType,
Expand Down Expand Up @@ -82,6 +83,9 @@ export class SchemaBundle {
#schemas = new Map<string, Schema>();
/** In-flight loads, so concurrent callers share one request. */
#pending = new Map<string, Promise<Schema>>();
#prose: ProsePool | undefined;
/** In-flight prose read, on the same terms as `#pending`. */
#proseInFlight: Promise<ProsePool> | undefined;

constructor(source: BundleSource) {
this.#source = source;
Expand Down Expand Up @@ -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<ProsePool> {
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<ProsePool> {
const pool = (await this.#source.read('docs.json')) as ProsePool;
this.#prose = pool;
return pool;
}

async #load(version: string): Promise<Schema> {
const index = await this.#loadIndex();
const fileName = index.manifests[version];
Expand Down
13 changes: 13 additions & 0 deletions packages/schemas/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,16 @@ export interface BundleIndex {
/** Per-version manifest file names, keyed by version string. */
manifests: Record<string, string>;
}

/**
* 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[];
65 changes: 64 additions & 1 deletion packages/schemas/tests/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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');
});
});
Loading