diff --git a/.changeset/grouping-notify-describe-align.md b/.changeset/grouping-notify-describe-align.md new file mode 100644 index 0000000000..55fa4d1809 --- /dev/null +++ b/.changeset/grouping-notify-describe-align.md @@ -0,0 +1,8 @@ +--- +'@objectstack/spec': patch +--- + +Align two schema `.describe()` strings with their measured acceptance faces (docs-only; no acceptance change — every previously-valid input is judged byte-identically): + +- `GroupingConfigSchema.fields` no longer claims "(supports up to 3 levels)". The gate is `.min(1)` with no upper bound, nothing downstream enforces a cap, and the grid renderer recurses over all configured levels — the describe now states the shape instead: array order is nesting order (first entry outermost), at least one field. (#7084) +- `NotifyConfigSchema.sourceObject` / `sourceId` no longer say "Requires sourceId." / "Requires sourceObject.". The schema deliberately accepts the half-specified pair — the executor drops it at execute time so the inbox never renders a dead link (the module JSDoc's recorded contract) — and the describes now state that tolerance. (#7085) diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index 213dc5acf3..5b51cc6826 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -101,8 +101,8 @@ const result = HttpConfigSchema.parse(data); | **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) | | **topic** | `string` | optional | Event topic (default: "notify") | | **severity** | `string` | optional | info \| warning \| critical | -| **sourceObject** | `string` | optional | Object name of the record the notification links to (writes sys_notification.source_object). Requires sourceId. | -| **sourceId** | `string` | optional | Record id the notification links to (writes sys_notification.source_id). Requires sourceObject. | +| **sourceObject** | `string` | optional | Object name of the record the notification links to (writes sys_notification.source_object). Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. | +| **sourceId** | `string` | optional | Record id the notification links to (writes sys_notification.source_id). Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link. | | **actorId** | `string` | optional | User id that caused the event (writes sys_notification.actor_id) | | **actionUrl** | `string` | optional | Explicit click-through URL; overrides the link synthesized from sourceObject/sourceId | | **payload** | `Record` | optional | Extra template inputs merged into the notification payload | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 3754b24c35..0b009859a7 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -340,7 +340,7 @@ Record grouping configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **fields** | `{ field: string; order: Enum<'asc' \| 'desc'>; collapsed: boolean }[]` | ✅ | Fields to group by (supports up to 3 levels) | +| **fields** | `{ field: string; order: Enum<'asc' \| 'desc'>; collapsed: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field) | --- diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index 83b346653a..9b1e02592f 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -102,6 +102,40 @@ describe('NotifyConfigSchema — strict as of #4001 批 9', () => { .error?.issues.some((i) => i.code === 'unrecognized_keys')).not.toBe(true); } }); + + it('sourceObject/sourceId describes state the documented pair tolerance, not a phantom requirement (#7085)', () => { + const shape = (NotifyConfigSchema as unknown as { shape: Record }).shape; + for (const [key, partner] of [ + ['sourceObject', 'sourceId'], + ['sourceId', 'sourceObject'], + ] as const) { + const doc = shape[key]!.description ?? ''; + + // Non-empty arm FIRST — the negative arm below passes vacuously on '' + // (the #6918 demonstration), so this arm is what gives it teeth. + expect(doc.length, `${key} .describe() must not be empty`).toBeGreaterThan(0); + + // Substance, by idiom borrowed from the module JSDoc (#6881 — no third + // spelling): the pair only takes effect together, and a half-specified + // click-through target is DROPPED at execute time rather than rejected + // at the gate. + expect(doc).toMatch(/only takes effect together/i); + expect(doc).toContain(partner); + expect(doc).toMatch(/dropped at execute time/i); + + // The #7085 defect: "Requires ." read as gate-enforced + // requiredness, while the schema deliberately keeps both keys optional + // (module JSDoc: the executor tolerates/drops the half pair). The + // phantom-requirement wording must not return in any casing or tense. + expect(doc).not.toMatch(/\brequire[sd]?\b/i); + } + + // The tolerance the describes now document, proven live on the same + // schema — this is the acceptance face this change must NOT move: each + // half pair still parses green. + expect(NotifyConfigSchema.safeParse({ recipients: 'u1', title: 't', sourceObject: 'showcase_task' }).success).toBe(true); + expect(NotifyConfigSchema.safeParse({ recipients: 'u1', title: 't', sourceId: 'r1' }).success).toBe(true); + }); }); describe('HttpConfigSchema — strict as of #4001 批 9', () => { diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 03dd68e1cd..d55f3fad26 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -160,10 +160,10 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ severity: z.string().optional().describe('info | warning | critical'), /** Click-through target object — only effective together with `sourceId` (#2675). */ sourceObject: z.string().optional() - .describe('Object name of the record the notification links to (writes sys_notification.source_object). Requires sourceId.'), + .describe('Object name of the record the notification links to (writes sys_notification.source_object). Only takes effect together with sourceId — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link.'), /** Click-through target record id — only effective together with `sourceObject`. */ sourceId: z.string().optional() - .describe('Record id the notification links to (writes sys_notification.source_id). Requires sourceObject.'), + .describe('Record id the notification links to (writes sys_notification.source_id). Only takes effect together with sourceObject — a half-specified click-through target is dropped at execute time, so the inbox never renders a dead link.'), /** User id that caused the event. */ actorId: z.string().optional().describe('User id that caused the event (writes sys_notification.actor_id)'), /** Explicit click-through URL; overrides the sourceObject/sourceId link. */ diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 56e846111c..d740ad1bff 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -1511,6 +1511,31 @@ describe('GroupingConfigSchema', () => { expect(() => GroupingConfigSchema.parse(grouping)).toThrow(); }); + + it('fields .describe() states shape semantics without a fixed level cap (#7084)', () => { + const shape = (GroupingConfigSchema as unknown as { shape: Record }).shape; + const doc = shape.fields!.description ?? ''; + + // Non-empty arm FIRST — the negative arms below pass vacuously on '', + // so this arm is what makes them non-vacuous (the #6918 demonstration). + expect(doc.length, 'fields .describe() must not be empty').toBeGreaterThan(0); + + // Substance, by idiom not verbatim: array order IS nesting order, and the + // gate's real lower bound (`.min(1)`) is stated. + expect(doc).toMatch(/nesting order/i); + expect(doc).toMatch(/outermost/i); + expect(doc).toMatch(/at least one/i); + + // The #7084 defect must not return under a new number: the gate is + // `.min(1)` with NO upper bound, and nothing downstream enforces one + // either (objectui useGroupedData's buildLevel recurses over ALL + // configured levels — its only stop is `depth >= fields.length`). So any + // fixed-count support envelope here is prose the acceptance face does not + // have; house rule E17 says "up to N" is the same defect as "up to 3". + expect(doc).not.toMatch(/\bup to \d+\b/i); + expect(doc).not.toMatch(/\b\d+\s+levels?\b/i); + expect(doc).not.toMatch(/\bmax(?:imum)?(?:\s+of)?\s+\d+\b/i); + }); }); describe('GroupingFieldSchema', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index afa68f516a..c166dc7d06 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -559,7 +559,7 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({ surface: 'this grouping configuration', history: VIEW_HISTORY, }, { - fields: z.array(GroupingFieldSchema).min(1).describe('Fields to group by (supports up to 3 levels)'), + fields: z.array(GroupingFieldSchema).min(1).describe('Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field)'), }).describe('Record grouping configuration')); /**