From adb8d101d5c4ecf6a2f8796c1f886b26a843efcc Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 15:38:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?demo(showcase):=20mark=20task=20status/prio?= =?UTF-8?q?rity=20trackHistory=20(ADR-0052=20=C2=A75b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates declarative activity: status/priority changes auto-appear on the record timeline ("Status: In Progress → In Review") with zero hook code. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/app-showcase/src/objects/task.object.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/app-showcase/src/objects/task.object.ts b/examples/app-showcase/src/objects/task.object.ts index b486b97617..b69c1c7892 100644 --- a/examples/app-showcase/src/objects/task.object.ts +++ b/examples/app-showcase/src/objects/task.object.ts @@ -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' }, @@ -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' }, From bdd0f72ccdef675ccb3b6377a15661527f6c8736 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 16:00:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(audit):=20declarative=20activity=20mil?= =?UTF-8?q?estones=20(ADR-0052=20=C2=A75b.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object-level `activityMilestones` — when a watched field transitions INTO a configured value, the platform emits a templated, semantic activity row ("✅ Task completed: {title}", "Deal won: {name}") on the record timeline, with zero hook/flow code. Complements field-level `trackHistory` (§5b.1) and takes precedence over the raw "Field: old → new" summary for the same update. Same declarative posture as Salesforce Flow / ServiceNow for milestone events. - spec: `activityMilestones` on ObjectSchema (top-level) + liveness classification - plugin-audit: matchMilestone() + getObjectDef(); summary precedence milestone → field-change → generic; milestone may set the activity `type` - tests: 2 new (interpolated milestone + precedence; no-fire when no transition) - showcase: showcase_task fires "✅ Task completed: {title}" on status→done Verified in the showcase browser: status→done renders the milestone row. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app-showcase/src/objects/task.object.ts | 7 ++ .../plugin-audit/src/audit-writers.test.ts | 72 ++++++++++++++++++- .../plugins/plugin-audit/src/audit-writers.ts | 68 ++++++++++++++++-- packages/spec/liveness/object.json | 1 + packages/spec/src/data/object.zod.ts | 19 ++++- 5 files changed, 158 insertions(+), 9 deletions(-) diff --git a/examples/app-showcase/src/objects/task.object.ts b/examples/app-showcase/src/objects/task.object.ts index b69c1c7892..6ead30125f 100644 --- a/examples/app-showcase/src/objects/task.object.ts +++ b/examples/app-showcase/src/objects/task.object.ts @@ -85,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: " 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, diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index b4780e8fdb..7196d31de4 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -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[] = []; @@ -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) ?? []; @@ -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'); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 61c0f42515..d072403b0f 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -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 @@ -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 @@ -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. diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index cea730a9ff..c07a23b1ad 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -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." }, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index a189efcd01..a257134805 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -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'), @@ -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