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
30 changes: 30 additions & 0 deletions .changeset/22573-sys-comment-reactions-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/plugin-audit': minor
'@objectstack/spec': minor
---

feat(plugin-audit)!: `sys_comment.reactions` retires; an emoji reaction lives only in `sys_comment_reaction`, and reactions stored in the old column are not migrated

Clause-②: no (narrowing)

<!-- adr-0087: registered sys-comment-reactions-column-retired -->

**BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line.

**Existing emoji reactions stored in `sys_comment.reactions` are gone after the upgrade.** They are not converted into `sys_comment_reaction` records, and no screen or API returns them again. Reactions now live in `sys_comment_reaction`, one row per comment, emoji and user, the reacting user's own record. Where the entry that introduces `sys_comment_reaction` says the column is unchanged, this entry supersedes it: the column retires.

- **`sys_comment`** (`@objectstack/plugin-audit`) no longer declares `reactions`. Nothing takes its place on the comment row: no stored aggregate, and no block computed on read. Its label leaves the four generated translation bundles.
- **The data door refuses the field.** A create or an update of a comment that still carries `reactions`, and a list filter on it, answer `400 INVALID_FIELD` naming the field, and nothing is written. The rest of the payload is not applied either. A read of a comment no longer returns `reactions`.
- **`@objectstack/spec`**: the protocol-18 step of the ADR-0087 migration chain gains the semantic entry `sys-comment-reactions-column-retired`, so `os migrate meta` and the upgrade guide carry the prescription below.

**What moves for consumers.**

| You wrote | Write instead |
|:---|:---|
| `sys_comment.reactions` read as a map of emoji to user ids | one read of `sys_comment_reaction` for the comments on screen (`comment_id` `$in` their ids), grouped by comment and emoji |
| an update of `sys_comment` that rewrites `reactions` to add or remove a user | create the user's own `sys_comment_reaction` row (`comment_id`, `emoji`) to react; delete that row to un-react |
| `reactions` in a seed row, filter or list-view column on `sys_comment` | delete it |

An application that shows reactions grants `sys_comment_reaction` read, create and delete beside its `sys_comment` grant; without it a member cannot react. The console's record chatter already reads and writes `sys_comment_reaction` rows wherever the deployment declares that object, as this framework does.

**Existing databases — nothing moves automatically.** Schema sync is additive: the physical `reactions` column stays with the values it holds, the boot drift report names it orphaned (`sys_comment.reactions: column exists in the database but not in metadata`), and the boot is otherwise clean. The data door neither returns nor accepts the column. To drop it, run `os migrate apply --allow-destructive` with the audit plugin composed (an auth secret resolves); the stored reactions go with it.
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { SysComment } from './index.js';
import { MIGRATIONS_BY_MAJOR } from '@objectstack/spec/migrations';
import { SysComment, SysCommentReaction } from './index.js';

/**
* #4756 — `sys_comment.visibility` / `sys_comment.reply_count` are RETIRED
Expand Down Expand Up @@ -72,3 +73,53 @@ describe('sys_comment — retired fields stay retired (#4756)', () => {
expect(Object.keys(fields)).toContain('thread_id');
});
});

/**
* #22573 — `sys_comment.reactions` is RETIRED (maintainer ruling A on #22505,
* as amended: the column retires and its stored reactions are not migrated;
* the delegated judgment: no read-only aggregate takes its place).
*
* A reaction is its reactor's own `sys_comment_reaction` record, so nothing
* about reactions lives on the comment row, stored or computed on read. Unlike
* the two #4756 removals above, this one carries an ADR-0087 D3 record of its
* own, because a client really wrote the column: the semantic entry is what an
* upgrading author is shown, and the data door's `INVALID_FIELD` refusal is
* the enforcement (pinned in `sys-comment-reactions-door.integration.test.ts`).
*/
const REACTIONS_RECORD_ID = 'sys-comment-reactions-column-retired';
const REACTIONS_PRESCRIPTION =
'a reaction is its reactor\'s own `sys_comment_reaction` record; nothing about reactions is '
+ 'stored or computed on the comment row (ruling A on #22505, as amended; #22573)';

describe('sys_comment — reactions stays retired (#22573)', () => {
it('sys_comment declares no reactions field, and no index, highlight or title names it', () => {
const object = SysComment as {
fields?: Record<string, unknown>;
indexes?: Array<{ fields?: string[] }>;
highlightFields?: string[];
titleFormat?: string;
nameField?: string;
displayNameField?: string;
};
expect(
Object.keys(object.fields ?? {}),
`sys_comment.reactions was retired — ${REACTIONS_PRESCRIPTION}`,
).not.toContain('reactions');
expect((object.indexes ?? []).flatMap((i) => i.fields ?? [])).not.toContain('reactions');
expect(object.highlightFields ?? []).not.toContain('reactions');
expect(object.titleFormat ?? '').not.toContain('reactions');
expect([object.nameField, object.displayNameField]).not.toContain('reactions');
});

it('the retirement record names it: a D3 semantic entry on the protocol-18 step', () => {
const entry = MIGRATIONS_BY_MAJOR[18]?.semantic.find((e) => e.id === REACTIONS_RECORD_ID);
expect(entry, `the ADR-0087 D3 record \`${REACTIONS_RECORD_ID}\` is missing from step 18`).toBeDefined();
expect(entry!.surface.startsWith('sys_comment.reactions ')).toBe(true);
expect(entry!.replacement).toContain('sys_comment_reaction');
});

it('keeps the replacement the record points at: one sys_comment_reaction row per comment, emoji and user', () => {
const fields = Object.keys((SysCommentReaction as { fields?: Record<string, unknown> }).fields ?? {});
expect(fields).toEqual(expect.arrayContaining(['comment_id', 'emoji', 'user_id']));
});
});
27 changes: 21 additions & 6 deletions packages/plugins/plugin-audit/src/objects/sys-comment.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
* Existing databases keep the two columns as unmanaged leftovers; there is no
* migration (same disposition as #2755).
*
* ## Retired: `reactions` (maintainer ruling A on #22505, as amended; #22573)
*
* `reactions` stored every emoji reaction on the comment's own row as one JSON
* map, so a member reacting to another member's comment had to update a row
* the author-only edit rule reserves for its author, and two members reacting
* at once overwrote each other. A reaction is now its reactor's own record,
* `sys_comment_reaction` (comment, emoji, user), and the column retires with
* NOTHING in its place on the comment row: no stored aggregate and no
* computed-on-read block. A reader that shows reactions reads the
* `sys_comment_reaction` rows of the comments on screen (`comment_id` `$in`
* their ids) and groups them by emoji. Reactions stored in the column are not
* converted into records; the D3 record is the semantic entry
* `sys-comment-reactions-column-retired`. An existing database keeps the
* physical column and its values (schema sync is additive): the boot drift
* report names it orphaned, the data door neither returns nor accepts it
* (`400 INVALID_FIELD` on a write that names it), and
* `os migrate apply --allow-destructive` drops it.
*
* @namespace sys
*/
export const SysComment = ObjectSchema.create({
Expand Down Expand Up @@ -122,12 +140,9 @@ export const SysComment = ObjectSchema.create({
group: 'Body',
}),

reactions: Field.textarea({
label: 'Reactions',
required: false,
description: 'JSON object mapping each emoji to the list of user ids who reacted',
group: 'Body',
}),
// No `reactions` field: an emoji reaction is its reactor's own
// `sys_comment_reaction` row (#22566), and nothing about reactions is
// stored or computed on the comment row (#22573).

// ── Lifecycle ────────────────────────────────────────────────
is_edited: Field.boolean({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A write that still names the retired `sys_comment.reactions` column is
* refused loudly (#22573, ruling A on #22505 as amended).
*
* The column retired with nothing in its place and no data migration, so the
* one question left for a client that still writes it — the console before
* its pin moved, or any other — is what the data door answers. Measured on a
* booted showcase over the REST door (`POST` and `PATCH
* /api/v1/data/sys_comment` carrying `reactions`): before the retirement both
* stored the map (201 / 200); after it both answer `400 INVALID_FIELD` naming
* the field, and nothing is written. This file pins that answer on the real
* engine, the real plugin and a real driver: the refusal is the engine's
* undeclared-field preflight, decided from `sys_comment`'s declared field map
* before any hook or driver runs, and `mapDataError` is the step the REST door
* takes from the thrown error to the wire.
*
* A silent drop would be the failure: a client would believe its reaction was
* saved. The assertions are on the envelope (`code`, `status`, the named
* field) and on the stored rows, never on a bare throw.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ObjectKernel } from '@objectstack/core';
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
import { mapDataError } from '@objectstack/types';

import { AuditPlugin } from './audit-plugin.js';

const COMMENT = 'sys_comment';
/** A parent object every caller reads. */
const OPEN = 'rr_board';
const HARNESS_PACKAGE = 'com.objectstack.audit.test.comment-reactions-door';

const SYS = { isSystem: true } as const;
const AUTHOR = { userId: 'u_author', positions: ['org_member'] };

const openObject = {
name: OPEN,
label: 'Board',
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
};

type Row = Record<string, any>;

const refusal = async (run: () => Promise<unknown>): Promise<unknown> => {
try {
await run();
} catch (e) {
return e;
}
return undefined;
};

describe('sys_comment — a write naming the retired reactions column is refused, not dropped (#22573)', () => {
let kernel: ObjectKernel;
let engine: ObjectQL;
let threadId: string;
let commentId: string;

const REACTIONS = JSON.stringify({ '👍': [AUTHOR.userId] });

beforeAll(async () => {
kernel = new ObjectKernel({ logger: { level: 'silent' } });
await kernel.use(new ObjectQLPlugin());
await kernel.use(new AuditPlugin());
await kernel.bootstrap();

engine = kernel.getService<ObjectQL>('objectql');
const driver = new SqliteWasmDriver({ filename: ':memory:' });
await driver.connect();
engine.registerDriver(driver, true);
engine.registry.registerObject(openObject as any, HARNESS_PACKAGE);
await engine.syncSchemas();

const board = await engine.insert(OPEN, { name: 'board' }, { context: SYS });
threadId = `${OPEN}:${board.id}`;
// The control: the same create without `reactions` goes through the door,
// so the refusals below are about the key and nothing else.
const created = await engine.insert(
COMMENT,
{ thread_id: threadId, author_id: AUTHOR.userId, body: 'original' },
{ context: AUTHOR },
);
commentId = String(created.id);
}, 120_000);

afterAll(async () => {
if (kernel) {
await Promise.race([kernel.shutdown(), new Promise<void>((r) => setTimeout(r, 10_000))]);
}
}, 30_000);

it('create: 400 INVALID_FIELD naming `reactions`, and no comment row is written', async () => {
const error = await refusal(() =>
engine.insert(
COMMENT,
{ thread_id: threadId, author_id: AUTHOR.userId, body: 'with reactions', reactions: REACTIONS },
{ context: AUTHOR },
),
);
expect(error, 'a create carrying `reactions` was accepted').toBeDefined();
const wire = mapDataError(error, COMMENT);
expect(wire.status).toBe(400);
expect(wire.body.code).toBe('INVALID_FIELD');
expect(wire.body.field).toBe('reactions');

const rows = (await engine.find(COMMENT, { context: SYS })) as Row[];
expect(rows.filter((r) => r.body === 'with reactions')).toHaveLength(0);
});

it('update: 400 INVALID_FIELD naming `reactions`, and the comment row is unchanged', async () => {
const error = await refusal(() =>
engine.update(
COMMENT,
{ body: 'edited', reactions: REACTIONS },
{ where: { id: commentId }, context: AUTHOR },
),
);
expect(error, 'an update carrying `reactions` was accepted').toBeDefined();
const wire = mapDataError(error, COMMENT);
expect(wire.status).toBe(400);
expect(wire.body.code).toBe('INVALID_FIELD');
expect(wire.body.field).toBe('reactions');

const row = (await engine.findOne(COMMENT, { where: { id: commentId }, context: SYS })) as Row;
expect(row.body).toBe('original');
expect(Object.keys(row)).not.toContain('reactions');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Mentions",
help: "JSON array of the user ids @mentioned in the comment"
},
reactions: {
label: "Reactions",
help: "JSON object mapping each emoji to the list of user ids who reacted"
},
is_edited: {
label: "Edited"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Menciones",
help: "Matriz JSON de los id de usuario mencionados con @ en el comentario."
},
reactions: {
label: "Reacciones",
help: "Objeto JSON que asigna cada emoji a la lista de id de usuario de quienes reaccionaron."
},
is_edited: {
label: "Editado"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "メンション",
help: "コメント内で @メンションされたユーザー ID の JSON 配列"
},
reactions: {
label: "リアクション",
help: "各絵文字を、リアクションしたユーザー ID のリストに対応付ける JSON オブジェクト"
},
is_edited: {
label: "編集済み"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "提及",
help: "评论中被 @ 提及的用户 ID 的 JSON 数组"
},
reactions: {
label: "回应",
help: "将每个表情映射到对其做出回应的用户 ID 列表的 JSON 对象"
},
is_edited: {
label: "已编辑"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #22573 (ruling A on #22505, as amended) — a platform-object COLUMN
// retirement, not a spec-key retirement: no authorable spec key moves, so
// nothing lands in RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair
// with (the ups-delegated-from-column-retired shape). The data door's own
// refusal of an undeclared field is the enforced channel; this entry carries
// the prescription. By the maintainer's word there is no data migration.
export const entry: SemanticMigration = {
id: 'sys-comment-reactions-column-retired',
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
// code span AND a table cell.
surface:
'sys_comment.reactions — the JSON textarea column that held every emoji reaction to a comment '
+ 'as one map of emoji to user ids, on the comment row itself, left the platform comment table '
+ 'declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-comment.object.ts), '
+ 'together with its label in the four generated translation bundles',
replacement:
'a reaction is its reactor\'s own `sys_comment_reaction` record (`comment_id`, `emoji`, '
+ '`user_id`): a member reacts by creating that row and un-reacts by deleting it. Nothing takes '
+ 'the column\'s place on the comment row, neither a stored aggregate nor a block computed on '
+ 'read. A screen that showed reactions reads the `sys_comment_reaction` rows of the comments it '
+ 'shows in one request (`comment_id` `$in` their ids) and groups them by comment and emoji; a '
+ 'client that rewrote the column writes the member\'s own reaction row instead. An application '
+ 'that shows reactions grants `sys_comment_reaction` read, create and delete beside its '
+ '`sys_comment` grant',
reason:
'Maintainer ruling 2026-10-10: a reaction is the reactor\'s own record, the column retires, and '
+ 'reactions stored in it are not migrated; the delegated judgment on the same day: no read-only '
+ 'aggregate replaces it. The column made every reaction an update of the comment\'s row, so a '
+ 'member reacting to another member\'s comment needed a write the author-only edit rule on '
+ '`sys_comment` refuses, and two members reacting at once overwrote each other\'s whole map. '
+ 'One row per reaction settles both by construction. An aggregate column was not kept because '
+ 'no declared field type holds the grouped map (a summary roll-up is scalar), so keeping one '
+ 'current would need a privileged writer on the author\'s row; a block computed on read has no '
+ 'seam, because comments are served by the generic data door. Census of the column\'s users: in '
+ 'this repository the field declaration and its four translation labels were the only '
+ 'references; the one client that read and wrote it was the console\'s record chatter, which '
+ 'reads and writes `sys_comment_reaction` rows wherever the deployment declares that object, as '
+ 'this framework does. Existing databases: schema sync is additive, so the physical column '
+ 'stays with the values it holds, the boot drift report names it orphaned, and the boot is '
+ 'otherwise clean; the data door neither returns nor accepts it. Those stored reactions are not '
+ 'converted into records and do not reappear. The operator drops the column with os migrate '
+ 'apply --allow-destructive, the remedy the drift report names.',
acceptanceCriteria:
'No client write and no authored seed row, filter or list-view column names `reactions` on '
+ '`sys_comment`: the data door answers a create or an update that carries it, and a list filter '
+ 'on it, with 400 INVALID_FIELD naming the field, and a read of a comment no longer returns it. '
+ 'A screen that shows reactions reads `sys_comment_reaction` rows, and its users hold read, '
+ 'create and delete on that object. After os migrate apply --allow-destructive, run with the '
+ 'audit plugin composed (an auth secret resolves), the boot no longer reports the orphaned '
+ 'column.',
};
Loading