Skip to content

Commit f658793

Browse files
fix(spec,core): give pluralToSingular a schema-free home on /meta-spelling (#12006)
* fix(spec,core): give pluralToSingular a schema-free home on /meta-spelling (#11503) Restores the #10096 standing invariant for core's store-key fold: the defineStack manifest-collection vocabulary moves to a leaf module on the /meta-spelling entry graph, /shared re-exports it, and core imports the fold from /meta-spelling instead of /shared. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K93P8PbH7kVjsAXkqzH1zY * chore(spec): record the widened ./meta-spelling surface in export-origins (#11503) gen:export-origins — the four manifest-collection symbols land on the meta-spelling shard and shared's origins repoint to the moved declaration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K93P8PbH7kVjsAXkqzH1zY * chore(spec): record the widened ./meta-spelling surface in api-surface (#11503) gen:api-surface — touched: meta-spelling.json; check:api-surface and check:generated green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K93P8PbH7kVjsAXkqzH1zY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac516ec commit f658793

12 files changed

Lines changed: 288 additions & 96 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": patch
4+
---
5+
6+
Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须
7+
schema-free」) for `@objectstack/core`'s plural→singular store-key fold.
8+
9+
`@objectstack/spec`: the `defineStack()` manifest-collection vocabulary
10+
(`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`,
11+
`singularToPlural`) moved to a schema-free module and is now ALSO exported
12+
from the sanctioned schema-free entry `@objectstack/spec/meta-spelling`
13+
(widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared`
14+
keeps the same four symbols as re-exports — no consumer-visible removal. The
15+
manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts
16+
(#8424).
17+
18+
`@objectstack/core`: `canonicalMetadataServiceType`'s one value import moves
19+
from `@objectstack/spec/shared` to `@objectstack/spec/meta-spelling`, so
20+
browser consumers of `@objectstack/core` (every `@objectstack/client` bundle)
21+
no longer link the zod schema closure through the store-key fold.

packages/core/src/metadata-service-contract.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,18 @@
8888
// `@objectstack/driver-memory`, 39 test files dead at load between them). The
8989
// typed literal below keeps the closed-set compile check without the runtime
9090
// import — the `packages/spec/src/contracts/storage-service.ts` pattern.
91-
// `/shared` cannot get the same treatment: `pluralToSingular` is a runtime
92-
// value and its map has ONE owner (#7378 row 2 — copying it here would be the
93-
// per-implementation folk normalization the ruling forbids), so the consumer
94-
// configs carry a `/shared` alias entry instead.
91+
// `pluralToSingular` cannot get the same treatment: it is a runtime value and
92+
// its map has ONE owner (#7378 row 2 — copying it here would be the
93+
// per-implementation folk normalization the ruling forbids). It is imported
94+
// from `/meta-spelling` — the schema-free vocabulary entry — NOT `/shared`:
95+
// this module is on every browser consumer's eager graph through
96+
// `@objectstack/client`, and a `/shared` value import here linked the whole
97+
// zod schema closure into every browser bundle (#11503, measured in the
98+
// console; #10096 standing principle: 「浏览器可达的 spec 导出面必须
99+
// schema-free」). Consumer vitest configs resolve the subpath through their
100+
// anchored `@objectstack/spec/<ns>` alias rule (#9457).
95101
import type { StandardErrorCode } from '@objectstack/spec/api';
96-
import { pluralToSingular } from '@objectstack/spec/shared';
102+
import { pluralToSingular } from '@objectstack/spec/meta-spelling';
97103

98104
/** The standard catalog's generic argument-validation code, type-checked against the closed set. */
99105
const REGISTER_REFUSAL_CODE: StandardErrorCode = 'VALIDATION_ERROR';
@@ -103,7 +109,7 @@ const REGISTER_REFUSAL_CODE: StandardErrorCode = 'VALIDATION_ERROR';
103109
* (#7378 row 2). Folds a plural manifest spelling to the singular metadata
104110
* type name (`'objects'` → `'object'`, `'views'` → `'view'`, …) through the
105111
* platform's one plural↔singular map (`PLURAL_TO_SINGULAR`,
106-
* `@objectstack/spec/shared`); a name with no plural mapping — which includes
112+
* `@objectstack/spec/meta-spelling`); a name with no plural mapping — which includes
107113
* every canonical singular type — passes through unchanged.
108114
*/
109115
export function canonicalMetadataServiceType(type: string): string {

packages/spec/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ The **Source of Truth** for the ObjectStack Protocol. Contains strictly typed Zo
1616

1717
The package publishes one entry per protocol domain (`@objectstack/spec/data`,
1818
`/ui`, `/kernel`, …) plus fine-grained vocabulary entries
19-
(`@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract).
19+
(`@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract
20+
and the `defineStack()` manifest-collection vocabulary, two deliberately
21+
distinct maps per objectstack#8424).
2022
Each entry is a self-contained bundle: what an entry's module graph reaches is
2123
what every consumer of that entry pays for.
2224

packages/spec/api-surface/meta-spelling.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
"entry": "./meta-spelling",
44
"exports": [
55
"META_URL_TO_SINGULAR (const)",
6+
"PLURAL_TO_SINGULAR (const)",
7+
"SINGULAR_TO_PLURAL (const)",
68
"canonicalMetaUrlType (function)",
79
"metaUrlSpellingRefusal (function)",
10+
"pluralToSingular (function)",
11+
"singularToPlural (function)",
812
"unrecognisedMetaTypeRefusal (function)"
913
]
1014
}

packages/spec/export-origins/meta-spelling.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
"entry": "./meta-spelling",
44
"exports": {
55
"META_URL_TO_SINGULAR": "src/meta-spelling/meta-url-data.generated.ts#META_URL_TO_SINGULAR (const)",
6+
"PLURAL_TO_SINGULAR": "src/meta-spelling/manifest-collection-spelling.ts#PLURAL_TO_SINGULAR (const)",
7+
"SINGULAR_TO_PLURAL": "src/meta-spelling/manifest-collection-spelling.ts#SINGULAR_TO_PLURAL (const)",
68
"canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)",
79
"metaUrlSpellingRefusal": "src/meta-spelling/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)",
10+
"pluralToSingular": "src/meta-spelling/manifest-collection-spelling.ts#pluralToSingular (function)",
11+
"singularToPlural": "src/meta-spelling/manifest-collection-spelling.ts#singularToPlural (function)",
812
"unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)"
913
}
1014
}

packages/spec/export-origins/shared.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"ObjectNameSchema": "src/shared/branded-types.zod.ts#ObjectNameSchema (const)",
6262
"ObjectStackRawIssue": "src/shared/error-map.zod.ts#ObjectStackRawIssue (type)",
6363
"P": "src/shared/expression.zod.ts#P (const)",
64-
"PLURAL_TO_SINGULAR": "src/shared/metadata-collection.zod.ts#PLURAL_TO_SINGULAR (const)",
64+
"PLURAL_TO_SINGULAR": "src/meta-spelling/manifest-collection-spelling.ts#PLURAL_TO_SINGULAR (const)",
6565
"Predicate": "src/shared/expression.zod.ts#Predicate (type)",
6666
"PredicateInput": "src/shared/expression.zod.ts#PredicateInput (type)",
6767
"PredicateInputSchema": "src/shared/expression.zod.ts#PredicateInputSchema (const)",
@@ -75,7 +75,7 @@
7575
"RoleName": "src/shared/branded-types.zod.ts#RoleName (type)",
7676
"RoleNameParsed": "src/shared/branded-types.zod.ts#RoleNameParsed (type)",
7777
"RoleNameSchema": "src/shared/branded-types.zod.ts#RoleNameSchema (const)",
78-
"SINGULAR_TO_PLURAL": "src/shared/metadata-collection.zod.ts#SINGULAR_TO_PLURAL (const)",
78+
"SINGULAR_TO_PLURAL": "src/meta-spelling/manifest-collection-spelling.ts#SINGULAR_TO_PLURAL (const)",
7979
"SchemaDiffEntry": "src/shared/external-errors.ts#SchemaDiffEntry (interface)",
8080
"SchemaDiffEntryKind": "src/shared/external-errors.ts#SchemaDiffEntryKind (type)",
8181
"SnakeCaseIdentifier": "src/shared/identifiers.zod.ts#SnakeCaseIdentifier (type)",
@@ -114,11 +114,11 @@
114114
"normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)",
115115
"normalizeVisibleWhen": "src/shared/visibility.ts#normalizeVisibleWhen (function)",
116116
"objectStackErrorMap": "src/shared/error-map.zod.ts#objectStackErrorMap (const)",
117-
"pluralToSingular": "src/shared/metadata-collection.zod.ts#pluralToSingular (function)",
117+
"pluralToSingular": "src/meta-spelling/manifest-collection-spelling.ts#pluralToSingular (function)",
118118
"renderDiffMessage": "src/shared/external-errors.ts#renderDiffMessage (function)",
119119
"resilientFetch": "src/shared/resilient-fetch.ts#resilientFetch (function)",
120120
"safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)",
121-
"singularToPlural": "src/shared/metadata-collection.zod.ts#singularToPlural (function)",
121+
"singularToPlural": "src/meta-spelling/manifest-collection-spelling.ts#singularToPlural (function)",
122122
"strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)",
123123
"suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)",
124124
"tmpl": "src/shared/expression.zod.ts#tmpl (function)",

packages/spec/scripts/build-meta-url-spelling.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
*
88
* ## Why the map is generated instead of derived at module load
99
*
10-
* The map's two sources are heavy on purpose-unrelated weight:
10+
* The map's two sources were heavy on purpose-unrelated weight:
1111
* `DEFAULT_METADATA_TYPE_REGISTRY` lives in the kernel zod graph, and
12-
* `PLURAL_TO_SINGULAR` sits in a module that reaches the ADR-0087 conversion
13-
* layer. Deriving at module load therefore made ONE string fold cost a
12+
* `PLURAL_TO_SINGULAR` sat in a module that reaches the ADR-0087 conversion
13+
* layer (it has since moved to the schema-free
14+
* `meta-spelling/manifest-collection-spelling`, #11503 — the registry input
15+
* still makes build time the right place). Deriving at module load made ONE string fold cost a
1416
* browser consumer +60.1 KB gzipped (#10096's measurement). The 2026-08-20
1517
* maintainer ruling minted the standing principle 「浏览器可达的 spec 导出面必
1618
* 须 schema-free」 and approved moving the derivation to build time: this
@@ -40,7 +42,10 @@ import { dirname, join } from 'node:path';
4042
import { fileURLToPath } from 'node:url';
4143

4244
import { DEFAULT_METADATA_TYPE_REGISTRY } from '../src/kernel/metadata-plugin.zod';
43-
import { PLURAL_TO_SINGULAR } from '../src/shared/metadata-collection.zod';
45+
// [#11503] `PLURAL_TO_SINGULAR`'s declaration moved to the schema-free
46+
// manifest-collection module (the doc above still describes where it USED to
47+
// sit); the derivation input is the same one owner, read at its new home.
48+
import { PLURAL_TO_SINGULAR } from '../src/meta-spelling/manifest-collection-spelling';
4449
import { restPluralOfMetaType, camelCaseOf } from '../src/meta-spelling/metadata-url-spelling';
4550

4651
const OUT = join(
Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,39 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* `@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract,
4+
* `@objectstack/spec/meta-spelling` — metadata type-name spelling vocabulary,
55
* schema-free (#10096, maintainer ruling 2026-08-20).
66
*
77
* This entry exists so a browser/client consumer who needs to spell (or fold,
8-
* or refuse) a `/meta/:type` path segment pays a few hundred bytes instead of
9-
* linking the zod schema graph — the measured cost of reaching the same four
10-
* symbols through `@objectstack/spec/shared` was +60.1 KB gzipped on a graph
11-
* that already carried `/ui` and `/kernel`.
8+
* or refuse) a metadata type name pays a few hundred bytes instead of linking
9+
* the zod schema graph — the measured cost of reaching the same symbols
10+
* through `@objectstack/spec/shared` was +60.1 KB gzipped on a graph that
11+
* already carried `/ui` and `/kernel`.
1212
*
1313
* The standing principle this entry implements (recorded verbatim,
1414
* untranslated): 「浏览器可达的 spec 导出面必须 schema-free」 — a
1515
* browser-reachable export surface carries vocabulary (maps, folds, enums,
1616
* pure predicates) without linking the zod schema/validation machinery.
1717
*
18-
* The published surface is the same four symbols `/shared` carries (#8424 —
19-
* `/shared` keeps them; this entry is additive, one declaration re-exported):
20-
* the map, the fold, and the two refusal verdicts. The map is derived at BUILD
21-
* time from `PLURAL_TO_SINGULAR` and `DEFAULT_METADATA_TYPE_REGISTRY`
22-
* (`gen:meta-url-spelling`), and `check:meta-url-spelling` enforces both its
23-
* freshness and the manifest/derived spelling agreement on every CI lap.
18+
* The published surface is TWO deliberately distinct spelling contracts
19+
* (#8424 — merging them once shipped an authorization bypass; see
20+
* `./metadata-url-spelling`'s module doc), each also kept on `/shared`
21+
* (`/shared` keeps its surface; this entry is additive, one declaration
22+
* re-exported per symbol):
23+
*
24+
* - the **`/meta/:type` URL-spelling contract** — `META_URL_TO_SINGULAR`,
25+
* the fold, and the two refusal verdicts. The map is derived at BUILD time
26+
* from `PLURAL_TO_SINGULAR` and `DEFAULT_METADATA_TYPE_REGISTRY`
27+
* (`gen:meta-url-spelling`), and `check:meta-url-spelling` enforces both
28+
* its freshness and the manifest/derived spelling agreement on every CI
29+
* lap;
30+
* - the **`defineStack()` manifest-collection vocabulary** (#11503) —
31+
* `PLURAL_TO_SINGULAR` / `SINGULAR_TO_PLURAL` and their folds, whose keys
32+
* are the collection properties an author writes in `defineStack()`. Widened
33+
* onto this entry because `@objectstack/core` keys every metadata store on
34+
* `pluralToSingular` (#7378 row 2 — the map has ONE owner), and reaching it
35+
* through `/shared` put the schema graph on every browser consumer's eager
36+
* closure.
2437
*
2538
* @module
2639
*/
@@ -31,3 +44,10 @@ export {
3144
metaUrlSpellingRefusal,
3245
unrecognisedMetaTypeRefusal,
3346
} from './metadata-url-spelling';
47+
48+
export {
49+
PLURAL_TO_SINGULAR,
50+
SINGULAR_TO_PLURAL,
51+
pluralToSingular,
52+
singularToPlural,
53+
} from './manifest-collection-spelling';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#11503] The manifest-collection vocabulary's schema-free home, pinned.
5+
*
6+
* `PLURAL_TO_SINGULAR` moved from `shared/metadata-collection.zod.ts` to this
7+
* entry's graph so `@objectstack/core`'s store-key fold (#7378 row 2 — the map
8+
* has ONE owner) stops linking the `/shared` zod closure into every browser
9+
* consumer (#10096 standing principle, recorded verbatim, untranslated:
10+
* 「浏览器可达的 spec 导出面必须 schema-free」). Two facts must hold and are
11+
* pinned here:
12+
*
13+
* 1. the move is a RE-EXPORT, not a fork — `/shared` and `/meta-spelling`
14+
* hand out reference-identical bindings (a faithful copy would pass every
15+
* value comparison, so identity is the discriminating check);
16+
* 2. widening the entry did NOT merge the two spelling contracts (#8424) —
17+
* the manifest map and the URL map stay distinct symbols with distinct
18+
* key sets.
19+
*
20+
* Schema-freeness of the BUILT entry is not asserted here — that is
21+
* `check:browser-reachable-entries`' job, on the real bundle.
22+
*/
23+
24+
import { describe, expect, it } from 'vitest';
25+
import {
26+
PLURAL_TO_SINGULAR,
27+
SINGULAR_TO_PLURAL,
28+
pluralToSingular,
29+
singularToPlural,
30+
META_URL_TO_SINGULAR,
31+
} from './index';
32+
33+
describe('#11503 — the manifest-collection vocabulary is the SAME contract on both entries', () => {
34+
it('`/meta-spelling` and `/shared` hand out identical bindings (one declaration, two entries)', async () => {
35+
const shared = await import('../shared/metadata-collection.zod');
36+
expect(shared.PLURAL_TO_SINGULAR).toBe(PLURAL_TO_SINGULAR);
37+
expect(shared.SINGULAR_TO_PLURAL).toBe(SINGULAR_TO_PLURAL);
38+
expect(shared.pluralToSingular).toBe(pluralToSingular);
39+
expect(shared.singularToPlural).toBe(singularToPlural);
40+
});
41+
42+
it('folds a manifest collection key and passes unmapped names through', () => {
43+
expect(pluralToSingular('objects')).toBe('object');
44+
expect(singularToPlural('object')).toBe('objects');
45+
expect(pluralToSingular('object')).toBe('object');
46+
expect(pluralToSingular('not_a_collection')).toBe('not_a_collection');
47+
});
48+
});
49+
50+
describe('#8424 — widening the entry did not merge the two spelling contracts', () => {
51+
it('keeps the manifest map and the URL map distinct symbols', () => {
52+
expect(PLURAL_TO_SINGULAR).not.toBe(META_URL_TO_SINGULAR);
53+
});
54+
55+
it('the manifest map still lacks the four registry-only spellings the URL map carries', () => {
56+
// These are URL spellings of registry types that are NOT stack-level
57+
// collections — adding any of them to the manifest map would advertise a
58+
// `defineStack()` collection that does not exist (the `fields:` incident,
59+
// see metadata-url-spelling.ts).
60+
for (const key of ['fields', 'seeds', 'translations', 'external_catalogs', 'externalCatalogs']) {
61+
expect(PLURAL_TO_SINGULAR[key], `${key} must not enter the manifest map`).toBeUndefined();
62+
}
63+
expect(META_URL_TO_SINGULAR['fields']).toBe('field');
64+
expect(META_URL_TO_SINGULAR['seeds']).toBe('seed');
65+
});
66+
});

0 commit comments

Comments
 (0)