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
21 changes: 21 additions & 0 deletions .changeset/eleven-maps-move-home.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": minor
"@objectstack/core": patch
---

Restore the #10096 standing invariant (「浏览器可达的 spec 导出面必须
schema-free」) for `@objectstack/core`'s plural→singular store-key fold.

`@objectstack/spec`: the `defineStack()` manifest-collection vocabulary
(`PLURAL_TO_SINGULAR`, `SINGULAR_TO_PLURAL`, `pluralToSingular`,
`singularToPlural`) moved to a schema-free module and is now ALSO exported
from the sanctioned schema-free entry `@objectstack/spec/meta-spelling`
(widened per the #10096 ruling's reference pattern). `@objectstack/spec/shared`
keeps the same four symbols as re-exports — no consumer-visible removal. The
manifest map and `META_URL_TO_SINGULAR` remain deliberately distinct contracts
(#8424).

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

/** The standard catalog's generic argument-validation code, type-checked against the closed set. */
const REGISTER_REFUSAL_CODE: StandardErrorCode = 'VALIDATION_ERROR';
Expand All@@ -103,7 +109,7 @@ const REGISTER_REFUSAL_CODE: StandardErrorCode = 'VALIDATION_ERROR';
* (#7378 row 2). Folds a plural manifest spelling to the singular metadata
* type name (`'objects'` → `'object'`, `'views'` → `'view'`, …) through the
* platform's one plural↔singular map (`PLURAL_TO_SINGULAR`,
* `@objectstack/spec/shared`); a name with no plural mapping — which includes
* `@objectstack/spec/meta-spelling`); a name with no plural mapping — which includes
* every canonical singular type — passes through unchanged.
*/
export function canonicalMetadataServiceType(type: string): string {
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,9 @@ The **Source of Truth** for the ObjectStack Protocol. Contains strictly typed Zo

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

Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface/meta-spelling.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,12 @@
"entry": "./meta-spelling",
"exports": [
"META_URL_TO_SINGULAR (const)",
"PLURAL_TO_SINGULAR (const)",
"SINGULAR_TO_PLURAL (const)",
"canonicalMetaUrlType (function)",
"metaUrlSpellingRefusal (function)",
"pluralToSingular (function)",
"singularToPlural (function)",
"unrecognisedMetaTypeRefusal (function)"
]
}
4 changes: 4 additions & 0 deletions packages/spec/export-origins/meta-spelling.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,8 +3,12 @@
"entry": "./meta-spelling",
"exports": {
"META_URL_TO_SINGULAR": "src/meta-spelling/meta-url-data.generated.ts#META_URL_TO_SINGULAR (const)",
"PLURAL_TO_SINGULAR": "src/meta-spelling/manifest-collection-spelling.ts#PLURAL_TO_SINGULAR (const)",
"SINGULAR_TO_PLURAL": "src/meta-spelling/manifest-collection-spelling.ts#SINGULAR_TO_PLURAL (const)",
"canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)",
"metaUrlSpellingRefusal": "src/meta-spelling/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)",
"pluralToSingular": "src/meta-spelling/manifest-collection-spelling.ts#pluralToSingular (function)",
"singularToPlural": "src/meta-spelling/manifest-collection-spelling.ts#singularToPlural (function)",
"unrecognisedMetaTypeRefusal": "src/meta-spelling/metadata-url-spelling.ts#unrecognisedMetaTypeRefusal (function)"
}
}
8 changes: 4 additions & 4 deletions packages/spec/export-origins/shared.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,7 @@
"ObjectNameSchema": "src/shared/branded-types.zod.ts#ObjectNameSchema (const)",
"ObjectStackRawIssue": "src/shared/error-map.zod.ts#ObjectStackRawIssue (type)",
"P": "src/shared/expression.zod.ts#P (const)",
"PLURAL_TO_SINGULAR": "src/shared/metadata-collection.zod.ts#PLURAL_TO_SINGULAR (const)",
"PLURAL_TO_SINGULAR": "src/meta-spelling/manifest-collection-spelling.ts#PLURAL_TO_SINGULAR (const)",
"Predicate": "src/shared/expression.zod.ts#Predicate (type)",
"PredicateInput": "src/shared/expression.zod.ts#PredicateInput (type)",
"PredicateInputSchema": "src/shared/expression.zod.ts#PredicateInputSchema (const)",
Expand All@@ -75,7 +75,7 @@
"RoleName": "src/shared/branded-types.zod.ts#RoleName (type)",
"RoleNameParsed": "src/shared/branded-types.zod.ts#RoleNameParsed (type)",
"RoleNameSchema": "src/shared/branded-types.zod.ts#RoleNameSchema (const)",
"SINGULAR_TO_PLURAL": "src/shared/metadata-collection.zod.ts#SINGULAR_TO_PLURAL (const)",
"SINGULAR_TO_PLURAL": "src/meta-spelling/manifest-collection-spelling.ts#SINGULAR_TO_PLURAL (const)",
"SchemaDiffEntry": "src/shared/external-errors.ts#SchemaDiffEntry (interface)",
"SchemaDiffEntryKind": "src/shared/external-errors.ts#SchemaDiffEntryKind (type)",
"SnakeCaseIdentifier": "src/shared/identifiers.zod.ts#SnakeCaseIdentifier (type)",
Expand DownExpand Up@@ -114,11 +114,11 @@
"normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)",
"normalizeVisibleWhen": "src/shared/visibility.ts#normalizeVisibleWhen (function)",
"objectStackErrorMap": "src/shared/error-map.zod.ts#objectStackErrorMap (const)",
"pluralToSingular": "src/shared/metadata-collection.zod.ts#pluralToSingular (function)",
"pluralToSingular": "src/meta-spelling/manifest-collection-spelling.ts#pluralToSingular (function)",
"renderDiffMessage": "src/shared/external-errors.ts#renderDiffMessage (function)",
"resilientFetch": "src/shared/resilient-fetch.ts#resilientFetch (function)",
"safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)",
"singularToPlural": "src/shared/metadata-collection.zod.ts#singularToPlural (function)",
"singularToPlural": "src/meta-spelling/manifest-collection-spelling.ts#singularToPlural (function)",
"strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)",
"suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)",
"tmpl": "src/shared/expression.zod.ts#tmpl (function)",
Expand Down
13 changes: 9 additions & 4 deletions packages/spec/scripts/build-meta-url-spelling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,10 +7,12 @@
*
* ## Why the map is generated instead of derived at module load
*
* The map's two sources are heavy on purpose-unrelated weight:
* The map's two sources were heavy on purpose-unrelated weight:
* `DEFAULT_METADATA_TYPE_REGISTRY` lives in the kernel zod graph, and
* `PLURAL_TO_SINGULAR` sits in a module that reaches the ADR-0087 conversion
* layer. Deriving at module load therefore made ONE string fold cost a
* `PLURAL_TO_SINGULAR` sat in a module that reaches the ADR-0087 conversion
* layer (it has since moved to the schema-free
* `meta-spelling/manifest-collection-spelling`, #11503 — the registry input
* still makes build time the right place). Deriving at module load made ONE string fold cost a
* browser consumer +60.1 KB gzipped (#10096's measurement). The 2026-08-20
* maintainer ruling minted the standing principle 「浏览器可达的 spec 导出面必
* 须 schema-free」 and approved moving the derivation to build time: this
Expand DownExpand Up@@ -40,7 +42,10 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

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

const OUT = join(
Expand Down
42 changes: 31 additions & 11 deletions packages/spec/src/meta-spelling/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,39 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `@objectstack/spec/meta-spelling` — the `/meta/:type` URL-spelling contract,
* `@objectstack/spec/meta-spelling` — metadata type-name spelling vocabulary,
* schema-free (#10096, maintainer ruling 2026-08-20).
*
* This entry exists so a browser/client consumer who needs to spell (or fold,
* or refuse) a `/meta/:type` path segment pays a few hundred bytes instead of
* linking the zod schema graph — the measured cost of reaching the same four
* symbols through `@objectstack/spec/shared` was +60.1 KB gzipped on a graph
* that already carried `/ui` and `/kernel`.
* or refuse) a metadata type name pays a few hundred bytes instead of linking
* the zod schema graph — the measured cost of reaching the same symbols
* through `@objectstack/spec/shared` was +60.1 KB gzipped on a graph that
* already carried `/ui` and `/kernel`.
*
* The standing principle this entry implements (recorded verbatim,
* untranslated): 「浏览器可达的 spec 导出面必须 schema-free」 — a
* browser-reachable export surface carries vocabulary (maps, folds, enums,
* pure predicates) without linking the zod schema/validation machinery.
*
* The published surface is the same four symbols `/shared` carries (#8424 —
* `/shared` keeps them; this entry is additive, one declaration re-exported):
* the map, the fold, and the two refusal verdicts. The map is derived at BUILD
* time from `PLURAL_TO_SINGULAR` and `DEFAULT_METADATA_TYPE_REGISTRY`
* (`gen:meta-url-spelling`), and `check:meta-url-spelling` enforces both its
* freshness and the manifest/derived spelling agreement on every CI lap.
* The published surface is TWO deliberately distinct spelling contracts
* (#8424 — merging them once shipped an authorization bypass; see
* `./metadata-url-spelling`'s module doc), each also kept on `/shared`
* (`/shared` keeps its surface; this entry is additive, one declaration
* re-exported per symbol):
*
* - the **`/meta/:type` URL-spelling contract** — `META_URL_TO_SINGULAR`,
* the fold, and the two refusal verdicts. The map is derived at BUILD time
* from `PLURAL_TO_SINGULAR` and `DEFAULT_METADATA_TYPE_REGISTRY`
* (`gen:meta-url-spelling`), and `check:meta-url-spelling` enforces both
* its freshness and the manifest/derived spelling agreement on every CI
* lap;
* - the **`defineStack()` manifest-collection vocabulary** (#11503) —
* `PLURAL_TO_SINGULAR` / `SINGULAR_TO_PLURAL` and their folds, whose keys
* are the collection properties an author writes in `defineStack()`. Widened
* onto this entry because `@objectstack/core` keys every metadata store on
* `pluralToSingular` (#7378 row 2 — the map has ONE owner), and reaching it
* through `/shared` put the schema graph on every browser consumer's eager
* closure.
*
* @module
*/
Expand All@@ -31,3 +44,10 @@ export {
metaUrlSpellingRefusal,
unrecognisedMetaTypeRefusal,
} from './metadata-url-spelling';

export {
PLURAL_TO_SINGULAR,
SINGULAR_TO_PLURAL,
pluralToSingular,
singularToPlural,
} from './manifest-collection-spelling';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#11503] The manifest-collection vocabulary's schema-free home, pinned.
*
* `PLURAL_TO_SINGULAR` moved from `shared/metadata-collection.zod.ts` to this
* entry's graph so `@objectstack/core`'s store-key fold (#7378 row 2 — the map
* has ONE owner) stops linking the `/shared` zod closure into every browser
* consumer (#10096 standing principle, recorded verbatim, untranslated:
* 「浏览器可达的 spec 导出面必须 schema-free」). Two facts must hold and are
* pinned here:
*
* 1. the move is a RE-EXPORT, not a fork — `/shared` and `/meta-spelling`
* hand out reference-identical bindings (a faithful copy would pass every
* value comparison, so identity is the discriminating check);
* 2. widening the entry did NOT merge the two spelling contracts (#8424) —
* the manifest map and the URL map stay distinct symbols with distinct
* key sets.
*
* Schema-freeness of the BUILT entry is not asserted here — that is
* `check:browser-reachable-entries`' job, on the real bundle.
*/

import { describe, expect, it } from 'vitest';
import {
PLURAL_TO_SINGULAR,
SINGULAR_TO_PLURAL,
pluralToSingular,
singularToPlural,
META_URL_TO_SINGULAR,
} from './index';

describe('#11503 — the manifest-collection vocabulary is the SAME contract on both entries', () => {
it('`/meta-spelling` and `/shared` hand out identical bindings (one declaration, two entries)', async () => {
const shared = await import('../shared/metadata-collection.zod');
expect(shared.PLURAL_TO_SINGULAR).toBe(PLURAL_TO_SINGULAR);
expect(shared.SINGULAR_TO_PLURAL).toBe(SINGULAR_TO_PLURAL);
expect(shared.pluralToSingular).toBe(pluralToSingular);
expect(shared.singularToPlural).toBe(singularToPlural);
});

it('folds a manifest collection key and passes unmapped names through', () => {
expect(pluralToSingular('objects')).toBe('object');
expect(singularToPlural('object')).toBe('objects');
expect(pluralToSingular('object')).toBe('object');
expect(pluralToSingular('not_a_collection')).toBe('not_a_collection');
});
});

describe('#8424 — widening the entry did not merge the two spelling contracts', () => {
it('keeps the manifest map and the URL map distinct symbols', () => {
expect(PLURAL_TO_SINGULAR).not.toBe(META_URL_TO_SINGULAR);
});

it('the manifest map still lacks the four registry-only spellings the URL map carries', () => {
// These are URL spellings of registry types that are NOT stack-level
// collections — adding any of them to the manifest map would advertise a
// `defineStack()` collection that does not exist (the `fields:` incident,
// see metadata-url-spelling.ts).
for (const key of ['fields', 'seeds', 'translations', 'external_catalogs', 'externalCatalogs']) {
expect(PLURAL_TO_SINGULAR[key], `${key} must not enter the manifest map`).toBeUndefined();
}
expect(META_URL_TO_SINGULAR['fields']).toBe('field');
expect(META_URL_TO_SINGULAR['seeds']).toBe('seed');
});
});
Loading
Loading