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
59 changes: 59 additions & 0 deletions .changeset/metadata-view-container-leaf-subpath.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/metadata/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
31 changes: 10 additions & 21 deletions packages/metadata/src/view-container-expansion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
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
Expand Down
83 changes: 83 additions & 0 deletions packages/metadata/src/view-container.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
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;
}
5 changes: 5 additions & 0 deletions packages/metadata/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading