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
36 changes: 36 additions & 0 deletions .changeset/schema-count-counts-the-definitions-shipped.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/spec": patch
---

fix(spec): the bundled JSON Schema's `x-schema-count` counts the definitions it carries (#12588)

`json-schema/objectstack.json` ships in the tarball (`json-schema` is in the
package's `files`), and `content/docs/deployment/troubleshooting.mdx` publishes
what the field means: "its `x-schema-count` field reports the total number of
definitions". It did not. The generator took the number from `count` — a
counter incremented once per emitted schema — while the bundle's `$defs` is
assembled from a map keyed by `<category>/<Name>`. Every def key written more
than once therefore widened a gap nothing reconciled: the published bundle
declared **1596** definitions while carrying **1585**.

`$defs` is now assembled before the envelope and the field is taken from its
size, so the artifact describes itself. The per-schema files on disk already
agreed with `$defs` (1585) — the same key collapses the file writes — so this
brings the one disagreeing number into line with both of the others, and the
docs sentence is true as written without changing it.

**The collapsed emits are now named rather than implied.** The 11 def keys
written twice are all **benign self-aliases** — `export const X = XSchema`
spelled as `Object.assign(XSchema, …)`, one schema object reached by two export
names, so the second write cannot change what is published. Eight in `api`
(`ApiEndpoint`, `RestApiConfig`, `RestServerConfig`, `ApiDocumentationConfig`,
`ApiTestCollection`, `OpenApiSpec`, `RestApiPluginConfig`,
`RestApiRouteRegistration`) and three in `system` (`MiddlewareConfig`,
`QueueConfig`, `Task`). No schema is being silently dropped: the existing
`findDefKeyCollisions` guard exits the build on any def key claimed by two
*different* schemas, so a build that produces a bundle at all has only exempt
ones — and `gen:schema` now prints that population instead of leaving it
visible only as a subtraction between two summary lines.

No schema content changes; only the bundle's self-description and the
generator's console output.
112 changes: 112 additions & 0 deletions packages/spec/scripts/build-schemas-check-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2185,6 +2185,118 @@ describe('build-schemas.ts — the output clean spares a sibling generator (#537
);
});

// ─────────────────────────────────────────────────────────────────────────────
// #12588 — the bundle's `x-schema-count` counts what the bundle carries.
//
// `objectstack.json` ships in the npm tarball and a docs page publishes the
// field's meaning ("its `x-schema-count` field reports the total number of
// definitions"), so the number is a contract, not a build log. It used to be
// taken from `count` — incremented once per EMIT — while `$defs` is assembled
// from a map keyed by `<category>/<Name>`. Every def key written twice therefore
// widened a gap nothing reconciled: the published bundle declared 1596
// definitions and shipped 1585.
//
// The unit half (scripts/def-key-collisions.test.ts) pins the arithmetic. What
// only this sandbox can assert is that the artifact the generator really writes
// describes itself correctly — the assertions below read the emitted bytes, not
// a helper's return value. `src/` is symlinked into the sandbox, so this runs
// over the REAL spec surface (~1600 schemas), which is also what makes the
// non-vacuity guard below meaningful: this build genuinely collapses emits.
//
// The invariant is pinned, never today's absolute number — 1585 moves with
// every schema anyone adds, and a test that has to be edited by unrelated PRs
// gets edited without being read.
describe('build-schemas.ts — the bundle counts the definitions it carries (#12588)', () => {
const OUT = () => path.join(sandbox, 'json-schema');

beforeEach(() => {
// A current, self-consistent tree, so the run exits 0 and these assertions
// are about the bundle rather than about some ratchet upstream of it.
seedManifest((s) => s);
const tip = seedBase((s) => s);
seedSurface((s) => s);
seedSurfaceBase(tip, (k) => k);
});

it(
'writes x-schema-count equal to its own $defs size, and to the files on disk',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
const { status, output } = run([]);
expect(status).toBe(0);

const bundle = JSON.parse(
fs.readFileSync(path.join(OUT(), 'objectstack.json'), 'utf8'),
) as { 'x-schema-count': number; $defs: Record<string, unknown> };
const defCount = Object.keys(bundle.$defs).length;

// 1. The artifact describes itself.
expect(bundle['x-schema-count']).toBe(defCount);

// 2. A second, independent instrument: one file per def key on disk. The
// per-schema writes collapse the same way the map does, so the tree is
// a witness the bundle cannot fabricate. `openapi.json` belongs to
// gen:openapi and objectstack.json is the bundle itself.
const onDisk = fs
.readdirSync(OUT(), { recursive: true, encoding: 'utf8' })
.filter(
(entry) =>
entry.endsWith('.json') &&
path.basename(entry) !== 'objectstack.json' &&
path.basename(entry) !== 'openapi.json',
);
expect(onDisk).toHaveLength(defCount);

// 3. The generator's own console agrees, so a reader of the build log and
// a reader of the artifact reach the same number.
expect(output).toContain(`objectstack.json (${defCount} definitions)`);
},
);

it(
'accounts for every emit the definition count does not include',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
const { status, output } = run([]);
expect(status).toBe(0);

const bundle = JSON.parse(
fs.readFileSync(path.join(OUT(), 'objectstack.json'), 'utf8'),
) as { 'x-schema-count': number };
const emitted = Number(/Successfully generated (\d+) schemas/.exec(output)?.[1]);
expect(Number.isFinite(emitted), 'summary line must report the emit total').toBe(true);

// Non-vacuity: this build must still collapse emits, or the case proves
// nothing about the defect. If a future PR removes the last self-alias
// from the spec, `x-schema-count: count` and the correct expression stop
// differing and this pin can no longer fail — delete it deliberately
// then, rather than discovering later that it had gone quiet.
expect(
emitted,
'no emit collapses any more — this build no longer models #12588',
).toBeGreaterThan(bundle['x-schema-count']);

// The delta is reported, not left as a subtraction between two lines —
// that silence is what let a wrong number ship unnoticed. The reported
// figure must reconcile the two totals exactly.
const collapsed = Number(/ℹ️\s+(\d+) emit\(s\) collapsed/.exec(output)?.[1]);
expect(Number.isFinite(collapsed), 'the collapsed-emit report line must be printed').toBe(true);
expect(emitted - collapsed).toBe(bundle['x-schema-count']);

// Every collapsed key is named, and named as a self-alias: the guard
// upstream exits on any def key written twice by DIFFERENT schemas, so a
// build that reaches here has only benign ones. Stating it in the report
// is what makes that population readable instead of implied.
expect(output).toContain('all self-aliases');
const named = [...output.matchAll(/ {5}json-schema\/(\S+)\.json {2}<- {2}/g)].map((m) => m[1]);
expect(named.length).toBeGreaterThan(0);
for (const defKey of named) {
expect(fs.existsSync(path.join(OUT(), `${defKey}.json`))).toBe(true);
}
},
);
});

// ─────────────────────────────────────────────────────────────────────────────
// #4659 — check (b) registers a tombstone by its EXACT key, not by its leaf.
//
Expand Down
51 changes: 41 additions & 10 deletions packages/spec/scripts/build-schemas.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,9 @@ import { spawnSync } from 'child_process';
import { z } from 'zod';
import { schemaNameFromExportKey } from './lib/schema-name';
import {
collapsedEmitCount,
findDefKeyCollisions,
findSelfAliasedDefKeys,
formatDefKeyCollisions,
type EmittedDef,
} from './lib/def-key-collisions';
Expand DownExpand Up@@ -475,6 +477,28 @@ if (defKeyCollisions.length > 0) {
process.exit(1);
}

// ─── Report: the writes the guard above exempted (#12588) ─────────────
// Reaching here means every def key written twice is a self-alias, because the
// guard exits on any that is not. Those writes still collapse — `count` is one
// per EMIT while `generatedSchemas` is keyed by def key — so the emit total is
// higher than the number of definitions this build publishes. That difference
// used to be visible only as a subtraction between two summary lines, and it
// leaked into the published bundle as an `x-schema-count` nobody could reconcile
// with the `$defs` beside it. Name the population instead of implying it.
// A report, not a gate: there is no threshold here and no exit path.
const selfAliasedDefKeys = findSelfAliasedDefKeys(emittedDefs);
const collapsedEmits = collapsedEmitCount(selfAliasedDefKeys);
if (collapsedEmits > 0) {
console.log(
`\nℹ️ ${collapsedEmits} emit(s) collapsed into ${selfAliasedDefKeys.length} existing def key(s) ` +
`— all self-aliases (one schema object reached by two export names), so ${count} emits publish ` +
`${generatedSchemas.size} definitions:`,
);
for (const alias of selfAliasedDefKeys) {
console.log(` json-schema/${alias.defKey}.json <- ${alias.exportKeys.join(', ')}`);
}
}

// ─── Ratchet: a published schema must never silently disappear ────────
// json-schema/ is a public contract surface (IDE validation, gen:docs input,
// $id URLs under schema.objectstack.io). The manifest is the committed record
Expand DownExpand Up@@ -2531,24 +2555,31 @@ if (defaultsChanged && !CHECK) {
// ─── Generate Bundled Schema ─────────────────────────────────────────
// Single-file bundled schema containing all generated schemas for IDE autocomplete

// Assemble bundled $defs from the in-memory map populated during generation.
// (Avoid re-reading the json-schema/ tree to dodge CI filesystem races.)
//
// Assembled BEFORE the envelope, so `x-schema-count` below is taken from what
// this bundle actually carries (#12588). It used to be `count`, the per-EMIT
// counter, while `$defs` is keyed by def key — so every self-aliased key
// reported above widened the gap, and the published artifact declared 1596
// definitions while shipping 1585. A self-describing artifact counts what it
// contains; the emit total is a property of the build, not of the file, and is
// still reported on the summary line at the end of this script.
const defs: Record<string, unknown> = {};
for (const [defKey, schema] of generatedSchemas) {
defs[defKey] = schema;
}

const bundledSchema: Record<string, unknown> = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: `${SCHEMA_BASE_URL}/objectstack.json`,
title: 'ObjectStack Protocol',
description: `ObjectStack Protocol v${SPEC_VERSION} — Complete bundled JSON Schema for IDE autocomplete`,
'x-spec-version': SPEC_VERSION,
'x-schema-count': count,
$defs: {} as Record<string, unknown>,
'x-schema-count': Object.keys(defs).length,
$defs: defs,
};

const defs = bundledSchema.$defs as Record<string, unknown>;

// Assemble bundled $defs from the in-memory map populated during generation.
// (Avoid re-reading the json-schema/ tree to dodge CI filesystem races.)
for (const [defKey, schema] of generatedSchemas) {
defs[defKey] = schema;
}

const bundledPath = path.join(OUT_DIR, 'objectstack.json');
writeFileWithRetry(bundledPath, JSON.stringify(bundledSchema, null, 2));
console.log(`\n✅ Generated bundled schema: objectstack.json (${Object.keys(defs).length} definitions)`);
Expand Down
110 changes: 110 additions & 0 deletions packages/spec/scripts/def-key-collisions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,9 @@ import path from 'node:path';
import { SCHEMA_MANIFEST_DIR_NAME } from './lib/sharded-artifacts';

import {
collapsedEmitCount,
findDefKeyCollisions,
findSelfAliasedDefKeys,
formatDefKeyCollisions,
type EmittedDef,
} from './lib/def-key-collisions';
Expand DownExpand Up@@ -110,6 +112,114 @@ describe('findDefKeyCollisions', () => {
});
});

// ─────────────────────────────────────────────────────────────────────────
// #12588 — the exempt population, enumerated.
// ─────────────────────────────────────────────────────────────────────────
//
// `findDefKeyCollisions` is silent about self-aliases because they are not a
// problem. They are, however, the reason a build's emit count exceeds the
// number of definitions it publishes — and that unexplained delta is what put a
// wrong `x-schema-count` into the shipped bundle. These pin the other half.

describe('findSelfAliasedDefKeys — the population the guard exempts (#12588)', () => {
it('reports a self-alias, which is exactly what findDefKeyCollisions stays silent about', () => {
const task = { type: 'object' };
const entries = [emitted('system', 'Task', task), emitted('system', 'TaskSchema', task)];

expect(findSelfAliasedDefKeys(entries)).toEqual([
{ defKey: 'system/Task', exportKeys: ['Task', 'TaskSchema'] },
]);
// The two verdicts are complementary, not overlapping.
expect(findDefKeyCollisions(entries)).toEqual([]);
});

it('is silent about a real collision — that one belongs to the guard, not to a report', () => {
const entries = [
emitted('shared', 'HttpMethod', { enum: ['GET', 'HEAD'] }),
emitted('shared', 'HttpMethodSchema', { enum: ['GET'] }),
];

expect(findSelfAliasedDefKeys(entries)).toEqual([]);
expect(findDefKeyCollisions(entries)).toHaveLength(1);
});

it('ignores a def key written once — one write collapses nothing', () => {
expect(findSelfAliasedDefKeys([emitted('data', 'FieldSchema', {})])).toEqual([]);
});

it('partitions every multiply-written def key between the two functions', () => {
// The invariant that makes "emits - definitions" fully accounted for: a key
// written more than once is either exempt or a collision, never neither and
// never both. Asserted over a mixed build rather than stated in prose.
const alias = {};
const entries = [
emitted('ui', 'ThemeModeSchema', alias),
emitted('ui', 'ThemeMode', alias),
emitted('shared', 'HttpMethod', { a: 1 }),
emitted('shared', 'HttpMethodSchema', { a: 2 }),
emitted('data', 'FieldSchema', {}),
];

const aliases = findSelfAliasedDefKeys(entries).map((a) => a.defKey);
const collisions = findDefKeyCollisions(entries).map((c) => c.defKey);

expect(aliases).toEqual(['ui/ThemeMode']);
expect(collisions).toEqual(['shared/HttpMethod']);
expect(aliases.filter((k) => collisions.includes(k))).toEqual([]);
// Every key with more than one write is claimed by exactly one of them.
const writtenTwice = ['ui/ThemeMode', 'shared/HttpMethod'];
expect([...aliases, ...collisions].sort()).toEqual([...writtenTwice].sort());
});

it('follows first-encounter order, so a build report is stable across runs', () => {
const a = {};
const b = {};
expect(
findSelfAliasedDefKeys([
emitted('system', 'QueueConfig', b),
emitted('api', 'ApiEndpoint', a),
emitted('api', 'ApiEndpointSchema', a),
emitted('system', 'QueueConfigSchema', b),
]).map((x) => x.defKey),
).toEqual(['system/QueueConfig', 'api/ApiEndpoint']);
});
});

describe('collapsedEmitCount — the emits a self-aliased key absorbs (#12588)', () => {
it('counts N-1 per key: the first write is the definition, the rest collapse onto it', () => {
expect(
collapsedEmitCount([
{ defKey: 'api/ApiEndpoint', exportKeys: ['ApiEndpoint', 'ApiEndpointSchema'] },
{ defKey: 'system/Task', exportKeys: ['Task', 'TaskSchema'] },
]),
).toBe(2);
});

it('counts a triple alias as two collapsed emits, not one', () => {
expect(
collapsedEmitCount([{ defKey: 'api/Thing', exportKeys: ['Thing', 'ThingSchema', 'ThingZod'] }]),
).toBe(2);
});

it('is zero on a build where nothing collapsed', () => {
expect(collapsedEmitCount([])).toBe(0);
});

it('reconciles the two totals: emits - collapsed = definitions', () => {
// The arithmetic the published `x-schema-count` got wrong, in miniature.
const alias = {};
const entries = [
emitted('api', 'ApiEndpoint', alias),
emitted('api', 'ApiEndpointSchema', alias),
emitted('data', 'FieldSchema', {}),
emitted('data', 'ObjectSchema', {}),
];
const definitions = new Set(entries.map((e) => `${e.category}/${e.schemaName}`)).size;

expect(entries.length - collapsedEmitCount(findSelfAliasedDefKeys(entries))).toBe(definitions);
});
});

describe('formatDefKeyCollisions', () => {
it('names the file that would be written, both export keys, and the source-side remedies', () => {
const message = formatDefKeyCollisions([
Expand Down
Loading
Loading