Skip to content

Commit 5c7c5be

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19098-retire-generate-schema
2 parents fac432a + 08c8484 commit 5c7c5be

199 files changed

Lines changed: 1678 additions & 270 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Every example predicate in the `ConditionalValidationSchema` TSDoc docblock (all seven Use Cases, plus the ObjectStack side of the "Salesforce Pattern Comparison" block, above the schema in `packages/spec/src/data/validation.zod.ts`) is rewritten in the CEL the evaluator actually accepts, so an author or agent who copies a documented example gets a rule that evaluates instead of one that refuses every write it guards (#20026).
6+
7+
Clause-②: no
8+
9+
17 of the 19 predicates used `=` for comparison (or `AND`/`NOT`/`REGEX(...)` word-form operators) — a CEL parse error, since `@marcbachmann/cel-js` rejects a lone `=` as an unexpected character; CEL equality is `==`, and CEL has no `AND`/`OR`/`NOT` keyword form — and the other 2 (`order_total > 10000`, `approval_amount > 50000`) named a bare field with no `record.` root, an unknown-variable type error. `REGEX(tax_id, "…")` is rewritten to the evaluator's registered `matches(record.tax_id, "…")` stdlib function — the CEL form it accepts, not a literal transliteration — with the escape-free character class `[0-9]` rather than `\d`: a `\d` inside the CEL string literal parses fine as written in this TSDoc comment (comments are not JS-escape-processed), but a reader who pastes the same text into a real `.ts` string literal gets ONE level of JS unescaping the comment never applied, so `\\d` in the comment becomes `\d` at runtime and cel-js refuses it (`Invalid escape sequence: \d` — this was this round's own rework: a prior revision of this changeset claimed a passing result for that predicate without measuring it on the copied literal). The Salesforce-formula half of the comparison (`IF(ISPICKVAL(...), AND(...), FALSE)`) is deliberately untouched: it documents Salesforce's own syntax, not CEL.
10+
11+
Measured through `ExpressionEngine.evaluate` (`@objectstack/formula`), taking each predicate as the RUNTIME STRING a reader gets by pasting the docblock's literal into real `.ts` source — the literal is extracted from the source file byte for byte and handed to Node's own parser to unescape, never hand-retyped — against a record that makes each rewritten predicate true and one that makes it false; both branches evaluate as expected for all 19 (plus one extra disjunct check on the regex branch) — transcript in the PR body. The strings ship in the published `dist/object.zod-*.d.ts`, confirmed before and after this change with a lit/dark control: every rewritten string is present exactly once and every original broken string is absent.
12+
13+
| old (fails) | new (evaluates) |
14+
|:--|:--|
15+
| `account_type = "enterprise"` | `record.account_type == 'enterprise'` |
16+
| `approval_status = null` | `record.approval_status == null` |
17+
| `requires_shipping = true` | `record.requires_shipping == true` |
18+
| `shipping_address = null OR shipping_address = ""` | `record.shipping_address == null \|\| record.shipping_address == ''` |
19+
| `order_total > 10000` | `record.order_total > 10000` |
20+
| `manager_approval_id = null` | `record.manager_approval_id == null` |
21+
| `payment_method = null` | `record.payment_method == null` |
22+
| `region = "EU"` | `record.region == 'EU'` |
23+
| `gdpr_consent_given = false` | `record.gdpr_consent_given == false` |
24+
| `tos_accepted = false` | `record.tos_accepted == false` |
25+
| `country = "US"` | `record.country == 'US'` |
26+
| `state = "CA"` | `record.state == 'CA'` |
27+
| `tax_id = null OR NOT(REGEX(tax_id, "^\d{2}-\d{7}$"))` | `record.tax_id == null \|\| !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")` |
28+
| `is_taxable = true` | `record.is_taxable == true` |
29+
| `tax_code = null OR tax_code = ""` | `record.tax_code == null \|\| record.tax_code == ''` |
30+
| `user_role = "manager"` | `record.user_role == 'manager'` |
31+
| `approval_amount > 50000` | `record.approval_amount > 50000` |
32+
| `type = "enterprise"` (Salesforce comparison) | `record.type == 'enterprise'` |
33+
| `amount > 100000 AND approval = null` (Salesforce comparison) | `record.amount > 100000 && record.approval == null` |
34+
35+
`packages/spec/src/data/validation.test.ts`'s `ConditionalValidationSchema` fixtures move with the docblock (parse-only — `ValidationRuleSchema.parse(...).not.toThrow()`, no CEL evaluation, so no behaviour change): the six exact copies of Use Cases 1-3's original seven strings; `manager_approval = null` (the `order_value_validation` fixture whose message text and structure mirror Use Case 3's manager-approval example one field-name spelling apart), now `record.manager_approval == null`; and, under this round's rework, ten more fixtures whose surrounding test name and message text are verbatim copies of Use Cases 4, 5, 6 and 7's docblock text — `is_taxable = true` / `tax_code = null` (`tax_validation`, Use Case 6, the `tax_code` fixture now the full `record.tax_code == null || record.tax_code == ''`), `region = "EU"` / `gdpr_consent_given = false` / `tos_accepted = false` (`regional_validation`, Use Case 4), `user_role = "manager"` / `approval_amount > 50000` (`role_based_validation`, Use Case 7), and `country = "US"` / `state = "CA"` / `tax_id = null` (`nested_validation`, Use Case 5 — the `tax_id` fixture now the full `record.tax_id == null || !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")`, escape-free for the same copy-paste reason as the docblock). 17 fixture strings moved in total. No schema, behaviour, or public export changes — TSDoc text only, in the same two files the strings already lived in.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': patch
4+
---
5+
6+
`@objectstack/spec/data` declares which field types are masked on read — `MASKED_ON_READ_FIELD_TYPES` and `isMaskedOnReadFieldType(fieldType, managedBy)` (#20141)
7+
8+
Clause-②: yes
9+
10+
The protocol used to state this only in prose (the `FieldType` comments), so
11+
two consumers each carried their own hand-written copy: objectql's
12+
`collectMaskedReadFields` (the generic read mask and the echoed-mask write
13+
guard) and the renderer's masked-type set. The fact is now declared once:
14+
15+
- `MASKED_ON_READ_FIELD_TYPES` — a deep-frozen per-type rule table:
16+
`secret` is masked on every object; `password` is masked on every object
17+
except `managedBy: 'better-auth'` ones. Each masked type carries its own
18+
`exemptManagedBy` list, typed against `ObjectSchema.managedBy`'s enum.
19+
- `isMaskedOnReadFieldType(fieldType, managedBy)` — the one reading of that
20+
table. `managedBy` is a required argument (pass `undefined` when the object
21+
has none), and exemptions fail closed: an absent or unlisted `managedBy`
22+
never unmasks a masked type.
23+
24+
`@objectstack/objectql`: `collectMaskedReadFields` and
25+
`collectMaskedPasswordFields` now ask `isMaskedOnReadFieldType` instead of
26+
carrying their own `type === 'secret'` / `'password'` arms. No behaviour
27+
change: the masked-on-read answer is identical for every `FieldType` ×
28+
`managedBy` cell, pinned by a table test.
29+
30+
`@objectstack/spec`: `ObjectSchema.create()`'s author-time warning for a `password` field on a non-auth object now reads its `managedBy` exemption from `isMaskedOnReadFieldType` instead of hard-coding `'better-auth'`, so it follows the declaration; which objects and fields warn is unchanged.
31+
32+
A client that renders credential fields (show the mask, offer no copy) should
33+
derive its set from `isMaskedOnReadFieldType` rather than keep its own list,
34+
so the server's mask and the client's cannot drift apart.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: a flattened `view` overlay is judged by the member its `viewKind` names, so a column-less list patch has its list keys judged instead of stripped by the form member (#20186)
6+
7+
**BREAKING** accept-set change on the `view` write door (`PUT /api/v1/meta/view/:name`, the Studio and MCP save), and on every door that parses `ViewMetadataSchema` (the assembled-manifest `viewItems:` union included). It narrows, and it widens two small classes, both declared below. It ships as `minor` under the repo's launch-window convention for breaking changes.
8+
9+
Clause-②: yes (narrowing)
10+
11+
## What was wrong
12+
13+
The two flattened overlay members of `ViewMetadataSchema` shared one `viewKind: 'list' | 'form'` enum. The list member required `columns`, so it refused a column-less `viewKind: 'list'` body. The union then tried the form member, which requires no list key and `.strip()`s every one, and ACCEPTED the body. `diagnoseViewMetadata` named `formOverlay`, the parse output was `type: 'simple'` (a form), and the body's `sort`, `searchableFields` and `timeline` were never judged. Measured through the real `saveMetaItem` on `origin/main` @ `4df101c3`, and again at `ce70876e`: a retired bare-string `sort`, a `timeline.metaFields` and a non-array `searchableFields` each answered `success: true`, and the row held them as sent. The mirror held too: the list member accepted a `viewKind: 'form'` body carrying list `columns`.
14+
15+
That column-less list body is not a malformed one. It is what the console stores on every toolbar save (sort, hidden fields, inline edit, column widths) for a code-defined list view: objectui's `persistViewPatch` stores the patch and nothing else, per the maintainer ruling 「`persistViewPatch` 只存 patch,不存 merged base」.
16+
17+
## What it does now
18+
19+
- **One `viewKind` per member.** The list overlay member admits `viewKind: 'list'` only; the form overlay member admits `viewKind: 'form'` only. A flattened body is judged by the member its `viewKind` names, and `diagnoseViewMetadata` names that member.
20+
- **The list member judges a column-less patch.** `columns` is optional on the flattened list overlay member only. The authoring `ListViewSchema` keeps it required: an authored list view is a full config, never a patch. The console's patch-only writes keep saving, stored verbatim, and a lean list patch now parses to a list (`type: 'grid'`), not to `type: 'simple'`.
21+
- **A column-less body that names a `type` is still refused**, now located at `columns`: a body that sets `type` is a full inline config, and a full config lists its columns. The refusal names both ways out.
22+
- **A field list under a form overlay's `columns` is refused** at `columns`: on a form view `columns` is the body-column count.
23+
- The served JSON Schema (`/api/v1/meta/types/view`) is still an `anyOf` of four members. The only movements: each overlay member's `viewKind` enum names one value, the list overlay member no longer lists `columns` as required, and in the output direction it no longer lists `type` as required (the `grid` default is still declared and still applied).
24+
25+
## FROM → TO
26+
27+
Each row is refused now and was accepted before, on a flattened overlay:
28+
29+
| you wrote | write instead |
30+
|:--|:--|
31+
| `viewKind: 'list'`, no `columns`, `sort: 'name desc'` | `sort: [{ field: 'name', order: 'desc' }]` — the bare string clause was retired in 17.5.0 |
32+
| `viewKind: 'list'`, no `columns`, `sort: [{ field, direction: 'desc' }]` | `sort: [{ field, order: 'desc' }]` |
33+
| `viewKind: 'list'`, no `columns`, `timeline: { …, metaFields: [...] }` | delete `metaFields`: the timeline block has no such key |
34+
| `viewKind: 'list'`, no `columns`, `searchableFields: 'name'` | `searchableFields: ['name']` |
35+
| `viewKind: 'list'`, no `columns`, `sharing: { enabled: true, publicLink, … }` (the form public-link block) | the list `sharing` block, `sharing: { type: 'personal' \| 'collaborative', lockedBy? }`, or delete `sharing` |
36+
| any other list key the list view schema refuses, on a column-less list overlay | the value the list view schema accepts — the refusal names the key |
37+
| `viewKind: 'form'` with `columns: ['name', …]` | a field list means a list view: `viewKind: 'list'`; for a form, `sections: [{ fields: ['name', …] }]` and `columns` as a count (`columns: 2`) |
38+
39+
**The one-line fix:** read the refusal. It is located at the key it refuses and says what that key takes.
40+
41+
A column-less list overlay that names a `type` (`{ viewKind: 'list', type: 'kanban', … }` without `columns`) was refused before and is refused now; only its location moved, to `columns`. Add `columns`, or drop `type` to save the body as a patch.
42+
43+
## Declared widening (why `Clause-②: yes`)
44+
45+
Two classes of column-less, type-less `viewKind: 'list'` bodies go from refused to accepted:
46+
47+
- **W2** — a list-legal value under a key both members declare with different schemas: `aria` (the form member carries a retirement tombstone there), an i18n `description` (the form member takes a plain string only), the list `sharing` block (the form member's is the public-link block), and a valid legacy `options` bag (the form member pins `options` absent). Measured: `{ name, object, viewKind: 'list', aria: { ariaLabel: 'Leads' } }` was refused, and is accepted. This is the change working: a list body is judged by list rules.
48+
- **W1** — an invalid value under one of the 19 form-only keys (`layout`, `sections`, `title`, …). Measured: `{ name, object, viewKind: 'list', isPinned: true, layout: 'diagonal' }` was refused (the form member judged `layout`), and is accepted with `layout` dropped unread. That is the list member's existing handling of a key it does not declare: a list overlay WITH `columns` and `layout: 'diagonal'` was already accepted the same way. It is a named residual, not a contract.
49+
50+
## Census
51+
52+
- **objectstack** @ `4df101c3` (examples, packages): no source writes a flattened `viewKind: 'list'` body without `columns` as a literal; every literal hit is a test fixture, a changelog line or a comment. `examples/**` authors views as containers (15 files with `listViews`) and carries no `viewKind` at all.
53+
- **objectui**, at the `.objectui-sha` pin `f8a9d0fb0` and at `main` `25c7d584e`, and **cloud** `main` `48d7066`: no source literal either. The one real producer is dynamic: objectui's `buildPersistedViewBody` returns `{ ...patch, viewKind }` for an overlay and `updateViewConfig` stamps `object`, `name` and the overlay marker. Those bodies keep saving, now judged by the list member.
54+
- **Production `sys_metadata` rows: NOT MEASURED.** No deployment's store is reachable from the repository. A stored row that fails keeps being read and served exactly as stored. It is refused only on its next save, and the refusal names the key.
55+
56+
<!-- adr-0087: registered view-overlay-judged-by-viewkind-arm -->

‎content/docs/ai/actions-as-tools.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
title: Actions as Tools
2+
title: MCP tools — expose actions to AI agents
3+
navTitle: Actions as Tools
34
description: Expose declarative Action metadata as AI-callable tools with explicit opt-in, HITL approval, and permission-aware execution
45
---
56

‎content/docs/ai/agents.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
title: AI Agents
2+
title: AI agents — declare tools, model and prompt
3+
navTitle: AI Agents
34
description: The two platform agents (ask and build), how skills extend them, and the shape of an AgentSchema record
45
---
56

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
title: Connect an MCP Client
2+
title: MCP client setup — Claude, Cursor and IDEs
3+
navTitle: Connect an MCP Client
34
description: Point Claude Code, Claude Desktop, or any MCP client at your running app — OAuth or API key — and verify the agent can see and operate it.
45
---
56

‎content/docs/ai/index.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
title: AI Overview
2+
title: AI features — MCP tools, agents and RAG
3+
navTitle: AI Overview
34
description: Complete guide to leveraging AI agents, RAG pipelines, and intelligent automation in ObjectStack
45
---
56

‎content/docs/ai/knowledge-rag.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
title: Knowledge & RAG
2+
title: RAG — embeddings and knowledge retrieval
3+
navTitle: Knowledge & RAG
34
description: The Knowledge Protocol — permission-aware RAG for agents via pluggable adapter plugins (memory, RAGFlow, custom)
45
---
56

‎content/docs/ai/natural-language-queries.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
title: Natural Language Queries
2+
title: Natural language queries over your data
3+
navTitle: Natural Language Queries
34
description: How agents query live data through the open MCP data tools (query_records, get_record, aggregate_records) under RLS
45
---
56

‎content/docs/ai/skills-reference.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
2-
title: AI Skills Reference
2+
title: AI skill reference — every field explained
3+
navTitle: AI Skills Reference
34
description: Reference for every ObjectStack AI skill — domain-scoped knowledge modules that teach AI assistants the protocol's schemas and constraints
45
---
56

0 commit comments

Comments
 (0)