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
43 changes: 43 additions & 0 deletions .changeset/console-spec-dist-injection.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/console": patch
---

fix(devx): the vendored Console SPA bundles THIS tree's `@objectstack/spec`, so a newly declared authorable key is reachable in the Studio designer on the day it lands (#8134)

`scripts/build-console.sh` injected only `OBJECTSTACK_CLIENT_DIST`. The console's
`@objectstack/spec` therefore always came from objectui's own lockfile, resolved
under `pnpm install --frozen-lockfile` — which means the **published** spec, never
this workspace's.

That made a whole class of change silently unreachable: an authorable key added to
`packages/spec` after the last spec publish is accepted and round-tripped by the
server, while the Studio designer — bundled against the published spec — rejects it
as an unrecognized key and refuses to auto-save. The framework-side card closes
green the whole time, because `packages/spec`'s own pins pass. Reaching the key took
three ordered cross-repo steps: spec publishes, objectui refreshes its lockfile, the
console pin moves.

The skew was not hypothetical at the time of this change: **102** schema description
strings declared in this tree's `packages/spec` were absent from the
`@objectstack/spec@17.0.0` the pinned objectui lockfile installs.

`build-console.sh` now exports `OBJECTSTACK_SPEC_DIST` alongside the client
injection, mirroring it including its preflight:

- a **hook-presence guard** that refuses the build, naming the pin, when the pinned
objectui predates the `OBJECTSTACK_SPEC_DIST` hook — an unguarded injection would
quietly rebuild the exact silent skew this change exists to end;
- a **build guard** that builds `packages/spec` when it is not built, keyed on both
`dist/index.mjs` and `json-schema/openapi.json`, because the spec's exports map
has one entry (`./openapi.json`) that a different generator produces;
- a **bundle assertion** that proves the injection actually landed.

The assertion is deliberately not a frozen literal like the client's canary. It
derives a witness on every run — a description string this tree's spec has and the
vendored one lacks — and pairs it with a control string both carry, so an absent
witness is told apart from an unbundled entry. A frozen literal would be carried by
the published spec within one release and pass forever while proving nothing, which
is the same silent-pass failure this change removes.

Consumers see no API change; the shipped console simply matches the framework
release it is published with.
226 changes: 226 additions & 0 deletions scripts/assert-console-spec-injection.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
#!/usr/bin/env node
// Assert that OBJECTSTACK_SPEC_DIST actually landed in the built console bundle.
//
// ## Why this is not a frozen literal like the client's BUNDLE_CANARY
//
// build-console.sh asserts the injected *client* with a fixed string
// ('import/jobs'). That works because the question is static: "is the client in
// here new enough to have the import-job API?". The spec question is not static.
// What must be true is "the bundle carries the surface the framework declares
// NOW", and any literal frozen today is carried by the published spec too within
// one release — after which the canary passes forever while proving nothing. A
// self-staling assertion is the exact silent-pass failure objectstack#8134 exists
// to end, so it must not be the fix for it.
//
// So both probes are DERIVED, on every run, from the two specs actually on disk:
//
// injected = this framework tree's packages/spec (what must be bundled)
// vendored = the @objectstack/spec objectui's own lockfile installed
// (what gets bundled when the injection is missing or broken)
//
// ## The test is two-sided, because one side is not enough
//
// Measured while building this check: asserting only "a string unique to the
// injected spec appears in the bundle" PASSES even with no injection at all. The
// console bundle already contains a second, transitive copy of this tree's spec,
// dragged in by the injected @objectstack/client — it lands in a different chunk
// from the console's own `@objectstack/spec` imports. A one-sided probe reads
// that copy and reports success while the designer still runs on the published
// schemas. So:
//
// FRESH WITNESS — text only the injected spec has; must be PRESENT.
// STALE DETECTOR — text only the vendored spec has; must be ABSENT.
//
// The stale detector is the one that actually catches this card's defect: it is
// positive evidence that the published spec is still in the bundle. The fresh
// witness alone cannot distinguish "injection worked" from "some other copy".
//
// ## Substring safety
//
// A probe is only usable if a literal search can tell the two specs apart, so
// each candidate is checked against the ENTIRE other spec's built output, not
// against a string set. Descriptions are routinely REWORDED by appending a
// clause, which makes the old text a prefix of the new one — three of the first
// candidates measured here were exactly that, and a set-difference check called
// them unique when a substring search would have matched both.
//
// ## When the two specs agree
//
// If neither side has text the other lacks, there is nothing to detect and the
// check reports "no skew" and exits 0. That is a real state — the build right
// after a spec publish — not a failure.
//
// Usage:
// node scripts/assert-console-spec-injection.mjs \
// --injected <framework packages/spec> \
// --vendored <objectui build tree node_modules/@objectstack/spec> \
// --assets <built console dist/assets>
//
// Exit: 0 = injection proven (or no skew to prove) · 1 = injection failed
// 2 = inconclusive / cannot run

import fs from 'node:fs';
import path from 'node:path';

/** Export conditions a browser/ESM bundler picks, in preference order.
* `types` is deliberately absent — it sits first in each condition object and
* would resolve every subpath at a `.d.mts` file. */
const IMPORT_CONDITIONS = ['import', 'module', 'browser', 'default'];

function fail(message) {
console.error(`✗ assert-console-spec-injection: ${message}`);
process.exit(2);
}

function parseArgs(argv) {
const out = {};
for (let i = 2; i < argv.length; i += 2) {
const key = argv[i];
if (!key.startsWith('--')) fail(`unexpected argument \`${key}\``);
if (argv[i + 1] === undefined) fail(`\`${key}\` has no value`);
out[key.slice(2)] = argv[i + 1];
}
for (const required of ['injected', 'vendored', 'assets']) {
if (!out[required]) fail(`--${required} is required`);
}
return out;
}

function pickImportTarget(value) {
if (typeof value === 'string') return value;
if (value === null || typeof value !== 'object') return null;
if (Array.isArray(value)) {
for (const candidate of value) {
const hit = pickImportTarget(candidate);
if (hit) return hit;
}
return null;
}
for (const condition of IMPORT_CONDITIONS) {
if (!Object.hasOwn(value, condition)) continue;
const hit = pickImportTarget(value[condition]);
if (hit) return hit;
}
return null;
}

/** Every JS file a package's exports map resolves to, concatenated once. */
function readSpecBlob(packageDir, label) {
const manifestPath = path.join(packageDir, 'package.json');
if (!fs.existsSync(manifestPath)) fail(`${label} spec has no package.json at \`${manifestPath}\``);
let exportsMap;
try {
exportsMap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).exports;
} catch (error) {
fail(`${label} \`${manifestPath}\` is not readable JSON (${error.message})`);
}
if (!exportsMap || typeof exportsMap !== 'object') fail(`${label} spec declares no exports map`);

const chunks = [];
for (const value of Object.values(exportsMap)) {
const target = pickImportTarget(value);
if (!target || !/\.(js|mjs|cjs)$/.test(target)) continue;
const absolute = path.resolve(packageDir, target);
if (!fs.existsSync(absolute)) continue;
chunks.push(fs.readFileSync(absolute, 'utf8'));
}
if (chunks.length === 0) fail(`${label} spec at \`${packageDir}\` has no built JavaScript to compare`);
return chunks.join('\n');
}

/**
* Candidate probe strings: Zod `.describe()` arguments.
*
* They are prose written by spec authors, which makes them stable across a
* bundler (plain string literals, preserved by minification) and specific enough
* that a match is not a coincidence — the property objectstack#8134's own
* measurement relied on, and the reason a bare key name like `object` is
* unusable here (`optionsFrom.object` false-positives).
*/
function describeCandidates(blob) {
const found = new Set();
const pattern = /\.describe\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g;
for (const match of blob.matchAll(pattern)) {
const text = match[2];
// Long enough to be unique, short enough to survive intact, and free of
// escapes and line breaks so a literal search means what it says.
if (text.length < 32 || text.length > 160) continue;
if (/[\\\r\n]/.test(text)) continue;
found.add(text);
}
return [...found].sort();
}

/** First candidate present in `mine` and absent from `theirs`, as raw text. */
function pickProbe(candidates, theirs) {
for (const candidate of candidates) {
if (!theirs.includes(candidate)) return candidate;
}
return null;
}

const args = parseArgs(process.argv);

const assetsDir = path.resolve(args.assets);
if (!fs.existsSync(assetsDir)) fail(`assets dir \`${assetsDir}\` does not exist`);
const assetChunks = [];
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) {
assetChunks.push(fs.readFileSync(path.join(assetsDir, entry.name), 'utf8'));
}
}
if (assetChunks.length === 0) fail(`no JavaScript assets under \`${assetsDir}\``);
const bundle = assetChunks.join('\n');

const injectedBlob = readSpecBlob(path.resolve(args.injected), 'injected');
const vendoredBlob = readSpecBlob(path.resolve(args.vendored), 'vendored');

const freshWitness = pickProbe(describeCandidates(injectedBlob), vendoredBlob);
const staleDetector = pickProbe(describeCandidates(vendoredBlob), injectedBlob);

if (!freshWitness && !staleDetector) {
console.log('✓ Injected and vendored @objectstack/spec declare the same descriptions');
console.log(' — no observable skew, so nothing for this check to assert.');
process.exit(0);
}

const freshPresent = freshWitness ? bundle.includes(freshWitness) : null;
const stalePresent = staleDetector ? bundle.includes(staleDetector) : null;

// Neither probe anywhere in the bundle means the spec is not in this build at
// all — the check cannot speak to an injection it cannot see.
if (freshPresent !== true && stalePresent !== true) {
console.error('✗ Neither spec appears in the built console — no @objectstack/spec');
console.error(' content matched. The injection is UNVERIFIED by this check.');
process.exit(2);
}

if (stalePresent === true) {
console.error("✗ Built console still carries the PUBLISHED @objectstack/spec.");
console.error(' The console resolved spec from objectui\'s lockfile, so any authorable');
console.error(' key this framework declared after the last spec publish is unreachable');
console.error(' in the Studio designer — the defect objectstack#8134 exists to end.');
console.error('');
console.error(' Text found in the bundle that ONLY the vendored spec has:');
console.error(` "${staleDetector}"`);
if (freshPresent === true) {
console.error('');
console.error(' Note: text unique to this tree\'s spec is ALSO in the bundle —');
console.error(' a second, transitive copy (via the injected @objectstack/client).');
console.error(' That copy is not what the designer imports; both must not coexist.');
}
process.exit(1);
}

if (freshPresent !== true) {
console.error('✗ The published spec is gone from the bundle, but nothing unique to');
console.error(" this tree's spec was found either — the build is in an unexpected");
console.error(' state and the injection is UNVERIFIED.');
console.error(` expected: "${freshWitness}"`);
process.exit(2);
}

console.log("✓ Console bundle carries THIS tree's @objectstack/spec, and only it.");
console.log(` present (injected only): "${freshWitness}"`);
if (staleDetector) console.log(` absent (vendored only): "${staleDetector}"`);
process.exit(0);
68 changes: 63 additions & 5 deletions scripts/build-console.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,11 +43,11 @@ fi

REPO_URL="${OBJECTUI_REPO_URL:-https://github.com/objectstack-ai/objectui.git}"
# The console app itself must NOT build through turbo: turbo v2 runs tasks in
# strict env mode and strips undeclared vars, so OBJECTSTACK_CLIENT_DIST
# (exported below) never reaches vite unless the pinned objectui SHA happens
# to declare it in turbo.json. Build the workspace deps through turbo
# (cacheable, env-independent), then invoke the console's own build script
# directly so the env survives.
# strict env mode and strips undeclared vars, so OBJECTSTACK_CLIENT_DIST and
# OBJECTSTACK_SPEC_DIST (both exported below) never reach vite unless the pinned
# objectui SHA happens to declare them in turbo.json. Build the workspace deps
# through turbo (cacheable, env-independent), then invoke the console's own build
# script directly so the env survives.
DEPS_BUILD_CMD="${OBJECTUI_DEPS_BUILD_CMD:-pnpm exec turbo run build --filter=@object-ui/console^...}"
BUILD_CMD="${OBJECTUI_BUILD_CMD:-pnpm --filter @object-ui/console run build}"
# Post-build canary: a literal that only exists in an up-to-date bundled
Expand DownExpand Up@@ -161,6 +161,50 @@ fi
export OBJECTSTACK_CLIENT_DIST="$CLIENT_PKG"
echo "→ Console will bundle @objectstack/client from ${CLIENT_PKG}"

# ── Bundle THIS framework's spec ─────────────────────────────────────
# The same class of skew as the client above, one level quieter. The console SPA
# inlines @objectstack/spec, and left to itself the objectui build resolves it
# from objectui's own lockfile under --frozen-lockfile — the last PUBLISHED spec,
# never this workspace. So an authorable key added to packages/spec after that
# publish is accepted and round-tripped by the server while the Studio designer
# rejects it as an unrecognized key and refuses to auto-save, and the
# framework-side card closes green because packages/spec's own pins all pass.
# Reaching the key took three ordered cross-repo steps: spec publishes, objectui
# refreshes its lockfile, this pin moves. Injecting this tree's spec collapses
# all three (objectstack#8134, hook added in objectui#4854).
#
# objectui honors OBJECTSTACK_SPEC_DIST in apps/console/vite.config.ts; fail hard
# if the pinned SHA predates that hook rather than silently drift — an unguarded
# injection would quietly rebuild the exact silent skew it exists to end.
SPEC_PKG="${FRAMEWORK_ROOT}/packages/spec"
if ! grep -q "OBJECTSTACK_SPEC_DIST" "${BUILD_ROOT}/apps/console/vite.config.ts"; then
echo "✗ objectui@${PINNED_SHA:0:12} has no OBJECTSTACK_SPEC_DIST hook in apps/console/vite.config.ts —"
echo " the bundled spec would come from objectui's lockfile, not this framework, so"
echo " any key this tree declares since the last spec publish would be unreachable"
echo " in the Studio designer."
echo " Bump .objectui-sha to a commit that includes the hook."
exit 1
fi
# The hook resolves EVERY entry of the spec's exports map and refuses any whose
# target is missing, so the package must be built before it is injected. Two
# sentinels, because two different generators produce those targets:
# dist/index.mjs is tsup's, and json-schema/openapi.json is `gen:openapi`'s — the
# one export entry that does not live under dist/, is not committed, and is wiped
# by a later `gen:schema` run. A guard keyed on dist/ alone sails past a tree
# where that happened, and the hook then throws in the middle of the console build.
#
# Unlike the client's guard this deliberately does NOT key on a declaration file:
# the hook resolves the `import` condition only, so a spec whose DTS pass never
# ran (OS_SKIP_DTS, or a DTS crash) is still complete for the injection.
SPEC_ESM="${SPEC_PKG}/dist/index.mjs"
SPEC_OPENAPI="${SPEC_PKG}/json-schema/openapi.json"
if [[ ! -f "$SPEC_ESM" || ! -f "$SPEC_OPENAPI" ]]; then
echo "→ @objectstack/spec dist absent or incomplete — building it and its deps first..."
(cd "$FRAMEWORK_ROOT" && pnpm exec turbo run build --filter=@objectstack/spec)
fi
export OBJECTSTACK_SPEC_DIST="$SPEC_PKG"
echo "→ Console will bundle @objectstack/spec from ${SPEC_PKG}"

pushd "$BUILD_ROOT" > /dev/null

# objectui's root package.json may pin packages that aren't available on
Expand DownExpand Up@@ -203,6 +247,20 @@ if ! grep -rq "$BUNDLE_CANARY" "${TARGET}/assets"; then
fi
echo "✓ Bundle canary '${BUNDLE_CANARY}' present — framework client is in the bundle."

# Assert the injected SPEC landed too. Deliberately NOT a frozen literal like
# BUNDLE_CANARY above: "does the bundle carry the surface the framework declares
# now" is a moving target, and any string pinned here would be carried by the
# published spec within one release — after which it passes forever while proving
# nothing, which is the same silent pass this injection exists to remove. The
# script derives its probes from the two specs on disk on every run, and tests
# BOTH directions: the console bundle also holds a second, transitive copy of
# this tree's spec (pulled in through the injected client above), which makes a
# one-sided "is the new text present" probe pass even with no injection at all.
node "${FRAMEWORK_ROOT}/scripts/assert-console-spec-injection.mjs" \
--injected "$SPEC_PKG" \
--vendored "${BUILD_ROOT}/node_modules/@objectstack/spec" \
--assets "${TARGET}/assets"

BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')"
echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}"

Expand Down
Loading