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 examples/app-showcase/src/objects/task.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const Task = ObjectSchema.create({
status: Field.select({
label: 'Status',
required: true,
// ADR-0052 §5b — declarative activity. Status changes auto-appear on the
// record timeline as "Status: To Do → In Progress" with zero hook code.
trackHistory: true,
options: [
{ label: 'Backlog', value: 'backlog', default: true, color: '#94A3B8' },
{ label: 'To Do', value: 'todo', color: '#3B82F6' },
Expand All @@ -50,6 +53,7 @@ export const Task = ObjectSchema.create({
}),
priority: Field.select({
label: 'Priority',
trackHistory: true,
options: [
{ label: 'Low', value: 'low', color: '#94A3B8' },
{ label: 'Medium', value: 'medium', default: true, color: '#3B82F6' },
Expand Down Expand Up @@ -81,6 +85,13 @@ export const Task = ObjectSchema.create({
sync_error: Field.textarea({ label: 'Sync Error' }),
},

// ADR-0052 §5b.2 — declarative semantic milestone. When status enters `done`,
// the platform emits "✅ Task completed: <title>" on the timeline (taking
// precedence over the raw "Status: In Review → Done" diff) — zero hook code.
activityMilestones: [
{ field: 'status', value: 'done', summary: '✅ Task completed: {title}', type: 'completed' },
],

validations: [
{
type: 'state_machine' as const,
Expand Down
72 changes: 70 additions & 2 deletions packages/plugins/plugin-audit/src/audit-writers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ interface CapturedRow {
* `engine.getSchema(name)` returns after `applySystemFields` has (or has
* not) injected `organization_id`.
*/
function makeEngine(schemas: Record<string, string[] | Record<string, any>>) {
function makeEngine(
schemas: Record<string, string[] | Record<string, any>>,
objectDefs: Record<string, any> = {},
) {
const hooks = new Map<string, Array<(ctx: any) => any>>();
const created: CapturedRow[] = [];

Expand All @@ -49,7 +52,7 @@ function makeEngine(schemas: Record<string, string[] | Record<string, any>>) {
const fieldMap = Array.isArray(fields)
? Object.fromEntries(fields.map((f) => [f, { type: 'text' }]))
: fields;
return { name, fields: fieldMap };
return { name, fields: fieldMap, ...(objectDefs[name] || {}) };
},
registerHook(event: string, fn: (ctx: any) => any) {
const list = hooks.get(event) ?? [];
Expand Down Expand Up @@ -177,3 +180,68 @@ describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)',
expect(activity?.row.summary).toBe('Updated crm_opportunity "Acme Renewal"');
});
});

describe('audit writers — declarative milestones (ADR-0052 §5b.2)', () => {
const FIELDS = {
id: { type: 'text' },
name: { type: 'text', label: 'Name' },
stage: {
type: 'select',
label: 'Stage',
trackHistory: true,
options: [
{ value: 'negotiation', label: 'Negotiation' },
{ value: 'closed_won', label: 'Closed Won' },
],
},
};
const SCHEMA = {
sys_audit_log: SINGLE_TENANT.sys_audit_log,
sys_activity: SINGLE_TENANT.sys_activity,
crm_opportunity: FIELDS,
};
// Object-level milestone: when stage enters closed_won → "Deal won: {name}".
const OBJECT_DEFS = {
crm_opportunity: {
activityMilestones: [
{ field: 'stage', value: 'closed_won', summary: 'Deal won: {name}', type: 'completed' },
],
},
};

it('emits the interpolated milestone summary (precedence over field-change) on transition', async () => {
const { engine, fire, created } = makeEngine(SCHEMA, OBJECT_DEFS);
installAuditWriters(engine as any, 'test.audit');

await fire('afterUpdate', {
object: 'crm_opportunity',
input: { id: 'opp-1', stage: 'closed_won' },
result: { id: 'opp-1', name: 'Acme Renewal', stage: 'closed_won' },
__previous: { id: 'opp-1', name: 'Acme Renewal', stage: 'negotiation' },
session: {},
});

const activity = created.find((c) => c.object === 'sys_activity');
// Milestone summary wins over the "Stage: Negotiation → Closed Won" diff.
expect(activity?.row.summary).toBe('Deal won: Acme Renewal');
expect(activity?.row.type).toBe('completed');
});

it('does not fire the milestone when the field does not transition into the value', async () => {
const { engine, fire, created } = makeEngine(SCHEMA, OBJECT_DEFS);
installAuditWriters(engine as any, 'test.audit');

await fire('afterUpdate', {
object: 'crm_opportunity',
input: { id: 'opp-1', stage: 'negotiation' },
result: { id: 'opp-1', name: 'Acme Renewal', stage: 'negotiation' },
__previous: { id: 'opp-1', name: 'Acme Renewal', stage: 'proposal' },
session: {},
});

const activity = created.find((c) => c.object === 'sys_activity');
// Falls back to the field-change render (trackHistory), not the milestone.
// `proposal` has no option entry here → raw value; `negotiation` → its label.
expect(activity?.row.summary).toBe('Stage: proposal → Negotiation');
});
});
68 changes: 62 additions & 6 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,39 @@ function renderTrackedChangeSummary(
return parts.length > 0 ? parts.join('; ') : null;
}

/**
* ADR-0052 §5b.2 — declarative semantic milestones. If a watched field
* transitioned INTO a configured `value` on this update (before ≠ value, after =
* value), return the interpolated summary (and optional activity type). `{token}`
* in the template is replaced by the record's field value, resolving select
* option labels when possible. Returns null when no milestone fired (needs the
* `before` snapshot to detect a transition). Takes precedence over the raw
* field-change summary.
*/
function matchMilestone(
objectDef: any,
fields: Record<string, any> | null,
before: Record<string, any> | null,
after: Record<string, any> | null,
): { summary: string; type?: string } | null {
const milestones =
objectDef && Array.isArray(objectDef.activityMilestones) ? objectDef.activityMilestones : null;
if (!milestones || !after || !before) return null;
for (const m of milestones) {
if (!m || typeof m.field !== 'string' || typeof m.summary !== 'string') continue;
if (after[m.field] === m.value && before[m.field] !== m.value) {
const summary = m.summary.replace(/\{(\w+)\}/g, (_match: string, key: string) => {
const v = after[key];
if (v === null || v === undefined || v === '') return '';
const field = fields ? fields[key] : undefined;
return field ? displayFieldValue(field, v) : String(v);
});
return { summary, type: typeof m.type === 'string' ? m.type : undefined };
}
}
return null;
}

/**
* Install audit + activity writers on the given engine. Idempotent per
* `packageId` — calling twice with the same id replaces the previous
Expand Down Expand Up @@ -240,6 +273,21 @@ export function installAuditWriters(
return defs;
};

// Cached full object definition (for ADR-0052 §5b.2 activityMilestones —
// object-level metadata, not just fields).
const objectDefCache = new Map<string, any>();
const getObjectDef = (objectName: string): any => {
if (objectDefCache.has(objectName)) return objectDefCache.get(objectName);
let def: any = null;
try {
def = typeof (engine as any).getSchema === 'function' ? (engine as any).getSchema(objectName) : null;
} catch {
/* ignore — best-effort */
}
objectDefCache.set(objectName, def);
return def;
};

/**
* beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo()
* so we can compute the diff in the afterXxx hook. We attach the snapshot
Expand Down Expand Up @@ -361,20 +409,28 @@ export function installAuditWriters(

const label = recordLabel(after ?? before, recordId ?? '');
let summary: string;
let activityType: string = activityTypeFor(action);
if (action === 'create') {
summary = `Created ${ctx.object} "${label}"`;
} else if (action === 'delete') {
summary = `Deleted ${ctx.object} "${label}"`;
} else {
// ADR-0052 §5b: if any changed field declares `trackHistory: true`, render
// the diff legibly ("Stage: Proposal → Closed Won"); else generic text.
summary =
renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
`Updated ${ctx.object} "${label}"`;
// ADR-0052 §5b — declarative activity, precedence: a configured semantic
// milestone (§5b.2) wins; else a tracked field-change diff ("Stage:
// Proposal → Closed Won", §5b.1); else the generic fallback.
const milestone = matchMilestone(getObjectDef(ctx.object), getFieldDefs(ctx.object), before, after);
if (milestone) {
summary = milestone.summary;
if (milestone.type) activityType = milestone.type;
} else {
summary =
renderTrackedChangeSummary(getFieldDefs(ctx.object), oldValue, newValue) ??
`Updated ${ctx.object} "${label}"`;
}
}

const activityRow: Record<string, any> = {
type: activityTypeFor(action),
type: activityType,
// Explicit ISO timestamp — `defaultValue: 'NOW()'` on the column
// isn't resolved by every driver and would otherwise leak the
// literal string "NOW()" into the row.
Expand Down
1 change: 1 addition & 0 deletions packages/spec/liveness/object.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"external": { "status": "live", "evidence": "packages/runtime/src/external-validation-plugin.ts", "note": "remote table + write gate." },
"indexes": { "status": "live", "evidence": "packages/plugins/driver-sql/src/sql-driver.ts:1181", "note": "DDL." },
"validations": { "status": "live", "evidence": "packages/objectql/src/validation/rule-validator.ts:154", "note": "incl. state_machine." },
"activityMilestones": { "status": "live", "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts", "note": "ADR-0052 §5b.2 — audit-writer matchMilestone() emits a templated activity row when a watched field transitions into the configured value." },
"actions": { "status": "live", "evidence": "packages/runtime/src/app-plugin.ts:929", "note": "served on /meta/objects/:name." },
"managedBy": { "status": "live", "evidence": "packages/objectql/src/registry.ts:208", "note": "default perms; ui crudAffordances." },
"userActions": { "status": "live", "note": "objectui ObjectGrid per-object CRUD." },
Expand Down
19 changes: 18 additions & 1 deletion packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const ObjectCapabilities = z.object({

/** Enable standard Activity suite (Tasks, Calendars, Events) */
activities: z.boolean().default(false).describe('Enable standard tasks and events tracking'),

/** Enable Recycle Bin / Soft Delete */
trash: z.boolean().default(true).describe('Enable soft-delete with restore capability'),

Expand Down Expand Up @@ -523,6 +523,23 @@ const ObjectSchemaBase = z.object({
*/
validations: z.array(ValidationRuleSchema).optional().describe('Object-level validation rules'),

/**
* Declarative semantic activity milestones (ADR-0052 §5b.2). When a watched
* field transitions INTO `value`, the platform emits a templated activity-row
* on the record timeline — no `*.hook.ts` / `*.flow.ts`. Complements field-level
* `trackHistory` (which renders raw "Field: old → new"): use milestones for
* business-meaningful events ("Deal won", "Task completed"). `summary` supports
* `{field}` tokens interpolated from the record; the milestone summary takes
* precedence over the field-change summary for the same update. Consumed by
* `@objectstack/plugin-audit` audit-writers (enforce-or-remove, ADR-0049).
*/
activityMilestones: z.array(z.object({
field: z.string().describe('Field to watch (typically a status/stage select).'),
value: z.string().describe('The value the field must transition INTO to fire the milestone.'),
summary: z.string().describe('Activity summary template; {field} tokens interpolate the record value. e.g. "Deal won: {name}".'),
type: z.string().optional().describe('Activity type for the emitted row (default "completed").'),
})).optional().describe('Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2).'),

// ADR-0020: record state machines are not a separate `stateMachines` map —
// each lifecycle is a `state_machine` rule in `validations` above (one rule
// per state field). Parallel lifecycles = multiple rules. The write path
Expand Down
Loading