Skip to content

Commit b93ba10

Browse files
Claudehotlong
andauthored
Add User Preferences Service core implementation
- Define UserPreferenceEntry, FavoriteEntry schemas in spec/identity - Define IUserPreferencesService and IUserFavoritesService contracts - Implement ObjectQL-based persistence adapter - Create UserPreferencesServicePlugin - Define REST API routes for preferences and favorites Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/6c82c1b8-239c-4a91-8794-7e22d1fb5fdc Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent be711c2 commit b93ba10

15 files changed

Lines changed: 1406 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@objectstack/service-user-preferences",
3+
"version": "1.0.0",
4+
"license": "Apache-2.0",
5+
"description": "User Preferences Service for ObjectStack — implements IUserPreferencesService and IUserFavoritesService with ObjectQL persistence and REST routes",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js",
13+
"require": "./dist/index.cjs"
14+
}
15+
},
16+
"scripts": {
17+
"build": "tsup --config ../../../tsup.config.ts",
18+
"test": "vitest run"
19+
},
20+
"dependencies": {
21+
"@objectstack/core": "workspace:*",
22+
"@objectstack/spec": "workspace:*"
23+
},
24+
"devDependencies": {
25+
"@types/node": "^25.5.2",
26+
"typescript": "^6.0.2",
27+
"vitest": "^4.1.2"
28+
}
29+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { randomUUID } from 'node:crypto';
4+
import type { IUserFavoritesService, IUserPreferencesService } from '@objectstack/spec/contracts';
5+
import type { FavoriteEntry, FavoritesValue } from '@objectstack/spec/identity';
6+
7+
/**
8+
* UserFavoritesService — Favorites management service.
9+
*
10+
* Implements IUserFavoritesService on top of IUserPreferencesService.
11+
* Favorites are stored as a structured preference with key 'favorites'.
12+
*/
13+
export class UserFavoritesService implements IUserFavoritesService {
14+
private readonly preferences: IUserPreferencesService;
15+
private readonly FAVORITES_KEY = 'favorites';
16+
17+
constructor(preferences: IUserPreferencesService) {
18+
this.preferences = preferences;
19+
}
20+
21+
async list(userId: string): Promise<FavoriteEntry[]> {
22+
const favorites = await this.preferences.get<FavoritesValue>(userId, this.FAVORITES_KEY);
23+
return favorites ?? [];
24+
}
25+
26+
async add(userId: string, entry: Omit<FavoriteEntry, 'id' | 'createdAt'>): Promise<FavoriteEntry> {
27+
const favorites = await this.list(userId);
28+
29+
// Check for duplicates (same type + target)
30+
const existing = favorites.find(f => f.type === entry.type && f.target === entry.target);
31+
if (existing) {
32+
return existing;
33+
}
34+
35+
const newEntry: FavoriteEntry = {
36+
id: `fav_${randomUUID()}`,
37+
...entry,
38+
createdAt: new Date().toISOString(),
39+
};
40+
41+
favorites.push(newEntry);
42+
await this.preferences.set(userId, this.FAVORITES_KEY, favorites);
43+
44+
return newEntry;
45+
}
46+
47+
async remove(userId: string, favoriteId: string): Promise<boolean> {
48+
const favorites = await this.list(userId);
49+
const index = favorites.findIndex(f => f.id === favoriteId);
50+
51+
if (index === -1) return false;
52+
53+
favorites.splice(index, 1);
54+
await this.preferences.set(userId, this.FAVORITES_KEY, favorites);
55+
56+
return true;
57+
}
58+
59+
async has(userId: string, type: string, target: string): Promise<boolean> {
60+
const favorites = await this.list(userId);
61+
return favorites.some(f => f.type === type && f.target === target);
62+
}
63+
64+
async toggle(userId: string, entry: Omit<FavoriteEntry, 'id' | 'createdAt'>): Promise<boolean> {
65+
const favorites = await this.list(userId);
66+
const existingIndex = favorites.findIndex(f => f.type === entry.type && f.target === entry.target);
67+
68+
if (existingIndex !== -1) {
69+
// Remove
70+
favorites.splice(existingIndex, 1);
71+
await this.preferences.set(userId, this.FAVORITES_KEY, favorites);
72+
return false;
73+
} else {
74+
// Add
75+
await this.add(userId, entry);
76+
return true;
77+
}
78+
}
79+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
export * from './objectql-preferences-adapter';
4+
export * from './favorites-adapter';
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { randomUUID } from 'node:crypto';
4+
import type {
5+
IUserPreferencesService,
6+
IDataEngine,
7+
} from '@objectstack/spec/contracts';
8+
import type { UserPreferenceEntry } from '@objectstack/spec/identity';
9+
10+
/** Object name used for persistence. */
11+
const USER_PREFERENCES_OBJECT = 'user_preferences';
12+
13+
/** Database row shape for user_preferences. */
14+
interface DbUserPreferenceRow {
15+
id: string;
16+
user_id: string;
17+
key: string;
18+
value: string | null;
19+
value_type: string | null;
20+
created_at: string;
21+
updated_at: string;
22+
}
23+
24+
/**
25+
* ObjectQLUserPreferencesService — Persistent implementation of IUserPreferencesService.
26+
*
27+
* Delegates all storage to an {@link IDataEngine} instance, using the
28+
* `user_preferences` object. This decouples the service from any specific
29+
* database driver (Turso, Postgres, SQLite, etc.).
30+
*
31+
* Production environments should use this implementation to ensure
32+
* preferences persist across service restarts.
33+
*/
34+
export class ObjectQLUserPreferencesService implements IUserPreferencesService {
35+
private readonly engine: IDataEngine;
36+
37+
constructor(engine: IDataEngine) {
38+
this.engine = engine;
39+
}
40+
41+
async get<T = unknown>(userId: string, key: string): Promise<T | undefined> {
42+
const row: DbUserPreferenceRow | null = await this.engine.findOne(USER_PREFERENCES_OBJECT, {
43+
where: { user_id: userId, key },
44+
});
45+
46+
if (!row || row.value === null) return undefined;
47+
48+
return this.deserializeValue<T>(row.value);
49+
}
50+
51+
async set(userId: string, key: string, value: unknown): Promise<void> {
52+
const now = new Date().toISOString();
53+
54+
// Check if preference already exists
55+
const existing: DbUserPreferenceRow | null = await this.engine.findOne(USER_PREFERENCES_OBJECT, {
56+
where: { user_id: userId, key },
57+
fields: ['id'],
58+
});
59+
60+
const serializedValue = this.serializeValue(value);
61+
const valueType = this.detectValueType(value);
62+
63+
if (existing) {
64+
// Update existing preference
65+
await this.engine.update(USER_PREFERENCES_OBJECT, {
66+
id: existing.id,
67+
value: serializedValue,
68+
value_type: valueType,
69+
updated_at: now,
70+
}, {
71+
where: { id: existing.id },
72+
});
73+
} else {
74+
// Insert new preference
75+
const id = `pref_${randomUUID()}`;
76+
await this.engine.insert(USER_PREFERENCES_OBJECT, {
77+
id,
78+
user_id: userId,
79+
key,
80+
value: serializedValue,
81+
value_type: valueType,
82+
created_at: now,
83+
updated_at: now,
84+
});
85+
}
86+
}
87+
88+
async setMany(userId: string, preferences: Record<string, unknown>): Promise<void> {
89+
const now = new Date().toISOString();
90+
91+
// Get all existing preferences for this user in a single query
92+
const existingRows: DbUserPreferenceRow[] = await this.engine.find(USER_PREFERENCES_OBJECT, {
93+
where: { user_id: userId },
94+
fields: ['id', 'key'],
95+
});
96+
97+
const existingMap = new Map(existingRows.map(row => [row.key, row.id]));
98+
99+
// Prepare batch updates and inserts
100+
const updates: Array<{ id: string; value: string; value_type: string; updated_at: string }> = [];
101+
const inserts: DbUserPreferenceRow[] = [];
102+
103+
for (const [key, value] of Object.entries(preferences)) {
104+
const serializedValue = this.serializeValue(value);
105+
const valueType = this.detectValueType(value);
106+
107+
const existingId = existingMap.get(key);
108+
if (existingId) {
109+
// Update
110+
updates.push({
111+
id: existingId,
112+
value: serializedValue,
113+
value_type: valueType,
114+
updated_at: now,
115+
});
116+
} else {
117+
// Insert
118+
inserts.push({
119+
id: `pref_${randomUUID()}`,
120+
user_id: userId,
121+
key,
122+
value: serializedValue,
123+
value_type: valueType,
124+
created_at: now,
125+
updated_at: now,
126+
});
127+
}
128+
}
129+
130+
// Execute batch operations
131+
if (updates.length > 0) {
132+
// Update each one (ObjectQL doesn't guarantee batch update support)
133+
await Promise.all(
134+
updates.map(update =>
135+
this.engine.update(USER_PREFERENCES_OBJECT, update, {
136+
where: { id: update.id },
137+
})
138+
)
139+
);
140+
}
141+
142+
if (inserts.length > 0) {
143+
await this.engine.insert(USER_PREFERENCES_OBJECT, inserts);
144+
}
145+
}
146+
147+
async delete(userId: string, key: string): Promise<boolean> {
148+
const existing: DbUserPreferenceRow | null = await this.engine.findOne(USER_PREFERENCES_OBJECT, {
149+
where: { user_id: userId, key },
150+
fields: ['id'],
151+
});
152+
153+
if (!existing) return false;
154+
155+
await this.engine.delete(USER_PREFERENCES_OBJECT, {
156+
where: { id: existing.id },
157+
});
158+
159+
return true;
160+
}
161+
162+
async getAll(userId: string, options?: { prefix?: string }): Promise<Record<string, unknown>> {
163+
const where: Record<string, unknown> = { user_id: userId };
164+
165+
// Apply prefix filter if specified
166+
if (options?.prefix) {
167+
// Use SQL LIKE pattern for prefix matching
168+
where.key = { $like: `${options.prefix}%` };
169+
}
170+
171+
const rows: DbUserPreferenceRow[] = await this.engine.find(USER_PREFERENCES_OBJECT, {
172+
where,
173+
orderBy: [{ field: 'key', order: 'asc' }],
174+
});
175+
176+
const result: Record<string, unknown> = {};
177+
for (const row of rows) {
178+
if (row.value !== null) {
179+
result[row.key] = this.deserializeValue(row.value);
180+
}
181+
}
182+
183+
return result;
184+
}
185+
186+
async has(userId: string, key: string): Promise<boolean> {
187+
const count = await this.engine.count(USER_PREFERENCES_OBJECT, {
188+
where: { user_id: userId, key },
189+
});
190+
191+
return count > 0;
192+
}
193+
194+
async clear(userId: string, options?: { prefix?: string }): Promise<void> {
195+
const where: Record<string, unknown> = { user_id: userId };
196+
197+
// Apply prefix filter if specified
198+
if (options?.prefix) {
199+
where.key = { $like: `${options.prefix}%` };
200+
}
201+
202+
await this.engine.delete(USER_PREFERENCES_OBJECT, {
203+
where,
204+
multi: true,
205+
});
206+
}
207+
208+
async listEntries(userId: string, options?: { prefix?: string }): Promise<UserPreferenceEntry[]> {
209+
const where: Record<string, unknown> = { user_id: userId };
210+
211+
// Apply prefix filter if specified
212+
if (options?.prefix) {
213+
where.key = { $like: `${options.prefix}%` };
214+
}
215+
216+
const rows: DbUserPreferenceRow[] = await this.engine.find(USER_PREFERENCES_OBJECT, {
217+
where,
218+
orderBy: [{ field: 'key', order: 'asc' }],
219+
});
220+
221+
return rows.map(row => ({
222+
id: row.id,
223+
userId: row.user_id,
224+
key: row.key,
225+
value: row.value !== null ? this.deserializeValue(row.value) : null,
226+
valueType: row.value_type ?? undefined,
227+
createdAt: row.created_at,
228+
updatedAt: row.updated_at,
229+
}));
230+
}
231+
232+
// ── Private helpers ──────────────────────────────────────────────
233+
234+
/**
235+
* Serialize a value to JSON string for storage.
236+
*/
237+
private serializeValue(value: unknown): string {
238+
return JSON.stringify(value);
239+
}
240+
241+
/**
242+
* Deserialize a JSON string to its original value.
243+
*/
244+
private deserializeValue<T = unknown>(value: string): T {
245+
try {
246+
return JSON.parse(value) as T;
247+
} catch {
248+
// Fallback to raw string if JSON parsing fails
249+
return value as T;
250+
}
251+
}
252+
253+
/**
254+
* Detect the type of a value for the value_type hint.
255+
*/
256+
private detectValueType(value: unknown): string {
257+
if (value === null) return 'null';
258+
if (Array.isArray(value)) return 'array';
259+
if (typeof value === 'object') return 'object';
260+
return typeof value; // 'string', 'number', 'boolean'
261+
}
262+
}

0 commit comments

Comments
 (0)