From 9b06e078b2b3ac82a91a7a9c7df1bb0e74bfe0bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 11:59:23 +0000 Subject: [PATCH] feat(metadata,objectql): a leaf `/view-container` subpath keeps objectql's lean ADR-0076 closure free of the manager, chokidar, glob and js-yaml (#14680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `engine.ts` reached `deriveViewContainerObject` through `@objectstack/metadata`'s root entry, so `@objectstack/objectql/core`'s module-init closure inherited `MetadataPlugin` -> `NodeMetadataManager` -> `chokidar`, plus `glob`, `js-yaml` and `readdirp` — for a six-line pure function. The same file already imports `isMissingTableError` from the leaf `/errors` subpath, whose comment states that the subpath exists precisely to avoid that; this is that pattern, taken again. Measured on the built artifacts: 190 -> 185 modules and -498,616 B for the ESM lean entry, 188 -> 183 and -513,204 B for CJS. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../metadata-view-container-leaf-subpath.md | 59 +++++++++++++ packages/metadata/package.json | 10 +++ .../metadata/src/view-container-expansion.ts | 31 +++---- packages/metadata/src/view-container.ts | 83 +++++++++++++++++++ packages/metadata/tsup.config.ts | 5 ++ packages/objectql/src/engine.ts | 8 +- 6 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 .changeset/metadata-view-container-leaf-subpath.md create mode 100644 packages/metadata/src/view-container.ts diff --git a/.changeset/metadata-view-container-leaf-subpath.md b/.changeset/metadata-view-container-leaf-subpath.md new file mode 100644 index 0000000000..ca89281928 --- /dev/null +++ b/.changeset/metadata-view-container-leaf-subpath.md @@ -0,0 +1,59 @@ +--- +'@objectstack/metadata': minor +'@objectstack/objectql': patch +--- + +feat(metadata): `deriveViewContainerObject` gets a leaf `/view-container` subpath, so objectql's lean ADR-0076 entry stops loading the manager, chokidar, glob and js-yaml for a six-line pure function + +`packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through +`@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry — +re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure +inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` -> +`chokidar`, plus `glob`, `js-yaml` and `readdirp`. + +The same file already carried the answer 79 lines above, at its +`@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a +cross-package consumer gets the predicate without the manager, the loaders or +the YAML/filesystem machinery behind the root entry". This is that pattern, +taken a second time. + +**Measured on the built artifacts, not asserted** — every module Node actually +evaluates when `@objectstack/objectql/core` is loaded in a fresh process, +recorded through a `module.registerHooks` load hook (ESM and CJS) plus +`require.cache`, byte sizes from `statSync`: + +| `@objectstack/objectql/core` | modules | bytes | +|:---|---:|---:| +| before (ESM `dist/core.mjs`) | 190 | 12,348,424 | +| after (ESM `dist/core.mjs`) | 185 | 11,849,808 | +| **delta** | **-5** | **-498,616 (-486.9 KiB)** | +| before (CJS `dist/core.js`) | 188 | 12,654,238 | +| after (CJS `dist/core.js`) | 183 | 12,141,034 | +| **delta** | **-5** | **-513,204 (-501.2 KiB)** | + +Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B), +`js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and +`readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal +module-init time for that root entry, measured on a warm lean closure, was +~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms. + +⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about +the delta and wrong about the baseline: the lean entry's closure was already +~11.5 MiB before this import existed, dominated by `@objectstack/spec` +(9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry +contributes. What the root import cost was ~487 KiB *on top of* that, not a +closure of 450 KB. + +The derivation itself moves to `packages/metadata/src/view-container.ts`, a +module with **no imports at all**, and `view-container-expansion.ts` imports +and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their +spelling and the symbol stays on the root entry — this subpath is an additional +door, not a relocation. A re-export shim onto `view-container-expansion.ts` was +tried first and rejected on measurement: esbuild tree-shakes the unused +`expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import +statements, so that shim's own closure was 84 modules / 3,035 KiB. The real +leaf's is 1 module / 469 B. + +`expandRuntimeViewContainer` is deliberately not exported from the new subpath: +`metadata-manager.ts` is its only caller, the root entry does not export it +either, and it is the half that carries the spec machinery. diff --git a/packages/metadata/package.json b/packages/metadata/package.json index d180f50634..7bdfc79e7a 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -46,6 +46,16 @@ "types": "./dist/errors.d.cts", "default": "./dist/errors.cjs" } + }, + "./view-container": { + "import": { + "types": "./dist/view-container.d.ts", + "default": "./dist/view-container.js" + }, + "require": { + "types": "./dist/view-container.d.cts", + "default": "./dist/view-container.cjs" + } } }, "files": [ diff --git a/packages/metadata/src/view-container-expansion.ts b/packages/metadata/src/view-container-expansion.ts index fcb31c2c98..219f269a56 100644 --- a/packages/metadata/src/view-container-expansion.ts +++ b/packages/metadata/src/view-container-expansion.ts @@ -45,30 +45,19 @@ import { type ExpandedViewItem, } from '@objectstack/spec'; import { applyProtection } from '@objectstack/spec/shared'; +import { deriveViewContainerObject } from './view-container.js'; /** - * Which object an aggregated view container binds to. - * - * The container's OWN top-level `object` field — `ViewSchema.object`, - * documented there as "how a stack-level `views: [...]` entry says which object - * its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`" - * — is the authorial, explicit signal and is consulted FIRST (#13407). The - * three-deep fallback below it is kept unchanged for every container written - * before that field was read here: `list.data.object`, then `form.data.object`, - * then the row's own `name` — which is the bound object only by convention, and - * is why a container that set the top-level field but not `list.data.object` - * used to bind under the wrong key or not at all. - * - * Returns `undefined` when no binding can be derived; every caller treats that - * as "no expansion" rather than an error. + * Re-exported from {@link ./view-container.ts}, which is also the + * `@objectstack/metadata/view-container` LEAF entry point (#14680). The + * derivation moved there so a cross-package consumer can reach it without the + * two `@objectstack/spec` imports below, which esbuild keeps in any bundle + * that includes this module — it tree-shakes the unused function, not an + * external package's import statement. Re-exported HERE so every existing + * importer (`index.ts`'s root export, `plugin.ts`) keeps its spelling: this + * module is still the package's stated home for "how a container binds". */ -export function deriveViewContainerObject(container: unknown): string | undefined { - if (!container || typeof container !== 'object') return undefined; - const c = container as Record; - const own = typeof c.object === 'string' && c.object ? c.object : undefined; - const byName = typeof c.name === 'string' && c.name ? c.name : undefined; - return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName; -} +export { deriveViewContainerObject }; /** * Expand an aggregated `defineView` container into the same independent diff --git a/packages/metadata/src/view-container.ts b/packages/metadata/src/view-container.ts new file mode 100644 index 0000000000..cbb12676f9 --- /dev/null +++ b/packages/metadata/src/view-container.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `@objectstack/metadata/view-container` — the one spelling of "which object + * does an aggregated `defineView` container bind to", as a LEAF entry point. + * + * ## Why this subpath exists + * + * This is the `/errors` pattern taken a second time, for the same reason + * (`src/errors.ts` carries the first telling). `@objectstack/metadata`'s ROOT + * entry pulls `MetadataPlugin` → `NodeMetadataManager` → `chokidar`, plus + * `glob` and `js-yaml`; a cross-package consumer that wants a six-line pure + * function should not have to load any of that. + * + * The consumer that forced it is `packages/objectql`'s ADR-0076 lean entry. + * `@objectstack/objectql/core` re-exports `engine.ts`, whose boot-loop + * registrar has to mint the same registry key as this package's registrars + * (#14399), so it imports {@link deriveViewContainerObject} rather than + * hand-copying the chain a fifth time — and reaching it through the root entry + * made that lean closure load the manager and the filesystem machinery for a + * function that touches neither. Measured on the built artifacts (#14680): + * through the root entry that call site loaded SIX extra modules into + * `@objectstack/objectql/core`'s module-init closure — + * `packages/metadata/dist/index.js`, `js-yaml`, `glob`, `chokidar` (two + * files) and `readdirp`, 499,162 B — for ~22 ms of extra init. Through this + * entry it loads one 469-byte module and nothing else. + * + * ## Why the function LIVES here rather than being re-exported from + * `view-container-expansion.ts` + * + * A re-export shim was written first and measured, because `/errors`' header + * states the requirement the shim has to meet: "this entry re-exports one leaf + * module and nothing else, so the cross-package edge stays a leaf edge". + * `view-container-expansion.ts` is not a leaf — its other export, + * `expandRuntimeViewContainer`, needs `@objectstack/spec` and + * `@objectstack/spec/shared` — and esbuild tree-shakes the unused FUNCTION but + * keeps both `import` statements, since it cannot prove an external package is + * side-effect-free. Measured: the shim's own closure was 84 + * modules / 3,035 KiB (all of `@objectstack/spec` and `zod`, reached through an + * import statement for a function that had been shaken out); this module's is + * 1 module / 469 B. + * + * So the derivation lives in this module, which imports nothing at all, and + * `view-container-expansion.ts` imports it from here and re-exports it — every + * existing importer (`index.ts`'s root export, `plugin.ts`) keeps its spelling. + * + * ## Scope of the promise + * + * Only {@link deriveViewContainerObject} is exported here. Its former module + * sibling `expandRuntimeViewContainer` is deliberately left off: it is internal + * to this package (`metadata-manager.ts` is its only caller, and the root entry + * does not export it either), and an exported symbol nobody imports is a promise + * made for nothing — Prime Directive #10 pointed at our own API surface, the + * same call `src/errors.ts` made about `isSchemaAlreadyExistsError`. + * + * The symbol stays on the ROOT entry as well: this subpath is an additional + * door, not a relocation, and the root export is a published promise with + * possible out-of-repo consumers. + */ + +/** + * Which object an aggregated view container binds to. + * + * The container's OWN top-level `object` field — `ViewSchema.object`, + * documented there as "how a stack-level `views: [...]` entry says which object + * its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`" + * — is the authorial, explicit signal and is consulted FIRST (#13407). The + * three-deep fallback below it is kept unchanged for every container written + * before that field was read here: `list.data.object`, then `form.data.object`, + * then the row's own `name` — which is the bound object only by convention, and + * is why a container that set the top-level field but not `list.data.object` + * used to bind under the wrong key or not at all. + * + * Returns `undefined` when no binding can be derived; every caller treats that + * as "no expansion" rather than an error. + */ +export function deriveViewContainerObject(container: unknown): string | undefined { + if (!container || typeof container !== 'object') return undefined; + const c = container as Record; + const own = typeof c.object === 'string' && c.object ? c.object : undefined; + const byName = typeof c.name === 'string' && c.name ? c.name : undefined; + return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName; +} diff --git a/packages/metadata/tsup.config.ts b/packages/metadata/tsup.config.ts index 4084e76695..0d57ae6343 100644 --- a/packages/metadata/tsup.config.ts +++ b/packages/metadata/tsup.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ // (#4728/#4825/#4867). Its own entry so a consumer that needs only the // predicate does not load the manager, the loaders and their deps. 'src/errors.ts', + // `@objectstack/metadata/view-container` — the shared "which object does + // this aggregated container bind to?" derivation (#14399/#14680). Its own + // entry for the same reason `errors` has one: objectql's ADR-0076 lean + // entry needs the pure function, not the manager, the loaders or their deps. + 'src/view-container.ts', ], splitting: false, sourcemap: true, diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 860d49623b..55914ec868 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -186,10 +186,10 @@ import { isViewContainerShaped, } from '@objectstack/spec'; // [#14399] The ONE spelling of "which object does an aggregated `defineView` -// container bind to", imported rather than re-spelled. See -// `resolveMetadataItemName` below for why this registrar had a fourth copy and -// why it lost it. -import { deriveViewContainerObject } from '@objectstack/metadata'; +// container bind to", imported rather than re-spelled — from the LEAF subpath, +// for the reason the `/errors` import above states (#14680). See +// `resolveMetadataItemName` below for why this registrar lost its fourth copy. +import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js';