Skip to content

Commit 3bd9b34

Browse files
claude[bot]claude
andauthored
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) (#15343)
`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. Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27875fc commit 3bd9b34

6 files changed

Lines changed: 171 additions & 25 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
'@objectstack/metadata': minor
3+
'@objectstack/objectql': patch
4+
---
5+
6+
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
7+
8+
`packages/objectql/src/engine.ts` reached `deriveViewContainerObject` through
9+
`@objectstack/metadata`'s ROOT entry. `core.ts` — the ADR-0076 lean entry —
10+
re-exports `engine.ts`, so `@objectstack/objectql/core`'s module-init closure
11+
inherited the whole root entry: `MetadataPlugin` -> `NodeMetadataManager` ->
12+
`chokidar`, plus `glob`, `js-yaml` and `readdirp`.
13+
14+
The same file already carried the answer 79 lines above, at its
15+
`@objectstack/metadata/errors` import: that leaf subpath exists "precisely so a
16+
cross-package consumer gets the predicate without the manager, the loaders or
17+
the YAML/filesystem machinery behind the root entry". This is that pattern,
18+
taken a second time.
19+
20+
**Measured on the built artifacts, not asserted** — every module Node actually
21+
evaluates when `@objectstack/objectql/core` is loaded in a fresh process,
22+
recorded through a `module.registerHooks` load hook (ESM and CJS) plus
23+
`require.cache`, byte sizes from `statSync`:
24+
25+
| `@objectstack/objectql/core` | modules | bytes |
26+
|:---|---:|---:|
27+
| before (ESM `dist/core.mjs`) | 190 | 12,348,424 |
28+
| after (ESM `dist/core.mjs`) | 185 | 11,849,808 |
29+
| **delta** | **-5** | **-498,616 (-486.9 KiB)** |
30+
| before (CJS `dist/core.js`) | 188 | 12,654,238 |
31+
| after (CJS `dist/core.js`) | 183 | 12,141,034 |
32+
| **delta** | **-5** | **-513,204 (-501.2 KiB)** |
33+
34+
Six modules stop loading — `packages/metadata/dist/index.js` (237,747 B),
35+
`js-yaml` (114,610 B), `glob` (82,749 B), `chokidar` (2 files, 54,220 B) and
36+
`readdirp` (9,836 B) — and one 469-byte module takes their place. Marginal
37+
module-init time for that root entry, measured on a warm lean closure, was
38+
~22 ms (median of 7; 20.4-27.5 ms) out of ~630 ms.
39+
40+
⚠️ The figure the finding was argued on — "~3.6 KB to ~450 KB" — is right about
41+
the delta and wrong about the baseline: the lean entry's closure was already
42+
~11.5 MiB before this import existed, dominated by `@objectstack/spec`
43+
(9,587,914 B) and `zod` (567,918 B), neither of which the metadata root entry
44+
contributes. What the root import cost was ~487 KiB *on top of* that, not a
45+
closure of 450 KB.
46+
47+
The derivation itself moves to `packages/metadata/src/view-container.ts`, a
48+
module with **no imports at all**, and `view-container-expansion.ts` imports
49+
and re-exports it, so `index.ts`'s root export and `plugin.ts` keep their
50+
spelling and the symbol stays on the root entry — this subpath is an additional
51+
door, not a relocation. A re-export shim onto `view-container-expansion.ts` was
52+
tried first and rejected on measurement: esbuild tree-shakes the unused
53+
`expandRuntimeViewContainer` but keeps its two `@objectstack/spec` import
54+
statements, so that shim's own closure was 84 modules / 3,035 KiB. The real
55+
leaf's is 1 module / 469 B.
56+
57+
`expandRuntimeViewContainer` is deliberately not exported from the new subpath:
58+
`metadata-manager.ts` is its only caller, the root entry does not export it
59+
either, and it is the half that carries the spec machinery.

packages/metadata/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@
4646
"types": "./dist/errors.d.cts",
4747
"default": "./dist/errors.cjs"
4848
}
49+
},
50+
"./view-container": {
51+
"import": {
52+
"types": "./dist/view-container.d.ts",
53+
"default": "./dist/view-container.js"
54+
},
55+
"require": {
56+
"types": "./dist/view-container.d.cts",
57+
"default": "./dist/view-container.cjs"
58+
}
4959
}
5060
},
5161
"files": [

packages/metadata/src/view-container-expansion.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,30 +45,19 @@ import {
4545
type ExpandedViewItem,
4646
} from '@objectstack/spec';
4747
import { applyProtection } from '@objectstack/spec/shared';
48+
import { deriveViewContainerObject } from './view-container.js';
4849

4950
/**
50-
* Which object an aggregated view container binds to.
51-
*
52-
* The container's OWN top-level `object` field — `ViewSchema.object`,
53-
* documented there as "how a stack-level `views: [...]` entry says which object
54-
* its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`"
55-
* — is the authorial, explicit signal and is consulted FIRST (#13407). The
56-
* three-deep fallback below it is kept unchanged for every container written
57-
* before that field was read here: `list.data.object`, then `form.data.object`,
58-
* then the row's own `name` — which is the bound object only by convention, and
59-
* is why a container that set the top-level field but not `list.data.object`
60-
* used to bind under the wrong key or not at all.
61-
*
62-
* Returns `undefined` when no binding can be derived; every caller treats that
63-
* as "no expansion" rather than an error.
51+
* Re-exported from {@link ./view-container.ts}, which is also the
52+
* `@objectstack/metadata/view-container` LEAF entry point (#14680). The
53+
* derivation moved there so a cross-package consumer can reach it without the
54+
* two `@objectstack/spec` imports below, which esbuild keeps in any bundle
55+
* that includes this module — it tree-shakes the unused function, not an
56+
* external package's import statement. Re-exported HERE so every existing
57+
* importer (`index.ts`'s root export, `plugin.ts`) keeps its spelling: this
58+
* module is still the package's stated home for "how a container binds".
6459
*/
65-
export function deriveViewContainerObject(container: unknown): string | undefined {
66-
if (!container || typeof container !== 'object') return undefined;
67-
const c = container as Record<string, any>;
68-
const own = typeof c.object === 'string' && c.object ? c.object : undefined;
69-
const byName = typeof c.name === 'string' && c.name ? c.name : undefined;
70-
return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName;
71-
}
60+
export { deriveViewContainerObject };
7261

7362
/**
7463
* Expand an aggregated `defineView` container into the same independent
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `@objectstack/metadata/view-container` — the one spelling of "which object
5+
* does an aggregated `defineView` container bind to", as a LEAF entry point.
6+
*
7+
* ## Why this subpath exists
8+
*
9+
* This is the `/errors` pattern taken a second time, for the same reason
10+
* (`src/errors.ts` carries the first telling). `@objectstack/metadata`'s ROOT
11+
* entry pulls `MetadataPlugin` → `NodeMetadataManager` → `chokidar`, plus
12+
* `glob` and `js-yaml`; a cross-package consumer that wants a six-line pure
13+
* function should not have to load any of that.
14+
*
15+
* The consumer that forced it is `packages/objectql`'s ADR-0076 lean entry.
16+
* `@objectstack/objectql/core` re-exports `engine.ts`, whose boot-loop
17+
* registrar has to mint the same registry key as this package's registrars
18+
* (#14399), so it imports {@link deriveViewContainerObject} rather than
19+
* hand-copying the chain a fifth time — and reaching it through the root entry
20+
* made that lean closure load the manager and the filesystem machinery for a
21+
* function that touches neither. Measured on the built artifacts (#14680):
22+
* through the root entry that call site loaded SIX extra modules into
23+
* `@objectstack/objectql/core`'s module-init closure —
24+
* `packages/metadata/dist/index.js`, `js-yaml`, `glob`, `chokidar` (two
25+
* files) and `readdirp`, 499,162 B — for ~22 ms of extra init. Through this
26+
* entry it loads one 469-byte module and nothing else.
27+
*
28+
* ## Why the function LIVES here rather than being re-exported from
29+
* `view-container-expansion.ts`
30+
*
31+
* A re-export shim was written first and measured, because `/errors`' header
32+
* states the requirement the shim has to meet: "this entry re-exports one leaf
33+
* module and nothing else, so the cross-package edge stays a leaf edge".
34+
* `view-container-expansion.ts` is not a leaf — its other export,
35+
* `expandRuntimeViewContainer`, needs `@objectstack/spec` and
36+
* `@objectstack/spec/shared` — and esbuild tree-shakes the unused FUNCTION but
37+
* keeps both `import` statements, since it cannot prove an external package is
38+
* side-effect-free. Measured: the shim's own closure was 84
39+
* modules / 3,035 KiB (all of `@objectstack/spec` and `zod`, reached through an
40+
* import statement for a function that had been shaken out); this module's is
41+
* 1 module / 469 B.
42+
*
43+
* So the derivation lives in this module, which imports nothing at all, and
44+
* `view-container-expansion.ts` imports it from here and re-exports it — every
45+
* existing importer (`index.ts`'s root export, `plugin.ts`) keeps its spelling.
46+
*
47+
* ## Scope of the promise
48+
*
49+
* Only {@link deriveViewContainerObject} is exported here. Its former module
50+
* sibling `expandRuntimeViewContainer` is deliberately left off: it is internal
51+
* to this package (`metadata-manager.ts` is its only caller, and the root entry
52+
* does not export it either), and an exported symbol nobody imports is a promise
53+
* made for nothing — Prime Directive #10 pointed at our own API surface, the
54+
* same call `src/errors.ts` made about `isSchemaAlreadyExistsError`.
55+
*
56+
* The symbol stays on the ROOT entry as well: this subpath is an additional
57+
* door, not a relocation, and the root export is a published promise with
58+
* possible out-of-repo consumers.
59+
*/
60+
61+
/**
62+
* Which object an aggregated view container binds to.
63+
*
64+
* The container's OWN top-level `object` field — `ViewSchema.object`,
65+
* documented there as "how a stack-level `views: [...]` entry says which object
66+
* its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`"
67+
* — is the authorial, explicit signal and is consulted FIRST (#13407). The
68+
* three-deep fallback below it is kept unchanged for every container written
69+
* before that field was read here: `list.data.object`, then `form.data.object`,
70+
* then the row's own `name` — which is the bound object only by convention, and
71+
* is why a container that set the top-level field but not `list.data.object`
72+
* used to bind under the wrong key or not at all.
73+
*
74+
* Returns `undefined` when no binding can be derived; every caller treats that
75+
* as "no expansion" rather than an error.
76+
*/
77+
export function deriveViewContainerObject(container: unknown): string | undefined {
78+
if (!container || typeof container !== 'object') return undefined;
79+
const c = container as Record<string, any>;
80+
const own = typeof c.object === 'string' && c.object ? c.object : undefined;
81+
const byName = typeof c.name === 'string' && c.name ? c.name : undefined;
82+
return own ?? c?.list?.data?.object ?? c?.form?.data?.object ?? byName;
83+
}

packages/metadata/tsup.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export default defineConfig({
1111
// (#4728/#4825/#4867). Its own entry so a consumer that needs only the
1212
// predicate does not load the manager, the loaders and their deps.
1313
'src/errors.ts',
14+
// `@objectstack/metadata/view-container` — the shared "which object does
15+
// this aggregated container bind to?" derivation (#14399/#14680). Its own
16+
// entry for the same reason `errors` has one: objectql's ADR-0076 lean
17+
// entry needs the pure function, not the manager, the loaders or their deps.
18+
'src/view-container.ts',
1419
],
1520
splitting: false,
1621
sourcemap: true,

packages/objectql/src/engine.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ import {
186186
isViewContainerShaped,
187187
} from '@objectstack/spec';
188188
// [#14399] The ONE spelling of "which object does an aggregated `defineView`
189-
// container bind to", imported rather than re-spelled. See
190-
// `resolveMetadataItemName` below for why this registrar had a fourth copy and
191-
// why it lost it.
192-
import { deriveViewContainerObject } from '@objectstack/metadata';
189+
// container bind to", imported rather than re-spelled — from the LEAF subpath,
190+
// for the reason the `/errors` import above states (#14680). See
191+
// `resolveMetadataItemName` below for why this registrar lost its fourth copy.
192+
import { deriveViewContainerObject } from '@objectstack/metadata/view-container';
193193
import { bindHooksToEngine } from './hook-binder.js';
194194
import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
195195
import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js';

0 commit comments

Comments
 (0)