Skip to content

Commit d1d42ec

Browse files
fix(plugin-audit): sys_activity.actor_name carries the acting user's display name, written with the row (#22526)
Fixes #22510 Clause-②: no ## What this changes `sys_activity` declares `actor_name` and lists it among its `highlightFields`, because its entries are "denormalized snapshots optimized for chronological … reads" (`sys-activity.object.ts` docblock). The one writer of that table, `plugin-audit`'s `writeAudit` (`audit-writers.ts`), filled `actor_id` and never `actor_name`. So every row named the user by id only, and the record History tab, which reads `actor_name`, showed "Unknown user" for every entry. The writer now records the acting user's display name when it writes the activity row: - **The source** is the title of the user's `sys_user` row, through the writer's existing `resolveLookupTitles`. That is the ADR-0079 title for `sys_user` (`nameField: 'name'`, a required column). It is the same column the approval timeline reads its actor names from, and the same title this writer already renders for a `user` reference in a tracked-change summary. - **The id is the same id.** The name is read for the `userId` that lands in `actor_id`, so the two columns cannot disagree. This includes the `attributedUserId` channel, a better-auth write authorized as the system and credited to a human. - **An unreadable name stays empty, never the id.** No row, a blank name, an unregistered `sys_user` or a failing read all leave `actor_name` unset. An id in a name column reads as a plausible name, and `actor_id` still says who acted. - **A write with no user stays empty, as before.** This is ADR-0118 D1. See "Open decision" below. - **Cost.** The name is read only when an activity row will be written (`enable.activities` is not `false`) and a user is present. It is memoized per user for 30 s, and the promise itself is cached, so a predicate write over N rows costs one `sys_user` read, not N. `writeAudit` is dispatched once per row, and this is the hot path that #6656 / PR #6977 took reads off. - ⛔ Nothing is resolved on read in the server, `sys-activity.object.ts` is unchanged, and rows already written are not backfilled. The renderer half for those rows is objectstack-ai/objectui#12067. Files: `packages/plugins/plugin-audit/src/audit-writers.ts`, `packages/plugins/plugin-audit/src/activity-actor-name.test.ts` (new) and `.changeset/22510-activity-actor-name.md` (`patch`, `@objectstack/plugin-audit`). ## Reproduction on a real stack (before → after) Stack: `pnpm dev:crm -- --fresh -p PORT`, built from this worktree. Before = `origin/main` `4e9fe9ff6`; after = this branch at `d2152c41d`. The head `82440a8b4` merges `origin/main` `ee8751d41` and carries the same `audit-writers.ts` blob (`fecc7a3d630c`). A sales rep (`Riley Rep`, created by the admin through `POST /api/v1/auth/admin/create-user`, given the `sales_rep` position through `sys_user_position`) creates a `crm_lead` and then edits it through `/api/v1/data/crm_lead`. The lead's History source rows are read through `GET /api/v1/data/sys_activity` (filter `object_name` + `record_id`). | | before (`4e9fe9ff6`) | after (`d2152c41d`) | |---|---|---| | rep's `created` row | `actor_id` = rep id, `actor_name: null` | `actor_id` = rep id (unchanged), `actor_name: "Riley Rep"` | | rep's `updated` row | `actor_id` = rep id, `actor_name: null` | `actor_id` = rep id (unchanged), `actor_name: "Riley Rep"` | | admin's `updated` row | (not measured before) | `actor_id` = admin id, `actor_name: "Dev Admin"` | | census of every `sys_activity` row on the fresh stack | 38 rows: 29 with `actor_id` null and no name, 9 with a user and **no name** | 39 rows: 29 with `actor_id` null and no name (unchanged), 10 with a user and **a name** (7 Dev Admin, 3 Riley Rep) | ## Pins (`src/activity-actor-name.test.ts`, through the real `ObjectQL` engine and a copy-returning, read-counting driver) | # | pin | kind | |---|---|---| | 1 | a user's create, update and delete each carry that user's name; `actor_id` is the same id (control) | positive + control | | 2 | a system-authorized write credited through `attributedUserId` names that human; `actor_id` is the same id | positive + control | | 3 | a user with no `sys_user` row, and a user with a blank name, leave `actor_name` empty, never the id | negative | | 4 | a write with no user (`isSystem`), and one by a service principal on `actor`, leave `actor_name` empty and read no user (ADR-0118 D1 / D5) | negative | | 5 | a predicate update over 3 rows names all 3 and costs 1 `sys_user` read; a later write by the same user costs 0 | cost | | 6 | an object with `enable.activities: false` writes no activity row and reads no user | cost | Pin 1 reads the STORED `sys_activity` row (the driver's store), not a renderer. That is the server-side proof for every reader of the column. ## Ablation (each leg through `scripts/ablation-replace.mjs` WRAP mode, the fix committed first, restore proven to the HEAD blob `fecc7a3d630c`, `git diff HEAD` empty; last run on head `82440a8b4`) | leg | mutation in `audit-writers.ts` | result | |---|---|---| | A | delete the `actor_name` stamp from the row literal | pins 1, 2, 5 red (`expected undefined to be 'Ada Lovelace'`), 3 / 4 / 6 green, 3 failed / 3 passed | | B | an unreadable name falls back to the id (`?? null` → `?? userId`) | pin 3 red (`expected 'usr_ghost' to be null`), 1 failed / 5 passed | | B2 | a write with no user writes `'System'` | pin 4 red (`expected 'System' to be null`), 1 failed / 5 passed | | C2 | the memo never hits | pin 5 red (`expected 3 to be 1`), 1 failed / 5 passed | | D2 | the `enable.activities` gate is ignored for the read | pin 6 red (`expected 1 to be +0`), 1 failed / 5 passed | Two first attempts did nothing and are reported, not hidden. Leg C's anchor also matched the locale memo (`ANCHOR AMBIGUOUS`, nothing written). Leg D's replacement was a substring of its own anchor, so the tool refused its evidence and restored. Both were re-run with unique anchors as C2 and D2 above. The test imports `./audit-writers.js` from `src`, so no `dist` rebuild was involved in any leg. ## Open decision: the system-context label (not implemented here, so the line above is `Part of`) Triage asked that "a system-context write records the platform's system label, not null". That conflicts with an accepted ADR, which binds until a superseding ADR says otherwise (AGENTS.md Prime Directive #13). ADR-0118 D1, verbatim: > **显示是渲染规则,不是数据**:UI 对系统写入行的空 actor 渲染「系统」(走 i18n),不落库。 D5 keeps the actor two-valued: a user, or the system as `null`. No named constant for a system label exists in this repository. The only label is objectui's `detail.systemActor` i18n key (`'System'`, `'系统'`, …). objectui's activity feed (`recordActivityFeed.ts`, `row.actor_name ?? systemActorLabel`) already renders it per viewer for a row with no name. So this PR leaves system rows exactly as they were and pins that (pin 4; leg B2 shows the pin catches a written label). The question is handed to the seat. If the maintainer rules for a written label, the change is that pin, the one line it guards, and an ADR-0118 amendment. ## Gates All measured on head `82440a8b4` (this branch merged with `origin/main` `ee8751d41`), after a full workspace rebuild. - `pnpm --filter @objectstack/plugin-audit test`: 43 files, 678 tests passed (exit 0, through `os-verify-lock`). - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0. The new test file is in `tsconfig.test.json`'s program (`--listFiles` count 1), and `check:test-typecheck` is OK. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 67 commands derived from the 3 changed paths. All 67 were run with their exit codes recorded, and all 67 exited 0. `--ran` reconcile: "67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED". - On the first battery run (pre-merge head `d2152c41d`), `check:objectql-double-limit` went red: the new test's store double ignored the caller's `limit` and could not be probed. Fixed in `0cff02f38`: the double applies the bound after the filter, and reads are counted by a spy outside it. The gate then graded it conforming (278 conforming doubles, up from 277; none new in the baseline). - Lint, narrowed: `eslint --no-inline-config --format json` over the 2 touched TypeScript files gave 2 files, 0 errors, 0 warnings. Both are in the config's population (`--print-config` exit 0). This repo's `eslint.config.mjs` never enables type-aware linting, so this diff cannot move the verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - NOT MEASURED locally (CI-owned): the path-scheduled CI jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core) and the workspace type-check lanes that `dispatch-gates` lists outside its runnable set. ## Acceptance notes - **The system-actor question is ruled** (seat, `6087899596`): option A, under accepted ADR-0118 D1. A system write keeps `actor_name` null, and the label is the renderer's. The open decision is closed, so line 1 is `Fixes #22510`. - **One write covers all three renderers.** `record:history`, `record:activity` and the record Discussion feed all read the same `sys_activity.actor_name` column. The `repo:hotcrm` seat measured the second and third on 17.7.0: every entry was authored "System", because objectui's feed reads `actor_name ?? systemActorLabel`. The same null showed as "Unknown user" in History. This PR fills the column at write time, so all three get the name from the row. The proof is the server-side pin on the stored row (pin 1) and the after-stack rows above. ⛔ No objectui edit: the renderers, and rows written before this change, are objectstack-ai/objectui#12067's lane. One case still reaches the renderers with no name: a user row whose name cannot be read (pin 3). The feed shows that row as "System" until card objectui#12067's `actor_id` lookup lands. - **Premise correction (no effect on the direction).** Triage cites `approval-service.ts:7103` as "another writer already fills it". Measured, that line is in `listActions`. It enriches `sys_approval_action` rows with `actor_name` when they are read, for the approval timeline. It is not a `sys_activity` writer and does not run at write time. No writer anywhere filled `sys_activity.actor_name`: zero `actor_name` writes in `git grep` over `packages/**` at `4e9fe9ff6`, and 0 of 9 user rows named on the before stack. The "fill it" direction still stands on the object's own docblock. - **Email arm not copied.** The approval reader falls back from `name` to `email`. This writer reads `name` only. `sys_user.name` is `required: true`, and a denormalized row every record reader can see is not where an address should be copied. - **Staleness is bounded and deliberate.** A rename shows on rows written after the 30 s window. Rows already written keep the name they were written with, which is what a snapshot is. - **Observed, not filed (a deliberate ruling).** On `examples/app-crm`, a `sales_rep` gets `403 PERMISSION_DENIED` on `GET /api/v1/data/sys_activity`, even for its own lead's rows, so its History tab is empty there. `default-permission-sets.ts` excludes `sys_activity` on purpose ("a separate question if it ever matters"). hotcrm grants it. - **Found, for the seat to route (same family, not fixed here).** - `sys_activity.actor_avatar_url` is declared and never written either. On the after stack, the admin's `sys_user.image` was set through `POST /api/v1/auth/update-user`, and the admin's next edit wrote `actor_avatar_url: null`. All 39 rows carry null. objectui reads it (`record-history.tsx` `user_avatar`, `recordActivityFeed.ts` `actorAvatarUrl`). - objectui `record-history.tsx` renders a system row (no `actor_id`, no name) as "Unknown user". ADR-0118 D1 says such a row renders the localized "System" label, which its sibling `recordActivityFeed.ts` already does. The `actor_id` fallback in objectstack-ai/objectui#12067 does not reach a row with no `actor_id`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d85615d commit d1d42ec

3 files changed

Lines changed: 376 additions & 6 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
`sys_activity.actor_name` is written: a user's create, update or delete records that user's display name on its activity row, so the record History tab names who acted instead of "Unknown user"
6+
7+
Clause-②: no
8+
9+
`sys_activity` declares `actor_name` and lists it among its highlight fields, because activity
10+
entries are denormalized snapshots read in time order. The audit writer filled `actor_id` and never
11+
`actor_name`, so every row carried the user's id and no name. A renderer that reads the name, such
12+
as the record History tab, showed "Unknown user" for every entry, for every user.
13+
14+
The writer now records the name when it writes the row. The name is the title of the user's
15+
`sys_user` row (its `name` column), read once per user and kept for 30 seconds. A predicate write
16+
over many rows costs one `sys_user` read for its actor, not one per row.
17+
18+
- **A user write.** `actor_name` is the user's display name. `actor_id` is unchanged, and the name
19+
is read for that same id. This includes a write the system authorized on behalf of a credited
20+
user (`attributedUserId`).
21+
- **A user whose name cannot be read** (no row, or a blank name). `actor_name` stays empty. The
22+
writer never puts the id in it.
23+
- **A write with no user** (boot, migration, a scheduled job, a flow running as the system).
24+
`actor_name` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and the UI
25+
renders the "System" label for it.
26+
- **An object with `enable.activities: false`.** No activity row is written and no name is read,
27+
as before.
28+
29+
Rows written before this change keep `actor_id` and no name. This change does not backfill them.
30+
Nothing you author changes: no key, option, export or type is added or removed.
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22510] `sys_activity.actor_name` is WRITTEN — the acting user's display
5+
* name, captured when the activity row is written.
6+
*
7+
* `sys_activity` declares the column (and lists it among its highlight
8+
* fields) because its entries are "denormalized snapshots" read
9+
* chronologically. No writer filled it: every row carried `actor_id` and a
10+
* null `actor_name`, and the record History tab — which reads `actor_name` —
11+
* showed "Unknown user" for every entry, for every user.
12+
*
13+
* Pinned through the REAL engine (`ObjectQL`), so the user travels the way it
14+
* does in production — `ExecutionContext.userId` → the hook session — rather
15+
* than as a hand-built hook context:
16+
*
17+
* 1. a user's write names that user, on create, update and delete alike;
18+
* 2. `actor_id` is unchanged, and is the id the name was read for (control);
19+
* 3. a user whose name cannot be read leaves `actor_name` empty — never the
20+
* id, which would read as a plausible name;
21+
* 4. a write with no user leaves it empty too: ADR-0118 D1 keeps the system
22+
* actor `null`, and its "System" label is a rendering rule, not data;
23+
* 5. the read is one per user, not one per row — `writeAudit` runs once per
24+
* row of a predicate write, and this is its hot path.
25+
*/
26+
27+
import { describe, it, expect, vi } from 'vitest';
28+
import { ObjectQL } from '@objectstack/objectql';
29+
import { installAuditWriters } from './audit-writers.js';
30+
31+
const f = (name: string, type: string, extra: Record<string, unknown> = {}) =>
32+
({ name, label: name, type, ...extra }) as any;
33+
34+
const sysAuditLog = {
35+
name: 'sys_audit_log', label: 'Audit Log',
36+
fields: {
37+
id: f('id', 'text', { primaryKey: true }), action: f('action', 'text'),
38+
user_id: f('user_id', 'text'), object_name: f('object_name', 'text'),
39+
record_id: f('record_id', 'text'), old_value: f('old_value', 'textarea'),
40+
new_value: f('new_value', 'textarea'), tenant_id: f('tenant_id', 'text'),
41+
},
42+
};
43+
44+
/** The two actor columns the shipped `sys_activity` declares, both present. */
45+
const sysActivity = {
46+
name: 'sys_activity', label: 'Activity',
47+
fields: {
48+
id: f('id', 'text', { primaryKey: true }), type: f('type', 'text'),
49+
timestamp: f('timestamp', 'datetime'), summary: f('summary', 'text'),
50+
actor_id: f('actor_id', 'text'), actor_name: f('actor_name', 'text'),
51+
object_name: f('object_name', 'text'), record_id: f('record_id', 'text'),
52+
record_label: f('record_label', 'text'), metadata: f('metadata', 'textarea'),
53+
},
54+
};
55+
56+
/** `nameField: 'name'`, as the shipped `sys_user` declares it. */
57+
const sysUser = {
58+
name: 'sys_user', label: 'User', nameField: 'name',
59+
fields: {
60+
id: f('id', 'text', { primaryKey: true }), name: f('name', 'text'),
61+
email: f('email', 'email'),
62+
},
63+
};
64+
65+
const bizTask = {
66+
name: 'biz_task', label: 'Task',
67+
fields: { id: f('id', 'text', { primaryKey: true }), title: f('title', 'text'), status: f('status', 'text') },
68+
};
69+
70+
/** `enable.activities: false` — this object's writes produce no activity row. */
71+
const bizQuiet = {
72+
name: 'biz_quiet', label: 'Quiet', enable: { activities: false },
73+
fields: { id: f('id', 'text', { primaryKey: true }), title: f('title', 'text') },
74+
};
75+
76+
/**
77+
* A copy-returning store that holds the caller's bound. Reads are counted by a
78+
* spy on `find`, outside the double, so the double itself stays a plain query.
79+
*/
80+
function makeCountingDriver() {
81+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
82+
const storeFor = (o: string) => {
83+
let s = stores.get(o);
84+
if (!s) { s = new Map(); stores.set(o, s); }
85+
return s;
86+
};
87+
let nextId = 0;
88+
const copy = <T,>(r: T): T => (r == null ? r : JSON.parse(JSON.stringify(r)));
89+
const matches = (row: Record<string, unknown>, where: any): boolean => {
90+
if (!where || typeof where !== 'object') return true;
91+
for (const [k, v] of Object.entries<any>(where)) {
92+
if (k === '$and' && Array.isArray(v)) {
93+
if (!v.every((sub) => matches(row, sub))) return false;
94+
continue;
95+
}
96+
if (k.startsWith('$')) continue;
97+
if (v && typeof v === 'object' && '$in' in v) {
98+
if (!(v.$in as unknown[]).includes(row[k])) return false;
99+
continue;
100+
}
101+
const expected = (v && typeof v === 'object' && '$eq' in v) ? v.$eq : v;
102+
if ((row[k] ?? null) !== (expected ?? null)) return false;
103+
}
104+
return true;
105+
};
106+
const select = (object: string, where: any, limit?: number) => {
107+
const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, where));
108+
// The caller's bound, after the filter, by presence.
109+
const page = typeof limit === 'number' ? rows.slice(0, limit) : rows;
110+
return page.map(copy);
111+
};
112+
const driver: any = {
113+
name: 'memory', version: '0.0.0', supports: {} as any,
114+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
115+
async execute() { return null; },
116+
async find(object: string, ast: any) { return select(object, ast?.where, ast?.limit); },
117+
async findOne(object: string, ast: any) { return select(object, ast?.where, 1)[0] ?? null; },
118+
async create(object: string, data: Record<string, unknown>) {
119+
nextId += 1;
120+
const id = (data.id as string) ?? `r_${nextId}`;
121+
const row = { ...data, id };
122+
storeFor(object).set(id, row);
123+
return copy(row);
124+
},
125+
async update(object: string, id: string, data: Record<string, unknown>) {
126+
const s = storeFor(object);
127+
const cur = s.get(id);
128+
if (!cur) return null;
129+
const updated = { ...cur, ...data, id };
130+
s.set(id, updated);
131+
return copy(updated);
132+
},
133+
async upsert(object: string, data: Record<string, unknown>) {
134+
const id = data.id as string | undefined;
135+
return id && storeFor(object).has(id) ? this.update(object, id, data) : this.create(object, data);
136+
},
137+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
138+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
139+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
140+
return Promise.all(rows.map((r) => this.create(object, r)));
141+
},
142+
async bulkUpdate() { return []; },
143+
async bulkDelete() {},
144+
async updateMany(object: string, ast: any, data: Record<string, unknown>) {
145+
const rows = await this.find(object, ast);
146+
const s = storeFor(object);
147+
for (const r of rows) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id });
148+
return rows.length;
149+
},
150+
async deleteMany(object: string, ast: any) {
151+
const rows = await this.find(object, ast);
152+
for (const r of rows) storeFor(object).delete(r.id as string);
153+
return rows.length;
154+
},
155+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
156+
async commit() {}, async rollback() {},
157+
};
158+
const findSpy = vi.spyOn(driver, 'find');
159+
/** How many `find` calls have read `object` so far. */
160+
const readsOf = (object: string) => findSpy.mock.calls.filter(([o]) => o === object).length;
161+
return { driver, readsOf, storeFor };
162+
}
163+
164+
const OWNER_PACKAGE = 'com.objectstack.test.activity-actor-name';
165+
const ADA = 'usr_ada';
166+
167+
async function boot() {
168+
const engine = new ObjectQL();
169+
const stub = makeCountingDriver();
170+
engine.registerDriver(stub.driver, true);
171+
await engine.init();
172+
for (const o of [sysAuditLog, sysActivity, sysUser, bizTask, bizQuiet]) {
173+
engine.registry.registerObject(o as any, OWNER_PACKAGE);
174+
}
175+
installAuditWriters(engine as any, 'test.audit');
176+
// Seeded with no context: a system write, which names nobody.
177+
await engine.insert('sys_user', { id: ADA, name: 'Ada Lovelace', email: 'ada@example.com' });
178+
return { engine, ...stub };
179+
}
180+
181+
const as = (userId: string) => ({ context: { userId } });
182+
183+
/** Every activity row written about `recordId`, in write order. */
184+
const activityFor = (storeFor: (o: string) => Map<string, Record<string, unknown>>, recordId: string) =>
185+
Array.from(storeFor('sys_activity').values()).filter((r) => r.record_id === recordId);
186+
187+
describe('[#22510] sys_activity.actor_name — the acting user\'s name, written with the row', () => {
188+
it('a user\'s create, update and delete each carry that user\'s name', async () => {
189+
const { engine, storeFor } = await boot();
190+
191+
await engine.insert('biz_task', { id: 't1', title: 'Call Acme', status: 'open' }, as(ADA) as any);
192+
await engine.update('biz_task', { status: 'done' }, { where: { id: 't1' }, ...as(ADA) } as any);
193+
await engine.delete('biz_task', { where: { id: 't1' }, ...as(ADA) } as any);
194+
195+
const rows = activityFor(storeFor, 't1');
196+
expect(rows.map((r) => r.type)).toEqual(['created', 'updated', 'deleted']);
197+
for (const row of rows) {
198+
expect(row.actor_name).toBe('Ada Lovelace');
199+
// Control: `actor_id` is exactly what it was — the same id the name was read for.
200+
expect(row.actor_id).toBe(ADA);
201+
}
202+
});
203+
204+
it('names the human a system-authorized write is ATTRIBUTED to — the same id `actor_id` records', async () => {
205+
const { engine, storeFor } = await boot();
206+
207+
// The better-auth envelope: authorized as the system, crediting a human
208+
// through provenance (`attributedUserId`) rather than the session.
209+
await engine.insert(
210+
'biz_task',
211+
{ id: 't2', title: 'Grade change', status: 'open' },
212+
{ context: { isSystem: true, attributedUserId: ADA } } as any,
213+
);
214+
215+
const [row] = activityFor(storeFor, 't2');
216+
expect(row.actor_id).toBe(ADA);
217+
expect(row.actor_name).toBe('Ada Lovelace');
218+
});
219+
220+
it('a user whose name cannot be read leaves actor_name empty — never the id', async () => {
221+
const { engine, storeFor } = await boot();
222+
await engine.insert('sys_user', { id: 'usr_blank', name: ' ', email: 'blank@example.com' });
223+
224+
// No `sys_user` row at all, and a row whose name is blank.
225+
await engine.insert('biz_task', { id: 't3', title: 'Ghost', status: 'open' }, as('usr_ghost') as any);
226+
await engine.insert('biz_task', { id: 't4', title: 'Blank', status: 'open' }, as('usr_blank') as any);
227+
228+
const [ghost] = activityFor(storeFor, 't3');
229+
const [blank] = activityFor(storeFor, 't4');
230+
expect(ghost.actor_id).toBe('usr_ghost');
231+
expect(ghost.actor_name ?? null).toBeNull();
232+
expect(blank.actor_id).toBe('usr_blank');
233+
expect(blank.actor_name ?? null).toBeNull();
234+
});
235+
236+
it('a write with no user leaves actor_name empty (ADR-0118 D1: the system actor is null) and reads no user', async () => {
237+
const { engine, storeFor, readsOf } = await boot();
238+
const before = readsOf('sys_user');
239+
240+
await engine.insert('biz_task', { id: 't5', title: 'Nightly', status: 'open' }, { context: { isSystem: true } } as any);
241+
// A service principal on `actor` is not a user either (ADR-0118 D5: user or system).
242+
await engine.insert(
243+
'biz_task',
244+
{ id: 't6', title: 'Flow', status: 'open' },
245+
{ context: { isSystem: true, actor: 'svc:flow:nightly' } } as any,
246+
);
247+
248+
for (const id of ['t5', 't6']) {
249+
const [row] = activityFor(storeFor, id);
250+
expect(row.actor_id).toBeNull();
251+
expect(row.actor_name ?? null).toBeNull();
252+
}
253+
expect(readsOf('sys_user') - before).toBe(0);
254+
});
255+
});
256+
257+
describe('[#22510] the name costs one read per user, not one per row', () => {
258+
it('a predicate update over three rows reads sys_user ONCE, and a later write by the same user not at all', async () => {
259+
const { engine, storeFor, readsOf } = await boot();
260+
for (const id of ['p1', 'p2', 'p3']) {
261+
await engine.insert('biz_task', { id, title: id, status: 'open' });
262+
}
263+
const before = readsOf('sys_user');
264+
265+
await engine.update('biz_task', { status: 'done' }, { where: { status: 'open' }, multi: true, ...as(ADA) } as any);
266+
267+
const updated = Array.from(storeFor('sys_activity').values()).filter((r) => r.type === 'updated');
268+
expect(updated).toHaveLength(3);
269+
for (const row of updated) expect(row.actor_name).toBe('Ada Lovelace');
270+
expect(readsOf('sys_user') - before).toBe(1);
271+
272+
await engine.update('biz_task', { title: 'renamed' }, { where: { id: 'p1' }, ...as(ADA) } as any);
273+
expect(readsOf('sys_user') - before).toBe(1);
274+
});
275+
276+
it('an object with `enable.activities: false` writes no activity row and reads no user', async () => {
277+
const { engine, storeFor, readsOf } = await boot();
278+
const before = readsOf('sys_user');
279+
280+
await engine.insert('biz_quiet', { id: 'q1', title: 'Quiet' }, as(ADA) as any);
281+
282+
expect(activityFor(storeFor, 'q1')).toHaveLength(0);
283+
expect(readsOf('sys_user') - before).toBe(0);
284+
});
285+
});

0 commit comments

Comments
 (0)