Skip to content

Commit de302c7

Browse files
fix(app-shell): the chatter reads and writes reactions as the member's own sys_comment_reaction records (objectui#12078) (#12090)
Fixes #12078 Clause-②: yes ## What changed The record chatter (`RecordDetailView` in `@object-ui/app-shell`) stores each reaction as the member's own `sys_comment_reaction` record. It no longer writes the whole `sys_comment.reactions` column. This follows ruling A, amended, on objectstack-ai/objectstack#22505 (「同意;sys_comment.reactions 可以退役;可以不用考虑历史数据迁移;」) and the director's point `6091621568` (no read-only aggregate). - **Read.** After the comment read, the chatter makes one `find` on `sys_comment_reaction`, with `comment_id` `$in` the feed's comment ids. It groups the rows client-side into the `{ emoji: userIds[] }` shape and runs them through the same aggregator the column used, so rendering is unchanged. There is no per-comment read. A thread with more than 100 comments is read in pages of 100 ids, in parallel (see the measurement below). - **Write.** A click creates the member's own row, `{ comment_id, emoji }`. The server stamps `user_id`, so the client sends none. A second click deletes that row by its id. The id comes from the read, or from the create that made the row. Writes for one (comment, emoji) run one after another, so a take-back clicked before its create answers deletes the row that create made. A refused write puts the reaction back and raises the existing `detail.reactionFailed` error. - **The cloud window: dual path, no `.objectui-sha` hold.** Where the deployment has no `sys_comment_reaction`, the column path runs exactly as before, byte for byte. That covers cloud's framework pin `56bf27affb` (v17). The chatter asks app-shell's existing `useObjectPresence('sys_comment_reaction')`; no new probe is written. Following that hook's own contract, only an earned `absent` keeps the column path. An unsettled registry holds the feed read until it answers. A registry that lists nothing is not read as absence. The file surface is `RecordDetailView.tsx` and its tests, as declared in the claim. ## Measurements (the five mechanism assumptions) 1. **The object** (objectstack `origin/main` `86da1949`, `plugin-audit/src/objects/sys-comment-reaction.object.ts`). Its declared fields: - `comment_id`: text, required, maxLength 255; an id column, not a lookup. - `emoji`: text, required, maxLength 64. - `user_id`: lookup to `sys_user`, required, stamped from the session on create; a client value is replaced. - `id` and `created_at`. Uniqueness is the index `(comment_id, emoji, user_id)`, `unique: 'organization'`. A second identical reaction answers `409 UNIQUE_VIOLATION`. `apiMethods` is `get, list, create, delete, bulk`, with no `update`. - **Create:** `installCommentAccessHooks` (`authorizeReactionInsert`) refuses a reaction on a comment the caller cannot read, and stamps `user_id`. - **Delete:** the platform's own-record floor (`owner_only_deletes`, `created_by` equals the caller). So a member deletes their own reaction and not another member's. - **Read:** a reaction is readable exactly when its comment is. The fake server in the new pins applies these same three rules. 2. **The cloud framework** `56bf27affb`. It has no `sys-comment-reaction.object.ts` (`git cat-file -e` exits 128; control `sys-comment.object.ts` exits 0), and `git grep sys_comment_reaction` in its `plugin-audit/src` returns no hits (control: hits on `origin/main`). - Its data door answers an unregistered name with `assertObjectRegistered` → `objectNotFoundError`: `404 OBJECT_NOT_FOUND` on find and create alike. - What the chatter would do there without the gate: `data-objectstack`'s `find` turns a 404 into `{ data: [] }` and memoises the resource as missing, so every reaction would read as empty. Every click's create would then reject, and the row would roll back with the error. The presence gate keeps the column path there instead. - On `origin/main` the `object` metadata list prunes nothing per caller (`createMetaListReadGate` returns the items unchanged for `object`), so `sys_comment_reaction` reads `present` wherever plugin-audit registers it. 3. **The `$in` read size.** - The feed's comment read has no page size. It reads the whole thread with no `$top`, and `findData` returns the full set when no limit is given. - The data door's `find` is a GET with the filter as JSON in the query string. Measured with Node 22.22.0 (`http.maxHeaderSize` 16384): each UUID-shaped id costs about 45 bytes, and 100 ids make a 4,588-byte URL. A local Node server accepted 350 ids and answered `431` at 360 ids. - So the read is paged at 100 ids (`REACTION_READ_COMMENT_IDS`), under both Node's limit and an 8 KB proxy request line. A thread of 100 comments or fewer costs exactly one read. 4. **What the user sees.** - Where the object exists, a reaction stored only in the column is no longer shown. Its chip disappears, as the maintainer's no-migration ruling allows; one pin records this. - Each member's click adds or removes only their own reaction, and two members reacting at once both stay. - Read from code, not measured live: a member who can read the record but not edit it could not react to someone else's comment before, because the column write is a `sys_comment` update, which only the author or a parent editor may make. Now they can, because creating a reaction requires only reading the comment. - On cloud (object absent) nothing changes. 5. **`CommentThread`** (`@object-ui/collaboration`) is not on the chatter's path at all. The console chatter renders reactions through plugin-detail's `ReactionPicker` from `FeedItem.reactions`. The only app-shell reference to `CommentThread` is a test. No prop changed. - The published surface is untouched. The diff touches only `RecordDetailView.tsx`, four test files beside it and one changeset, and adds no `export` line. - `Clause-②: yes` is copied from the claim as asked. The surface reading above is for the in-seat review to judge. ## Pins New file `RecordDetailView.reactionRecords-12078.test.tsx`. It drives the real `RecordDetailView` and `ReactionPicker` over one fake server that several mounted views share, each view signed in as a different member. - A member's click on another member's comment creates their own record and nothing else (no `user_id` sent, no `sys_comment` update). The feed renders it grouped, and a fresh read shows `👍 2`, marked as the clicker's own. - A second click removes it. The delete uses the id the create returned, and on a fresh mount the id the read returned. - Two members reacting at once both show. Two views are mounted at once, both react before either write answers, and the comment's author then reads `👍 2`. - One batched read for 3 comments. A 250-comment thread is read in pages of 100, 100 and 50 ids. - A reaction stored only in the column is not shown on the records path. - A refused create rolls back and raises the error once. - A take-back clicked before the create answers deletes the row that create made. - The registry decides the store: absent means the column path, with no reaction read and a `sys_comment.reactions` write; a registry listing nothing means the records path; the comment read waits for a registry that has not answered. The column-path pins for objectui#11019, objectui#10899 and objectui#11035 now declare a registry without `sys_comment_reaction`, so they keep pinning the path cloud runs. A header note in each says so. **Ablation** (one-time, not kept). On the committed tree (HEAD `086db35`), the records branch was switched off and the column-only guard removed, which puts the whole-set column write back on a deployment that has the object. - Landing was verified on disk: injected marker count 1, guard count 0. - Result: `Tests 5 failed | 5 passed (10)`. The concurrent pin went red with `expected [] to deeply equal [ '👍 2' ]`, and the other four write pins went red with it. - Restore was `git checkout HEAD --`, then a blob hash equal to the HEAD blob `eba397e8` and an empty `git diff HEAD`. ## Verification Every result below is on HEAD `086db35`, the last commit. Each heavy step ran through the shared verify lock. - Dependency closure build: `pnpm --workspace-concurrency=2 --filter "@object-ui/app-shell^..." build`, 29 packages. VERDICT command-exit 0. - `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. The test config includes the new test file: its first run, on `b886ae3`, reported type errors in that file, and they were fixed. - `pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/views/RecordDetailView`, covering every `RecordDetailView.*` test (the 12 that read `sys_comment` among them): `Test Files 47 passed (47)`, `Tests 294 passed (294)`. - eslint on the six touched files: 0 errors. The warnings on added lines are `no-explicit-any` in the new test, the same pattern as its sibling tests. Two more sit on lines this diff edited but did not cause: the existing `res: any`, and the existing missing-`t` dependency note. - Static checks, all exit 0: `check:new-line-citations` (0 new), `check:control-bytes`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-presence.mjs` and `check-changeset-no-major.mjs`. `check-governed-queue-guard.mjs --test` answers NOT GOVERNED for the six paths. - Not owed: the i18n checks. No locale string changed; the error reuses `detail.reactionFailed`. - NOT MEASURED: `check:eager-closure`, because it needs a full console production build. CI's `Bundle Analysis` runs it on this pull request. No module enters the first-load closure: `useObjectPresence` is already reached through the header's `sharedUserFeeds`. Only the bytes of the chunk that holds `RecordDetailView` grow. ## Acceptance notes - The column branch is transitional. It can be deleted, with its three column pins, once cloud's framework carries `sys_comment_reaction`; objectstack-ai/objectstack#22573 retires the column itself. No card is filed for this. - If the registry ever settles as `error` on a v17 server, the records path runs there: reactions read empty and a click is refused with the error. This is the `useObjectPresence` contract (only an earned `absent` changes the path). It is a narrow window, and the console's nav and object views need the same registry anyway. - A stale screen: when a member has already reacted in another tab and clicks the same emoji here, the create answers `409 UNIQUE_VIOLATION`. The chip rolls back with the error until the next read shows the stored reaction. Nothing is overwritten. - The feed's `sys_comment` read itself has no page size. That is unchanged here and only observed. Session: `https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z` --- _Generated by [Claude Code](https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d0ff23 commit de302c7

6 files changed

Lines changed: 848 additions & 20 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The record chatter stores each reaction as the member's own `sys_comment_reaction` record, and stops writing the whole `sys_comment.reactions` column (objectui#12078; ruling A, amended, on objectstack-ai/objectstack#22505).
6+
7+
A reaction click used to write the comment's whole reaction set back with one `sys_comment` update. Two members reacting at the same moment therefore left only the later write stored. A member who could read a record but not edit it could not react to another member's comment either, because that update is the comment author's (or a parent editor's) to make.
8+
9+
- **Read.** After the comment read, the chatter reads the comments' reactions in one `sys_comment_reaction` read, `comment_id` `$in` the comment ids, and groups the rows into the reactions the panel already renders. It never reads per comment. A thread of more than 100 comments is read in pages of 100 ids, in parallel, which keeps each request URL near 4.6 KB.
10+
- **Write.** A click creates the member's own reaction row (`comment_id`, `emoji`; the server stamps `user_id`), and a second click deletes that row. Nothing else is written, so another member's reaction can no longer be overwritten, and both of two simultaneous reactions stay. A refused write puts the reaction back and shows the same "Your reaction was not saved" error as before.
11+
- **Reactions stored only in the column are not shown** where the object exists. The maintainer ruled that the column's reactions need no migration.
12+
- **A deployment without `sys_comment_reaction` keeps the column path, unchanged.** This covers a framework that predates objectstack-ai/objectstack#22566, such as cloud's v17 pin. The chatter asks the object registry it already loads. Only an earned "absent" answer keeps the column path; a registry that lists nothing, or has not answered, is not read as absence. The comment read waits until the registry has answered.
13+
14+
Nothing on the package entry changes: no export, prop, type member or language-pack key. `CommentThread` (`@object-ui/collaboration`) and `Reaction` (`@object-ui/types`) are untouched.

‎packages/app-shell/src/views/RecordDetailView.reactionKeepIds-11019.test.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
* data source whose `sys_comment` `update` payload is the observed channel. The
1818
* signed-in user is the varied axis, so one case can hand the row a first user
1919
* stored and read it back as a second user.
20+
*
21+
* Since objectui#12078 this is the column store, which the page uses only where
22+
* the deployment has no `sys_comment_reaction` (the registry below lists none).
23+
* Where it has one, a click writes the member's own reaction row instead:
24+
* `RecordDetailView.reactionRecords-12078.test.tsx`.
2025
*/
2126

2227
import * as React from 'react';
@@ -124,7 +129,10 @@ async function mountAs(userId: string, dataSource: any) {
124129
invalidate: () => {},
125130
ensureType: async () => pages,
126131
getItem: async () => null,
127-
getItemsByType: (type: string) => (type === 'page' ? pages : []),
132+
// The registry lists this deployment's objects, and `sys_comment_reaction`
133+
// is not one of them, so the page keeps reactions in the
134+
// `sys_comment.reactions` column (objectui#12078).
135+
getItemsByType: (type: string) => (type === 'page' ? pages : type === 'object' ? OBJECTS : []),
128136
} as any;
129137
render(
130138
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>

0 commit comments

Comments
 (0)