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
48 changes: 48 additions & 0 deletions .changeset/5896-feeditem-single-constructor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@object-ui/plugin-detail': minor
'@object-ui/app-shell': minor
---

One `sys_activity` row → `FeedItem` constructor, and the console record page
stops dropping author-extended activity types in silence (objectui#5896).

**The defect.** `RecordDetailView`'s `sys_activity` merge read the shared type
table (objectui#5878) and then built the `FeedItem` itself, ending in
`if (!feedType) continue;`. That one `continue` collapsed two different
situations: a type the table maps to `undefined` **on purpose** (`commented` /
`mentioned` / `login` / `logout`), and a type the table has never heard of. The
second is an **author-extended** value — `sys_activity.type` is
author-extensible (objectstack#11507 direction 4, ruled 2026-08-24), every
column on that table is `readonly` so objectql never validates a write, and
ADR-0052 §5b.2 forwards an author's `activityMilestones[].type` into it
verbatim. So an activity that happened, was written and is queryable had no row
on the console record page: no placeholder, no empty state, no console message.
Stored, queryable, invisible — objectui#5840's failure mode reached by another
route, and one objectui#5969 (PR #6112) had already removed from the block
side, leaving the two surfaces disagreeing about the same row of the same table.

**The fix is convergence, not a second decision.** `@object-ui/plugin-detail`
now exports the whole reading — `activityRowToFeedItem`,
`UNMAPPED_ACTIVITY_FEED_TYPE` and the `resetUnknownActivityTypeWarnings` test
seam alongside `ACTIVITY_TYPE_TO_FEED_TYPE` — and `RecordDetailView` calls the
constructor instead of paraphrasing it. Publishing the table alone had left the
mirror one level up, and it had already drifted three ways: the silent drop, a
timestamp fallback that could leave `createdAt` `undefined` where the helper
yields `''`, and a second hand-written system-actor lookup.

**Behaviour change on the console record page** (breaking in the objectui sense,
shipped `minor` — objectui's `major` tracks `@objectstack`):

- an unmapped `sys_activity.type` now **renders** through the generic
`UNMAPPED_ACTIVITY_FEED_TYPE` (`'system'`) presentation instead of vanishing,
and is announced once per distinct value on `console.warn` — a **missing
decision**, not lost data: the row is visible, what it lacks is its own icon
and colour. `FeedItemType` is a closed spec enum, so minting a kind for "we
don't know" would be a platform change, not this surface's.
- `createdAt` is always a string for a row with neither a usable `timestamp`
nor a `created_at`.

**Unchanged, deliberately:** the four exclusions still produce no row and no
warning. They are decisions — comment content lives in `sys_comment`, and
login/logout are account events rather than record activity — and a warning
about a decision teaches authors to ignore the channel.
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,18 @@
* stopped delivering rows fails as a broken probe rather than reporting the
* consumer forked.
*
* ## Out of scope, deliberately
* ## What used to be out of scope here — closed by objectui#5896
*
* Only the TABLE is shared. The row -> `FeedItem` construction around it is
* still written twice (this merge builds the item inline; the block calls
* `activityRowToFeedItem`), so the console surface still drops an unmapped
* type SILENTLY where the block warns once. That is the same class of mirror
* one level up, and it is not this card's fence.
* This card shared only the TABLE; the row -> `FeedItem` construction around
* it stayed written twice, so the console surface still dropped an unmapped
* type SILENTLY where the block warned once. objectui#5896 converged that too:
* the merge now calls `activityRowToFeedItem`, and an unmapped type RENDERS
* through `UNMAPPED_ACTIVITY_FEED_TYPE` with one diagnostic. The pins for that
* behaviour and for the single constructor live in
* `RecordDetailView.activityUnmappedType-5896.test.tsx`; this file keeps
* pinning the TABLE's identity, which the convergence preserves — the shared
* constructor reads the same shared object, so both legs below still measure a
* real read.
*/

import * as React from 'react';
Expand Down Expand Up @@ -308,19 +313,32 @@ describe('the previously mapped kinds are unchanged — regression controls', ()
}
});

it('the console surface still shows a mapped row and still drops the rest', async () => {
renderWith([
activityRow('updated', 0),
activityRow('commented', 1),
activityRow('zzz_not_an_activity_type', 2),
]);
it('a mapped row shows, a DELIBERATE exclusion does not', async () => {
// Updated by objectui#5896. This leg used to also assert that
// `zzz_not_an_activity_type` produced no row — it pinned the silent drop,
// which was the defect, not the contract. Under the objectstack#11507
// direction-4 ruling an unmapped type is an author-extended value and
// renders through the fallback; that is asserted in
// `RecordDetailView.activityUnmappedType-5896.test.tsx`, together with the
// diagnostic that accompanies it.
//
// What survives here is the distinction the table draws and this card is
// about: `commented` maps to `undefined` ON PURPOSE (its content lives in
// `sys_comment`), and a decision stays a decision.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
renderWith([activityRow('updated', 0), activityRow('commented', 1)]);

expect(await screen.findByText(summaryFor('updated'))).toBeTruthy();
// `commented` is a deliberate exclusion (its content comes from
// `sys_comment`); the third is a type nothing maps.
await waitFor(() => {
expect(screen.queryByText(summaryFor('commented'))).toBeNull();
expect(screen.queryByText(summaryFor('zzz_not_an_activity_type'))).toBeNull();
});
expect(await screen.findByText(summaryFor('updated'))).toBeTruthy();
await waitFor(() => {
expect(screen.queryByText(summaryFor('commented'))).toBeNull();
});
// ...and silently: no `[record:activity]` diagnostic for an exclusion.
expect(
warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('[record:activity]')),
).toHaveLength(0);
} finally {
warn.mockRestore();
}
});
});
Loading
Loading