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:
// Plugin registration
import { SvelarActivityLogPlugin } from '@beeblock/svelar-activity-log/server';
// Core API
import { Activity, ActivityLogger, activity, ActivityService, LogsActivity, setCauserResolver, getCauserResolver } from '@beeblock/svelar-activity-log';
// 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 } 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
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
log_name TEXT DEFAULT 'default',
description TEXT NOT NULL,
subject_type TEXT,
subject_id INTEGER,
causer_type TEXT DEFAULT 'users',
causer_id INTEGER,
properties TEXT DEFAULT '{}',
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_activity_log_subject ON activity_log(subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_activity_log_causer ON activity_log(causer_type, causer_id);
CREATE INDEX IF NOT EXISTS idx_activity_log_name ON activity_log(log_name);
You can also retrieve this SQL programmatically:
import { ActivityService } from '@beeblock/svelar-activity-log';
const sql = ActivityService.getMigrationSQL();
3. Log an Activity
import { activity } from '@beeblock/svelar-activity-log';
await activity()
.causedBy(user)
.performedOn(post)
.withProperties({ ip: '192.168.1.1' })
.log('updated');
Configuration
The SvelarActivityLogPlugin constructor accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
prefix |
string |
'/api' |
API route prefix |
defaultLogName |
string |
'default' |
Default log name for entries |
defaultCauserType |
string |
'users' |
Default causer type string |
cleanupDays |
number |
90 |
Delete activities older than N days during cleanup |
logOnlyDirty |
boolean |
true |
Only log attributes that actually changed |
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';
// 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 numeric 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
Wraps a database row into a structured, read-only object:
const entry = await activity().causedBy(user).log('created');
entry.id; // number
entry.logName; // string
entry.description; // string
entry.subjectType; // string | null
entry.subjectId; // number | null
entry.causerType; // string
entry.causerId; // number | 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';
// 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 |
ActivityService.getMigrationSQL() |
string |
Get the migration SQL |
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
Automatically logs created, updated, and deleted events on any Model:
import { Model } from '@beeblock/svelar/database';
import { LogsActivity } from '@beeblock/svelar-activity-log';
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
Set a global causer resolver so LogsActivity automatically knows who performed the action:
// In hooks.server.ts
import { setCauserResolver } from '@beeblock/svelar-activity-log';
setCauserResolver(() => {
// Return the current authenticated user
const user = getCurrentUser();
return user ? { id: user.id, type: 'users' } : null;
});
Server-Side
ActivityController
Handles activity feed API routes with filtering and pagination:
// 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 |
number |
Filter by subject ID |
causer_type |
string |
Filter by causer type |
causer_id |
number |
Filter by 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 = $state(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/database';
import { LogsActivity } from '@beeblock/svelar-activity-log';
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 { setCauserResolver } from '@beeblock/svelar-activity-log';
setCauserResolver(() => {
// Your logic to get the current user
return currentUser ? { id: currentUser.id, type: 'users' } : null;
});
// 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 { ActivityService } from '@beeblock/svelar-activity-log';
export async function load() {
const result = await ActivityService.query()
.latest()
.paginate(1, 20);
return { activities: result.data, total: result.total, hasMore: result.hasMore };
}
<!-- src/routes/admin/activity/+page.svelte -->
<script lang="ts">
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(data.activities);
let hasMore = $state(data.hasMore);
let page = $state(1);
let loading = $state(false);
async function handleFilter(filters: ActivityFilterOptions) {
loading = true;
const res = await apiFetch('/api/activities', {
method: 'POST',
body: JSON.stringify({ ...filters, page: 1, per_page: 20 }),
});
const result = await res.json();
activities = result.data;
hasMore = result.hasMore;
page = 1;
loading = false;
}
async function handleLoadMore() {
loading = true;
page += 1;
const res = await apiFetch(`/api/activities?page=${page}&per_page=20`);
const result = await res.json();
activities = [...activities, ...result.data];
hasMore = result.hasMore;
loading = false;
}
</script>
<h1>Activity Log</h1>
<ActivityFeed
{activities}
showFilters={true}
logNames={['default', 'auth', 'posts', 'billing']}
subjectTypes={['posts', 'users', 'invoices']}
onFilter={handleFilter}
onLoadMore={handleLoadMore}
{hasMore}
{loading}
/>