You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
`os i18n extract` reports key counts that describe the bytes it emitted, and its summary is a partition of the skeleton rather than a sum over it.
6
+
7
+
`extractTranslations` returned `counts[locale]` as a WALK counter — `count += 1` once per expected entry, unconditionally — and the command spent it as the number of keys in the file it had just written. Under the default `--objects-only` the module holds only the `objects` sub-tree, so the two are different numbers. Driven on a one-object, one-app stack with `i18n.defaultLocale: 'zh-CN'`:
The file that run wrote holds **2** leaves. The true split of the 776 is 2 objects + 1 app + 773 metadata-form baseline, so the summary appended a number the 776 already contained and read as 1549 out of 776 — an operator could not derive the truth from it, and the `(776 keys)` described no file the run produced. Both lines now read off the emitted tree:
Wrote OUT/zh-CN.metadata-forms.generated.ts (773 keys)
22
+
```
23
+
24
+
**What each number now means.**`ExtractResult.counts[locale]` is a leaf count of `bundles[locale]` — the whole skeleton built for that locale, taken off the tree instead of off the walk that built it. It is explicitly not the size of any one file: which sections of the skeleton become committed modules is the caller's decision. The command therefore takes every count it reports off that module's own payload, selected with `translationModulePayload` — the same function `renderTranslationModule` renders from, so the number and the bytes cannot drift apart, including for a sub-tree mode added later. Nothing subtracts one count from another at a print site: that would repair today's two modes and leave the third wrong in the same way.
25
+
26
+
**The summary line's shape changed** from `N key(s) (of N expected) + M metadataForms key(s)` to `E of S key(s) emitted` with a per-module breakdown. `E` is what this run's modules hold together and `S` is what the locale's skeleton holds, so `E ≤ S` always and the gap is exactly the keys a flag excluded — one app label under the default `--objects-only`, and nothing at all under `--no-objects-only`. A module a flag SUPPRESSED is named in the breakdown too, with its size and the words `not emitted` that keep it out of `E`: under `--no-metadata-forms` the row reads `2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted`, so the operator still sees how big the baseline they switched off is — which the old, double-counting line did tell them.
27
+
28
+
**A module with no leaves is no longer written.** The emit gate was `counts[locale] > 0`, a property of the skeleton: on a stack whose only surface is apps, the default `--objects-only` wrote a `<locale>.objects.generated.ts` holding `{}` and announced it as 774 keys. The gate is now the module's own leaf count.
29
+
30
+
**`--json`**: `counts` is now the leaf count of the `bundles` payload printed beside it, instead of the extractor's skeleton size. The skeleton total is unchanged and still reported, under its own name, as `totalExpected`.
31
+
32
+
⚠️ That is **not** the relationship `metadataFormsCounts` has to `metadataForms`, and nothing here changes the latter. `metadataFormsCounts` reports the baseline as BUILT, emitted or not: under `--no-metadata-forms` the payload carries `metadataFormsCounts: { 'zh-CN': 773 }` beside an empty `metadataForms`, deliberately, and a pin holds it there. So the payload carries two count semantics — `counts` is what was emitted, `metadataFormsCounts` is what was built. Both faces are unchanged by this note; it exists because an earlier draft of it claimed a symmetry that does not hold.
33
+
34
+
**No committed bundle moves.** All nine extract configs in this repository run under the default `--objects-only` on stacks that do author objects, and every emitted module is byte-for-byte unchanged; `pnpm check:i18n` stays green on the committed tree. What changed is stdout, the `--json` counts, and the emission of a module that would have been empty.
35
+
36
+
The regression pin spawns the real CLI in four flag states and compares each printed count against a structural leaf count of the module it wrote, parsed back off disk. That comparison is the thing the defect precluded: a walk counter cannot disagree with the walk, so no assertion over `ExtractResult` could have failed while the printed number was wrong by two orders of magnitude. Its `--json` case drives `--metadata-forms` in both states, because a case that drives one state of a flag cannot see what that flag does — driving it ON only is exactly how the symmetry claim above survived unmeasured into a first draft.
The registry's three conflict refusals now publish their error `code` as an importable constant.
6
+
7
+
`SchemaRegistry`'s install-time and registration refusals each already told the reader, in their own docblocks, to identify them by `code` rather than `instanceof` — and offered nothing to import. `NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME` and `OBJECT_OWNERSHIP_CONFLICT` were inline string literals, so the only way to follow that instruction was to re-spell the string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so.
8
+
9
+
Three new exports from `@objectstack/objectql`:
10
+
11
+
-`NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal.
12
+
-`DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal.
13
+
-`OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal.
14
+
15
+
**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary.
16
+
17
+
**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; the refusals throw the same `code`, the same `status: 422` and the same message as before. Existing consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing.
18
+
19
+
**The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace.
The plural reads' failure posture. Both read a **set** through the same
138
+
registered loaders, and — unlike the singular reads above, which collapse every
139
+
fault into one `null` — they answer two different kinds of fault differently.
140
+
141
+
| condition | outcome |
142
+
|:---|:---|
143
+
| A loader cannot be read — a storage outage, an unreachable `sys_metadata`, any other throw |**Degrade** — that loader is reported once and skipped; the read resolves with what the reachable loaders hold |
144
+
| One metadata name is derived from more than one file — `twin.json` beside `twin.yaml` in one type directory |**Refuse** — `AmbiguousMetadataStemError` propagates out of both reads |
145
+
146
+
**Degrade** is the older of the two postures and the one nothing announces to
147
+
the caller: `list` and `listNames` still resolve, the caller still gets an
148
+
array, nothing 500s, and the set is quietly short. `listDiagnosed` is what
149
+
tells a short set apart from a complete one — it returns the same items plus
150
+
`degraded` and `errors`, and `degraded` is true when at least one loader could
151
+
not be read while the set was assembled. It says the set is **known-partial**,
152
+
never that it is empty and never that it is wrong: a reason to withhold a claim
153
+
of *completeness*, never a reason to withhold the items.
154
+
155
+
`listNames` has **no diagnosed counterpart**. A short name set is not
156
+
distinguishable by its caller at all — the lost loader is reported at `error`
157
+
in the server log and nowhere else.
158
+
159
+
**Refuse** is an authoring error rather than an outage, so it is deliberately
160
+
not absorbed by the degrade seam above. The filesystem loader derives a
161
+
metadata name by stripping the extension from a flat file's basename, so two
162
+
files under one type directory sharing a stem produce one name that is listed
163
+
twice while only one of them is reachable under that name. Instead of picking a
164
+
winner by extension precedence, the loader throws, and both plural reads
165
+
re-raise it. The error carries the ADR-0112 envelope — code
166
+
`AMBIGUOUS_METADATA_STEM`, status `500` (the request is well formed and no
167
+
caller can fix it by sending something else; only deleting or renaming a file
168
+
does), plus the metadata `type`, the `stem`, and **every** colliding path,
169
+
sorted — never just the precedence winner. Catch it with
170
+
`isAmbiguousMetadataStemError` from `@objectstack/metadata` wherever you need
171
+
to tell it apart from an outage.
172
+
173
+
<Callouttype="info">
174
+
Only stems the loader would actually resolve collide: the comparison is
175
+
case-sensitive, it covers just the extensions whose serializers are registered
176
+
(`.js` is not in the default set), and a nested file sharing a flat file's
177
+
basename is not a collision.
178
+
</Callout>
179
+
134
180
### register / unregister
135
181
136
182
`register` saves (creates or replaces) the full definition for a `(type, name)`.
0 commit comments