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
8 changes: 8 additions & 0 deletions .changeset/grouping-notify-describe-align.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions content/docs/references/automation/io-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | optional | Extra template inputs merged into the notification payload |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |


---
Expand Down
34 changes: 34 additions & 0 deletions packages/spec/src/automation/io-node-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { description?: string }> }).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 <partner>." 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', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/automation/io-node-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
25 changes: 25 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { description?: string }> }).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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

/**
Expand Down
Loading