@coffer-org/server 2.3.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ export declare const MigrationRowSchema: EntitySchema<any, never, import("@mikro
16
16
  export declare const SeedRowSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
17
17
  export declare const EmbeddingSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
18
18
  export declare const PluginStateSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
19
+ export declare const RecordActivitySchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
19
20
  export declare const ThreadMessageSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
20
21
  export declare function buildPluginEntities(plugins: PluginManifest[]): EntitySchema[];
21
22
  export declare const MsgLogSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
@@ -42,6 +42,7 @@ export function buildEntitySchema(m) {
42
42
  id: { type: 'integer', primary: true, autoincrement: true },
43
43
  created_at: { type: 'text' },
44
44
  updated_at: { type: 'text' },
45
+ deleted_at: { type: 'text', nullable: true },
45
46
  };
46
47
  addFieldProps(properties, m.fields, (f) => !f.required);
47
48
  const name = shelfTableName(m.library, m.shelf);
@@ -151,6 +152,19 @@ export const PluginStateSchema = new EntitySchema({
151
152
  value: { type: 'text' },
152
153
  },
153
154
  });
155
+ export const RecordActivitySchema = new EntitySchema({
156
+ name: '_RecordActivity',
157
+ tableName: '_record_activity',
158
+ properties: {
159
+ user_id: { type: 'integer', primary: true },
160
+ library: { type: 'text', primary: true },
161
+ shelf: { type: 'text', primary: true },
162
+ record_id: { type: 'integer', primary: true },
163
+ favorite: { type: 'integer' },
164
+ view_count: { type: 'integer' },
165
+ last_viewed_at: { type: 'text', nullable: true },
166
+ },
167
+ });
154
168
  export const ThreadMessageSchema = new EntitySchema({
155
169
  name: '_ThreadMessage',
156
170
  tableName: '_thread_message',
@@ -267,7 +281,7 @@ export const OAuthTokenSchema = new EntitySchema({
267
281
  });
268
282
  export const systemEntities = [
269
283
  EventSchema, PluginRowSchema, MigrationRowSchema, SeedRowSchema,
270
- EmbeddingSchema, PluginStateSchema, MsgLogSchema,
284
+ EmbeddingSchema, PluginStateSchema, RecordActivitySchema, MsgLogSchema,
271
285
  UserSchema, SessionSchema, ApiTokenSchema,
272
286
  OAuthClientSchema, OAuthCodeSchema, OAuthTokenSchema,
273
287
  ThreadMessageSchema,
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { baseUrl } from "./public-url.js";
24
24
  import { discoverPluginAssets, discoverRuntime } from "./plugin-discovery.js";
25
25
  import { checkLatestVersion, resolveUpdateTarget, resolveAllUpdateTargets, resolveBaseTargets, resolveRuntimeTarget, runNpmInstall, } from "./plugin-updates.js";
26
26
  import { buildClientSchema } from "./schema-api.js";
27
- import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, } from "./records-api.js";
27
+ import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, UnknownShelfError, } from "./records-api.js";
28
28
  import { maskTree, preserveTree } from "./field-masking.js";
29
29
  import { writePluginSettings } from "./settings-write.js";
30
30
  import { rootLogger, getLogger } from "./log.js";
@@ -34,6 +34,7 @@ import { verifyUploadTicket } from "./upload-ticket.js";
34
34
  import { setPluginState } from "./plugin-state.js";
35
35
  import { SEARCH_STATE_PLUGIN, SEARCH_CURSOR_KEY } from "./search-indexer.js";
36
36
  import { globalSearch } from "./global-search.js";
37
+ import { applySmartRanking, getRecordActivity, markRecordViewed, setRecordFavorite } from "./record-activity.js";
37
38
  const ENV_FILE = join(process.cwd(), '.env');
38
39
  if (existsSync(ENV_FILE))
39
40
  process.loadEnvFile(ENV_FILE);
@@ -363,6 +364,48 @@ function maskRecordRow(library, shelf, row) {
363
364
  }
364
365
  return out;
365
366
  }
367
+ function tracksRecordActivity(req) {
368
+ const marker = req.headers['x-coffer-automated'];
369
+ const value = Array.isArray(marker) ? marker[0] : marker;
370
+ return value?.toLowerCase().trim() !== 'true';
371
+ }
372
+ app.get('/api/trash', async (_req, reply) => {
373
+ const rows = await recordTrashList();
374
+ return rows.map((entry) => ({ ...entry, record: maskRecordRow(entry.library, entry.shelf, entry.record) }));
375
+ });
376
+ app.post('/api/trash/:library/:shelf/:id/restore', async (req, reply) => {
377
+ const { library, shelf, id } = req.params;
378
+ const rid = Number(id);
379
+ if (isNaN(rid))
380
+ return reply.code(400).send({ error: 'invalid_id' });
381
+ try {
382
+ await recordRestore(library, shelf, rid);
383
+ return { ok: true };
384
+ }
385
+ catch (e) {
386
+ if (e instanceof UnknownShelfError || e instanceof NotFoundError)
387
+ return reply.code(404).send({ error: 'not_found' });
388
+ throw e;
389
+ }
390
+ });
391
+ app.delete('/api/trash/:library/:shelf/:id', async (req, reply) => {
392
+ const { library, shelf, id } = req.params;
393
+ const rid = Number(id);
394
+ if (isNaN(rid))
395
+ return reply.code(400).send({ error: 'invalid_id' });
396
+ if (req.body?.confirmed !== true) {
397
+ return reply.code(400).send({ error: 'explicit_confirmation_required' });
398
+ }
399
+ try {
400
+ await recordPurge(library, shelf, rid);
401
+ return reply.code(204).send();
402
+ }
403
+ catch (e) {
404
+ if (e instanceof UnknownShelfError || e instanceof NotFoundError)
405
+ return reply.code(404).send({ error: 'not_found' });
406
+ throw e;
407
+ }
408
+ });
366
409
  app.get('/api/:library/:shelf', async (req, reply) => {
367
410
  const { library, shelf } = req.params;
368
411
  const { _fields, _extends, limit, offset, ...query } = req.query;
@@ -373,14 +416,19 @@ app.get('/api/:library/:shelf', async (req, reply) => {
373
416
  };
374
417
  try {
375
418
  if (!paged) {
376
- const rows = await recordList(library, shelf, query, opts);
419
+ let rows = await recordList(library, shelf, query, opts);
420
+ if (req.user && tracksRecordActivity(req))
421
+ rows = await applySmartRanking(req.user.id, library, shelf, rows);
377
422
  return rows.map((r) => maskRecordRow(library, shelf, r));
378
423
  }
379
- const page = await recordListPage(library, shelf, query, {
424
+ let page = await recordListPage(library, shelf, query, {
380
425
  ...opts,
381
426
  limit: limit === undefined ? undefined : Number(limit),
382
427
  offset: offset === undefined ? undefined : Number(offset),
383
428
  });
429
+ if (req.user && tracksRecordActivity(req)) {
430
+ page = { ...page, rows: await applySmartRanking(req.user.id, library, shelf, page.rows) };
431
+ }
384
432
  return { rows: page.rows.map((r) => maskRecordRow(library, shelf, r)), total: page.total };
385
433
  }
386
434
  catch (e) {
@@ -389,6 +437,43 @@ app.get('/api/:library/:shelf', async (req, reply) => {
389
437
  throw e;
390
438
  }
391
439
  });
440
+ app.get('/api/:library/:shelf/:id/activity', async (req, reply) => {
441
+ const { library, shelf, id } = req.params;
442
+ const rid = Number(id);
443
+ if (isNaN(rid))
444
+ return reply.code(400).send({ error: 'invalid_id' });
445
+ try {
446
+ const row = await recordGet(library, shelf, rid);
447
+ if (!row)
448
+ return reply.code(404).send({ error: 'not_found' });
449
+ return { activity: await getRecordActivity(req.user.id, library, shelf, rid) };
450
+ }
451
+ catch (e) {
452
+ if (e instanceof UnknownShelfError)
453
+ return reply.code(404).send({ error: 'unknown_shelf' });
454
+ throw e;
455
+ }
456
+ });
457
+ app.patch('/api/:library/:shelf/:id/activity', async (req, reply) => {
458
+ const { library, shelf, id } = req.params;
459
+ const rid = Number(id);
460
+ if (isNaN(rid))
461
+ return reply.code(400).send({ error: 'invalid_id' });
462
+ const body = (req.body ?? {});
463
+ if (typeof body.favorite !== 'boolean')
464
+ return reply.code(400).send({ error: 'favorite_must_be_boolean' });
465
+ try {
466
+ const row = await recordGet(library, shelf, rid);
467
+ if (!row)
468
+ return reply.code(404).send({ error: 'not_found' });
469
+ return { activity: await setRecordFavorite(req.user.id, library, shelf, rid, body.favorite) };
470
+ }
471
+ catch (e) {
472
+ if (e instanceof UnknownShelfError)
473
+ return reply.code(404).send({ error: 'unknown_shelf' });
474
+ throw e;
475
+ }
476
+ });
392
477
  app.get('/api/:library/:shelf/:id', async (req, reply) => {
393
478
  const { library, shelf, id } = req.params;
394
479
  const rid = Number(id);
@@ -398,7 +483,11 @@ app.get('/api/:library/:shelf/:id', async (req, reply) => {
398
483
  const row = await recordGet(library, shelf, rid);
399
484
  if (!row)
400
485
  return reply.code(404).send({ error: 'not_found' });
401
- return maskRecordRow(library, shelf, row);
486
+ const masked = maskRecordRow(library, shelf, row);
487
+ if (req.user && tracksRecordActivity(req)) {
488
+ return { ...masked, _activity: await markRecordViewed(req.user.id, library, shelf, rid) };
489
+ }
490
+ return masked;
402
491
  }
403
492
  catch (e) {
404
493
  if (e instanceof UnknownShelfError)
@@ -14,4 +14,7 @@ export declare class LocalClient implements CofferClientApi {
14
14
  createRecord(library: string, shelf: string, fields: Record<string, unknown>): Promise<unknown>;
15
15
  updateRecord(library: string, shelf: string, id: number, fields: Record<string, unknown>): Promise<unknown>;
16
16
  deleteRecord(library: string, shelf: string, id: number): Promise<unknown>;
17
+ listTrash(): Promise<unknown>;
18
+ restoreRecord(library: string, shelf: string, id: number): Promise<unknown>;
19
+ purgeRecord(library: string, shelf: string, id: number): Promise<unknown>;
17
20
  }
package/dist/mcp-local.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ValidationError, NotFoundError } from '@coffer-org/mcp/client';
2
- import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, } from "./records-api.js";
2
+ import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, recordTrashList, recordRestore, recordPurge, } from "./records-api.js";
3
3
  import { ValidationError as ServerValidationError } from "./mutate.js";
4
4
  import { buildClientSchema } from "./schema-api.js";
5
5
  export function mapError(e) {
@@ -63,4 +63,30 @@ export class LocalClient {
63
63
  mapError(e);
64
64
  }
65
65
  }
66
+ async listTrash() {
67
+ try {
68
+ return await recordTrashList();
69
+ }
70
+ catch (e) {
71
+ mapError(e);
72
+ }
73
+ }
74
+ async restoreRecord(library, shelf, id) {
75
+ try {
76
+ await recordRestore(library, shelf, id);
77
+ return null;
78
+ }
79
+ catch (e) {
80
+ mapError(e);
81
+ }
82
+ }
83
+ async purgeRecord(library, shelf, id) {
84
+ try {
85
+ await recordPurge(library, shelf, id);
86
+ return null;
87
+ }
88
+ catch (e) {
89
+ mapError(e);
90
+ }
91
+ }
66
92
  }
package/dist/mcp-tools.js CHANGED
@@ -265,7 +265,9 @@ export async function buildDomainSections() {
265
265
  "some are collections (nested rows — an array). Extends add extra field-sets to a shelf's records, shown only when a " +
266
266
  'condition holds (the "when …" notes below); in a fetched record they sit under `_extends`. ' +
267
267
  'Read: list_libraries → describe_shelf → list_records/get_record. Write: create_record/update_record (call describe_shelf first); ' +
268
- 'to remove a record use delete_record — do not blank its fields.';
268
+ 'delete_record moves a record to reversible trash — do not blank fields. Before editing, read the complete record and patch only requested fields. ' +
269
+ 'For duplicates, read both records completely, compare fields/collections/extends/attachments, recommend the less complete record for trash, and offer a field-by-field merge first. ' +
270
+ 'Use list_trash and restore_record for recovery; purge_record is irreversible and requires explicit confirmation.';
269
271
  const rules = (await collectPluginInstructions()).map(({ id, instructions }) => `## ${id}\n${instructions}`);
270
272
  const site = await frontendInstructions();
271
273
  return [
@@ -281,6 +283,7 @@ export function buildMcpInstructions(sections) {
281
283
  'Use the tools to read and write this data: call list_libraries to see what exists, describe_shelf before create_record/update_record, then list_records / get_record to read. When a search_records tool is available, use it for semantic lookup.',
282
284
  'The per-library notes below name the shelves and the rules — not every field. For exact field names, types, and which are required, call describe_shelf(library, shelf) rather than guessing from the notes.',
283
285
  'Record field values may be JSON-encoded (e.g. quantity {"value":2000,"unit":"ml"}) — parse them.',
286
+ 'Action safety: read a complete record before editing or deleting it. update_record must be the smallest patch and must preserve unmentioned fields. delete_record moves the record to reversible trash; never blank fields to simulate deletion. For suspected duplicates, read both complete records, compare fields/collections/extends/attachments, identify the less complete record, and offer a field-by-field merge before moving anything to trash. Use list_trash/restore_record for recovery; purge_record is irreversible and requires explicit confirmation.',
284
287
  'File fields (file/document/audio/video/image/media/avatar/cover/poster) hold a file uploaded to this server, ' +
285
288
  'never a remote URL: {"name":"<filename from POST /api/upload>"}. Writing a URL is rejected. ' +
286
289
  'To store a picture found on the web, download it locally first, then create_upload_ticket → POST /api/upload → write the returned name. ' +
package/dist/msg-log.d.ts CHANGED
@@ -8,4 +8,14 @@ export interface MsgLogRow {
8
8
  tokensOut: number | null;
9
9
  ms: number | null;
10
10
  }
11
+ export interface MsgLogDiagnosticRow {
12
+ id: number;
13
+ ts: string;
14
+ connector: string;
15
+ role: 'user' | 'assistant';
16
+ tokensIn: number | null;
17
+ tokensOut: number | null;
18
+ ms: number | null;
19
+ }
11
20
  export declare function logMessage(row: MsgLogRow): Promise<void>;
21
+ export declare function listRecentMessageMetrics(limit?: number): Promise<MsgLogDiagnosticRow[]>;
package/dist/msg-log.js CHANGED
@@ -3,3 +3,19 @@ export async function logMessage(row) {
3
3
  await getEm().fork().getConnection().execute(`INSERT INTO _orch_msg_log (ts, connector, chat_id, user_id, role, text, tokens_in, tokens_out, ms)
4
4
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [new Date().toISOString(), row.connector, row.chatId, row.userId, row.role, row.text, row.tokensIn, row.tokensOut, row.ms]);
5
5
  }
6
+ export async function listRecentMessageMetrics(limit = 25) {
7
+ const bounded = Math.max(1, Math.min(100, Math.trunc(limit)));
8
+ const rows = (await getEm().fork().getConnection().execute(`SELECT id, ts, connector, role, tokens_in, tokens_out, ms
9
+ FROM _orch_msg_log
10
+ ORDER BY id DESC
11
+ LIMIT ?`, [bounded]));
12
+ return rows.map((r) => ({
13
+ id: Number(r['id']),
14
+ ts: String(r['ts']),
15
+ connector: String(r['connector']),
16
+ role: r['role'] === 'assistant' ? 'assistant' : 'user',
17
+ tokensIn: r['tokens_in'] == null ? null : Number(r['tokens_in']),
18
+ tokensOut: r['tokens_out'] == null ? null : Number(r['tokens_out']),
19
+ ms: r['ms'] == null ? null : Number(r['ms']),
20
+ }));
21
+ }
package/dist/mutate.d.ts CHANGED
@@ -20,8 +20,14 @@ export declare class NotFoundError extends Error {
20
20
  constructor();
21
21
  }
22
22
  export declare function toIssue(i: z.ZodIssue): ValidationIssue;
23
- export declare function listRecords(entityName: string): Promise<Record<string, unknown>[]>;
24
- export declare function getRecord(m: ShelfDef, entityName: string, id: number): Promise<Record<string, unknown> | undefined>;
23
+ export declare function listRecords(entityName: string, opts?: {
24
+ deleted?: 'active' | 'deleted' | 'all';
25
+ }): Promise<Record<string, unknown>[]>;
26
+ export declare function getRecord(m: ShelfDef, entityName: string, id: number, opts?: {
27
+ includeDeleted?: boolean;
28
+ }): Promise<Record<string, unknown> | undefined>;
25
29
  export declare function createRecord(m: ShelfDef, entityName: string, input: unknown, ctx: MutateCtx, afterBase?: AfterBaseHook): Promise<Record<string, unknown>>;
26
30
  export declare function updateRecord(m: ShelfDef, entityName: string, id: number, input: unknown, ctx: MutateCtx, afterBase?: AfterBaseHook): Promise<Record<string, unknown>>;
27
- export declare function deleteRecord(m: ShelfDef, entityName: string, id: number, ctx: MutateCtx, afterBase?: AfterBaseHook): Promise<void>;
31
+ export declare function deleteRecord(m: ShelfDef, entityName: string, id: number, ctx: MutateCtx): Promise<void>;
32
+ export declare function restoreRecord(m: ShelfDef, entityName: string, id: number, ctx: MutateCtx, afterBase?: AfterBaseHook): Promise<Record<string, unknown>>;
33
+ export declare function purgeRecord(m: ShelfDef, entityName: string, id: number, ctx: MutateCtx, afterBase?: AfterBaseHook): Promise<void>;
package/dist/mutate.js CHANGED
@@ -50,14 +50,19 @@ function writeEvent(em, actor, op, type, recordId, before, after) {
50
50
  after: after == null ? null : JSON.stringify(after),
51
51
  });
52
52
  }
53
- export async function listRecords(entityName) {
53
+ export async function listRecords(entityName, opts = {}) {
54
54
  const fork = getEm().fork();
55
- const rows = await fork.findAll(entityName, { orderBy: { id: 'asc' } });
55
+ const deleted = opts.deleted ?? 'active';
56
+ const where = deleted === 'active' ? { deleted_at: null } : deleted === 'deleted' ? { deleted_at: { $ne: null } } : {};
57
+ const rows = await fork.findAll(entityName, { where, orderBy: { id: 'asc' } });
56
58
  return serialize(rows);
57
59
  }
58
- export async function getRecord(m, entityName, id) {
60
+ export async function getRecord(m, entityName, id, opts = {}) {
59
61
  const fork = getEm().fork();
60
- const row = await fork.findOne(entityName, { id });
62
+ const row = await fork.findOne(entityName, {
63
+ id,
64
+ ...(opts.includeDeleted ? {} : { deleted_at: null }),
65
+ });
61
66
  if (!row)
62
67
  return undefined;
63
68
  const flat = decodeTemporal(m, serialize(row));
@@ -148,11 +153,45 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
148
153
  notifyRecordsChanged();
149
154
  return result;
150
155
  }
151
- export async function deleteRecord(m, entityName, id, ctx, afterBase) {
156
+ export async function deleteRecord(m, entityName, id, ctx) {
152
157
  await getEm()
153
158
  .fork()
154
159
  .transactional(async (tx) => {
155
- const found = await tx.findOne(entityName, { id });
160
+ const found = await tx.findOne(entityName, { id, deleted_at: null });
161
+ if (!found)
162
+ throw new NotFoundError();
163
+ const existing = serialize(found);
164
+ found.deleted_at = nowIso();
165
+ await tx.flush();
166
+ writeEvent(tx, ctx.actor, 'delete', `${m.library}/${m.shelf}`, id, existing, null);
167
+ });
168
+ notifyRecordsChanged();
169
+ }
170
+ export async function restoreRecord(m, entityName, id, ctx, afterBase) {
171
+ let result;
172
+ await getEm()
173
+ .fork()
174
+ .transactional(async (tx) => {
175
+ const found = await tx.findOne(entityName, { id, deleted_at: { $ne: null } });
176
+ if (!found)
177
+ throw new NotFoundError();
178
+ found.deleted_at = null;
179
+ await tx.flush();
180
+ const flat = decodeTemporal(m, serialize(found));
181
+ const nested = nestEmbedded(m, flat);
182
+ const collections = await readCollections(tx, m, id);
183
+ const _extends = afterBase ? await afterBase(tx, id) : undefined;
184
+ result = { ...nested, ...collections, ...(_extends ? { _extends } : {}) };
185
+ writeEvent(tx, ctx.actor, 'restore', `${m.library}/${m.shelf}`, id, null, result);
186
+ });
187
+ notifyRecordsChanged();
188
+ return result;
189
+ }
190
+ export async function purgeRecord(m, entityName, id, ctx, afterBase) {
191
+ await getEm()
192
+ .fork()
193
+ .transactional(async (tx) => {
194
+ const found = await tx.findOne(entityName, { id, deleted_at: { $ne: null } });
156
195
  if (!found)
157
196
  throw new NotFoundError();
158
197
  const existing = serialize(found);
@@ -160,7 +199,7 @@ export async function deleteRecord(m, entityName, id, ctx, afterBase) {
160
199
  if (afterBase)
161
200
  await afterBase(tx, id);
162
201
  tx.remove(found);
163
- writeEvent(tx, ctx.actor, 'delete', `${m.library}/${m.shelf}`, id, existing, null);
202
+ writeEvent(tx, ctx.actor, 'purge', `${m.library}/${m.shelf}`, id, existing, null);
164
203
  });
165
204
  notifyRecordsChanged();
166
205
  }
@@ -0,0 +1,11 @@
1
+ export interface RecordActivityClient {
2
+ favorite: boolean;
3
+ viewCount: number;
4
+ lastViewedAt: string | null;
5
+ score: number;
6
+ }
7
+ export declare function getRecordActivity(userId: number, library: string, shelf: string, recordId: number): Promise<RecordActivityClient>;
8
+ export declare function markRecordViewed(userId: number, library: string, shelf: string, recordId: number, now?: string): Promise<RecordActivityClient>;
9
+ export declare function setRecordFavorite(userId: number, library: string, shelf: string, recordId: number, favorite: boolean): Promise<RecordActivityClient>;
10
+ export declare function deleteRecordActivity(library: string, shelf: string, recordId: number): Promise<void>;
11
+ export declare function applySmartRanking(userId: number, library: string, shelf: string, rows: Record<string, unknown>[], now?: Date): Promise<Record<string, unknown>[]>;
@@ -0,0 +1,118 @@
1
+ import { getEm } from "./db.js";
2
+ import { shelfTableName } from "./entity-schema.js";
3
+ const DAY_MS = 24 * 60 * 60 * 1000;
4
+ const CREATED_HALF_LIFE_DAYS = 30;
5
+ const EDITED_HALF_LIFE_DAYS = 14;
6
+ function activityKey(userId, library, shelf, recordId) {
7
+ return { user_id: userId, library, shelf, record_id: recordId };
8
+ }
9
+ function toClient(row) {
10
+ return {
11
+ favorite: row?.favorite === 1,
12
+ viewCount: Number(row?.view_count ?? 0),
13
+ lastViewedAt: row?.last_viewed_at ?? null,
14
+ score: 0,
15
+ };
16
+ }
17
+ async function getActivityRow(userId, library, shelf, recordId) {
18
+ const em = getEm().fork();
19
+ return (await em.findOne('_RecordActivity', activityKey(userId, library, shelf, recordId)));
20
+ }
21
+ export async function getRecordActivity(userId, library, shelf, recordId) {
22
+ return toClient((await getActivityRow(userId, library, shelf, recordId)) ?? undefined);
23
+ }
24
+ export async function markRecordViewed(userId, library, shelf, recordId, now = new Date().toISOString()) {
25
+ const em = getEm().fork();
26
+ const key = activityKey(userId, library, shelf, recordId);
27
+ const row = (await em.findOne('_RecordActivity', key));
28
+ if (row) {
29
+ row.view_count = Number(row.view_count ?? 0) + 1;
30
+ row.last_viewed_at = now;
31
+ await em.flush();
32
+ return toClient(row);
33
+ }
34
+ const created = em.create('_RecordActivity', {
35
+ ...key,
36
+ favorite: 0,
37
+ view_count: 1,
38
+ last_viewed_at: now,
39
+ });
40
+ await em.flush();
41
+ return toClient(created);
42
+ }
43
+ export async function setRecordFavorite(userId, library, shelf, recordId, favorite) {
44
+ const em = getEm().fork();
45
+ const key = activityKey(userId, library, shelf, recordId);
46
+ const row = (await em.findOne('_RecordActivity', key));
47
+ if (row) {
48
+ row.favorite = favorite ? 1 : 0;
49
+ await em.flush();
50
+ return toClient(row);
51
+ }
52
+ const created = em.create('_RecordActivity', {
53
+ ...key,
54
+ favorite: favorite ? 1 : 0,
55
+ view_count: 0,
56
+ last_viewed_at: null,
57
+ });
58
+ await em.flush();
59
+ return toClient(created);
60
+ }
61
+ export async function deleteRecordActivity(library, shelf, recordId) {
62
+ const em = getEm().fork();
63
+ await em.nativeDelete('_RecordActivity', { library, shelf, record_id: recordId });
64
+ }
65
+ async function listActivityRows(userId, library, shelf, ids) {
66
+ if (ids.length === 0)
67
+ return new Map();
68
+ const em = getEm().fork();
69
+ const rows = (await em.find('_RecordActivity', { user_id: userId, library, shelf, record_id: { $in: ids } }));
70
+ return new Map(rows.map((row) => [Number(row.record_id), row]));
71
+ }
72
+ function decayBonus(base, timestamp, halfLifeDays, nowMs) {
73
+ const then = timestamp ? Date.parse(timestamp) : NaN;
74
+ if (!Number.isFinite(then))
75
+ return 0;
76
+ const ageDays = Math.max(0, nowMs - then) / DAY_MS;
77
+ return base * 2 ** (-ageDays / halfLifeDays);
78
+ }
79
+ export async function applySmartRanking(userId, library, shelf, rows, now = new Date()) {
80
+ if (rows.length === 0)
81
+ return rows;
82
+ const ids = rows.map((row) => Number(row.id)).filter((id) => Number.isInteger(id));
83
+ const activities = await listActivityRows(userId, library, shelf, ids);
84
+ const em = getEm().fork();
85
+ const timestamps = (await em.find(shelfTableName(library, shelf), { id: { $in: ids }, deleted_at: null }, { fields: ['id', 'created_at', 'updated_at'] }));
86
+ const dates = new Map(timestamps.map((row) => [Number(row.id), row]));
87
+ const viewed = rows
88
+ .map((row) => ({ id: Number(row.id), last: activities.get(Number(row.id))?.last_viewed_at }))
89
+ .filter((row) => typeof row.last === 'string' && Number.isFinite(Date.parse(row.last)))
90
+ .sort((a, b) => Date.parse(b.last) - Date.parse(a.last));
91
+ const recentReadIds = new Set(viewed.slice(0, Math.max(1, Math.ceil(rows.length * 0.1))).map((row) => row.id));
92
+ const nowMs = now.getTime();
93
+ const decorated = rows.map((row) => {
94
+ const id = Number(row.id);
95
+ const activity = activities.get(id);
96
+ const date = dates.get(id);
97
+ const created = date?.created_at;
98
+ const updated = date?.updated_at;
99
+ const createdBonus = decayBonus(75, created, CREATED_HALF_LIFE_DAYS, nowMs);
100
+ const edited = created && updated && Date.parse(updated) > Date.parse(created) ? updated : null;
101
+ const editedBonus = decayBonus(50, edited, EDITED_HALF_LIFE_DAYS, nowMs);
102
+ const score = (activity?.favorite === 1 ? 100 : 0) + createdBonus + editedBonus + (recentReadIds.has(id) ? 30 : 0);
103
+ const client = {
104
+ favorite: activity?.favorite === 1,
105
+ viewCount: Number(activity?.view_count ?? 0),
106
+ lastViewedAt: activity?.last_viewed_at ?? null,
107
+ score,
108
+ };
109
+ return { row: { ...row, _activity: client }, client, updated: updated ?? '' };
110
+ });
111
+ return decorated
112
+ .sort((a, b) => b.client.score - a.client.score ||
113
+ b.client.viewCount - a.client.viewCount ||
114
+ String(b.client.lastViewedAt ?? '').localeCompare(String(a.client.lastViewedAt ?? '')) ||
115
+ b.updated.localeCompare(a.updated) ||
116
+ Number(b.row.id) - Number(a.row.id))
117
+ .map((item) => item.row);
118
+ }
@@ -12,6 +12,7 @@ export type RecordListOpts = {
12
12
  limit?: number;
13
13
  offset?: number;
14
14
  orderBy?: Record<string, 'ASC' | 'DESC'>;
15
+ deleted?: 'active' | 'deleted' | 'all';
15
16
  };
16
17
  export declare const MAX_PAGE = 500;
17
18
  export declare function coerceFilter(v: string, column: string): unknown;
@@ -21,14 +22,25 @@ export declare function rowMatch(mdef: ShelfDef, row: Record<string, unknown>, t
21
22
  score: number;
22
23
  snippet: string;
23
24
  } | null;
24
- export declare function recordCount(library: string, shelf: string, query?: RecordListQuery): Promise<number>;
25
+ export declare function recordCount(library: string, shelf: string, query?: RecordListQuery, opts?: Pick<RecordListOpts, 'deleted'>): Promise<number>;
25
26
  export declare function recordList(library: string, shelf: string, query?: RecordListQuery, opts?: RecordListOpts): Promise<Record<string, unknown>[]>;
26
27
  export declare function isPagedRequest(limit?: string, offset?: string): boolean;
27
28
  export declare function recordListPage(library: string, shelf: string, query?: RecordListQuery, opts?: RecordListOpts): Promise<{
28
29
  rows: Record<string, unknown>[];
29
30
  total: number;
30
31
  }>;
31
- export declare function recordGet(library: string, shelf: string, id: number): Promise<Record<string, unknown> | null>;
32
+ export declare function recordGet(library: string, shelf: string, id: number, opts?: {
33
+ includeDeleted?: boolean;
34
+ }): Promise<Record<string, unknown> | null>;
32
35
  export declare function recordCreate(library: string, shelf: string, body: unknown, actor?: string): Promise<Record<string, unknown>>;
33
36
  export declare function recordUpdate(library: string, shelf: string, id: number, body: unknown, actor?: string): Promise<Record<string, unknown>>;
34
37
  export declare function recordDelete(library: string, shelf: string, id: number, actor?: string): Promise<void>;
38
+ export declare function recordRestore(library: string, shelf: string, id: number, actor?: string): Promise<void>;
39
+ export declare function recordPurge(library: string, shelf: string, id: number, actor?: string): Promise<void>;
40
+ export interface TrashRecord {
41
+ library: string;
42
+ shelf: string;
43
+ label: string;
44
+ record: Record<string, unknown>;
45
+ }
46
+ export declare function recordTrashList(): Promise<TrashRecord[]>;
@@ -1,13 +1,14 @@
1
1
  import { fieldMap, textSearchKeys, titleKey, recordTitle, listKeys, storageColumnsFor } from '@coffer-org/sdk/shelf';
2
2
  import { tokenize, matchScoreFolded, foldText } from '@coffer-org/core/search';
3
3
  import { serialize } from '@mikro-orm/core';
4
- import { getShelf, getExtendsFor } from "./registry-context.js";
4
+ import { getActiveRegistry, getShelf, getExtendsFor } from "./registry-context.js";
5
5
  import { shelfTableName } from "./entity-schema.js";
6
6
  import { getExtendRecord, getExtendRecords } from "./extend-table.js";
7
7
  import { getEm } from "./db.js";
8
8
  import { decodeTemporal, dtStringToDate } from "./temporal.js";
9
9
  import { splitBody, saveExtends, deleteExtends, validateExtends, readExtends } from "./extend-io.js";
10
- import { createRecord, updateRecord, getRecord, deleteRecord } from "./mutate.js";
10
+ import { createRecord, updateRecord, getRecord, deleteRecord, restoreRecord, purgeRecord } from "./mutate.js";
11
+ import { deleteRecordActivity } from "./record-activity.js";
11
12
  export class UnknownShelfError extends Error {
12
13
  }
13
14
  export const MAX_PAGE = 500;
@@ -98,20 +99,29 @@ function buildWhere(m, filterParams) {
98
99
  }
99
100
  return where;
100
101
  }
101
- export async function recordCount(library, shelf, query = {}) {
102
+ export async function recordCount(library, shelf, query = {}, opts = {}) {
102
103
  const { m, ename } = resolve(library, shelf);
103
104
  const { q: _q, ...filterParams } = query;
104
105
  const where = buildWhere(m, filterParams);
105
- if (m.standalone === false && Object.keys(where).length === 0)
106
+ const deleted = opts.deleted ?? 'active';
107
+ if (deleted === 'active')
108
+ where.deleted_at = null;
109
+ else if (deleted === 'deleted')
110
+ where.deleted_at = { $ne: null };
111
+ if (m.standalone === false && Object.keys(filterParams).length === 0)
106
112
  return 0;
107
113
  return getEm().fork().count(ename, where);
108
114
  }
109
115
  export async function recordList(library, shelf, query = {}, opts = {}) {
110
- const { view = 'full', extends: withExt = true } = opts;
116
+ const { view = 'full', extends: withExt = true, deleted = 'active' } = opts;
111
117
  const { m, ename } = resolve(library, shelf);
112
118
  const { q, ...filterParams } = query;
113
119
  const where = buildWhere(m, filterParams);
114
- if (m.standalone === false && Object.keys(where).length === 0)
120
+ if (deleted === 'active')
121
+ where.deleted_at = null;
122
+ else if (deleted === 'deleted')
123
+ where.deleted_at = { $ne: null };
124
+ if (m.standalone === false && Object.keys(filterParams).length === 0)
115
125
  return [];
116
126
  const fork = getEm().fork();
117
127
  const tokens = typeof q === 'string' && q ? tokenize(q) : [];
@@ -157,14 +167,14 @@ export async function recordListPage(library, shelf, query = {}, opts = {}) {
157
167
  return { rows: all.slice(offset, offset + limit), total: all.length };
158
168
  }
159
169
  const rows = await recordList(library, shelf, query, { ...opts, orderBy, limit, offset });
160
- const total = await recordCount(library, shelf, query);
170
+ const total = await recordCount(library, shelf, query, opts);
161
171
  return { rows, total };
162
172
  }
163
- export async function recordGet(library, shelf, id) {
173
+ export async function recordGet(library, shelf, id, opts = {}) {
164
174
  const { m, ename } = resolve(library, shelf);
165
175
  if (m.standalone === false)
166
176
  return null;
167
- const row = await getRecord(m, ename, id);
177
+ const row = await getRecord(m, ename, id, opts);
168
178
  if (!row)
169
179
  return null;
170
180
  return withExtends(row, library, shelf);
@@ -191,5 +201,24 @@ export async function recordUpdate(library, shelf, id, body, actor = 'gui') {
191
201
  }
192
202
  export async function recordDelete(library, shelf, id, actor = 'gui') {
193
203
  const { m, ename } = resolve(library, shelf);
194
- await deleteRecord(m, ename, id, { actor }, (tx) => deleteExtends(tx, library, shelf, id));
204
+ await deleteRecord(m, ename, id, { actor });
205
+ }
206
+ export async function recordRestore(library, shelf, id, actor = 'gui') {
207
+ const { m, ename } = resolve(library, shelf);
208
+ await restoreRecord(m, ename, id, { actor }, (tx, recordId) => readExtends(tx, library, shelf, recordId));
209
+ }
210
+ export async function recordPurge(library, shelf, id, actor = 'gui') {
211
+ const { m, ename } = resolve(library, shelf);
212
+ await purgeRecord(m, ename, id, { actor }, (tx, recordId) => deleteExtends(tx, library, shelf, recordId));
213
+ await deleteRecordActivity(library, shelf, id);
214
+ }
215
+ export async function recordTrashList() {
216
+ const shelves = getActiveRegistry().shelves.filter((s) => s.standalone !== false);
217
+ const out = [];
218
+ for (const m of shelves) {
219
+ const rows = await recordList(m.library, m.shelf, {}, { view: 'full', extends: true, deleted: 'deleted' });
220
+ for (const record of rows)
221
+ out.push({ library: m.library, shelf: m.shelf, label: m.label, record });
222
+ }
223
+ return out.sort((a, b) => String(b.record.deleted_at ?? '').localeCompare(String(a.record.deleted_at ?? '')));
195
224
  }
@@ -23,7 +23,7 @@ export async function indexSearchOnce(batch = DEFAULT_BATCH) {
23
23
  if (!library || !shelf)
24
24
  continue;
25
25
  const key = `${r.type}/${r.record_id}`;
26
- if (r.op === 'delete') {
26
+ if (r.op === 'delete' || r.op === 'purge') {
27
27
  byRef.set(key, { shelf: r.type, recordId: r.record_id, op: 'delete', folded: '' });
28
28
  continue;
29
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/server",
3
- "version": "2.3.2",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"