Skip to content

Commit f2d308e

Browse files
committed
wip: #4667 schema edits (in flight)
1 parent 5966c2a commit f2d308e

4 files changed

Lines changed: 142 additions & 27 deletions

File tree

packages/spec/src/system/book.zod.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { z } from 'zod';
44
import { lazySchema } from '../shared/lazy-schema';
55
import { strictObject } from '../shared/strict-object';
6+
import { retiredKey } from '../shared/retired-key';
67
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
78

89
/**
@@ -51,17 +52,44 @@ export const BookIncludeSchema = lazySchema(() =>
5152
);
5253
export type BookInclude = string | { tag: string };
5354

55+
/**
56+
* Book-level and group-level inline translation maps, retired in 17.0.0
57+
* (#4667, ADR-0049).
58+
*
59+
* The trap here was PROXIMITY, not plausibility. `doc.translations` — two files
60+
* over, the same shape, the same name — is read on every path that renders a
61+
* doc. So a book's own map reads as the same feature switched on one level up.
62+
* It never was: the tree endpoint and the portal render `label` / `description`
63+
* verbatim, and the generic bundle translator covers view / action / object /
64+
* app / dashboard / page only (`i18n-resolver.ts`). A localized book was parsed,
65+
* stored, round-tripped — and rendered in the authoring locale to every reader.
66+
*
67+
* Shared by both levels deliberately: an author who localized one almost
68+
* certainly localized the other, and splitting the wording would make the second
69+
* rejection read like a different problem.
70+
*/
71+
const BOOK_TRANSLATIONS_RETIRED =
72+
'Inline `translations` on a book (and on a book group) was removed in @objectstack/spec '
73+
+ '17.0.0 (#4667, ADR-0049) — no resolver ever read it. The book tree endpoint and the '
74+
+ 'docs portal render `label` / `description` verbatim in every locale, so a localized '
75+
+ 'book shipped its authoring-locale strings to every reader. Delete the key. NOTE the '
76+
+ 'near neighbour that DOES work: `doc.translations` is live and read on every doc render '
77+
+ 'path — localize the docs themselves, and the portal picks the reader\'s locale up from '
78+
+ 'there. Run `os migrate meta --from 16` to rewrite existing sources automatically.';
79+
5480
export const BookGroupSchema = lazySchema(() =>
5581
z.object({
5682
key: z
5783
.string()
5884
.regex(/^[a-z][a-z0-9_]*$/, 'group key must be lowercase snake_case')
5985
.describe('Stable group key (used by overrides, deep links, explicit `doc.group`)'),
6086
label: z.string().describe('Section title — first-class, i18n-homed'),
61-
translations: z
62-
.record(z.string(), z.object({ label: z.string() }))
63-
.optional()
64-
.describe('Per-locale label variants'),
87+
// TOMBSTONE, not a deletion: `BookGroupSchema` is a plain `z.object` with
88+
// no `.strict()`, so a bare delete would have zod silently STRIP the key —
89+
// replacing one silent no-op with another. `retiredKey` types it `never`
90+
// (a tsc error at the authoring site) and raises the prescription on parse.
91+
// Its liveness row therefore STAYS: the key is still in the walked shape.
92+
translations: retiredKey(BOOK_TRANSLATIONS_RETIRED),
6593
order: z.number().optional().describe('Order of THIS group within the book'),
6694
include: BookIncludeSchema.optional().describe('Rule that derives membership (glob or tag)'),
6795
package: z
@@ -77,7 +105,6 @@ export const BookGroupSchema = lazySchema(() =>
77105
export type BookGroup = {
78106
key: string;
79107
label: string;
80-
translations?: Record<string, { label: string }>;
81108
order?: number;
82109
include?: BookInclude;
83110
package?: string;
@@ -110,7 +137,12 @@ export const BookSchema = lazySchema(() =>
110137
aliases: {
111138
title: 'label', sections: 'groups', chapters: 'groups', toc: 'groups',
112139
access: 'audience', visibility: 'audience', sort: 'order', position: 'order',
113-
url: 'slug', path: 'slug', i18n: 'translations',
140+
url: 'slug', path: 'slug',
141+
// `i18n: 'translations'` retired with the key it pointed at (#4667).
142+
},
143+
guidance: {
144+
translations: BOOK_TRANSLATIONS_RETIRED,
145+
i18n: BOOK_TRANSLATIONS_RETIRED,
114146
},
115147
}, {
116148
name: z
@@ -119,9 +151,7 @@ export const BookSchema = lazySchema(() =>
119151
.describe('Book name (namespace prefix recommended, like every metadata name)'),
120152
label: z.string().optional().describe('Display title'),
121153
description: z.string().optional(),
122-
translations: z
123-
.record(z.string(), z.object({ label: z.string().optional(), description: z.string().optional() }))
124-
.optional(),
154+
// `translations` removed in 17.0.0 (#4667) — see BOOK_TRANSLATIONS_RETIRED.
125155
slug: z.string().optional().describe('Portal URL segment; defaults to name sans prefix'),
126156
icon: z.string().optional(),
127157
order: z.number().optional().describe('Orders books within the portal'),
@@ -141,7 +171,6 @@ export type Book = {
141171
name: string;
142172
label?: string;
143173
description?: string;
144-
translations?: Record<string, { label?: string; description?: string }>;
145174
slug?: string;
146175
icon?: string;
147176
order?: number;

packages/spec/src/system/job.zod.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,33 @@ export type RetryPolicy = z.infer<typeof RetryPolicySchema>;
8787
* }
8888
* }
8989
*/
90+
/**
91+
* `job.id`, retired in 17.0.0 (#4667, ADR-0049).
92+
*
93+
* The `describe()` did the damage: "defaults to `name` when omitted" implies an
94+
* identity OVERRIDE that never existed. Nothing read the key. `name` is the
95+
* job's identity at every layer that has one — the scheduling key, the `sys_job`
96+
* row key (the DB adapter upserts by `name` and mints its own row id), and the
97+
* `JobExecution.jobId` stamp — so two jobs differing only in `id` were never two
98+
* jobs, they were one job declared twice, with the second silently winning.
99+
*/
100+
const JOB_ID_RETIRED =
101+
'`job.id` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — nothing ever read '
102+
+ 'it, and its own description ("defaults to `name` when omitted") advertised an identity '
103+
+ 'override that did not exist. `name` IS the job\'s identity everywhere: the scheduling '
104+
+ 'key, the `sys_job` row key, and the `JobExecution.jobId` stamp. Two jobs differing only '
105+
+ 'in `id` were the same job. Delete the key; rename the job via `name` if you need a '
106+
+ 'different identity. Run `os migrate meta --from 16` to rewrite existing sources '
107+
+ 'automatically.';
108+
90109
export const JobSchema = lazySchema(() => strictObject({
91110
surface: 'this job',
92111
history:
93112
'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.',
94113
aliases: { cron: 'schedule', interval: 'schedule', fn: 'handler', function: 'handler', retry: 'retryPolicy', enabled_: 'enabled', timeoutMs: 'timeout' },
114+
guidance: { id: JOB_ID_RETIRED },
95115
}, {
96-
id: z.string().optional().describe('Unique job identifier (defaults to `name` when omitted)'),
116+
// `id` removed in 17.0.0 (#4667) — see JOB_ID_RETIRED. `name` is the identity.
97117
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Job name (snake_case)'),
98118
label: z.string().optional().describe('Human-readable label'),
99119
description: z.string().optional().describe('Job description / purpose'),

packages/spec/src/system/translation.zod.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,14 +281,35 @@ export type LegacyObjectFirstKey = (typeof LEGACY_OBJECT_FIRST_KEYS)[number];
281281
* when the two carry different inner shapes and the content has to be rewritten,
282282
* not renamed.
283283
*/
284-
const TRANSLATION_KEY_GUIDANCE: Record<LegacyObjectFirstKey, string> = {
284+
const TRANSLATION_KEY_GUIDANCE: Record<LegacyObjectFirstKey | 'validationMessages', string> = {
285+
// Not a legacy object-first key — a group that was live-looking and unread
286+
// until 17.0.0 (#4667, ADR-0049). The platform's own signature was on it
287+
// twice: the schema example showed a concrete override
288+
// ({"discount_limit": "折扣不能超过40%"}), and #3778's migration table steered
289+
// retired `errors:` authors straight into it. Both signposts pointed at a
290+
// group no resolver read — objectui's spec-translations transform passed it
291+
// through to the client tree and nothing downstream consumed it.
292+
validationMessages:
293+
'`validationMessages` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no '
294+
+ 'resolver ever read it, so a translated rule message was stored and never shown. '
295+
+ 'Validation messages are not translated through a translation group: author the '
296+
+ 'message on the rule itself (`object.validations[].message`), which the engine '
297+
+ 'evaluates and returns on every rejected write. Delete the key. Run '
298+
+ '`os migrate meta --from 16` to rewrite existing sources automatically.',
285299
o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.<object_name>'",
286300
app: "`app` is the retired object-first dialect, which no resolver reads — use 'apps.<app_name>'",
287301
nav: "`nav` is the retired object-first dialect, which no resolver reads — use 'apps.<app_name>.navigation.<node_id>.label'",
288302
dashboard: "`dashboard` is the retired object-first dialect, which no resolver reads — use 'dashboards.<dashboard_name>' (plural)",
289303
reports: '`reports` is the retired object-first dialect — reports have no translation group, omit them',
290304
notifications: '`notifications` is the retired object-first dialect — notifications have no translation group, omit them',
291-
errors: "`errors` is the retired object-first dialect — use 'validationMessages' for rule messages; other errors have no translation group",
305+
// Was: "use 'validationMessages' for rule messages". That was a signpost to a
306+
// key with no reader — #3778 retired `errors` by pointing authors at
307+
// `validationMessages`, which was itself dead, so taking the advice moved the
308+
// content from one unread group to another. Both are gone now (#4667).
309+
errors:
310+
'`errors` is the retired object-first dialect and has no replacement — rule messages are '
311+
+ 'not translated through a translation group at all. Author the message on the rule '
312+
+ "itself (`object.validations[].message`); omit `errors`.",
292313
_globalOptions: "`_globalOptions` is the retired object-first dialect — use 'objects.<object_name>.fields.<field_name>.options'",
293314
_meta: "`_meta` is the retired object-first dialect — use the top-level 'locale' field (on a bundle, the locale is the map key)",
294315
namespace: '`namespace` is not part of the translation contract — omit it (ADR-0006 D4 retired namespaces platform-wide)',
@@ -352,8 +373,10 @@ const translationDataShape = () => ({
352373
/** UI Messages */
353374
messages: z.record(z.string(), z.string()).optional().describe('UI message translations keyed by message ID'),
354375

355-
/** Validation Error Messages */
356-
validationMessages: z.record(z.string(), z.string()).optional().describe('Translatable validation error messages keyed by rule name (e.g., {"discount_limit": "折扣不能超过40%"})'),
376+
// `validationMessages` removed in 17.0.0 (#4667) — see the
377+
// TRANSLATION_KEY_GUIDANCE entry. Removing it from this shared shape retires
378+
// it at BOTH doors at once (bundle entry + registered item), which is the
379+
// asymmetry #3778's item-only guard got wrong.
357380

358381
/**
359382
* Global (object-less) action translations keyed by action name (snake_case).

packages/spec/src/ui/app.zod.ts

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -632,8 +632,8 @@ export const NavigationAreaSchema = lazySchema(() => z.object({
632632
/** Icon name (Lucide) */
633633
icon: z.string().optional().describe('Area icon name'),
634634

635-
/** Sort order among areas (lower = first) */
636-
order: z.number().optional().describe('Sort order among areas (lower = first)'),
635+
// `order` removed in 17.0.0 (#4667) — see AREA_ORDER_RETIRED. Reorder the
636+
// `areas` array instead; declaration order is display order.
637637

638638
/** Area description */
639639
description: I18nLabelSchema.optional().describe('Area description'),
@@ -652,8 +652,10 @@ export const NavigationAreaSchema = lazySchema(() => z.object({
652652
}, {
653653
error: strictUnknownKeyError({
654654
surface: 'this navigation area',
655-
knownKeys: ['id', 'label', 'icon', 'order', 'description', 'visible', 'requiredPermissions', 'navigation'],
656-
aliases: { visiblewhen: 'visible', visibleon: 'visible', title: 'label', name: 'id', sort: 'order', permissions: 'requiredPermissions', items: 'navigation', children: 'navigation' },
655+
knownKeys: ['id', 'label', 'icon', 'description', 'visible', 'requiredPermissions', 'navigation'],
656+
// `sort: 'order'` retired with the key it pointed at (#4667).
657+
aliases: { visiblewhen: 'visible', visibleon: 'visible', title: 'label', name: 'id', permissions: 'requiredPermissions', items: 'navigation', children: 'navigation' },
658+
guidance: { order: AREA_ORDER_RETIRED, sort: AREA_ORDER_RETIRED },
657659
history:
658660
'Until #4001 these were dropped silently — the area still parsed, so its gating or ' +
659661
'ordering was quietly ignored.',
@@ -888,6 +890,41 @@ const APP_KEYS = [
888890
'version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation',
889891
] as const;
890892

893+
/**
894+
* `app.homePageId`, retired in 17.0.0 (#4667, ADR-0049).
895+
*
896+
* The schema's own hedge gave it away — "if not set, usually defaults to the
897+
* first navigation item" describes the ONLY behaviour that exists. No shell
898+
* reads the key: an app's landing page is its first navigation item in `order`,
899+
* and the ROOT landing follows `isDefault` routing (objectui's
900+
* `RootLandingRedirect`). So an author pinning a home page got the first nav
901+
* item anyway, and "usually" was doing the work of "always".
902+
*/
903+
const HOME_PAGE_ID_RETIRED =
904+
'`app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no shell '
905+
+ 'ever read it. An app\'s landing page IS its first navigation item (by `order`), and the '
906+
+ 'root landing follows `isDefault` routing. Delete the key; to change where an app opens, '
907+
+ 'reorder `navigation` so the intended entry is first, and set `isDefault` on the app that '
908+
+ 'should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources '
909+
+ 'automatically.';
910+
911+
/**
912+
* `app.areas[].order`, retired in 17.0.0 (#4667, ADR-0049).
913+
*
914+
* The sibling that works is what made this one read alive: nav-item `order` IS
915+
* sorted (`NavigationRenderer.tsx:1154`). Area-level order is not — `AppSidebar`
916+
* and `AppSchemaRenderer` both iterate the `areas` array as authored — so
917+
* declaration order has always been display order, and an author who set
918+
* `order` to rearrange areas saw nothing move.
919+
*/
920+
const AREA_ORDER_RETIRED =
921+
'`areas[].order` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no renderer '
922+
+ 'ever sorted areas; both the sidebar and the schema renderer iterate the array as '
923+
+ 'authored, so declaration order already IS display order. Delete the key and reorder the '
924+
+ '`areas` array itself. NOTE the neighbour that behaves differently: a navigation ITEM\'s '
925+
+ '`order` is genuinely sorted — this removal does not touch it. Run '
926+
+ '`os migrate meta --from 16` to rewrite existing sources automatically.';
927+
891928
const appUnknownKeyError = strictUnknownKeyError({
892929
surface: 'this app',
893930
knownKeys: APP_KEYS,
@@ -902,9 +939,9 @@ const appUnknownKeyError = strictUnknownKeyError({
902939
sections: 'areas',
903940
groups: 'areas',
904941
permissions: 'requiredPermissions',
905-
home: 'homePageId',
906-
homepage: 'homePageId',
907-
landingpage: 'homePageId',
942+
// `home` / `homepage` / `landingpage` aliased `homePageId`, retired in
943+
// 17.0.0 (#4667). They fall through to the tombstone's own prescription
944+
// rather than renaming onto a key that no longer exists.
908945
agent: 'defaultAgent',
909946
logo: 'branding',
910947
theme: 'branding',
@@ -922,6 +959,11 @@ const appUnknownKeyError = strictUnknownKeyError({
922959
flows:
923960
'`flows` is not an App field — flows are top-level stack metadata ' +
924961
'(`defineStack({ flows })`), not app-scoped.',
962+
// The three retired `homePageId` aliases. `retiredKey` already answers the
963+
// canonical spelling; these cover the spellings that used to route to it.
964+
home: HOME_PAGE_ID_RETIRED,
965+
homepage: HOME_PAGE_ID_RETIRED,
966+
landingpage: HOME_PAGE_ID_RETIRED,
925967
},
926968
history:
927969
'Until #4001 these were dropped silently — the app still parsed, so navigation or ' +
@@ -1014,12 +1056,13 @@ export const AppSchema = lazySchema(() => z.object({
10141056
contextSelectors: z.array(AppContextSelectorSchema).optional()
10151057
.describe('App-level scope dropdowns whose value is injected into nav items as {<id>} template vars'),
10161058

1017-
/**
1018-
* App-level Home Page Override
1019-
* ID of the navigation item to act as the landing page.
1020-
* If not set, usually defaults to the first navigation item.
1059+
/**
1060+
* REMOVED in 17.0.0 (#4667) — see {@link HOME_PAGE_ID_RETIRED}. Tombstoned
1061+
* rather than deleted, matching the seven #4142 retirements on this schema:
1062+
* `retiredKey` types it `never`, so an app still authoring it fails to
1063+
* compile as well as to parse.
10211064
*/
1022-
homePageId: z.string().optional().describe('ID of the navigation item to serve as landing page'),
1065+
homePageId: retiredKey(HOME_PAGE_ID_RETIRED),
10231066

10241067
/**
10251068
* Access Control

0 commit comments

Comments
 (0)