Skip to content

Commit 7eb5576

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-4m2bvo
# Conflicts: # packages/spec/src/type-alias-convention.pin.test.ts
2 parents 97934f0 + 493a6c4 commit 7eb5576

80 files changed

Lines changed: 2118 additions & 1764 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/skills': minor
5+
---
6+
7+
feat(spec,metadata-protocol): a seed dataset column can be written only when its row is created (`columns.FIELD.applyOn: 'create'`)
8+
9+
Clause-②: yes
10+
Metadata-protocol: added
11+
12+
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.
13+
14+
A seed dataset now takes a per-column write policy, keyed by field name:
15+
16+
```ts
17+
defineSeed(Instalment, {
18+
externalId: 'code',
19+
columns: {
20+
status: { applyOn: 'create' },
21+
is_expiring: { applyOn: 'create' },
22+
},
23+
records: [{ code: 'INS-001', amount: 1200, status: 'pending', is_expiring: false }],
24+
});
25+
```
26+
27+
- **`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.
28+
- **`applyOn: 'always'`** (the default, and every column without a `columns` entry) is the behaviour before this change.
29+
- 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.
30+
- 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.
31+
- `SeedSchema` refuses an unknown key on a column (`unrecognized_keys`), and a dataset-level `applyOn` is pointed at `columns`.
32+
33+
`@objectstack/skills` only regenerates its schema reference indexes, which now list `SeedColumnSchema` and `ColumnApplyOnSchema`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/lint': minor
5+
'@objectstack/metadata-protocol': minor
6+
'@objectstack/rest': minor
7+
'@object-ui/app-shell': minor
8+
'@object-ui/i18n': minor
9+
---
10+
11+
feat(spec,cli): reports are translatable — `reports.<name>` in the translation bundle
12+
13+
Clause-②: yes
14+
Metadata-protocol: added
15+
16+
An app authored in one language listed its reports (`reports[].label`, `reports[].description`) in that language on the app's report index and the report page heading. Before this change the translation bundle had no key for them: `reports` was a retired object-first dialect key, so a bundle carrying it was refused by `os validate`, and `os i18n check` could not report the gap.
17+
18+
- **`@objectstack/spec`:** `TranslationData` (and the registered `translation` item and the platform bundle, which share its groups) gains `reports`, keyed by the report's `name`, carrying `label` and `description` (strict: any other key is refused — `name` or `title` with a message pointing at `label`, and the singular spelling `report` with a message pointing at `reports`). `reports` leaves `LEGACY_OBJECT_FIRST_KEYS`, so a stored translation row carrying it now loads through the authored-row sync instead of being skipped as a retired dialect; the key's measured footprint in the legacy dialect was zero (#3778). A new translator, `translateReport`, overlays the two strings and is registered in the metadata-document dispatch, so `GET /meta/report` (list and item) is served in the request's locale. A key the bundle does not carry keeps the authored string. A report is overlay-editable (ADR-0126 Regime O), so the translator honours the packaged base: a label or description an org re-authored is served as written (ADR-0029 D9.2a), and the strings it did not touch are still translated.
19+
- **`@objectstack/metadata-protocol`:** `getPackagedReportBase(name)` returns the shipped report document beneath any overlay, the base the REST read path hands the translator.
20+
- **`@objectstack/rest`:** the meta read path supplies that base for `report`, as it does for `dashboard` and `view`.
21+
- **`@objectstack/lint`:** `translation-target-unknown` reports a `reports.<name>` key that names no report a package of the stack declares, as an error with the nearest declared name.
22+
- **`@objectstack/cli`:** `os i18n extract` scaffolds `reports.<name>.label` / `.description` from the stack's `reports[]`, and `os i18n check` / `os lint` coverage report an untranslated one in its own bucket (`i18n/missing-report`).
23+
- **`@object-ui/app-shell`, `@object-ui/i18n`:** the translation designer's preview draws the `reports` group (a "Reports" / 「报表」 category beside the others), and the console's translation loader recognises a payload that carries only `reports`.
24+
25+
Example (`zh-CN` bundle of an English-authored app):
26+
27+
```ts
28+
reports: { opps_by_stage: { label: '各阶段商机', description: '按阶段汇总的商机' } },
29+
```
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a refusal a flow's write node meets is worded in the language of the person who started the run, as their own record save is
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A validation rule tripped through a record door (`POST` / `PATCH /api/v1/data/...`, batch, import) answers in the caller's locale: the engine looks the authored `message` up at `objects.OBJECT._validations.RULE.message` in the app's bundle. The same rule tripped by a flow's `create_record` or `update_record` answered in the authored English, and a `DELETE_RESTRICTED` refusal met by `delete_record` named its objects by their English labels — so a `FLOW_FAILED` from `POST /api/v1/actions/...` carried an English `innerRefusal.message` on a request whose screens rendered in Chinese.
11+
12+
The run already knew the language (`AutomationContext.locale`, seeded by the `/actions` and trigger doors); `resolveRunDataContext` dropped it from the context its data nodes hand the engine. It now carries `locale` on both `runAs` arms, and the engine's one translation hook does the rest. Nothing new translates anything: a run no person started has no locale and keeps the authored text.
13+
14+
What to do: nothing. Ship your translations under `objects.OBJECT._validations.RULE.message` as you already do for the record doors; a flow write now reads the same entry.

‎content/docs/data-modeling/seed-data.mdx‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,52 @@ so a typo like `'complete'` for `'completed'` still surfaces before boot.
147147

148148
---
149149

150+
## Columns Your App Owns After the First Seed — `applyOn: 'create'`
151+
152+
An `upsert` replay re-asserts every column a record declares on the row it
153+
matched. That is right for master data and wrong for a column the app itself
154+
owns once the row exists: a status a scheduled job advances, a flag a job sets
155+
after it sends a notice, a reference a workflow re-points. Replayed, the seed
156+
would put those back, undoing the job's work on every boot.
157+
158+
Declare such a column `applyOn: 'create'` under `columns`:
159+
160+
```typescript
161+
defineSeed(Instalment, {
162+
externalId: 'code',
163+
mode: 'upsert',
164+
columns: {
165+
status: { applyOn: 'create' }, // the payment job owns it after the first seed
166+
is_expiring: { applyOn: 'create' }, // the renewal job sets it
167+
},
168+
records: [
169+
{ code: 'INS-001', amount: 1200, status: 'pending', is_expiring: false },
170+
],
171+
});
172+
```
173+
174+
- When the seed **creates** the row, every column is written, `status` and
175+
`is_expiring` included.
176+
- When a replay **matches** an existing row, `status` and `is_expiring` are left
177+
out of the write: the row keeps the values the jobs gave it, while `amount`
178+
(`applyOn: 'always'`, the default) is refreshed. A row whose only differences
179+
are create-only columns is not written at all.
180+
- An `update` dataset never writes a create-only column. `insert`, `ignore` and
181+
`replace` never update a matched row, so the policy changes nothing there.
182+
- A create-only **reference** on a matched row is not resolved at all, so a
183+
replay neither re-points it nor reports it.
184+
- A column of the `externalId` is what the row was matched by, so it stays in
185+
the update whatever it declares.
186+
187+
`applyOn` takes the same values, with the same default and meaning, as an import
188+
mapping entry's [`applyOn`](/docs/data-modeling/import-mappings). A `columns` key
189+
must name a field of the object (or a system column the platform injects on it,
190+
such as `owner_id`): `defineSeed` refuses one that does not, and the loader logs
191+
an error naming the column whenever it loads a seed that names one, since a
192+
stored seed never passes through `defineSeed`.
193+
194+
---
195+
150196
## Environment Scoping
151197

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

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

601649
The returned `Seed` object is a plain serialisable value — pass it to your
602650
stack's seed runner or store it in an export array.

‎content/docs/getting-started/quick-reference.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Categories that have no section here at all are named under
2222
[Categories Without a Section](#categories-without-a-section) — that curation is stated,
2323
not left implicit.
2424

25-
## Data Protocol (15 of 29 schemas)
25+
## Data Protocol (15 of 30 schemas)
2626

2727
Core business logic and data modeling schemas.
2828

‎content/docs/protocol/kernel/i18n-standard.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ Three rules the shape enforces, all of them closed since #4001:
399399

400400
- **The top level is the declared group set and nothing else** — `objects`,
401401
`picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`,
402-
`pages`, `flows`, `positions`, `permissions`, `metadataForms`,
402+
`reports`, `pages`, `flows`, `positions`, `permissions`, `metadataForms`,
403403
`settingsCommon`. A group invented beside them
404404
(`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected **by name**
405405
at both authoring doors, with the group to use instead named in the
@@ -483,7 +483,7 @@ what the overlay writes back as the node's `title`.
483483
The contract is `t(key, locale, params?)` — the target locale is always passed
484484
explicitly, and the key itself is returned when no translation is found (there
485485
is no `defaultValue` option). The key is a **dot path into `TranslationData`**,
486-
so it starts at one of the thirteen declared groups.
486+
so it starts at one of the fourteen declared groups.
487487

488488
```typescript
489489
const i18n = kernel.getService('i18n');
@@ -1063,6 +1063,7 @@ beneath an action entry are judged the same way under either.
10631063
| `flows.{flow}` | a flow's machine `name`, declared |
10641064
| `positions.{name}` | a position's `name`, declared, or an audience anchor (`everyone`, `guest`) |
10651065
| `permissions.{name}` | a permission set's `name`, declared |
1066+
| `reports.{name}` | a report's `name`, declared |
10661067
| `flows.{flow}.screens.{node}` | the `id` of a `type: 'screen'` node in that flow, nodes nested in loop, parallel and try/catch regions included |
10671068
| `flows.{flow}.screens.{node}.fields.{field}` | a `config.fields[].name` on that screen — an object-form screen (`config.objectName`) declares none, and its input labels live under `objects.{objectName}.fields` |
10681069

‎content/docs/references/api/package-api-assembled.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Installed package row whose manifest is the assembled package body
9999
| **dependencies** | `Record<string, string>` | optional | Package dependencies |
100100
| **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. |
101101
| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions |
102-
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
102+
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; columns?: Record<string, object>; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
103103
| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) |
104104
| **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`). |
105105
| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages |
@@ -281,7 +281,7 @@ This schema accepts one of the following structures:
281281
| **dependencies** | `Record<string, string>` | optional | Package dependencies |
282282
| **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. |
283283
| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions |
284-
| **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) |
284+
| **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) |
285285
| **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. |
286286
| **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`). |
287287
| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages |
@@ -345,7 +345,7 @@ This schema accepts one of the following structures:
345345
| **dependencies** | `Record<string, string>` | optional | Package dependencies |
346346
| **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. |
347347
| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions |
348-
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
348+
| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; columns?: Record<string, object>; … }[]` | optional | Seed Data / Fixtures for bootstrapping |
349349
| **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) |
350350
| **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`). |
351351
| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages |

0 commit comments

Comments
 (0)