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
50 changes: 50 additions & 0 deletions .changeset/generate-scaffolds-object-view-action-app-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` scaffolds `object`, `view`, `action` and `app` that `os validate` accepts (#14336)

Four of the seven `os generate` templates wrote artifacts the platform's own
validator refused, so an author following the documented path got a file their
own toolchain rejected. `#14087` fixed `flow` and recorded these four in a
shrink-only ledger; this empties it. Measured through the same harness, on the
same two steps `os validate` performs — schema parse, then the author-time rule
registry:

```
objects[0].sharingModel security-owd-unset — declares no sharingModel (OWD)
views[0].list unrecognized key(s) on this list view: `pageSize`
views[0] unrecognized key(s) on this view container: `type`, `objectName`
actions[0].type invalid option: expected "script"|"url"|"modal"|"flow"|"api"|"form"
actions[0] unrecognized key(s) on this action: `handler`
apps[0].navigation expected array, received object
```

**`object`** now authors `sharingModel: 'private'`. This is not a new decision:
`security-owd-unset` is an error-severity rule asking for an authored org-wide
default, and `#9666` already took that decision for the `os init` templates —
this emits the same value with the same explanation, so both doors an author
can arrive through agree.

**`view`** now emits a view CONTAINER instead of a flat list view. The
container's slots are `list` / `form` / `listViews` / `formViews`; `type`
belongs to a single view and the object binding is `object`, not `objectName`.
The flat shape mattered beyond the refusal — it parses to an *empty* container,
so zero views register and the Console renders nothing. `pageSize` moved to
`pagination`, which is the schema that declares it.

**`action`** now emits `type: 'flow'` with `target` naming the flow, which is
what its `handler: { type: 'flow', target }` block was trying to express.
`custom` is not an `ActionType`, and the second handler slot was removed in
protocol 17 so no consumer has two places to disagree about. The target is the
name `os g flow NAME` writes, so the two scaffolds compose.

**`app`** now emits `navigation` as the array of nav items it is declared as,
carrying one real `type: 'object'` entry rather than the `{ type: 'sidebar',
items: [] }` wrapper, which is not on the authoring surface at all. The entry
points at the object `os g object NAME` writes.

`KNOWN_UNVALIDATED_SCAFFOLDS` is now empty, so every generator on the roster is
held to the clean pin: a template that stops validating is red on the day it
lands. The ledger stays shrink-only — a red there is a template to fix, never a
line to add. No other generator's output changed.
102 changes: 85 additions & 17 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ const GENERATORS: Record<string, {
object: {
description: 'Business data object',
defaultDir: 'src/objects',
/**
* Carries an AUTHORED `sharingModel` (#14336).
*
* Unlike the other three repairs on that card this one is not shape drift:
* the object parsed fine and was refused one layer later, by
* `security-owd-unset` — an author-time ERROR rule saying the org-wide
* default must be a decision rather than an accident. So the scaffold
* handed the author a file their own `os validate` rejected.
*
* The value is NOT a fresh decision taken here. #9666 took it once for the
* `os init` templates, and this emits the SAME value with the same
* explanation, so the two doors an author can arrive through agree. If
* that template's value ever moves, this one moves with it.
*/
generate: (name: string) => `import * as Data from '@objectstack/spec/data';

/**
Expand All @@ -54,6 +68,13 @@ const ${toCamelCase(name)}: Data.Object = {
label: 'Description',
},
},
// Org-wide default (OWD): who can see records they don't own. 'private' is
// owner-only until access is widened by a permission grant or a sharing
// rule. Declaring it is required, deliberately: \`objectstack build\`
// refuses an object that declares no OWD, so the baseline is always an
// authored decision rather than an accident. The other values, and how to
// widen access safely: https://objectstack.ai/docs/permissions/sharing-rules
sharingModel: 'private',
};

export default ${toCamelCase(name)};
Expand All @@ -63,33 +84,66 @@ export default ${toCamelCase(name)};
view: {
description: 'List or form view',
defaultDir: 'src/views',
/**
* A view CONTAINER — which is what a `view` artifact is (#14336).
*
* `ViewSchema` is `.strict()` and its view slots are `list` / `form` /
* `listViews` / `formViews`; `type` and `objectName` belong to a single
* VIEW, not to the container holding it. The template used to write both
* spellings at once: a flat list view's keys on the container AND a `list`
* block. `defineView` has guarded the flat shape since the container was
* introduced, and for a reason worth restating — a flat view parses to an
* EMPTY container, so zero views register and the Console renders nothing.
*
* `pageSize` moved too: it is `PaginationConfigSchema`'s key, reached
* through the list view's `pagination`, not a key on the list view itself.
*
* The object binding is `object` — the key `getViewsByObject()` reads and
* the one a stack-level `views: [...]` entry needs to say which object its
* views belong to. `objectName` is the spelling on the QUERY surface.
*/
generate: (name: string) => `import * as UI from '@objectstack/spec/ui';

/**
* ${toTitleCase(name)} List View
* ${toTitleCase(name)} Views
*/
const ${toCamelCase(name)}ListView: UI.View = {
name: '${toSnakeCase(name)}_list',
label: '${toTitleCase(name)} List',
type: 'list',
objectName: '${toSnakeCase(name)}',
const ${toCamelCase(name)}Views: UI.View = {
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
object: '${toSnakeCase(name)}',
list: {
type: 'grid',
columns: [
{ field: 'name', width: 200 },
],
sort: [{ field: 'name', order: 'asc' }],
pageSize: 25,
pagination: { pageSize: 25 },
},
};

export default ${toCamelCase(name)}ListView;
export default ${toCamelCase(name)}Views;
`,
},

action: {
description: 'Button or batch action',
defaultDir: 'src/actions',
/**
* `type` comes from `ActionType` — `script | url | modal | flow | api |
* form` — and the handler binding is the single `target` slot (#14336).
*
* The template used to write `type: 'custom'`, which is not a member, plus
* a `handler: { type, target }` block, which is not an Action key: the
* `execute`/`handler` second slot was removed in protocol 17 precisely so
* no consumer has two places to disagree about. What that block was trying
* to express is exactly `type: 'flow'` with `target` naming the flow, so
* that is what it now says — and it targets the name `os g flow NAME`
* writes, so the two scaffolds compose.
*
* `target` is REQUIRED for every type but `script`, enforced by
* `ActionSchema`'s own refinement, so this cannot drift back to an action
* bound to nothing.
*/
generate: (name: string) => `import * as UI from '@objectstack/spec/ui';

/**
Expand All @@ -98,12 +152,9 @@ export default ${toCamelCase(name)}ListView;
const ${toCamelCase(name)}Action: UI.Action = {
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
type: 'custom',
type: 'flow',
objectName: '${toSnakeCase(name)}',
handler: {
type: 'flow',
target: '${toSnakeCase(name)}_flow',
},
target: '${toSnakeCase(name)}_flow',
};

export default ${toCamelCase(name)}Action;
Expand Down Expand Up @@ -198,6 +249,19 @@ export default ${toCamelCase(name)}Dashboard;
app: {
description: 'Application navigation',
defaultDir: 'src/apps',
/**
* `AppSchema.navigation` is an ARRAY of nav items (#14336).
*
* The template used to write `{ type: 'sidebar', items: [] }`. There is no
* `sidebar` wrapper on the authoring surface: the array IS the sidebar
* tree, and it nests through `type: 'group'` items carrying `children`.
*
* It scaffolds one real entry rather than an empty array, because the
* entry shape is the thing an author copies to add the second one — and
* because an app with no navigation renders a shell with nothing in it.
* The entry points at the object `os g object NAME` writes, so the two
* scaffolds compose.
*/
generate: (name: string) => `import * as UI from '@objectstack/spec/ui';

/**
Expand All @@ -206,10 +270,14 @@ export default ${toCamelCase(name)}Dashboard;
const ${toCamelCase(name)}App: UI.App = {
name: '${toSnakeCase(name)}_app',
label: '${toTitleCase(name)}',
navigation: {
type: 'sidebar',
items: [],
},
navigation: [
{
id: '${toSnakeCase(name)}_nav',
type: 'object',
label: '${toTitleCase(name)}s',
objectName: '${toSnakeCase(name)}',
},
],
};

export default ${toCamelCase(name)}App;
Expand Down
50 changes: 25 additions & 25 deletions packages/cli/test/generate-scaffold-validates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@
* either, so a generator added tomorrow is measured by this file on the day it
* lands rather than the day someone remembers to extend a hand-kept list.
*
* ## The ledger, and why this card did not empty it
* ## The ledger, and why it is now EMPTY
*
* Running the roster is how it emerged that `flow` is not the only scaffold
* `os validate` refuses. Measured on the same commit, same harness:
* Running the roster is how it emerged that `flow` was not the only scaffold
* `os validate` refuses. Measured by this harness when #14087 landed:
*
* object parses, then FAILS the author-time rules — `security-owd-unset`
* view `views[0].list.pageSize`, and `type` / `objectName` on the container
Expand All @@ -60,20 +60,27 @@
* dashboard clean
* skill clean
*
* Those four are a separate card by triage's own fence — a census of the other
* artifacts is explicitly NOT folded into #14087 — and are filed as #14336, so
* this file RECORDS them instead of fixing them, in the shrink-only shape this
* repo uses elsewhere
* (`KNOWN_UNALIASED_TEST_IMPORTS`, the type-check debt ledger). Two properties
* follow, and both are asserted below:
* Those four were a separate card by triage's own fence — a census of the
* other artifacts was explicitly NOT folded into #14087 — so this file first
* RECORDED them, in the shrink-only shape this repo uses elsewhere
* (`KNOWN_UNALIASED_TEST_IMPORTS`, the type-check debt ledger). #14336 then
* repaired all four templates and deleted all four entries, which is the whole
* lifecycle the ledger was built to have. Two properties held throughout, and
* both are still asserted below:
*
* - a kind NOT in the ledger must validate clean. That is the pin.
* - a kind IN the ledger must still FAIL. So whoever repairs one of them
* turns this file red and deletes its entry in the same PR; the ledger
* cannot quietly outlive the defect it records, and it can never grow to
* cover a regression (a newly-broken kind is not in it, so it just fails).
* - a kind IN the ledger must still FAIL. So whoever repairs one turns this
* file red and deletes its entry in the same PR; the ledger cannot quietly
* outlive the defect it records, and it can never grow to cover a
* regression (a newly-broken kind is not in it, so it just fails).
*
* `flow` is additionally asserted to be absent from the ledger, so this card's
* With the table empty, the second property has nothing to range over and the
* first covers the WHOLE roster: every generator this repo ships must write a
* stack `os validate` accepts, and a new one that does not is red on the day
* it lands. ⛔ The table is not the place to make that red go away — it is
* shrink-only, and it has already shrunk to nothing.
*
* `flow` is additionally asserted to be absent from the ledger, so #14087's
* own defect cannot be re-admitted by adding a line to a table.
*/

Expand All @@ -98,18 +105,11 @@ import { BUNDLE_REQUIRE_EXTERNALS } from '../src/utils/config.js';
* — see the header. Adding an entry to silence a failure is the one edit this
* table must never receive; the assertions below make a stale entry fail too.
*
* All four are #14336. Repair the template, delete the line, same PR.
* EMPTY since #14336 repaired the last four (`object` / `view` / `action` /
* `app`), which means every generator on the roster is held to the clean pin.
* Keep it that way: a red here is a template to fix, never a line to add.
*/
const KNOWN_UNVALIDATED_SCAFFOLDS: Record<string, string> = {
object:
'parses, then fails the author-time rules: `security-owd-unset` (no sharingModel authored).',
view:
'unrecognized `pageSize` on the list view, and `type` / `objectName` on the view container.',
action:
"`type: 'custom'` is not an Action type, and `handler` is not an Action key.",
app:
'`navigation` takes an array of nav items; the scaffold writes a `{ type, items }` object.',
};
const KNOWN_UNVALIDATED_SCAFFOLDS: Record<string, string> = {};

/** The name `os g <type> <name>` is invoked with throughout this file. */
const STEM = 'probe_thing';
Expand Down
Loading