Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/add-user-preferences-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/service-user-preferences": minor
---

Add User Preferences Service for managing user preferences and favorites with ObjectQL persistence
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@objectstack/service-analytics": "workspace:*",
"@objectstack/service-automation": "workspace:*",
"@objectstack/service-feed": "workspace:*",
"@objectstack/service-user-preferences": "workspace:*",
"@objectstack/spec": "workspace:*",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { MetadataPlugin } from '@objectstack/metadata';
import { AIServicePlugin } from '@objectstack/service-ai';
import { AutomationServicePlugin } from '@objectstack/service-automation';
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences';
import { getRequestListener } from '@hono/node-server';
import type { Hono } from 'hono';
import { createBrokerShim } from '../src/lib/create-broker-shim.js';
Expand Down Expand Up @@ -129,6 +130,7 @@ async function ensureKernel(): Promise<ObjectKernel> {
await kernel.use(new SecurityPlugin());
await kernel.use(new AuditPlugin());
await kernel.use(new FeedServicePlugin());
await kernel.use(new UserPreferencesServicePlugin());
await kernel.use(new MetadataPlugin({ watch: false }));
await kernel.use(new AIServicePlugin());
await kernel.use(new AutomationServicePlugin());
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/mocks/createKernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
import { MetadataPlugin } from '@objectstack/metadata';
import { AIServicePlugin } from '@objectstack/service-ai';
import { FeedServicePlugin } from '@objectstack/service-feed';
import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences';
import { createBrokerShim } from '../lib/create-broker-shim';

// System object definitions — resolved via Vite aliases to plugin source (no runtime deps)
Expand Down Expand Up @@ -86,6 +87,7 @@ export async function createKernel(options: KernelOptions) {
// so that the setupNav service is available during their init() phase
await kernel.use(new SetupPlugin());
await kernel.use(new FeedServicePlugin());
await kernel.use(new UserPreferencesServicePlugin());
await kernel.use(new MetadataPlugin({ watch: false }));
await kernel.use(new AIServicePlugin());
await kernel.use(new AutomationServicePlugin());
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,22 @@ export default class Serve extends Command {
}
}

// 5. Auto-register UserPreferencesServicePlugin if not already loaded by config plugins.
const hasUserPrefsPlugin = plugins.some(
(p: any) => p.name === 'com.objectstack.service-user-preferences'
|| p.constructor?.name === 'UserPreferencesServicePlugin'
);
if (!hasUserPrefsPlugin) {
try {
const userPrefsPkg = '@objectstack/service-user-preferences';
const { UserPreferencesServicePlugin } = await import(/* webpackIgnore: true */ userPrefsPkg);
await kernel.use(new UserPreferencesServicePlugin());
trackPlugin('UserPreferences');
} catch {
// @objectstack/service-user-preferences not installed — preferences features unavailable
}
}

// ── Studio UI ─────────────────────────────────────────────────
// In dev mode, Studio UI is enabled by default (use --no-ui to disable).
// Always serves the pre-built dist/ — no Vite dev server, no extra port.
Expand Down
29 changes: 29 additions & 0 deletions packages/services/service-user-preferences/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# @objectstack/service-user-preferences

## 1.0.0 (2026-04-09)

### Features

- **Initial Release** - User Preferences Service implementation
- **IUserPreferencesService** - Full service contract with get/set/setMany/delete/getAll/has/clear/listEntries methods
- **IUserFavoritesService** - Specialized favorites service with add/remove/has/toggle/list methods
- **ObjectQL Persistence** - Database-agnostic storage via IDataEngine
- **REST API** - Complete HTTP routes for preferences and favorites management
- `/api/v1/user/preferences` - CRUD operations for preferences
- `/api/v1/user/favorites` - Favorites management endpoints
- **Type Safety** - Full TypeScript support with Zod schemas
- **Prefix Filtering** - Query preferences by key prefix (e.g., `plugin.ai.*`)
- **Well-Known Keys** - Predefined system preference keys (theme, locale, timezone, etc.)
- **Auto-Registration** - Automatic plugin registration in CLI and Studio

### Schema

- **UserPreferenceEntry** - Core preference data model
- **FavoriteEntry** - Favorite item structure with type/target/metadata
- **WellKnownPreferenceKeys** - Enum of reserved system preference keys

### Tests

- Comprehensive unit tests for all service methods
- In-memory IDataEngine stub for fast testing
- Test coverage for scalar values, structured data, and edge cases
208 changes: 208 additions & 0 deletions packages/services/service-user-preferences/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# @objectstack/service-user-preferences

User Preferences Service for ObjectStack — implements IUserPreferencesService and IUserFavoritesService with ObjectQL persistence and REST routes.

## Features

- **Scalar Preferences**: Simple key-value storage for user settings (theme, locale, etc.)
- **Structured Data**: Store complex data structures (favorites, recent items)
- **ObjectQL Persistence**: Leverages IDataEngine for database-agnostic storage
- **REST API**: Full HTTP routes for preferences and favorites management
- **Type Safety**: Complete TypeScript support with Zod schemas
- **Multi-tenant**: User-scoped preferences with isolation
- **Prefix Filtering**: Query preferences by key prefix (e.g., all `plugin.ai.*` settings)

## Installation

```bash
pnpm add @objectstack/service-user-preferences
```

## Usage

### Basic Setup

```typescript
import { ObjectKernel } from '@objectstack/core';
import { ObjectQLPlugin } from '@objectstack/objectql';
import { DriverPlugin } from '@objectstack/runtime';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { UserPreferencesServicePlugin } from '@objectstack/service-user-preferences';

const kernel = new ObjectKernel();

await kernel.use(new ObjectQLPlugin());
await kernel.use(new DriverPlugin(new InMemoryDriver()));
await kernel.use(new UserPreferencesServicePlugin());

await kernel.bootstrap();

const prefs = kernel.getService<IUserPreferencesService>('user-preferences');
```

### Scalar Preferences

```typescript
// Set a preference
await prefs.set('user123', 'theme', 'dark');

// Get a preference
const theme = await prefs.get('user123', 'theme'); // => 'dark'

// Set multiple preferences at once
await prefs.setMany('user123', {
theme: 'dark',
locale: 'en-US',
sidebar_collapsed: true,
});

// Get all preferences
const all = await prefs.getAll('user123');
// => { theme: 'dark', locale: 'en-US', sidebar_collapsed: true }

// Delete a preference
await prefs.delete('user123', 'theme');

// Check if a preference exists
const hasTheme = await prefs.has('user123', 'theme');
```

### Structured Data (Favorites)

```typescript
const favorites = kernel.getService<IUserFavoritesService>('user-favorites');

// Add a favorite
const entry = await favorites.add('user123', {
type: 'view',
target: 'kanban_tasks',
label: 'My Tasks',
icon: 'kanban',
});

// List all favorites
const allFavorites = await favorites.list('user123');

// Remove a favorite
await favorites.remove('user123', entry.id);

// Check if an item is favorited
const isFav = await favorites.has('user123', 'view', 'kanban_tasks');

// Toggle a favorite (add if not exists, remove if exists)
const added = await favorites.toggle('user123', {
type: 'view',
target: 'kanban_tasks',
});
```

### Prefix Filtering

```typescript
// Set plugin-specific preferences
await prefs.setMany('user123', {
'plugin.ai.auto_save': true,
'plugin.ai.model': 'gpt-4',
'plugin.security.mfa_enabled': false,
});

// Get all AI plugin preferences
const aiPrefs = await prefs.getAll('user123', { prefix: 'plugin.ai.' });
// => { 'plugin.ai.auto_save': true, 'plugin.ai.model': 'gpt-4' }

// Clear all AI plugin preferences
await prefs.clear('user123', { prefix: 'plugin.ai.' });
```

## REST API

The plugin automatically registers HTTP routes when started:

### Preferences Routes

- **GET `/api/v1/user/preferences`** - Get all preferences (with optional `?prefix=` query param)
- **GET `/api/v1/user/preferences/:key`** - Get a single preference
- **POST `/api/v1/user/preferences`** - Batch set preferences (body: `{ preferences: { ... } }`)
- **PUT `/api/v1/user/preferences/:key`** - Set a single preference (body: `{ value: ... }`)
- **DELETE `/api/v1/user/preferences/:key`** - Delete a preference

### Favorites Routes

- **GET `/api/v1/user/favorites`** - List all favorites
- **POST `/api/v1/user/favorites`** - Add a favorite (body: `{ type, target, label?, icon?, metadata? }`)
- **DELETE `/api/v1/user/favorites/:id`** - Remove a favorite
- **POST `/api/v1/user/favorites/toggle`** - Toggle a favorite (body: `{ type, target, label?, icon?, metadata? }`)

All routes require authentication and the user's ID is automatically extracted from the request context.

## Well-Known Preference Keys

The following preference keys are reserved for system-level settings:

- `theme` - UI theme (`'light'` | `'dark'` | `'system'`)
- `locale` - User's preferred locale (`'en-US'`, `'zh-CN'`, etc.)
- `timezone` - User's timezone (`'America/New_York'`, etc.)
- `favorites` - User's favorite items (structured array)
- `recent_items` - Recently accessed items (structured array)
- `sidebar_collapsed` - UI: sidebar state (boolean)
- `page_size` - Default pagination size (number)

Plugins can define custom keys using their own namespace, e.g., `plugin.ai.auto_save`.

## Schema

### UserPreferenceEntry

```typescript
{
id: string; // Auto-generated (e.g., 'pref_abc123')
userId: string; // User ID
key: string; // Preference key
value: unknown; // JSON-serializable value
valueType?: string; // Type hint: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
createdAt: string; // ISO timestamp
updatedAt: string; // ISO timestamp
}
```

### FavoriteEntry

```typescript
{
id: string; // Auto-generated (e.g., 'fav_xyz789')
type: 'object' | 'view' | 'app' | 'dashboard' | 'report' | 'record';
target: string; // Target reference (object name, view name, etc.)
label?: string; // Display label override
icon?: string; // Icon override
metadata?: Record<string, unknown>; // Custom metadata
createdAt: string; // ISO timestamp
}
```

## Database

The plugin creates a `user_preferences` object in ObjectQL with the following schema:

- `id` (text, primary) - Unique identifier
- `user_id` (text, indexed) - User who owns the preference
- `key` (text, indexed) - Preference key
- `value` (textarea) - JSON-serialized value
- `value_type` (select) - Type hint for client-side type safety
- `created_at` (datetime) - Creation timestamp
- `updated_at` (datetime) - Last update timestamp

Unique composite index: `(user_id, key)`

## Architecture

The service follows ObjectStack's standard patterns:

1. **Spec Layer** (`@objectstack/spec/identity`) - Zod schemas for preferences and favorites
2. **Contract Layer** (`@objectstack/spec/contracts`) - Service interfaces (IUserPreferencesService, IUserFavoritesService)
3. **Implementation Layer** - ObjectQL-based adapter for persistence
4. **Plugin Layer** - Kernel plugin with service registration and HTTP routes
5. **Client Layer** - Type-safe client SDK (future enhancement)

## License

Apache-2.0 © ObjectStack
29 changes: 29 additions & 0 deletions packages/services/service-user-preferences/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@objectstack/service-user-preferences",
"version": "1.0.0",
"license": "Apache-2.0",
"description": "User Preferences Service for ObjectStack — implements IUserPreferencesService and IUserFavoritesService with ObjectQL persistence and REST routes",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup --config ../../../tsup.config.ts",
"test": "vitest run"
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.5.2",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
}
}
Loading
Loading