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
11 changes: 11 additions & 0 deletions .changeset/30036-activity-feed-default-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-audit': patch
---

fix(plugin-audit): the activity list opens newest first, without the platform's own rows, under a plain name and description

Clause-②: no
Metadata-protocol: none

- **`sys_activity` declares two list views.** `recent` comes first, so it is the list Home's "view all activity" and the bell's Activity tab open (both name no view): newest first, and only rows a person wrote (`actor_id` set) about something other than the platform's own objects: the metadata store, the data-migration ledger, and every identity object `@objectstack/platform-objects/identity` declares (users, members, organizations, sessions, keys, SCIM): every sign-in stamps the last-login columns on `sys_user` under the signing-in person's own name, the signing key in `sys_jwks` is minted under the first sign-in's session, and adding a person writes `sys_user` and `sys_member` rows that person can read. Sign-in stamps, migrations, seeds and metadata history no longer sit between the business rows; an administrator's edits to users are in `all`. `all` lists every row the reader may read, platform rows included, newest first. Nothing is removed from storage and nothing changes in what is written; every excluded row still has its `sys_audit_log` entry.
- **Label and description.** The object reads `Activity Feed` (zh-CN `动态`) instead of `Activity`, which collided with an app's own activity object (a CRM's calls and meetings), and its description says what the page is instead of `Recent activity stream entries (lightweight, denormalized)`.
103 changes: 100 additions & 3 deletions packages/plugins/plugin-audit/src/objects/sys-activity.object.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,58 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ObjectSchema, Field, SYS_ACTIVITY_BUILTIN_TYPES } from '@objectstack/spec/data';
import { SysMigration, SysMigrationJournal } from '@objectstack/platform-objects/system';
import * as IdentityObjects from '@objectstack/platform-objects/identity';
import { SystemObjectName } from '@objectstack/spec/system';

/** Every object the identity domain declares (users, members, organizations, sessions, keys, SCIM, …). */
const IDENTITY_OBJECT_NAMES: readonly string[] = (Object.values(IdentityObjects) as unknown[])
.filter((value): value is { name: string; fields: object } =>
!!value && typeof value === 'object' && typeof (value as { name?: unknown }).name === 'string'
&& typeof (value as { fields?: unknown }).fields === 'object')
.map((object) => object.name)
.sort();

/**
* The platform objects whose rows the default `recent` view leaves out, even
* when a person's session wrote them:
*
* - the metadata store (published items, drafts, history, commits, the
* store's own audit): publishing in Studio writes these under the
* publisher's name;
* - the data-migration ledger;
* - the identity domain, every object `@objectstack/platform-objects/identity`
* declares. Measured on a fresh boot, all under a person's own id: every
* sign-in stamps `last_login_at` / `last_login_ip` on `sys_user`; the
* token-signing key in `sys_jwks` is minted under the first sign-in's
* session; adding a person writes a `sys_user` and a `sys_member` row, which
* that person can then read. The only column that tells a sign-in stamp from
* a profile edit is `changed_fields`, which a filter may not name for every
* reader (`parent-field-query-guard.ts`), so the cut is by object.
*
* Their writes are mirrored like any other object's, and those rows stay
* stored and readable: the `all` view below lists them, and each one has its
* compliance row in `sys_audit_log`.
*
* Read from the definitions and the protocol's name constants where this file
* may import them. The three metadata-store names are spelled out: an object
* file is loaded from source on its own by `check:platform-object-tenancy-census`,
* and `@objectstack/metadata-core` publishes its definitions only from its root
* entry, whose graph that loader cannot evaluate.
* `sys-activity-default-view.integration.test.ts` holds the list equal to the
* definitions' own names, so a rename reds there. Deliberately not a name
* prefix: `sys_*` also names objects whose rows are people's work (a file
* someone uploads).
*/
export const ACTIVITY_FEED_EXCLUDED_OBJECTS: readonly string[] = Object.freeze([
SystemObjectName.METADATA,
'sys_metadata_history',
'sys_metadata_commit',
'sys_metadata_audit',
SysMigration.name,
SysMigrationJournal.name,
...IDENTITY_OBJECT_NAMES,
]);

/**
* sys_activity — Lightweight Activity Stream
Expand All @@ -18,8 +70,10 @@ import { ObjectSchema, Field, SYS_ACTIVITY_BUILTIN_TYPES } from '@objectstack/sp
*/
export const SysActivity = ObjectSchema.create({
name: 'sys_activity',
label: 'Activity',
pluralLabel: 'Activities',
// Not "Activity": an app's own object is often called that (a CRM's calls
// and meetings), and this page sat beside it under the same word.
label: 'Activity Feed',
pluralLabel: 'Activity Feed',
icon: 'activity',
isSystem: true,
managedBy: 'append-only',
Expand All @@ -32,11 +86,54 @@ export const SysActivity = ObjectSchema.create({
storage: { strategy: 'rotation', shards: 14, unit: 'day' },
reclaim: true,
},
description: 'Recent activity stream entries (lightweight, denormalized)',
description: 'What people did to records recently, newest first: who created, changed or deleted what. Entries are kept for 14 days.',
displayNameField: 'summary',
nameField: 'summary', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
highlightFields: ['timestamp', 'type', 'actor_name', 'summary'],

// The list a reader lands on with no view named (Home's "view all activity"
// and the bell's Activity tab both open the bare object route) is the FIRST
// view declared here, so `recent` stays first.
//
// `recent` is the cut Home's activity card makes, as far as a query column
// can say it: a person acted (`actor_id` set; a system write leaves it null,
// ADR-0118 D1: a migration, a seed, a platform setting written at boot), and
// the row is not about one of the platform objects
// `ACTIVITY_FEED_EXCLUDED_OBJECTS` names. It names no value-bearing column
// (`parent-field-query-guard.ts`), so every reader may run it.
//
// `all` is every row the reader may read, platform rows included: where an
// administrator still finds what `recent` leaves out. Nothing is removed from
// storage and nothing changes in what is written.
listViews: {
recent: {
type: 'grid',
name: 'recent',
label: 'Recent activity',
data: { provider: 'object', object: 'sys_activity' },
columns: ['timestamp', 'actor_name', 'type', 'summary'],
filter: [
{ field: 'actor_id', operator: 'is_not_null' },
{ field: 'object_name', operator: 'not_in', value: [...ACTIVITY_FEED_EXCLUDED_OBJECTS] },
],
sort: [{ field: 'timestamp', order: 'desc' }],
pagination: { pageSize: 50 },
emptyState: {
title: 'No recent activity',
message: 'Changes people make to records appear here, newest first.',
},
},
all: {
type: 'grid',
name: 'all',
label: 'All entries',
data: { provider: 'object', object: 'sys_activity' },
columns: ['timestamp', 'actor_name', 'type', 'summary'],
sort: [{ field: 'timestamp', order: 'desc' }],
pagination: { pageSize: 100 },
},
},

fields: {
id: Field.text({
label: 'Activity ID',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `sys_activity`'s default list: what a reader sees when Home's "view all
* activity" or the bell's Activity tab opens the bare object route.
*
* Both doors name no view, so the Console opens the FIRST declared list view.
* That view (`recent`) must answer newest first, and must leave out the rows
* about the platform's own objects (the metadata store, the migration ledger,
* and `sys_user`, whose sign-in stamp is written under the signing-in person's
* own name) and the rows no person wrote (a migration, a seed: the system actor
* is `null`, ADR-0118 D1). The second view (`all`) still lists every row, which
* is where an administrator finds the rest.
*
* ## Why a real engine and a real driver
*
* The view is a filter and a sort the Console sends to the server. Whether
* that filter selects the right rows is a question about the Filter Protocol
* as a SQL driver executes it (`$null`, `$nin`), so the view's own rules are
* lowered the way the Console lowers a view filter (one `[field, operator,
* value]` node per rule under `and`) and parsed by the server's own
* `parseFilterAST`, then run through the real engine on SQLite. The human and
* system rows on a business object are written by the real audit writer, so
* the "system write leaves `actor_id` null" premise is the writer's, not the
* fixture's.
*/

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 { parseFilterAST } from '@objectstack/spec/data';
import {
SysMetadataObject,
SysMetadataHistoryObject,
SysMetadataCommitObject,
SysMetadataAuditObject,
} from '@objectstack/metadata-core';
import { SysMigration, SysMigrationJournal } from '@objectstack/platform-objects/system';
import * as IdentityObjects from '@objectstack/platform-objects/identity';
import { SystemObjectName } from '@objectstack/spec/system';

import { AuditPlugin } from './audit-plugin.js';
import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js';
import { installDefaultOrganization } from './default-organization.testkit.js';
import { SysActivity } from './objects/index.js';
import { ACTIVITY_FEED_EXCLUDED_OBJECTS } from './objects/sys-activity.object.js';

const ACTIVITY = 'sys_activity';
const DEAL = 'act_deal';
const HARNESS_PACKAGE = 'com.objectstack.audit.test.activity-default-view';

const SYS = { isSystem: true } as const;
const ADMIN = { userId: 'u_admin', tenantId: 'org_default', positions: ['org_admin'] };

const dealObject = {
name: DEAL,
label: 'Deal',
fields: { name: { name: 'name', label: 'Name', type: 'text' as const } },
};

type Row = Record<string, any>;
type ViewRule = { field: string; operator: string; value?: unknown };
type ListView = { filter?: ViewRule[]; sort?: Array<{ field: string; order: 'asc' | 'desc' }> };

/** Every object the identity domain exports, read the same way the declaration reads it. */
const identityNames = Object.values(IdentityObjects)
.filter((v: any) => v && typeof v === 'object' && typeof v.name === 'string' && typeof v.fields === 'object')
.map((v: any) => v.name as string);

const views = (SysActivity as unknown as { listViews: Record<string, ListView> }).listViews;

/** The Console's lowering of a view filter: one node per rule, under `and`. */
function whereOf(view: ListView): Record<string, unknown> | undefined {
const rules = view.filter ?? [];
if (rules.length === 0) return undefined;
return parseFilterAST(['and', ...rules.map((r) => [r.field, r.operator, r.value])]) as Record<string, unknown>;
}

describe('sys_activity default list view — the declaration', () => {
it('the first declared view is `recent`, the one a door with no view name opens', () => {
expect(Object.keys(views)[0]).toBe('recent');
});

it('both views read newest first', () => {
expect(views.recent.sort).toEqual([{ field: 'timestamp', order: 'desc' }]);
expect(views.all.sort).toEqual([{ field: 'timestamp', order: 'desc' }]);
});

it('the excluded objects are read from the definitions they name', () => {
expect([...ACTIVITY_FEED_EXCLUDED_OBJECTS].sort()).toEqual(
[
SysMetadataObject.name,
SysMetadataHistoryObject.name,
SysMetadataCommitObject.name,
SysMetadataAuditObject.name,
SysMigration.name,
SysMigrationJournal.name,
...identityNames,
].sort(),
);
});

it('the identity objects measured writing under a person\'s id are among the excluded', () => {
for (const object of [
IdentityObjects.SysUser,
IdentityObjects.SysMember,
IdentityObjects.SysJwks,
IdentityObjects.SysOrganization,
IdentityObjects.SysInvitation,
]) {
expect(ACTIVITY_FEED_EXCLUDED_OBJECTS).toContain(object.name);
}
});

it('`all` carries no filter, so every readable row stays one click away', () => {
expect(views.all.filter).toBeUndefined();
});

it('the description is not the developer sentence the page used to show', () => {
expect(SysActivity.description).not.toMatch(/denormalized/i);
expect(SysActivity.label).not.toBe('Activity');
});
});

describe('sys_activity default list view — the rows it serves', () => {
let kernel: ObjectKernel;
let engine: ObjectQL;
const summaries = (rows: Row[]) => rows.map((r) => r.summary);

beforeAll(async () => {
kernel = new ObjectKernel({ logger: { level: 'silent' } });
await kernel.use(new ObjectQLPlugin());
await kernel.use(singlePostureTenancy());
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(dealObject as any, HARNESS_PACKAGE);
await engine.syncSchemas();
await installDefaultOrganization(engine);

// The real CRUD mirror: a person's write and a system write on a business object.
await engine.insert(DEAL, { name: 'person deal' }, { context: ADMIN });
await engine.insert(DEAL, { name: 'seeded deal' }, { context: SYS });

// Rows in the shapes the platform's own writers leave, at known instants.
const at = (minute: number) => new Date(Date.UTC(2026, 10, 4, 16, minute)).toISOString();
const rows: Row[] = [
{ summary: 'person edits a deal (16:44)', object_name: DEAL, record_id: 'd1', actor_id: 'u_admin', timestamp: at(44) },
{ summary: 'person creates a deal (16:34)', object_name: DEAL, record_id: 'd2', actor_id: 'u_admin', timestamp: at(34) },
{ summary: 'person deletes a deal (16:40)', object_name: DEAL, record_id: 'd3', actor_id: 'u_admin', timestamp: at(40) },
// Measured on a fresh `dev:crm` boot: the sign-in stamp carries the person who signed in.
{ summary: 'sign-in stamp', object_name: 'sys_user', record_id: 'u_admin', actor_id: 'u_admin', timestamp: at(45) },
{ summary: 'metadata publish', object_name: SysMetadataObject.name, record_id: 'm1', actor_id: 'u_admin', timestamp: at(46) },
{ summary: 'metadata history', object_name: SysMetadataHistoryObject.name, record_id: 'h1', actor_id: 'u_admin', timestamp: at(47) },
{ summary: 'member added', object_name: IdentityObjects.SysMember.name, record_id: 'mb1', actor_id: 'u_admin', timestamp: at(50) },
{ summary: 'signing key minted', object_name: SystemObjectName.JWKS, record_id: 'k1', actor_id: 'u_admin', timestamp: at(49) },
{ summary: 'migration', object_name: SysMigration.name, record_id: 'g1', actor_id: null, timestamp: at(48) },
];
for (const row of rows) {
await engine.insert(ACTIVITY, { type: 'updated', ...row }, { context: SYS });
}
});

afterAll(async () => {
await kernel?.shutdown?.();
});

it('a system write on a business object leaves actor_id null; a person\'s carries the person', async () => {
const mirrored = (await engine.find(ACTIVITY, {
where: { object_name: DEAL, record_id: { $nin: ['d1', 'd2', 'd3'] } },
context: SYS,
})) as Row[];
const byActor = mirrored.map((r) => r.actor_id ?? null).sort();
expect(byActor).toEqual(['u_admin', null].sort());
});

it('`recent` serves only what people did to records, newest first', async () => {
const recent = (await engine.find(ACTIVITY, {
where: whereOf(views.recent),
orderBy: views.recent.sort,
context: SYS,
})) as Row[];
const fixed = summaries(recent).filter((s) => / \(16:\d\d\)$/.test(s));
expect(fixed).toEqual([
'person edits a deal (16:44)',
'person deletes a deal (16:40)',
'person creates a deal (16:34)',
]);
// The mirrored person row is in, the seeded one is not.
expect(recent.filter((r) => r.object_name === DEAL && !/ \(16:/.test(r.summary))).toHaveLength(1);
for (const row of recent) {
expect(row.actor_id).toBeTruthy();
expect(ACTIVITY_FEED_EXCLUDED_OBJECTS).not.toContain(row.object_name);
}
const stamps = recent.map((r) => Date.parse(r.timestamp));
expect(stamps).toEqual([...stamps].sort((a, b) => b - a));
});

it('`all` still lists the rows `recent` leaves out', async () => {
const all = summaries((await engine.find(ACTIVITY, {
where: whereOf(views.all),
orderBy: views.all.sort,
context: SYS,
})) as Row[]);
for (const left of ['sign-in stamp', 'metadata publish', 'metadata history', 'signing key minted', 'member added', 'migration']) {
expect(all).toContain(left);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
}
},
sys_activity: {
label: "Activity",
pluralLabel: "Activities",
description: "Recent activity stream entries (lightweight, denormalized)",
label: "Activity Feed",
pluralLabel: "Activity Feed",
description: "What people did to records recently, newest first: who created, changed or deleted what. Entries are kept for 14 days.",
fields: {
id: {
label: "Activity ID"
Expand Down Expand Up @@ -193,6 +193,18 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Milestone Key",
help: "For a fired activity milestone, one sorted, comma-joined key of every field the object's activityMilestones declaration watched when it fired; NULL for every other row. Stamped by the platform at write time and never served: the read rule compares it with the current declaration."
}
},
_views: {
recent: {
label: "Recent activity",
emptyState: {
title: "No recent activity",
message: "Changes people make to records appear here, newest first."
}
},
all: {
label: "All entries"
}
}
},
sys_comment: {
Expand Down
Loading
Loading