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
33 changes: 33 additions & 0 deletions .changeset/25761-seed-column-apply-on.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-protocol': minor
'@objectstack/skills': minor
---

feat(spec,metadata-protocol): a seed dataset column can be written only when its row is created (`columns.FIELD.applyOn: 'create'`)

Clause-②: yes
Metadata-protocol: added

A seed is replayed on every dev-server boot and every package re-publish, and an `upsert` replay re-asserts every column a record declares on the row it matched. That reverts the columns an app's own jobs write after the first seed (a status a payment job advances, a flag a renewal job sets), so a re-run undid the jobs' work with nothing in the log.

A seed dataset now takes a per-column write policy, keyed by field name:

```ts
defineSeed(Instalment, {
externalId: 'code',
columns: {
status: { applyOn: 'create' },
is_expiring: { applyOn: 'create' },
},
records: [{ code: 'INS-001', amount: 1200, status: 'pending', is_expiring: false }],
});
```

- **`applyOn: 'create'`** writes the column when the seed creates the row and leaves it out of every write to a row the seed matched by its `externalId`: an `upsert` replay over an existing row, and every row of an `update` dataset. The matched row keeps the value it holds, while the `always` columns are refreshed. A row whose only differences are create-only columns is not written at all. A create-only reference column on a matched row is not resolved and not deferred to pass 2, so nothing later in the load writes it back. A column of the `externalId` is the key the row was matched by and stays in its update. `insert`, `ignore` and `replace` never update a matched row, so nothing changes on those modes.
- **`applyOn: 'always'`** (the default, and every column without a `columns` entry) is the behaviour before this change.
- The values, the default and the meaning are those of the import door's `FieldMappingEntry.applyOn`. Both doors now read one enum, exported as `ColumnApplyOnSchema` from `@objectstack/spec/data`. `SeedColumnSchema` (with `SeedColumn` / `SeedColumnParsed`) is the per-column shape.
- A `columns` key must name a column the object has: a declared field, or a system column the platform injects on it such as `owner_id`. `defineSeed` refuses one that does not when it runs (and at compile time for an object literal), naming the key with a near miss. The seed loader logs an `error` naming the column, once per dataset, whenever it loads a seed that names one, since a stored seed never passes through `defineSeed`. The rows still load.
- `SeedSchema` refuses an unknown key on a column (`unrecognized_keys`), and a dataset-level `applyOn` is pointed at `columns`.

`@objectstack/skills` only regenerates its schema reference indexes, which now list `SeedColumnSchema` and `ColumnApplyOnSchema`.
50 changes: 49 additions & 1 deletion content/docs/data-modeling/seed-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,52 @@ so a typo like `'complete'` for `'completed'` still surfaces before boot.

---

## Columns Your App Owns After the First Seed — `applyOn: 'create'`

An `upsert` replay re-asserts every column a record declares on the row it
matched. That is right for master data and wrong for a column the app itself
owns once the row exists: a status a scheduled job advances, a flag a job sets
after it sends a notice, a reference a workflow re-points. Replayed, the seed
would put those back, undoing the job's work on every boot.

Declare such a column `applyOn: 'create'` under `columns`:

```typescript
defineSeed(Instalment, {
externalId: 'code',
mode: 'upsert',
columns: {
status: { applyOn: 'create' }, // the payment job owns it after the first seed
is_expiring: { applyOn: 'create' }, // the renewal job sets it
},
records: [
{ code: 'INS-001', amount: 1200, status: 'pending', is_expiring: false },
],
});
```

- When the seed **creates** the row, every column is written, `status` and
`is_expiring` included.
- When a replay **matches** an existing row, `status` and `is_expiring` are left
out of the write: the row keeps the values the jobs gave it, while `amount`
(`applyOn: 'always'`, the default) is refreshed. A row whose only differences
are create-only columns is not written at all.
- An `update` dataset never writes a create-only column. `insert`, `ignore` and
`replace` never update a matched row, so the policy changes nothing there.
- A create-only **reference** on a matched row is not resolved at all, so a
replay neither re-points it nor reports it.
- A column of the `externalId` is what the row was matched by, so it stays in
the update whatever it declares.

`applyOn` takes the same values, with the same default and meaning, as an import
mapping entry's [`applyOn`](/docs/data-modeling/import-mappings). A `columns` key
must name a field of the object (or a system column the platform injects on it,
such as `owner_id`): `defineSeed` refuses one that does not, and the loader logs
an error naming the column whenever it loads a seed that names one, since a
stored seed never passes through `defineSeed`.

---

## Environment Scoping

The `env` array controls which deployment environments receive the records. The
Expand Down Expand Up @@ -587,6 +633,7 @@ function defineSeed<
config: {
externalId?: string | string[]; // single field, or a composite list (join tables); default: 'name'
mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
columns?: { [field: string]: { applyOn?: 'create' | 'always' } }; // per-column write policy; default: 'always'
env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test']
locale?: string[]; // BCP-47 tags; omitted = every locale
records: Array<SeedRecord<TObj['fields']>>; // declared fields + injectable system columns
Expand All @@ -596,7 +643,8 @@ function defineSeed<

`SeedRecord` keys are the object's declared fields (a `lookup` / `master_detail`
field takes its target's natural-key string) plus the injectable system column
names. Every record key is checked again when the call runs.
names. Every record key, and every `columns` key, is checked again when the call
runs.

The returned `Seed` object is a plain serialisable value — pass it to your
stack's seed runner or store it in an export array.
Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Categories that have no section here at all are named under
[Categories Without a Section](#categories-without-a-section) — that curation is stated,
not left implicit.

## Data Protocol (15 of 29 schemas)
## Data Protocol (15 of 30 schemas)

Core business logic and data modeling schemas.

Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/api/package-api-assembled.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Installed package row whose manifest is the assembled package body
| **dependencies** | `Record<string, string>` | optional | Package dependencies |
| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. |
| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions |
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; columns?: Record<string, object>; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) |
| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). |
| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages |
Expand Down Expand Up @@ -281,7 +281,7 @@ This schema accepts one of the following structures:
| **dependencies** | `Record<string, string>` | optional | Package dependencies |
| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. |
| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions |
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) |
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; columns?: Record<string, object>; … }[]` | optional | Initial seed data (prefer top-level data field) |
| **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. |
| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). |
| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages |
Expand Down Expand Up @@ -345,7 +345,7 @@ This schema accepts one of the following structures:
| **dependencies** | `Record<string, string>` | optional | Package dependencies |
| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. |
| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions |
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; columns?: Record<string, object>; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) |
| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). |
| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages |
Expand Down
Loading
Loading