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
25 changes: 25 additions & 0 deletions .changeset/19722-scaffold-object-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/cli": patch
---

`os init` (the `app` and `plugin` templates) and `os generate object` now declare the object they scaffold with `ObjectSchema.create({ … })` — the one authorised shape for a `*.object.ts` — instead of a `Data.ServiceObject`-annotated object literal (#19722).

The factory parses the declaration against `ObjectSchema` when the file is evaluated, so a mistake surfaces in the file where it was written; the typed literal deferred every check to a build the author might never run. `create-objectstack`'s starter, the data-modeling docs ("Every object definition follows this pattern") and every object file in this repository already used the factory — the two CLI doors were the outliers, and they now write the same shape as each other and as everything else.

- **What a new scaffold contains**: `import { ObjectSchema } from '@objectstack/spec/data';` (a value import — the factory runs), `const myAppItem = ObjectSchema.create({ … });`, and the unchanged `export default myAppItem;`. The barrel lines both commands write (`export { default as … }`) are unchanged, as are the object's fields, its `sharingModel` and the comment explaining it.
- **Projects you already scaffolded keep working.** Nothing reads the old file differently at runtime, and nothing here renames or rewrites a file you have.
- **Converting an existing file is one mechanical rewrite** — wrap the literal in `ObjectSchema.create( … )`, drop the annotation, and import the factory:

```ts
// before
import * as Data from '@objectstack/spec/data';
const myAppItem: Data.ServiceObject = { name: 'my_app_item', /* … */ };
export default myAppItem;

// after
import { ObjectSchema } from '@objectstack/spec/data';
const myAppItem = ObjectSchema.create({ name: 'my_app_item', /* … */ });
export default myAppItem;
```

If the converted file now throws when it loads, the factory has found something the literal was carrying unchecked — an unknown top-level key, for example — and the message names it.
11 changes: 10 additions & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ os init my-app --no-install # Skip dependency installation
| `plugin` | **Metadata package**: declarative objects, built by `objectstack compile`, `private` — *not* the kernel code plugin `os create plugin` emits |
| `empty` | Minimal project with just `objectstack.config.ts` |

The `app` and `plugin` templates declare their starter object with
`ObjectSchema.create({ … })` — the one authorised shape for a `*.object.ts`, and the
same one `os generate object` writes. The factory validates the declaration against
the object protocol when the file is evaluated, so a mistake surfaces in the file
where it was written. A project scaffolded by an earlier release carries a
`Data.ServiceObject`-annotated object literal instead; converting it is one mechanical
rewrite — wrap the literal in `ObjectSchema.create( … )`, drop the annotation, and
import `ObjectSchema` from `@objectstack/spec/data`.

#### `os dev`

Starts development mode. Three usage shapes:
Expand Down Expand Up @@ -1438,7 +1447,7 @@ third-party extension primitive, authored as `src/skills/<name>.skill.ts` with
- `--dry-run` — Preview without writing files

**What it does:**
1. Creates a typed TypeScript file using `Data.ServiceObject`, `UI.View`, `Automation.Flow`, etc.
1. Creates the TypeScript file — an `object` declared with `ObjectSchema.create({ … })`, the same shape the `os init` templates write; a `skill` declared with `defineSkill({ … })`; the other types as typed literals (`UI.View`, `UI.Action`, `Automation.Flow`, `UI.Dashboard`, `UI.App`)
2. Creates or updates the barrel `index.ts` in the target directory
3. Shows a hint to run `objectstack validate`

Expand Down
24 changes: 18 additions & 6 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,25 @@ const GENERATORS: Record<string, {
* `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.
*
* The same parity covers the DECLARATION SHAPE. Both doors declare the
* object through `ObjectSchema.create({ … })`, the one authorised shape
* for a `*.object.ts` (ruling 5644350230, decision batch #122 item 1): the
* factory parses the declaration against `ObjectSchema` when the file is
* evaluated, so a mistake surfaces where it was written instead of at a
* build the author may never run. `ObjectSchema` is imported as a VALUE —
* a type-only import is erased at compile time, and the emitted module
* would then throw on its first evaluation. The binding stays the file's
* default export because the barrel line below re-exports `default` for
* every generator. `scaffold-object-declaration-shape.test.ts` pins both
* doors to one shape, so neither can move alone.
*/
generate: (name: string) => `import * as Data from '@objectstack/spec/data';
generate: (name: string) => `import { ObjectSchema } from '@objectstack/spec/data';

/**
* ${toTitleCase(name)} Object
*/
const ${toCamelCase(name)}: Data.ServiceObject = {
const ${toCamelCase(name)} = ObjectSchema.create({
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
pluralLabel: '${toTitleCase(name)}s',
Expand All @@ -119,7 +131,7 @@ const ${toCamelCase(name)}: Data.ServiceObject = {
// 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 Down Expand Up @@ -961,9 +973,9 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
//
// This command ran no name validation at all, so a name that is legal as a
// NAME but not as an IDENTIFIER was interpolated straight into a binding
// position and written out under `exit 0` — `const foo.bar:
// Data.ServiceObject = {`, plus a matching barrel line: two files that are
// not TypeScript, from a command that reported success.
// position and written out under `exit 0` — `const foo.bar =
// ObjectSchema.create({` in today's emission, plus a matching barrel line:
// two files that are not TypeScript, from a command that reported success.
//
// The criterion is PARSEABILITY, not a charset. `findEmissionParseFailures`
// asks the compiler about the bytes above and about nothing else, which is
Expand Down
12 changes: 6 additions & 6 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,9 +646,9 @@ export default defineStack({
srcFiles: {
'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object';
`,
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
'src/objects/__name___item.object.ts': (_name, namespace) => `import { ObjectSchema } from '@objectstack/spec/data';

const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
const ${toCamelCase(namespace)}Item = ObjectSchema.create({
name: '${namespace}_item',
label: '${toTitleCase(namespace)} Item',
fields: {
Expand Down Expand Up @@ -679,7 +679,7 @@ const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
// 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(namespace)}Item;
`,
Expand Down Expand Up @@ -741,9 +741,9 @@ export default defineStack({
srcFiles: {
'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item.object';
`,
'src/objects/__name___item.object.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
'src/objects/__name___item.object.ts': (_name, namespace) => `import { ObjectSchema } from '@objectstack/spec/data';

const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
const ${toCamelCase(namespace)}Item = ObjectSchema.create({
name: '${namespace}_item',
label: '${toTitleCase(namespace)} Item',
fields: {
Expand All @@ -760,7 +760,7 @@ const ${toCamelCase(namespace)}Item: Data.ServiceObject = {
// 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(namespace)}Item;
`,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/emitted-source-parses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* straight into a binding position:
*
* os generate object foo.bar exit 0
* src/objects/foo.bar.object.ts -> const foo.bar: Data.ServiceObject = {
* src/objects/foo.bar.object.ts -> const foo.bar = ObjectSchema.create({
* src/objects/index.ts -> export { default as foo.bar } from './foo.bar.object';
*
* Two files, neither of them TypeScript, and a command that reported success.
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/test/generate-emission-parses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* the name went into a binding position untouched:
*
* os generate object foo.bar exit 0
* src/objects/foo.bar.object.ts -> const foo.bar: Data.ServiceObject = {
* src/objects/foo.bar.object.ts -> const foo.bar = ObjectSchema.create({
* src/objects/index.ts -> export { default as foo.bar } from './foo.bar.object';
*
* One name, TWO broken files, and a command that reported success. The blast
Expand Down Expand Up @@ -145,7 +145,7 @@ describe('[#16541] CANARY — the card`s measured name is refused, at both emiss
const object = ROSTER.find((t) => t.type === 'object');
if (!object) throw new Error('the `object` generator is gone');
const { scaffold } = emissionsFor('object', object.generate, 'foo.bar');
expect(scaffold.source).toContain('const foo.bar: Data.ServiceObject = {');
expect(scaffold.source).toContain('const foo.bar = ObjectSchema.create({');
const failures = await findEmissionParseFailures([scaffold]);
expect(failures).toHaveLength(1);
expect(failures[0].diagnostics.length).toBeGreaterThan(0);
Expand All @@ -164,7 +164,7 @@ describe('[#16541] DISCRIMINATOR — the verdict comes from the compiler, not fr
const object = ROSTER.find((t) => t.type === 'object');
if (!object) throw new Error('the `object` generator is gone');
const { scaffold } = emissionsFor('object', object.generate, 'class');
expect(scaffold.source).toContain('const class:');
expect(scaffold.source).toContain('const class =');
expect(await findEmissionParseFailures([scaffold])).not.toEqual([]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
* ACCEPTED it (its emission parses clean, asserted here against the very
* instrument the command runs). Delete the gate and this name generates.
* - `class` — ADMITTED by the gate (every character is in the charset), and
* REFUSED by the parse check for `object`, because `const class:` is not a
* REFUSED by the parse check for `object`, because `const class =` is not a
* declaration. Delete the parse check and this name generates.
*
* ## Why a child process
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/test/generate-refuses-unparseable-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
*
* So the parse check is measured through `class`, added here for that purpose.
* It is inside the charset (every character is a lowercase letter), the gate
* admits it, and `const class:` is still not a declaration — so it is the name
* admits it, and `const class =` is still not a declaration — so it is the name
* that proves this command consults the compiler before it writes, and that
* the layer in front did not swallow the layer behind. ⛔ Nothing was deleted
* to make room for it: every `foo.bar` assertion that is still about the
Expand Down Expand Up @@ -252,7 +252,7 @@ describe('[#16541] CONTROL — an ordinary name is untouched by this change', ()
it('writes a scaffold that parses, still binding `orderLine`', () => {
const scaffold = readFileSync(join(controlDir, 'src', 'objects', 'order_line.object.ts'), 'utf8');
expect(parseErrors(scaffold)).toEqual([]);
expect(scaffold).toContain('const orderLine: Data.ServiceObject = {');
expect(scaffold).toContain('const orderLine = ObjectSchema.create({');
});

it('writes a barrel that parses, still re-exporting `orderLine`', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/test/scaffold-emission-typechecks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@
* the hand-written docs already used (`concepts/metadata-driven.mdx`,
* `getting-started/quick-reference.mdx`). Nothing was added to the spec.
*
* The object scaffolds have since moved off the annotation altogether: they
* declare through `ObjectSchema.create({ … })`, the one authorised shape for a
* `*.object.ts` (ruling 5644350230), whose return type is derived from the
* declaration it validates. This pin is unchanged by that — the other
* generators still annotate with namespace members (`UI.View`,
* `Automation.Flow`, …), and the factory's VALUE import from
* `@objectstack/spec/data` is exactly what this sandbox has to resolve.
*
* ## ⭐ Why every existing scaffold pin was green through it
*
* This package already had two scaffold sweeps, and NEITHER could see this
Expand Down
Loading
Loading