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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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