Activity Log Plugin
A Spatie-inspired activity log plugin for Svelar/SvelteKit that records user actions, tracks model changes, and provides a timeline feed with filtering, pagination, and pre-built UI components.
Package: @beeblock/svelar-activity-log
Install:
npx svelar plugin:install @beeblock/svelar-activity-log
Imports:
// Dedicated server-only plugin registration
import SvelarActivityLogPlugin from '@beeblock/svelar-activity-log/plugin';
// Core API
import { Activity, ActivityLogger, activity, ActivityService, LogsActivity, setCauserResolver, getCauserResolver } from '@beeblock/svelar-activity-log/server';
// Server-side (controller)
import { ActivityController } from '@beeblock/svelar-activity-log/server';
// UI components
import { ActivityFeed, ActivityItem, ActivityFilters } from '@beeblock/svelar-activity-log/ui';
// Types
import type { ActivityRecord, ActivityData, ChangeProperties, ActivityFilterOptions, PaginatedActivities, ActivityLogPluginConfig, ActivityFeedItem, ActivityLogClassNames, LogsActivityConfig, ModelIdentifier, ActivityIdentifiable } from '@beeblock/svelar-activity-log';
Quick Start
1. Register the Plugin
// src/lib/plugins.ts
import { SvelarActivityLogPlugin } from '@beeblock/svelar-activity-log/server';
export const activityLogPlugin = new SvelarActivityLogPlugin({
prefix: '/api',
defaultLogName: 'default',
defaultCauserType: 'users',
cleanupDays: 90,
logOnlyDirty: true,
});
2. Run the Migration
npx svelar plugin:publish @beeblock/svelar-activity-log
npx svelar migrate
The plugin publishes create_activity_log_table.ts, which creates the table and indexes through Svelar migrations. ActivityService.getMigrationSQL() is not part of the current API. Do not create this table from a route, service, or application bootstrap.
subject_id and causer_id are nullable VARCHAR(255) polymorphic references. Accept string | number inputs; storage and resources return canonical strings. UUIDs, ULIDs, custom primary keys, case, trailing spaces, and leading zeros retain their identity. A numeric 1 becomes '1', while '001' remains distinct. The activity row's own id remains numeric. MySQL queries compare both morph type and ID exactly with parameterized binary predicates.
These revised initial migrations belong to the unpublished alpha package. An existing installation must add a reviewed application migration that converts its old numeric morph columns and preserves its indexes and data. Polymorphic references do not add a host-user foreign key. Any application-owned foreign key must match its referenced primary key's actual database type, size, signedness, and collation; changing a column to VARCHAR does not make it compatible with a BIGINT or native UUID key.
3. Log an Activity
import { activity } from '@beeblock/svelar-activity-log/server';
await activity()
.causedBy(user)
.performedOn(post)
.withProperties({ ip: '192.168.1.1' })
.log('updated');
Configuration
The SvelarActivityLogPlugin constructor registers these defaults under the activity-log configuration key:
| Option | Type | Default | Description |
|---|---|---|---|
prefix |
string |
'/api' |
Route-prefix configuration metadata; bind the actual route in the application |
defaultLogName |
string |
'default' |
Configuration default; choose the runtime name with activity(name) |
defaultCauserType |
string |
'users' |
Configuration default; use causedBy(id, type) or causerType(type) |
cleanupDays |
number |
90 |
Retention metadata; pass days to the cleanup request/service |
logOnlyDirty |
boolean |
true |
Set static logOnlyDirty on the mixed model to control tracking |
The logger, cleanup controller, and model mixin do not automatically read these configuration overrides. Pass the corresponding arguments or model settings explicitly. Publishing creates a migration; it does not bind routes or schedule retention cleanup.
Core API
ActivityLogger (Fluent Builder)
The primary way to log activities. Create one via the activity() factory function:
import { activity } from '@beeblock/svelar-activity-log/server';
// Basic usage
await activity().log('user signed in');
// Full fluent API
await activity('billing')
.causedBy(user) // or .causedBy(userId, 'admins')
.performedOn(invoice) // or .onSubject('invoices', 42)
.withProperties({ amount: 99.99, currency: 'USD' })
.causerType('admins')
.useLog('billing')
.log('invoice paid');
| Method | Returns | Description |
|---|---|---|
causedBy(causer, type?) |
this |
Set who performed the action (model instance or string | number ID) |
performedOn(subject) |
this |
Set what was acted upon (model instance or { type, id }) |
onSubject(type, id) |
this |
Set the subject by type string and ID directly |
withProperties(props) |
this |
Attach arbitrary key-value metadata (merged with existing) |
causerType(type) |
this |
Override the causer type string |
useLog(name) |
this |
Override the log name |
log(description) |
Promise<Activity> |
Persist the activity to the database |
Activity Class
A Svelar ORM model with JSON-cast properties and a consistent resource representation:
const entry = await activity().causedBy(user).log('created');
entry.id; // number
entry.logName; // string
entry.description; // string
entry.subjectType; // string | null
entry.subjectId; // string | null
entry.causerType; // string
entry.causerId; // string | null
entry.properties; // Record<string, any>
entry.createdAt; // string (ISO)
Instance methods:
| Method | Returns | Description |
|---|---|---|
getChanges() |
ChangeProperties |
Get { old, new } change tracking data |
hasChanges() |
boolean |
Check if this activity has old/new change data |
getProperty(key, fallback?) |
any |
Get a specific property value |
toData() |
ActivityData |
Convert to a plain serializable object |
toJSON() |
ActivityData |
Alias for toData() |
Static methods:
| Method | Returns | Description |
|---|---|---|
Activity.fromRecord(record) |
Activity |
Create from a database row |
Activity.fromRecords(records) |
Activity[] |
Create multiple from database rows |
ActivityService
Static service class for querying and managing activity logs:
import { ActivityService } from '@beeblock/svelar-activity-log/server';
// Fluent query builder
const activities = await ActivityService.query()
.logName('billing')
.forSubject('invoices', 42)
.causedBy(1)
.withDescription('paid')
.since('2026-01-01')
.until('2026-12-31')
.latest() // newest first (default)
.limit(10)
.get();
// Paginated results
const page = await ActivityService.query()
.forSubject('posts')
.paginate(1, 15);
// => { data: ActivityData[], total, page, perPage, lastPage, hasMore }
// Count matching activities
const count = await ActivityService.query()
.logName('auth')
.count();
Convenience methods:
| Method | Returns | Description |
|---|---|---|
ActivityService.query() |
ActivityQueryBuilder |
Start a new fluent query |
ActivityService.forSubject(type, id, limit?) |
Promise<Activity[]> |
Get activities for a subject |
ActivityService.forCauser(causerId, limit?) |
Promise<Activity[]> |
Get activities by a causer |
ActivityService.latest(limit?) |
Promise<Activity[]> |
Get the latest N activities (default 20) |
ActivityService.cleanOlderThan(days) |
Promise<number> |
Delete old activities, returns count deleted |
ActivityService.deleteForSubject(type, id) |
Promise<void> |
Delete all activities for a subject |
Query builder methods:
| Method | Description |
|---|---|
.logName(name) |
Filter by log name |
.forSubject(type, id?) |
Filter by subject type and optionally ID |
.causedBy(causerId, causerType?) |
Filter by causer |
.withDescription(description) |
Filter by exact description match |
.since(date) |
Filter from a date (Date or ISO string) |
.until(date) |
Filter until a date |
.limit(n) |
Limit number of results |
.latest() |
Sort newest first (default) |
.oldest() |
Sort oldest first |
.get() |
Execute and return Activity[] |
.paginate(page, perPage) |
Execute and return PaginatedActivities |
.count() |
Execute and return count |
LogsActivity Mixin
The logger and mixin read a model key with model.getAttribute(model.constructor.primaryKey). Passing a real model also infers its table as the morph type, including custom primary-key names. setCauserResolver accepts a real model or { id: string | number, type?: string }.
Automatically logs created, updated, and deleted events on any Model:
import { Model } from '@beeblock/svelar/orm';
import { LogsActivity } from '@beeblock/svelar-activity-log/server';
class Post extends LogsActivity(Model) {
static table = 'posts';
static fillable = ['title', 'content', 'published'];
// Which attributes to track (empty = all fillable)
static logAttributes: string[] = ['title', 'content', 'published'];
// Activity log name (defaults to table name)
static logName = 'posts';
// Only log attributes that actually changed (default: true)
static logOnlyDirty = true;
// Attributes to exclude from logging (hidden fields are also excluded)
static logExcept: string[] = [];
}
When a model with LogsActivity is created, updated, or deleted, an activity log entry is automatically inserted with the tracked attribute changes in the properties field:
- Created:
{ new: { title: '...', content: '...' } } - Updated:
{ old: { title: 'Old' }, new: { title: 'New' } } - Deleted:
{ old: { title: '...', content: '...' } }
Static methods on the mixed model:
| Method | Description |
|---|---|
Post.disableLogging() |
Temporarily disable automatic logging |
Post.enableLogging() |
Re-enable automatic logging |
Post.withoutLogging(fn) |
Run an async callback with logging disabled |
Causer Resolver
Register one resolver at application setup and read the actor from request-scoped context. Do not replace a global resolver with a closure over each request's user.
// This wrapper runs after the application's Svelar authentication middleware.
import { ApplicationContext } from '@beeblock/svelar/support';
import { setCauserResolver } from '@beeblock/svelar-activity-log/server';
setCauserResolver(() => ApplicationContext.get('activityCauser') ?? null);
export async function handle({ event, resolve }) {
return ApplicationContext.run(
{ activityCauser: event.locals.auth?.user() ?? null },
() => resolve(event),
);
}
Automatic logs follow instance lifecycle hooks. Hidden fields and logExcept are excluded. withoutLogging suppresses only its async operation, while disableLogging changes the model's shared setting.
Server-Side
ActivityController
Handles activity feed API routes with filtering and pagination. Authentication must populate the current user first. Configure the application policies/permissions behind both activity-log.view and activity-log.cleanup Gate abilities; missing or denied abilities fail closed. Register literal capability manifests and seed their catalogs/profiles when access is user-configurable. Protect the host page/controller with its reviewed capability scope as well.
Generic plugin endpoints reject bearer credentials, so a credential ceiling cannot inherit browser-user authority. POST requests require CSRF. Reads validate the shared filter schema; cleanup uses CleanupActivitiesRequest → CleanupActivitiesDTO → CleanupActivitiesAction → ORM service. Successful reads return { data: ActivityData[], meta: { total, page, perPage, lastPage, hasMore } }; cleanup returns { data: { deleted }, meta: {} }.
Keep route files thin:
// src/routes/api/activities/+server.ts
import { ActivityController } from '@beeblock/svelar-activity-log/server';
// Supports both GET (query params) and POST (JSON body)
export const GET = async (event) => ActivityController.handle(event);
export const POST = async (event) => ActivityController.handle(event);
// src/routes/api/activities/cleanup/+server.ts
import { ActivityController } from '@beeblock/svelar-activity-log/server';
export const POST = async (event) => ActivityController.handleCleanup(event);
Query parameters / body fields:
| Parameter | Type | Description |
|---|---|---|
log_name |
string |
Filter by log name |
subject_type |
string |
Filter by subject type |
subject_id |
string | number |
Exact canonical subject ID; requires subject_type |
causer_type |
string |
Exact causer type; requires causer_id |
causer_id |
string | number |
Exact canonical causer ID |
description |
string |
Filter by description |
since |
string |
Filter from date (ISO) |
until |
string |
Filter until date (ISO) |
page |
number |
Page number (default: 1) |
per_page |
number |
Items per page (default: 15, max: 100) |
sort |
'asc' | 'desc' |
Sort direction |
UI Components
ActivityFeed
A timeline feed that displays activity entries with icons, relative timestamps, and expandable change details:
<script lang="ts">
import { ActivityFeed } from '@beeblock/svelar-activity-log/ui';
import type { ActivityData, ActivityFilterOptions } from '@beeblock/svelar-activity-log';
interface Props {
data: { activities: ActivityData[] };
}
let { data }: Props = $props();
let activities = $derived(data.activities);
</script>
<ActivityFeed
{activities}
showFilters={true}
showSubject={true}
showCauser={true}
showProperties={true}
logNames={['default', 'auth', 'billing']}
subjectTypes={['posts', 'users']}
emptyText="No activity yet"
hasMore={false}
loading={false}
onFilter={(filters) => console.log('Filter:', filters)}
onLoadMore={() => console.log('Load more')}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
activities |
ActivityData[] |
required | Array of activity data objects |
showFilters |
boolean |
false |
Show the filter panel |
showSubject |
boolean |
true |
Show subject type/ID in each entry |
showCauser |
boolean |
true |
Show causer type/ID in each entry |
showProperties |
boolean |
true |
Show properties and change details |
logNames |
string[] |
[] |
Available log names for the filter dropdown |
subjectTypes |
string[] |
[] |
Available subject types for the filter dropdown |
emptyText |
string |
'No activity yet' |
Text shown when there are no activities |
classNames |
ActivityLogClassNames |
{} |
CSS class overrides |
onFilter |
(filters) => void |
undefined |
Callback when filters are applied |
onLoadMore |
() => void |
undefined |
Callback for "Load more" button |
hasMore |
boolean |
false |
Whether more entries can be loaded |
loading |
boolean |
false |
Show loading spinner |
ActivityItem
A single timeline entry with icon, description, causer, subject, and expandable change table:
<script lang="ts">
import { ActivityItem } from '@beeblock/svelar-activity-log/ui';
</script>
<ActivityItem
activity={feedItem}
showSubject={true}
showCauser={true}
showProperties={true}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
activity |
ActivityFeedItem |
required | The activity feed item to display |
showSubject |
boolean |
true |
Show subject info |
showCauser |
boolean |
true |
Show causer info |
showProperties |
boolean |
true |
Show properties/changes |
classNames |
ActivityLogClassNames |
{} |
CSS class overrides |
ActivityFilters
A filter panel with inputs for log name, subject type, causer ID, description, and date range:
<script lang="ts">
import { ActivityFilters } from '@beeblock/svelar-activity-log/ui';
</script>
<ActivityFilters
onFilter={(filters) => console.log(filters)}
logNames={['default', 'auth']}
subjectTypes={['posts', 'users']}
/>
| Prop | Type | Default | Description |
|---|---|---|---|
onFilter |
(filters) => void |
required | Callback when filters are applied or cleared |
logNames |
string[] |
[] |
Log names for the dropdown (shows text input if empty) |
subjectTypes |
string[] |
[] |
Subject types for the dropdown |
classNames |
ActivityLogClassNames |
{} |
CSS class overrides |
Full Working Example
// src/lib/models/Post.ts
import { Model } from '@beeblock/svelar/orm';
import { LogsActivity } from '@beeblock/svelar-activity-log/server';
export class Post extends LogsActivity(Model) {
static table = 'posts';
static fillable = ['title', 'content', 'published'];
static logAttributes = ['title', 'content', 'published'];
static logName = 'posts';
}
// src/hooks.server.ts
import { ApplicationContext } from '@beeblock/svelar/support';
import { setCauserResolver } from '@beeblock/svelar-activity-log/server';
setCauserResolver(() => ApplicationContext.get('activityCauser') ?? null);
// Run this wrapper after authentication has populated event.locals.auth.
export async function handle({ event, resolve }) {
return ApplicationContext.run(
{ activityCauser: event.locals.auth?.user() ?? null },
() => resolve(event),
);
}
// src/routes/api/activities/+server.ts
import { ActivityController } from '@beeblock/svelar-activity-log/server';
export const GET = async (event) => ActivityController.handle(event);
export const POST = async (event) => ActivityController.handle(event);
// src/routes/api/activities/cleanup/+server.ts
import { ActivityController } from '@beeblock/svelar-activity-log/server';
export const POST = async (event) => ActivityController.handleCleanup(event);
// src/routes/admin/activity/+page.server.ts
import { error } from '@sveltejs/kit';
import { ActivityController } from '@beeblock/svelar-activity-log/server';
// Host page authorization and the controller's activity-log.view Gate both apply.
export async function load(event) {
const response = await ActivityController.handle(event);
const result = await response.json();
if (!response.ok) error(response.status, result.meta?.message ?? 'Unable to load activity.');
return { activities: result.data, total: result.meta.total, hasMore: result.meta.hasMore };
}
<!-- src/routes/admin/activity/+page.svelte -->
<script lang="ts">
import { untrack } from 'svelte';
import { ActivityFeed } from '@beeblock/svelar-activity-log/ui';
import { apiFetch } from '@beeblock/svelar/http';
import type { ActivityData, ActivityFilterOptions } from '@beeblock/svelar-activity-log';
interface Props {
data: { activities: ActivityData[]; total: number; hasMore: boolean };
}
let { data }: Props = $props();
let activities = $state(untrack(() => data.activities));
let hasMore = $state(untrack(() => data.hasMore));
let page = $state(1);
let loading = $state(false);
let errorMessage = $state('');
let currentFilters = $state<ActivityFilterOptions>({});
async function fetchPage(filters: ActivityFilterOptions, nextPage: number) {
const res = await apiFetch('/api/activities', {
method: 'POST', showToast: false,
body: JSON.stringify({
log_name: filters.logName, subject_type: filters.subjectType,
subject_id: filters.subjectId, causer_type: filters.causerType,
causer_id: filters.causerId, description: filters.description,
since: filters.since, until: filters.until, sort: filters.sortDirection,
page: nextPage, per_page: 20,
}),
});
const result = await res.json();
if (!res.ok) throw new Error(result.meta?.message ?? 'Unable to load activity.');
return result;
}
async function handleFilter(filters: ActivityFilterOptions) {
if (loading) return;
loading = true; errorMessage = '';
try {
const result = await fetchPage(filters, 1);
currentFilters = filters; activities = result.data;
hasMore = result.meta.hasMore; page = 1;
} catch (error) { errorMessage = error instanceof Error ? error.message : 'Unable to load activity.'; }
finally { loading = false; }
}
async function handleLoadMore() {
if (loading || !hasMore) return;
loading = true; errorMessage = '';
try {
const result = await fetchPage(currentFilters, page + 1);
activities = [...activities, ...result.data];
hasMore = result.meta.hasMore; page += 1;
} catch (error) { errorMessage = error instanceof Error ? error.message : 'Unable to load activity.'; }
finally { loading = false; }
}
</script>
<h1>Activity Log</h1>
{#if errorMessage}<p role='alert'>{errorMessage}</p>{/if}
<ActivityFeed
{activities}
showFilters={true}
logNames={['default', 'auth', 'posts', 'billing']}
subjectTypes={['posts', 'users', 'invoices']}
onFilter={handleFilter}
onLoadMore={handleLoadMore}
{hasMore}
{loading}
/>