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
2 changes: 1 addition & 1 deletion .changeset/19518-picklist-kind.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
'@objectstack/platform-objects': patch
Expand All @@ -11,7 +11,7 @@

- **The kind.** `PicklistSchema` — `{ name, label, description?, options }`, where `options` is the field option shape (`SelectOptionSchema`) reused as is. Authored in a package as `*.picklist.ts` (`definePicklist`) or `defineStack({ picklists })`. It is a registered kind (`MetadataTypeSchema`, `DEFAULT_METADATA_TYPE_REGISTRY`, `getMetadataTypeSchema('picklist')`) that loads before `object`. It is package-owned, so a runtime create or a per-organization overlay is refused.
- **The reference.** `Field.select({ picklist: 'industry' })` adds a `picklist` key to `FieldSchema`. It is valid on the option types only (select, radio, multiselect, checkboxes, tags). A field that declares both `picklist` and `options` is refused at `options`, with a prescription. The functional-completeness predicate counts a `picklist` reference as the field's option source.
- **The served shape.** `PicklistServedFieldSchema` declares what a client reads for a picklist-bound field: the resolved `options` next to the `picklist` that names the list. This release does not resolve the reference. Until the runtime does, a picklist-bound field is served without options, and the liveness ledger grades the key `planned` and warns an author who writes it.
- **The served shape.** `PicklistServedFieldSchema` declares what a client reads for a picklist-bound field: the resolved `options` next to the `picklist` that names the list. The runtime resolves the reference onto that served field; see the picklist runtime entry of this release.
- **Extensions.** `defineStack({ picklistExtensions: [{ extend, options }] })` adds options to a picklist that another package owns. It can only add; removing or renaming a value stays with the owning package.
- **Translation.** `TranslationData` gains `picklists.<name>.{ label?, options: { value: label } }`. `translatePicklist` translates a served picklist item. `translateObject` gives a picklist-bound field the list's option labels, and a field-level `options` entry still wins over them.
- **Studio type label.** `@objectstack/platform-objects` carries the `picklist` type's label and description in its metadata-forms translation bundles (en, zh-CN, ja-JP, es-ES).
Expand Down
17 changes: 17 additions & 0 deletions .changeset/19519-picklist-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/objectql': minor
'@objectstack/metadata': patch
'@objectstack/spec': patch
---

feat(objectql): the runtime resolves a field's `picklist` onto its served options, validates writes against the resolved list, and merges `picklistExtensions` additively

Clause-②: no

- **Load.** `defineStack({ picklists })` and `defineStack({ picklistExtensions })` now register, from a manifest and from a nested plugin, through the same registration seam as every other collection. The compiled-artifact door registers `picklists` as `picklist` items, so `GET /meta/picklist` serves them on an artifact boot.
- **Merge.** A picklist's options are its own, followed by the options every `picklistExtensions` entry adds. A value the list already carries is refused with `422 INVALID_METADATA`, which names both declarations, whichever of the two registered first. The later declaration never replaces the earlier one. A package that registers again replaces its own extension. Uninstalling a package removes the values it added.
- **Serve.** A field with `picklist: 'NAME'` is served with the resolved options written onto it and `picklist` kept (`PicklistServedFieldSchema`), on every object read, including objects stored in `sys_metadata`. The list's translations (`picklists.NAME.options.VALUE`) relabel those options per request locale. An option marked `default: true` in the list fills an omitted field on insert, as an inline option does, and the import template reads it the same way.
- **Unknown name.** A packaged field that names a picklist no loaded package declares fails the boot at `kernel:ready` with `INVALID_METADATA`, and so does a `picklistExtensions` entry that extends such a list. The error names every such field or extension and the package that declared it. After boot, an artifact registered through the `manifest` service is checked before any of it registers. A field whose list does not resolve is served with no options and accepts no value.
- **Write validation.** The write door judges a picklist-bound field against the resolved options, and its refusal names the picklist. The wire code stays `invalid_option`. The validation message catalog gains three message keys for this (`invalid_option_picklist`, `invalid_option_value_picklist`, `invalid_option_picklist_unresolved`) in en, zh-CN, ja-JP and es-ES. They change the message text only, never the wire.
- **Writing the served body back.** The served body carries `picklist` and `options` together. Writing it back through the metadata door is still refused, with the prescription to drop `options`, as `FieldSchema` declares. Nothing strips it on the write side.
- **Ledger.** `field.picklist`, the `picklist` kind's rows and `translation.picklists` are `live`. `field.picklist` no longer carries `authorWarn`, so `os lint` / `os validate` stop warning an author who writes it.
9 changes: 5 additions & 4 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,10 +1432,11 @@ describe('the object/field walk, against a synthetic ledger directory (#19268)',
// If `field.json` ever warns again these two flip, and the block above
// ("field walk: a malformed `fields` array …") can take its real subject back
// — but this block keeps working either way, which is the point.
it('the SHIPPED field ledger warns on `picklist` alone — which is why the walk needs a subject of its own', () => {
// `picklist` is `planned` + `authorWarn` until the server resolves picklist
// references; the synthetic slot below is still warned by nothing shipped.
expect([...authorWarnedProperties('field')]).toEqual(['picklist']);
it('the SHIPPED field ledger warns on nothing — which is why the walk needs a subject of its own', () => {
// `picklist` was the last warned row (`planned` + `authorWarn`) and left
// when the server began resolving picklist references (`live`); the
// synthetic slot below is warned by nothing shipped either.
expect([...authorWarnedProperties('field')]).toEqual([]);
expect(
lintLivenessProperties({
objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }],
Expand Down
11 changes: 11 additions & 0 deletions packages/metadata-protocol/src/reference-sites.derivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,14 @@ describe('[#9190] derived reference sites — derivation is a pure function of t
expect(flatten(a)).toEqual(flatten(REFERENCE_SITES));
});
});

describe('a field\'s `picklist` is a reference site of the `picklist` kind', () => {
// The admin "Used by" panel and the delete-safety check read this index. A
// select field that names a shared list (`Field.select({ picklist })`)
// must count as a use of that list, or the list reads as safe to delete
// while objects take their options from it. Derived by the naming rule —
// the property spells the target — so no row exists to forget.
it('object.fields{}.picklist points at the picklist it names', () => {
expect(sitesFor('picklist')).toContainEqual({ fromType: 'object', property: 'picklist' });
});
});
6 changes: 6 additions & 0 deletions packages/metadata/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
ragPipelines: 'rag_pipeline',
hooks: 'hook',
mappings: 'mapping',
// Shared option lists. Registered as items here so the artifact boot
// serves `GET /meta/picklist` like every other kind; what a FIELD is
// served with is resolved by the ObjectQL registry, which also merges
// `picklistExtensions` into the list they name — that collection is not a
// kind of its own and has no entry here (see check:stack-collection-maps).
picklists: 'picklist',
analyticsCubes: 'analytics_cube',
connectors: 'connector',
emailTemplates: 'email_template',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const REPRESENTATIVE: Record<string, Record<string, unknown>> = {
hook: { name: 'account_audit', object: 'account', events: ['beforeInsert'], handler: 'audit_account' },
seed: { object: 'account', records: [{ name: 'Acme' }] },
mapping: { name: 'account_import', targetObject: 'account', fieldMapping: [] },
picklist: { name: 'industry', label: 'Industry', options: [{ label: 'Technology', value: 'technology' }] },
datasource: { name: 'warehouse', driver: 'sqlite', config: {} },
analytics_cube: { name: 'account_cube', sql: 'account', measures: {}, dimensions: {} },
page: { name: 'account_home', label: 'Account Home', regions: [] },
Expand Down Expand Up @@ -81,7 +82,7 @@ const REPRESENTATIVE: Record<string, Record<string, unknown>> = {
*/
const BOUND_SCHEMA: Record<string, string> = {
object: 'ObjectSchema', field: 'FieldSchema', hook: 'HookSchema', seed: 'SeedSchema',
mapping: 'MappingSchema', datasource: 'DatasourceSchema', analytics_cube: 'CubeSchema',
mapping: 'MappingSchema', picklist: 'PicklistSchema', datasource: 'DatasourceSchema', analytics_cube: 'CubeSchema',
page: 'PageSchema', dashboard: 'DashboardSchema', app: 'AppSchema', action: 'ActionSchema',
report: 'ReportSchema', dataset: 'DatasetSchema', flow: 'FlowSchema', webhook: 'WebhookSchema',
job: 'JobSchema', translation: 'TranslationItemSchema', email_template: 'EmailTemplateDefinitionSchema',
Expand Down
1 change: 1 addition & 0 deletions packages/metadata/src/serializers/typescript-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ANNOTATION_BY_METADATA_TYPE: ReadonlyMap<string, readonly [typeName: strin
['hook', ['Hook', 'data']],
['seed', ['Seed', 'data']],
['mapping', ['Mapping', 'data']],
['picklist', ['Picklist', 'data']],
['datasource', ['Datasource', 'data']],
['analytics_cube', ['Cube', 'data']],
['page', ['Page', 'ui']],
Expand Down
Loading
Loading