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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
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
48 changes: 48 additions & 0 deletions .changeset/generate-flow-scaffold-validates.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate flow` scaffolds a flow `os validate` accepts (#14087)

The `flow` scaffold could not survive its own toolchain. Measured on 17.2.0,
`os g flow my_flow` followed by `os validate` produced four refusals in one
parse:

```
flows[0].nodes[0].label expected string, received undefined
flows[0].nodes[0] unrecognized key(s): `name`, `next`
flows[0].edges expected array, received undefined
flows[0] unrecognized key(s): `trigger`
```

`FlowSchema` is `.strict()` and has never declared a top-level `trigger` on
protocol 17. A record-change flow binds on the START node's `config` —
`{ objectName, triggerType, condition }` — which is where
`AutomationEngine.resolveTriggerBinding` reads it from. The scaffold also wrote
an `events: ['after_insert', 'after_update']` vocabulary that exists nowhere on
the current surface, and named a single node it then pointed at a node it never
emitted.

So the first flow anybody scaffolded was a file their own `os validate`
rejected — against a `.strict()` error enumerating what is allowed rather than
saying where the trigger had moved to.

The template now emits the shape the schema accepts and the engine binds:
`type: 'record_change'`, a labelled START node carrying
`config: { objectName, triggerType: 'record-after-write' }`, a labelled END
node, and the `edges` array joining them. `status` stays `'draft'` — the arming
decision is the author's, and `os validate` says so.

`generate-scaffold-validates.test.ts` puts every generator's output through the
two steps `os validate` runs on an authored stack (schema parse, then the
author-time rule registry), loaded through the same `bundle-require` path
`loadConfig` uses, so the generator and the schema cannot drift apart again
silently. Both layers are needed: a flow node's `config` is an open slot
(ADR-0018), so the schema cannot judge the trigger vocabulary at all — the
`record-*` grammar is held by `validate-flow-trigger-readiness` one layer
later.

No other generator's output changed. Four of them (`object`, `view`, `action`,
`app`) are refused for unrelated reasons of their own; the new test records
them in a shrink-only ledger that fails when one is repaired and its entry is
left behind.
77 changes: 62 additions & 15 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,29 @@ export default ${toCamelCase(name)}Action;
flow: {
description: 'Automation flow',
defaultDir: 'src/flows',
/**
* A record-change flow in the shape `FlowSchema` accepts (#14087).
*
* What this template used to write refused to load: a top-level `trigger:
* { type, object, events }` block, nodes carrying `name`/`next`, and no
* `edges`. `FlowSchema` is `.strict()` and declares none of that, so the
* FIRST flow anybody scaffolded was a file their own `os validate`
* rejected — with an error enumerating what is allowed rather than saying
* where the trigger had moved to.
*
* The binding lives on the START node's `config`, which is where
* `AutomationEngine.resolveTriggerBinding` reads it from: `objectName`,
* one `record-*` `triggerType` token, and an optional bare-CEL
* `condition`. `triggerType` is NOT judged by the schema — a node `config`
* is an open slot (ADR-0018) — so the token's grammar is held by
* `validate-flow-trigger-readiness`, an author-time rule `os validate`
* gates on. `generate-scaffold-validates.test.ts` puts this output through
* both layers, which is the drift this template is not allowed to repeat.
*
* `status` stays `'draft'`: the scaffold fixes the SHAPE and leaves the
* arming decision to the author (`os validate` says so — draft flows do
* fire, so declare `'active'` to arm deliberately).
*/
generate: (name: string) => `import * as Automation from '@objectstack/spec/automation';

/**
Expand All@@ -121,20 +144,32 @@ export default ${toCamelCase(name)}Action;
const ${toCamelCase(name)}Flow: Automation.Flow = {
name: '${toSnakeCase(name)}_flow',
label: '${toTitleCase(name)} Flow',
type: 'autolaunched',
type: 'record_change',
status: 'draft',
trigger: {
type: 'record_change',
object: '${toSnakeCase(name)}',
events: ['after_insert', 'after_update'],
},
nodes: [
{
id: 'start',
type: 'start',
name: 'Start',
next: 'end',
label: 'Start',
// A record-change flow binds its trigger HERE, on the START node's
// config — there is no top-level \`trigger\` key.
// objectName the object whose writes fire this flow
// triggerType one record-{before,after}-{create,update,delete,write}
// token ('write' is create OR update, in one flow)
// condition optional bare-CEL gate, e.g. 'record.amount >= 500'
config: {
objectName: '${toSnakeCase(name)}',
triggerType: 'record-after-write',
},
},
{
id: 'end',
type: 'end',
label: 'End',
},
],
edges: [
{ id: 'e1', source: 'start', target: 'end', type: 'default' },
],
};

Expand DownExpand Up@@ -257,15 +292,27 @@ export default ${toCamelCase(name)}Skill;
};

/**
* Every metadata type `os generate` can scaffold, with the directory it
* scaffolds into — derived from `GENERATORS` rather than restated.
* Every metadata type `os generate` can scaffold — the directory it scaffolds
* into, and the source it writes — derived from `GENERATORS` rather than
* restated.
*
* Exported for `generate-file-name-registry-parity.test.ts`, and derived on
* purpose: the pin's job is to hold for the NEXT generator somebody adds, and
* a hand-kept list would leave that one unmeasured while still reading green.
* Exported for `generate-file-name-registry-parity.test.ts` (which reads
* `type` / `defaultDir`) and `generate-scaffold-validates.test.ts` (which
* reads `generate` to materialize each scaffold and put it through the schema
* `os validate` parses it with). Derived on purpose: each pin's job is to hold
* for the NEXT generator somebody adds, and a hand-kept list would leave that
* one unmeasured while still reading green.
*/
export const GENERATOR_SCAFFOLD_TARGETS: readonly { type: string; defaultDir: string }[] =
Object.entries(GENERATORS).map(([type, gen]) => ({ type, defaultDir: gen.defaultDir }));
export const GENERATOR_SCAFFOLD_TARGETS: readonly {
type: string;
defaultDir: string;
generate: (name: string) => string;
}[] =
Object.entries(GENERATORS).map(([type, gen]) => ({
type,
defaultDir: gen.defaultDir,
generate: gen.generate,
}));

// ─── Retired Generators ─────────────────────────────────────────────

Expand Down
Loading
Loading