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
43 changes: 43 additions & 0 deletions .changeset/docs-gen-numeric-literal-quoting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": patch
---

fix(spec): the reference generator quotes a literal by its `typeof`, so numeric literals stop being written down as strings (#5729)

`formatType()`'s two literal branches wrapped every value in `'…'` without
looking at its type — `prop.enum.map(e => `'${e}'`)` and
`return `'${prop.const}'`` — so a **numeric** literal union was printed as a
**string** one. `FormSection.columns` is the specimen the issue was filed from:
it is declared

```ts
z.union([z.enum(['1', '2', '3', '4']), z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
```

and `content/docs/references/ui/view.mdx` rendered it as
`Enum<'1' | '2' | '3' | '4'> | '1' | '2' | '3' | '4'`, where the second half is
those four `z.literal(<number>)` variants. The two halves came out
indistinguishable, so the page claimed the key takes strings only — while the
schema accepts both `2` and `'2'`.

For these pages that is a contract error, not a typo. They are the authoritative
input for an AI author (ADR-0033), and a literal union is a copy-the-spelling
surface: whatever quoting the page shows is what gets written. #5611 paid for it
directly — its `RecordDetailsProps.sections[].columns` was meant to be a numeric
literal union, the generated reference said strings, and the PR abandoned the
shape for `z.number().int().min(1).max(4)` to escape the contradiction. The
generator was defining the contract backwards.

Quoting is now decided per **value**, not per node, because JSON Schema states
member types per value and an `enum` may mix them (`z.nativeEnum({ A: 1 })`
emits a numeric `enum`): strings stay quoted, `number`/`boolean` render bare,
and `null` renders as the keyword.

Eight reference pages change, 11 lines, and in one direction only — quotes are
removed from 18 non-string literals and no string literal loses its:
`api/dispatcher`, `api/errors` (`success: false`), `data/data-engine`
(`sort: Record< string, 1 | -1 >`), `data/driver-nosql` (`projection:
Record< string, 0 | 1 >`), `data/object` (`systemFields` / `stageField: false`),
`security/explain` (`version: 1`), `ui/dashboard` (`filterBindings`) and
`ui/view` (`columns: … | 1 | 2 | 3 | 4`). Every `Enum<'a' | 'b'>` on those
pages is untouched.
2 changes: 1 addition & 1 deletion content/docs/references/api/dispatcher.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Route-resolution failure mode emitted in `error.code`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `'false'` | ✅ | |
| **success** | `false` | ✅ | |
| **error** | `{ code: string; message: string; httpStatus?: integer; route?: string; … }` | ✅ | |


Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const result = EnhancedApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `'false'` | ✅ | Always false for error responses |
| **success** | `false` | ✅ | Always false for error responses |
| **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED'>; message: string; category?: Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>; httpStatus?: number; … }` | ✅ | Error details |
| **meta** | `{ timestamp?: string; requestId?: string; traceId?: string }` | optional | Response metadata |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ Query options for IDataEngine.find() operations
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
| **filter** | `Record<string, any> \| any` | optional | Data Engine query filter conditions |
| **select** | `string[]` | optional | |
| **sort** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, '1' \| '-1'> \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition |
| **sort** | `Record<string, Enum<'asc' \| 'desc'>> \| Record<string, 1 \| -1> \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition |
| **limit** | `integer` | optional | |
| **skip** | `integer` | optional | |
| **top** | `integer` | optional | |
Expand Down Expand Up @@ -382,7 +382,7 @@ Type: `Record<string, Enum<'asc' | 'desc'>>`

#### Option 2

Type: `Record<string, '1' | '-1'>`
Type: `Record<string, 1 | -1>`

---

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/driver-nosql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const result = AggregationPipelineSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **collection** | `string` | ✅ | Collection/table name |
| **stages** | `{ operator: string; options: Record<string, any> }[]` | ✅ | Aggregation pipeline stages |
| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record<string, '0' \| '1'>; timeout?: integer; … }` | optional | Query options |
| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record<string, 0 \| 1>; timeout?: integer; … }` | optional | Query options |


---
Expand Down Expand Up @@ -203,7 +203,7 @@ const result = AggregationPipelineSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override |
| **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas |
| **projection** | `Record<string, '0' \| '1'>` | optional | Field projection |
| **projection** | `Record<string, 0 \| 1>` | optional | Field projection |
| **timeout** | `integer` | optional | Query timeout (ms) |
| **useCursor** | `boolean` | optional | Use cursor instead of loading all results |
| **batchSize** | `integer` | optional | Cursor batch size |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const result = ApiMethod.parse(data);
| **managedBy** | `Enum<'platform' \| 'config' \| 'system-data' \| 'engine-owned' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system-data (platform-defined schema, admin/user-writable data) \| engine-owned (engine owns the lifecycle, no user writes) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. |
| **ownership** | `Enum<'user' \| 'org' \| 'none'>` | optional | Record-ownership model: user (default — injects reassignable owner_id) \| org \| none (no per-record owner, skips owner_id). Distinct from the package own/extend contribution kind. |
| **userActions** | `{ create?: boolean; import?: boolean; edit?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; delete?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; … }` | optional | Per-object override of the resolved CRUD affordance matrix. |
| **systemFields** | `'false' \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
Expand All @@ -135,7 +135,7 @@ const result = ApiMethod.parse(data);
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
| **titleFormat** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. |
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
| **stageField** | `string \| 'false'` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
| **listViews** | `Record<string, { name?: string; label?: string; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const result = AccessMatrixSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **version** | `'1'` | ✅ | |
| **version** | `1` | ✅ | |
| **entries** | `{ permissionSet: string; object: string; create: boolean; read: boolean; … }[]` | ✅ | |


Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Dashboard header action
| **values** | `string[]` | ✅ | Measure names — Y (at least one) |
| **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) |
| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record<string, any>` | optional | Widget specific configuration |
| **filterBindings** | `Record<string, string \| 'false'>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
| **filterBindings** | `Record<string, string \| false>` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out |
| **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget |
| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. The shared `ResponsiveConfig` shape is NOT gone — it stays live on `page.components[].responsive`, which objectui `useResponsiveConfig` really does read; move the layout there if you need breakpoint behaviour today. Run `os migrate meta --from 16` to rewrite it automatically. |
| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `app.aria` and `page.components[].aria`. Run `os migrate meta --from 16` to rewrite it automatically. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Column footer summary configuration
| **collapsed** | `boolean` | optional | |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). |
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| '1' \| '2' \| '3' \| '4'` | optional | |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; options?: { label: string; value: string; color?: string; default?: boolean; … }[]; reference?: string; … })[]` | ✅ | |

Expand Down
Loading
Loading