@coffer-org/server 2.6.1 → 3.0.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.
package/dist/auth-api.js CHANGED
@@ -75,9 +75,18 @@ export async function registerAuthApi(app) {
75
75
  return reply.code(409).send({ error: 'already_setup' });
76
76
  const body = (req.body ?? {});
77
77
  if (!body.login || !body.password || !body.displayName) {
78
- return reply.code(422).send({ issues: [{ field: !body.login ? 'login' : !body.password ? 'password' : 'displayName', code: 'required' }] });
78
+ return reply
79
+ .code(422)
80
+ .send({
81
+ issues: [{ field: !body.login ? 'login' : !body.password ? 'password' : 'displayName', code: 'required' }],
82
+ });
79
83
  }
80
- const user = await createUser({ login: body.login, password: body.password, displayName: body.displayName, role: 'admin' });
84
+ const user = await createUser({
85
+ login: body.login,
86
+ password: body.password,
87
+ displayName: body.displayName,
88
+ role: 'admin',
89
+ });
81
90
  const { raw } = await createSession(user.id, SESSION_TTL_MS);
82
91
  setCookie(reply, raw, SESSION_TTL_MS / 1000);
83
92
  return publicUser(user);
@@ -129,7 +138,12 @@ export async function registerAuthApi(app) {
129
138
  if (!body.login || !body.password || !body.displayName || (body.role !== 'admin' && body.role !== 'member')) {
130
139
  return reply.code(422).send({ issues: [{ field: 'login', code: 'required' }] });
131
140
  }
132
- const user = await createUser({ login: body.login, password: body.password, displayName: body.displayName, role: body.role });
141
+ const user = await createUser({
142
+ login: body.login,
143
+ password: body.password,
144
+ displayName: body.displayName,
145
+ role: body.role,
146
+ });
133
147
  return publicUser(user);
134
148
  });
135
149
  app.patch('/api/auth/users/:id', async (req, reply) => {
@@ -1,7 +1,13 @@
1
1
  import { getEm } from "./db.js";
2
2
  import { hashPassword, generateToken, hashToken } from "./auth-crypto.js";
3
3
  function toAuthUser(row) {
4
- return { id: row.id, login: row.login, displayName: row.display_name, role: row.role, disabled: Boolean(row.disabled) };
4
+ return {
5
+ id: row.id,
6
+ login: row.login,
7
+ displayName: row.display_name,
8
+ role: row.role,
9
+ disabled: Boolean(row.disabled),
10
+ };
5
11
  }
6
12
  export async function countUsers() {
7
13
  const em = getEm().fork();
@@ -22,7 +22,10 @@ export function makeScheduler(opts = {}) {
22
22
  function arm(cb, ms) {
23
23
  if (stopped)
24
24
  return;
25
- const h = setTimer(() => { timers.delete(h); cb(); }, ms);
25
+ const h = setTimer(() => {
26
+ timers.delete(h);
27
+ cb();
28
+ }, ms);
26
29
  timers.add(h);
27
30
  }
28
31
  function enqueue(task) {
@@ -51,7 +54,11 @@ export function makeScheduler(opts = {}) {
51
54
  const timeoutMs = task.timeoutMs ?? defaultTimeoutMs;
52
55
  let to;
53
56
  const timeout = new Promise((resolve) => {
54
- to = setTimer(() => { timers.delete(to); log.warn(`${task.name}: timed out after ${timeoutMs}ms`); resolve(); }, timeoutMs);
57
+ to = setTimer(() => {
58
+ timers.delete(to);
59
+ log.warn(`${task.name}: timed out after ${timeoutMs}ms`);
60
+ resolve();
61
+ }, timeoutMs);
55
62
  timers.add(to);
56
63
  });
57
64
  try {
@@ -62,7 +69,10 @@ export function makeScheduler(opts = {}) {
62
69
  catch (e) {
63
70
  runPromise = Promise.reject(e);
64
71
  }
65
- await Promise.race([runPromise.catch((e) => log.warn(`${task.name}: ${e.message}`)), timeout]);
72
+ await Promise.race([
73
+ runPromise.catch((e) => log.warn(`${task.name}: ${e.message}`)),
74
+ timeout,
75
+ ]);
66
76
  }
67
77
  finally {
68
78
  clearTimer(to);
@@ -70,13 +80,17 @@ export function makeScheduler(opts = {}) {
70
80
  }
71
81
  }
72
82
  return {
73
- register(task) { tasks.push(task); },
83
+ register(task) {
84
+ tasks.push(task);
85
+ },
74
86
  start() {
75
87
  if (started || stopped)
76
88
  return;
77
89
  started = true;
78
- arm(() => { for (const t of tasks)
79
- enqueue(t); }, startupDelayMs);
90
+ arm(() => {
91
+ for (const t of tasks)
92
+ enqueue(t);
93
+ }, startupDelayMs);
80
94
  },
81
95
  stop() {
82
96
  stopped = true;
@@ -47,14 +47,17 @@ export async function writeAt(tx, prefix, fields, parentId, collections) {
47
47
  await deleteAt(tx, table, childFields, ex.id);
48
48
  await tx.nativeDelete(table, { parent_id: parentId });
49
49
  const sKeys = scalarKeys(childFields);
50
- const jsonKeys = new Set(fieldEntries(childFields).filter(([, fm]) => isJsonStored(fm)).map(([k]) => k));
50
+ const jsonKeys = new Set(fieldEntries(childFields)
51
+ .filter(([, fm]) => isJsonStored(fm))
52
+ .map(([k]) => k));
51
53
  let position = 0;
52
54
  for (const r of rows) {
53
55
  const flatR = flattenEmbeddedAt(childFields, r);
54
56
  const picked = {};
55
57
  for (const k of sKeys)
56
58
  if (flatR[k] !== undefined)
57
- picked[k] = jsonKeys.has(k) && flatR[k] !== null && typeof flatR[k] === 'object' ? JSON.stringify(flatR[k]) : flatR[k];
59
+ picked[k] =
60
+ jsonKeys.has(k) && flatR[k] !== null && typeof flatR[k] === 'object' ? JSON.stringify(flatR[k]) : flatR[k];
58
61
  const child = tx.create(table, { parent_id: parentId, position, ...picked });
59
62
  await tx.flush();
60
63
  const childId = child.id;
@@ -29,7 +29,8 @@ function parseErrorBody(body) {
29
29
  }
30
30
  function classifyEmbeddingFailure(status, code, detail) {
31
31
  const marker = `${code ?? ''} ${detail ?? ''}`.toLowerCase();
32
- if (status === 402 || /insufficient_quota|billing|payment_required|payment required|hard.?limit|quota exceeded/.test(marker))
32
+ if (status === 402 ||
33
+ /insufficient_quota|billing|payment_required|payment required|hard.?limit|quota exceeded/.test(marker))
33
34
  return 'billing';
34
35
  if (status === 401 || status === 403)
35
36
  return 'auth';
@@ -12,7 +12,10 @@ async function loadCache() {
12
12
  const em = getEm().fork();
13
13
  const rows = (await em.find('_Embedding', {}));
14
14
  cache = rows.map((r) => ({
15
- shelfKey: r.shelf_key, recordId: r.record_id, snippet: r.snippet, full: decodeVector(r.vector),
15
+ shelfKey: r.shelf_key,
16
+ recordId: r.record_id,
17
+ snippet: r.snippet,
18
+ full: decodeVector(r.vector),
16
19
  }));
17
20
  return cache;
18
21
  }
@@ -30,7 +33,9 @@ export async function migrateEmbeddingVectorsToBlob() {
30
33
  const em = getEm().fork();
31
34
  if (dialectOf(em) !== 'sqlite')
32
35
  return 0;
33
- const [{ n }] = (await em.getConnection().execute("SELECT count(*) AS n FROM _embeddings WHERE typeof(vector) = 'text'"));
36
+ const [{ n }] = (await em
37
+ .getConnection()
38
+ .execute("SELECT count(*) AS n FROM _embeddings WHERE typeof(vector) = 'text'"));
34
39
  if (n === 0)
35
40
  return 0;
36
41
  const rows = (await em.find('_Embedding', {}));
@@ -105,6 +110,9 @@ export async function searchEmbeddings(queryVec, k, opts = {}) {
105
110
  }
106
111
  export async function listEventsSince(lastId, limit) {
107
112
  const em = getEm().fork();
108
- const rows = (await em.find('_Event', { id: { $gt: lastId } }, { orderBy: { id: 'ASC' }, limit }));
113
+ const rows = (await em.find('_Event', { id: { $gt: lastId } }, {
114
+ orderBy: { id: 'ASC' },
115
+ limit,
116
+ }));
109
117
  return rows;
110
118
  }
@@ -18,6 +18,7 @@ export declare const EmbeddingSchema: EntitySchema<any, never, import("@mikro-or
18
18
  export declare const PluginStateSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
19
19
  export declare const RecordActivitySchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
20
20
  export declare const ThreadMessageSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
21
+ export declare const ThreadStateSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
21
22
  export declare function buildPluginEntities(plugins: PluginManifest[]): EntitySchema[];
22
23
  export declare const MsgLogSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
23
24
  export declare const UserSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
@@ -180,6 +180,17 @@ export const ThreadMessageSchema = new EntitySchema({
180
180
  reply_to_id: { type: 'text', nullable: true },
181
181
  },
182
182
  });
183
+ export const ThreadStateSchema = new EntitySchema({
184
+ name: '_ThreadState',
185
+ tableName: '_thread_state',
186
+ properties: {
187
+ connector: { type: 'text', primary: true },
188
+ chat_id: { type: 'text', primary: true },
189
+ agent_id: { type: 'text', nullable: true },
190
+ preset_id: { type: 'text', nullable: true },
191
+ updated_at: { type: 'text' },
192
+ },
193
+ });
183
194
  export function buildPluginEntities(plugins) {
184
195
  return plugins.flatMap((p) => [
185
196
  ...(p.libraries ?? []).flatMap((v) => v.shelves.flatMap((m) => [buildEntitySchema(m), ...buildCollectionEntities(m)])),
@@ -202,6 +213,8 @@ export const MsgLogSchema = new EntitySchema({
202
213
  tokens_in: { type: 'integer', nullable: true },
203
214
  tokens_out: { type: 'integer', nullable: true },
204
215
  ms: { type: 'integer', nullable: true },
216
+ agent_id: { type: 'text', nullable: true },
217
+ preset_id: { type: 'text', nullable: true },
205
218
  },
206
219
  });
207
220
  export const UserSchema = new EntitySchema({
@@ -280,9 +293,20 @@ export const OAuthTokenSchema = new EntitySchema({
280
293
  },
281
294
  });
282
295
  export const systemEntities = [
283
- EventSchema, PluginRowSchema, MigrationRowSchema, SeedRowSchema,
284
- EmbeddingSchema, PluginStateSchema, RecordActivitySchema, MsgLogSchema,
285
- UserSchema, SessionSchema, ApiTokenSchema,
286
- OAuthClientSchema, OAuthCodeSchema, OAuthTokenSchema,
296
+ EventSchema,
297
+ PluginRowSchema,
298
+ MigrationRowSchema,
299
+ SeedRowSchema,
300
+ EmbeddingSchema,
301
+ PluginStateSchema,
302
+ RecordActivitySchema,
303
+ MsgLogSchema,
304
+ UserSchema,
305
+ SessionSchema,
306
+ ApiTokenSchema,
307
+ OAuthClientSchema,
308
+ OAuthCodeSchema,
309
+ OAuthTokenSchema,
287
310
  ThreadMessageSchema,
311
+ ThreadStateSchema,
288
312
  ];
package/dist/extend-io.js CHANGED
@@ -2,6 +2,7 @@ import { getExtendsFor } from "./registry-context.js";
2
2
  import { buildExtendZodPartial } from '@coffer-org/sdk/extend';
3
3
  import { upsertExtendRecord, deleteExtendRecord, getExtendRecord } from "./extend-table.js";
4
4
  import { toIssue, ValidationError } from "./mutate.js";
5
+ import { normalizeFileFieldsAt } from "./file-fields.js";
5
6
  export function splitBody(body) {
6
7
  const raw = (body ?? {});
7
8
  const base = {};
@@ -48,7 +49,11 @@ export async function saveExtends(em, library, shelf, baseId, extData) {
48
49
  continue;
49
50
  const parsed = buildExtendZodPartial(e).safeParse(data);
50
51
  if (parsed.success && Object.keys(parsed.data).length > 0) {
51
- await upsertExtendRecord(em, e, baseId, parsed.data);
52
+ const row = parsed.data;
53
+ const fileIssues = normalizeFileFieldsAt(e.fields, row);
54
+ if (fileIssues.length)
55
+ throw new ValidationError(fileIssues);
56
+ await upsertExtendRecord(em, e, baseId, row);
52
57
  }
53
58
  }
54
59
  }
@@ -1,5 +1,7 @@
1
1
  import { getEm } from "./db.js";
2
- import { splitAt, writeAt, readAt, readAtMany, deleteAt } from "./collection-io.js";
2
+ import { splitAt, writeAt, readAt, readAtMany, deleteAt, flattenEmbeddedAt, nestEmbeddedAt } from "./collection-io.js";
3
+ import { encodeJsonAt } from "./mutate.js";
4
+ import { encodeTemporalAt, decodeTemporalAt } from "./temporal.js";
3
5
  import { chunk } from "./batch.js";
4
6
  import { selectRows } from "./read-rows.js";
5
7
  export function extendEntityName(e) {
@@ -11,8 +13,9 @@ export async function getExtendRecord(e, baseId, em) {
11
13
  const [flat] = (await selectRows(fork, name, { base_id: baseId }, { limit: 1 }));
12
14
  if (!flat)
13
15
  return undefined;
16
+ const decoded = nestEmbeddedAt(e.fields, decodeTemporalAt(e.fields, flat));
14
17
  const collections = await readAt(fork, name, e.fields, baseId);
15
- return { ...flat, ...collections };
18
+ return { ...decoded, ...collections };
16
19
  }
17
20
  export async function getExtendRecords(e, baseIds, em) {
18
21
  const out = new Map();
@@ -30,14 +33,16 @@ export async function getExtendRecords(e, baseIds, em) {
30
33
  const collectionsById = await readAtMany(fork, name, e.fields, presentIds);
31
34
  for (const flat of flats) {
32
35
  const id = Number(flat.base_id);
33
- out.set(id, { ...flat, ...(collectionsById.get(id) ?? {}) });
36
+ const decoded = nestEmbeddedAt(e.fields, decodeTemporalAt(e.fields, flat));
37
+ out.set(id, { ...decoded, ...(collectionsById.get(id) ?? {}) });
34
38
  }
35
39
  return out;
36
40
  }
37
41
  export async function upsertExtendRecord(em, e, baseId, data) {
38
42
  const name = extendEntityName(e);
39
43
  const { base, collections } = splitAt(e.fields, data);
40
- await em.upsert(name, { base_id: baseId, ...base });
44
+ const flat = encodeJsonAt(e.fields, encodeTemporalAt(e.fields, flattenEmbeddedAt(e.fields, base)));
45
+ await em.upsert(name, { base_id: baseId, ...flat });
41
46
  await writeAt(em, name, e.fields, baseId, collections);
42
47
  }
43
48
  export async function deleteExtendRecord(em, e, baseId) {
@@ -1,6 +1,8 @@
1
1
  import type { ShelfDef } from '@coffer-org/sdk/shelf';
2
+ import type { LayoutEl } from '@coffer-org/sdk/fields';
2
3
  import type { ValidationIssue } from './mutate.ts';
3
4
  export declare function mimeForName(name: string): string | undefined;
4
5
  export declare function touchesFileFields(m: ShelfDef, input: unknown): boolean;
5
6
  export declare function dropUnchangedFileFields(m: ShelfDef, input: Record<string, unknown>, stored: Record<string, unknown>): Record<string, unknown>;
7
+ export declare function normalizeFileFieldsAt(fields: LayoutEl[], data: Record<string, unknown>): ValidationIssue[];
6
8
  export declare function normalizeFileFields(m: ShelfDef, data: Record<string, unknown>): ValidationIssue[];
@@ -1,6 +1,6 @@
1
1
  import { statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { fieldEntries } from '@coffer-org/sdk/shelf';
3
+ import { fieldEntries, collectionGroups } from '@coffer-org/sdk/shelf';
4
4
  import { jsonValue } from '@coffer-org/sdk/fields';
5
5
  import { uploadsDir } from "./uploads.js";
6
6
  const MIME_BY_EXT = {
@@ -87,9 +87,9 @@ export function dropUnchangedFileFields(m, input, stored) {
87
87
  }
88
88
  return out ?? input;
89
89
  }
90
- export function normalizeFileFields(m, data) {
90
+ function normalizeLevel(fields, data, path) {
91
91
  const issues = [];
92
- for (const [key, f] of fieldEntries(m.fields)) {
92
+ for (const [key, f] of fieldEntries(fields)) {
93
93
  if (f.prim !== 'file')
94
94
  continue;
95
95
  if (!(key in data))
@@ -101,21 +101,40 @@ export function normalizeFileFields(m, data) {
101
101
  const isArray = Array.isArray(parsed);
102
102
  const items = (isArray ? parsed : [parsed]);
103
103
  const out = [];
104
+ let failed = false;
104
105
  for (const it of items) {
105
106
  if (typeof it !== 'object' || it === null || typeof it.name !== 'string') {
106
- issues.push({ field: key, code: 'file_not_uploaded', path: [key] });
107
+ issues.push({ field: key, code: 'file_not_uploaded', path: [...path, key] });
108
+ failed = true;
107
109
  continue;
108
110
  }
109
111
  const resolved = resolveEntry(it);
110
112
  if (!resolved) {
111
- issues.push({ field: key, code: 'file_not_uploaded', params: { name: it.name }, path: [key] });
113
+ issues.push({ field: key, code: 'file_not_uploaded', params: { name: it.name }, path: [...path, key] });
114
+ failed = true;
112
115
  continue;
113
116
  }
114
117
  out.push(resolved);
115
118
  }
116
- if (issues.length)
119
+ if (failed)
117
120
  continue;
118
121
  data[key] = isArray ? out : out[0];
119
122
  }
123
+ for (const c of collectionGroups(fields)) {
124
+ const rows = data[c.key];
125
+ if (!Array.isArray(rows))
126
+ continue;
127
+ rows.forEach((row, i) => {
128
+ if (typeof row !== 'object' || row === null)
129
+ return;
130
+ issues.push(...normalizeLevel(c.group.fields, row, [...path, c.key, i]));
131
+ });
132
+ }
120
133
  return issues;
121
134
  }
135
+ export function normalizeFileFieldsAt(fields, data) {
136
+ return normalizeLevel(fields, data, []);
137
+ }
138
+ export function normalizeFileFields(m, data) {
139
+ return normalizeFileFieldsAt(m.fields, data);
140
+ }
package/dist/index.js CHANGED
@@ -546,7 +546,10 @@ app.patch('/api/:library/:shelf/:id', (req, reply) => guard(reply, async () => {
546
546
  const bk = `_extend_${def.id}`;
547
547
  const sub = body[bk];
548
548
  if (sub && typeof sub === 'object') {
549
- body = { ...body, [bk]: preserveTree(def.fields, sub, (exRecs[def.id] ?? {})) };
549
+ body = {
550
+ ...body,
551
+ [bk]: preserveTree(def.fields, sub, (exRecs[def.id] ?? {})),
552
+ };
550
553
  }
551
554
  }
552
555
  }
@@ -13,7 +13,10 @@ export async function freshMcpApp(opts = {}) {
13
13
  await orm.schema.update({ safe: false, dropTables: false });
14
14
  const app = Fastify();
15
15
  app.addHook('onRequest', async (req, reply) => {
16
- const gated = req.url.startsWith('/api/') || req.url.startsWith('/uploads/') || req.url === '/mcp' || req.url.startsWith('/mcp?');
16
+ const gated = req.url.startsWith('/api/') ||
17
+ req.url.startsWith('/uploads/') ||
18
+ req.url === '/mcp' ||
19
+ req.url.startsWith('/mcp?');
17
20
  if (!gated)
18
21
  return;
19
22
  if (PUBLIC_API_PATHS.some((p) => req.url.startsWith(p)))
package/dist/mcp-tools.js CHANGED
@@ -39,7 +39,7 @@ const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
39
39
  export async function resolveRagDeps() {
40
40
  const db = (await getPluginSettings('claude-agent'));
41
41
  const enabled = db['rag_enabled'] !== false;
42
- const embeddingApiKey = (process.env.OPENAI_API_KEY ?? db['openai_api_key'] ?? '');
42
+ const embeddingApiKey = process.env.OPENAI_API_KEY ?? db['openai_api_key'] ?? '';
43
43
  if (!enabled || !embeddingApiKey)
44
44
  return null;
45
45
  return { embeddingApiKey };
@@ -81,7 +81,7 @@ export async function collectMcpTools(opts = {}) {
81
81
  how_to: `curl -H "Authorization: Bearer <token>" -F "file=@<path>" ${uploadUrl} → {"name":"<name>"}. Then set a file field to {"name":"<name>"}. mime/size are filled in by the server — do not send your own.` +
82
82
  (base
83
83
  ? ''
84
- : ' The server has no public URL configured (core settings publicUrl / PUBLIC_URL env), so the path is relative — resolve it against this MCP server\'s own origin.'),
84
+ : " The server has no public URL configured (core settings publicUrl / PUBLIC_URL env), so the path is relative — resolve it against this MCP server's own origin."),
85
85
  });
86
86
  },
87
87
  });
@@ -328,9 +328,7 @@ export async function buildDomainSections() {
328
328
  const singles = collectSingleShelves();
329
329
  const singleSection = singles.length
330
330
  ? '## Single-record shelves (one document each — read the record, do not search the shelf)\n\n' +
331
- singles
332
- .map((s) => `- ${s.library}/${s.shelf}: ${s.claude.replace(/\s*\n\s*/g, ' ')}`)
333
- .join('\n')
331
+ singles.map((s) => `- ${s.library}/${s.shelf}: ${s.claude.replace(/\s*\n\s*/g, ' ')}`).join('\n')
334
332
  : null;
335
333
  const dataModel = '## Data model\n' +
336
334
  'Library (top-level area) → shelf (a kind of record, e.g. things/item) → record (addressed library/shelf/id) → fields. ' +
package/dist/msg-log.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface MsgLogRow {
7
7
  tokensIn: number | null;
8
8
  tokensOut: number | null;
9
9
  ms: number | null;
10
+ agentId: string | null;
11
+ presetId: string | null;
10
12
  }
11
13
  export interface MsgLogDiagnosticRow {
12
14
  id: number;
package/dist/msg-log.js CHANGED
@@ -1,11 +1,29 @@
1
1
  import { getEm } from "./db.js";
2
2
  export async function logMessage(row) {
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
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [new Date().toISOString(), row.connector, row.chatId, row.userId, row.role, row.text, row.tokensIn, row.tokensOut, row.ms]);
3
+ await getEm()
4
+ .fork()
5
+ .getConnection()
6
+ .execute(`INSERT INTO _orch_msg_log (ts, connector, chat_id, user_id, role, text, tokens_in, tokens_out, ms, agent_id, preset_id)
7
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
8
+ new Date().toISOString(),
9
+ row.connector,
10
+ row.chatId,
11
+ row.userId,
12
+ row.role,
13
+ row.text,
14
+ row.tokensIn,
15
+ row.tokensOut,
16
+ row.ms,
17
+ row.agentId,
18
+ row.presetId,
19
+ ]);
5
20
  }
6
21
  export async function listRecentMessageMetrics(limit = 25) {
7
22
  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
23
+ const rows = (await getEm()
24
+ .fork()
25
+ .getConnection()
26
+ .execute(`SELECT id, ts, connector, role, tokens_in, tokens_out, ms
9
27
  FROM _orch_msg_log
10
28
  ORDER BY id DESC
11
29
  LIMIT ?`, [bounded]));
package/dist/mutate.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { EntityManager } from '@mikro-orm/core';
2
2
  import type { ShelfDef } from '@coffer-org/sdk/shelf';
3
+ import type { LayoutEl } from '@coffer-org/sdk/fields';
3
4
  import { z } from 'zod';
4
- export declare function encodeJson(m: ShelfDef, data: Record<string, unknown>): Record<string, unknown>;
5
+ export declare function encodeJsonAt(fields: LayoutEl[], data: Record<string, unknown>): Record<string, unknown>;
6
+ export declare const encodeJson: (m: ShelfDef, data: Record<string, unknown>) => Record<string, unknown>;
5
7
  export interface MutateCtx {
6
8
  actor: string;
7
9
  }
package/dist/mutate.js CHANGED
@@ -2,6 +2,7 @@ import { serialize } from '@mikro-orm/core';
2
2
  import { buildZodObject, buildZodObjectPartial, fieldEntries } from '@coffer-org/sdk/shelf';
3
3
  import { isJsonStored, decodeVmsg } from '@coffer-org/sdk/fields';
4
4
  import { resolveFlag } from '@coffer-org/sdk/condition';
5
+ import { applyDerived } from '@coffer-org/sdk/derive';
5
6
  import { getEm } from "./db.js";
6
7
  import { selectRows } from "./read-rows.js";
7
8
  import { normalizeFileFields, dropUnchangedFileFields, touchesFileFields } from "./file-fields.js";
@@ -11,15 +12,16 @@ import { splitCollections, writeCollections, deleteCollections, flattenEmbedded,
11
12
  function nowIso() {
12
13
  return new Date().toISOString();
13
14
  }
14
- export function encodeJson(m, data) {
15
+ export function encodeJsonAt(fields, data) {
15
16
  const out = { ...data };
16
- for (const [k, f] of fieldEntries(m.fields)) {
17
+ for (const [k, f] of fieldEntries(fields)) {
17
18
  if (isJsonStored(f) && out[k] !== null && out[k] !== undefined && typeof out[k] === 'object') {
18
19
  out[k] = JSON.stringify(out[k]);
19
20
  }
20
21
  }
21
22
  return out;
22
23
  }
24
+ export const encodeJson = (m, data) => encodeJsonAt(m.fields, data);
23
25
  export class ValidationError extends Error {
24
26
  issues;
25
27
  constructor(issues) {
@@ -86,6 +88,11 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
86
88
  await tx.flush();
87
89
  id = entity.id;
88
90
  await writeCollections(tx, m, id, collections);
91
+ const derived = applyDerived(m.fields, { ...parsedData, id });
92
+ if (Object.keys(derived).length) {
93
+ await tx.nativeUpdate(entityName, { id }, encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived))));
94
+ Object.assign(parsedData, derived);
95
+ }
89
96
  const _extends = afterBase ? await afterBase(tx, id) : undefined;
90
97
  writeEvent(tx, ctx.actor, 'create', `${m.library}/${m.shelf}`, id, null, {
91
98
  ...parsedData,
@@ -119,10 +126,11 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
119
126
  const existingFlat = decodeTemporal(m, serialize(found));
120
127
  const existing = nestEmbedded(m, existingFlat);
121
128
  const ts = nowIso();
122
- const { base, collections } = splitCollections(m, { ...parsed.data });
123
- const fileIssues = normalizeFileFields(m, base);
129
+ const patchData = parsed.data;
130
+ const fileIssues = normalizeFileFields(m, patchData);
124
131
  if (fileIssues.length)
125
132
  throw new ValidationError(fileIssues);
133
+ const { base, collections } = splitCollections(m, { ...patchData });
126
134
  const merged = { ...existing, ...base };
127
135
  const reqIssues = [];
128
136
  for (const [key, f] of fieldEntries(m.fields)) {
@@ -142,6 +150,11 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
142
150
  await tx.upsert(entityName, dbRow);
143
151
  await writeCollections(tx, m, id, collections);
144
152
  const allCollections = await readCollections(tx, m, id);
153
+ const derived = applyDerived(m.fields, { ...merged, ...allCollections });
154
+ if (Object.keys(derived).length) {
155
+ await tx.nativeUpdate(entityName, { id }, encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived))));
156
+ Object.assign(merged, derived);
157
+ }
145
158
  const _extends = afterBase ? await afterBase(tx, id) : undefined;
146
159
  const after = { ...merged, ...allCollections, updated_at: ts, ...(_extends ? { _extends } : {}) };
147
160
  writeEvent(tx, ctx.actor, 'update', `${m.library}/${m.shelf}`, id, existing, after);
package/dist/oauth-api.js CHANGED
@@ -3,10 +3,7 @@ import { resolveRequestUser, startPasswordSession } from "./auth-api.js";
3
3
  import { baseUrl, mcpResource } from "./public-url.js";
4
4
  import { getLogger } from "./log.js";
5
5
  const log = getLogger('oauth');
6
- const DEFAULT_REDIRECT_ALLOW = [
7
- 'https://claude.ai/api/mcp/auth_callback',
8
- 'https://claude.com/api/mcp/auth_callback',
9
- ];
6
+ const DEFAULT_REDIRECT_ALLOW = ['https://claude.ai/api/mcp/auth_callback', 'https://claude.com/api/mcp/auth_callback'];
10
7
  function redirectAllowlist() {
11
8
  const extra = (process.env['OAUTH_REDIRECT_ALLOW'] ?? '')
12
9
  .split(',')
@@ -147,7 +144,10 @@ export function registerOAuthApi(app) {
147
144
  .code(400)
148
145
  .send({ error: 'invalid_redirect_uri', error_description: `redirect_uri not allowed: ${bad}` });
149
146
  }
150
- const client = await registerClient({ clientName: body.client_name?.slice(0, 200) || 'MCP client', redirectUris: uris });
147
+ const client = await registerClient({
148
+ clientName: body.client_name?.slice(0, 200) || 'MCP client',
149
+ redirectUris: uris,
150
+ });
151
151
  log.info(`registered client ${client.clientId} (${client.clientName})`);
152
152
  return reply.code(201).send({
153
153
  client_id: client.clientId,
@@ -160,14 +160,23 @@ export function registerOAuthApi(app) {
160
160
  });
161
161
  async function validateAuthz(p) {
162
162
  if (!p.client_id || !p.redirect_uri) {
163
- return { ok: false, html: page('Invalid request', '<h1>Invalid request</h1><p>Missing client_id or redirect_uri.</p>') };
163
+ return {
164
+ ok: false,
165
+ html: page('Invalid request', '<h1>Invalid request</h1><p>Missing client_id or redirect_uri.</p>'),
166
+ };
164
167
  }
165
168
  const client = await findClient(p.client_id);
166
169
  if (!client) {
167
- return { ok: false, html: page('Unknown client', '<h1>Unknown client</h1><p>This application is not registered.</p>') };
170
+ return {
171
+ ok: false,
172
+ html: page('Unknown client', '<h1>Unknown client</h1><p>This application is not registered.</p>'),
173
+ };
168
174
  }
169
175
  if (!client.redirectUris.includes(p.redirect_uri)) {
170
- return { ok: false, html: page('Invalid redirect', '<h1>Invalid redirect</h1><p>redirect_uri does not match this client.</p>') };
176
+ return {
177
+ ok: false,
178
+ html: page('Invalid redirect', '<h1>Invalid redirect</h1><p>redirect_uri does not match this client.</p>'),
179
+ };
171
180
  }
172
181
  return { ok: true, clientName: client.clientName };
173
182
  }
@@ -197,12 +206,18 @@ export function registerOAuthApi(app) {
197
206
  return reply.code(429).type('text/html').send(page('Slow down', '<h1>Too many attempts</h1>'));
198
207
  const user = await startPasswordSession(reply, String(body['login'] ?? ''), String(body['password'] ?? ''));
199
208
  if (!user)
200
- return reply.code(401).type('text/html').send(loginForm(p, check.clientName, 'Wrong login or password.'));
209
+ return reply
210
+ .code(401)
211
+ .type('text/html')
212
+ .send(loginForm(p, check.clientName, 'Wrong login or password.'));
201
213
  return reply.type('text/html').send(consentForm(p, check.clientName, user));
202
214
  }
203
215
  const user = await resolveRequestUser(req);
204
216
  if (!user)
205
- return reply.code(401).type('text/html').send(loginForm(p, check.clientName, 'Your session expired. Sign in again.'));
217
+ return reply
218
+ .code(401)
219
+ .type('text/html')
220
+ .send(loginForm(p, check.clientName, 'Your session expired. Sign in again.'));
206
221
  if (body['action'] !== 'approve')
207
222
  return redirectWithError(reply, p, 'access_denied');
208
223
  if (!p.code_challenge)
@@ -0,0 +1,6 @@
1
+ export interface PluginI18n {
2
+ lang: string;
3
+ t(key: string, fallback?: string): string;
4
+ }
5
+ export declare function loadPluginI18n(localesDir: URL | string, fallbackLang?: string): Promise<PluginI18n>;
6
+ export declare function clearPluginI18nCache(): void;
@@ -0,0 +1,38 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ const cache = new Map();
3
+ async function systemLanguage(fallback) {
4
+ try {
5
+ const { getPluginSettings } = await import("./plugin-runtime.js");
6
+ return (await getPluginSettings('core'))['language'] || fallback;
7
+ }
8
+ catch {
9
+ return fallback;
10
+ }
11
+ }
12
+ export async function loadPluginI18n(localesDir, fallbackLang = 'uk') {
13
+ const lang = await systemLanguage(fallbackLang);
14
+ const url = new URL(`${lang}.json`, localesDir);
15
+ let dict = cache.get(url.href);
16
+ if (!dict) {
17
+ try {
18
+ dict = JSON.parse(await readFile(url, 'utf8'));
19
+ }
20
+ catch {
21
+ dict = {};
22
+ }
23
+ cache.set(url.href, dict);
24
+ }
25
+ const loaded = dict;
26
+ return {
27
+ lang,
28
+ t(key, fallback) {
29
+ const value = key
30
+ .split('.')
31
+ .reduce((node, part) => (node == null ? undefined : node[part]), loaded);
32
+ return typeof value === 'string' ? value : (fallback ?? key);
33
+ },
34
+ };
35
+ }
36
+ export function clearPluginI18nCache() {
37
+ cache.clear();
38
+ }
@@ -89,7 +89,6 @@ export async function hybridSearch(opts, deps = {}) {
89
89
  score: entry.score,
90
90
  };
91
91
  })
92
- .sort((a, b) => b.score - a.score ||
93
- hitKey(a.shelfKey, a.recordId).localeCompare(hitKey(b.shelfKey, b.recordId)))
92
+ .sort((a, b) => b.score - a.score || hitKey(a.shelfKey, a.recordId).localeCompare(hitKey(b.shelfKey, b.recordId)))
94
93
  .slice(0, opts.k);
95
94
  }
@@ -0,0 +1,6 @@
1
+ import type { ShelfDef } from '@coffer-org/sdk/shelf';
2
+ export interface RecomputeResult {
3
+ scanned: number;
4
+ changed: number;
5
+ }
6
+ export declare function recomputeDerivedFields(m: ShelfDef, entityName: string): Promise<RecomputeResult>;
@@ -0,0 +1,40 @@
1
+ import { derivedEntries, applyDerived } from '@coffer-org/sdk/derive';
2
+ import { getEm } from "./db.js";
3
+ import { selectRows } from "./read-rows.js";
4
+ import { encodeJson } from "./mutate.js";
5
+ import { encodeTemporal, decodeTemporal } from "./temporal.js";
6
+ import { flattenEmbedded, nestEmbedded, readCollections } from "./collection-io.js";
7
+ function sameValue(a, b) {
8
+ if (a instanceof Date && b instanceof Date)
9
+ return a.getTime() === b.getTime();
10
+ if (a instanceof Date || b instanceof Date)
11
+ return false;
12
+ return a === b;
13
+ }
14
+ export async function recomputeDerivedFields(m, entityName) {
15
+ if (derivedEntries(m.fields).length === 0)
16
+ return { scanned: 0, changed: 0 };
17
+ const fork = getEm().fork();
18
+ const rows = await selectRows(fork, entityName, {}, { orderBy: { id: 'asc' } });
19
+ let changed = 0;
20
+ for (const row of rows) {
21
+ const id = row['id'];
22
+ const flat = decodeTemporal(m, row);
23
+ const nested = nestEmbedded(m, flat);
24
+ const collections = await readCollections(fork, m, id);
25
+ const derived = applyDerived(m.fields, { ...nested, ...collections });
26
+ if (Object.keys(derived).length === 0)
27
+ continue;
28
+ const encoded = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived)));
29
+ const dirty = {};
30
+ for (const [k, v] of Object.entries(encoded)) {
31
+ if (!sameValue(row[k], v))
32
+ dirty[k] = v;
33
+ }
34
+ if (Object.keys(dirty).length === 0)
35
+ continue;
36
+ await fork.nativeUpdate(entityName, { id }, dirty);
37
+ changed += 1;
38
+ }
39
+ return { scanned: rows.length, changed };
40
+ }
@@ -116,7 +116,9 @@ export async function recordCount(library, shelf, query = {}, opts = {}) {
116
116
  where.deleted_at = { $ne: null };
117
117
  if (m.standalone === false && Object.keys(filterParams).length === 0)
118
118
  return 0;
119
- return getEm().fork().count(ename, where);
119
+ return getEm()
120
+ .fork()
121
+ .count(ename, where);
120
122
  }
121
123
  async function listRecords(library, shelf, query = {}, opts = {}) {
122
124
  const { view = 'full', extends: withExt = true, deleted = 'active' } = opts;
@@ -57,11 +57,7 @@ export async function upsertSearchRow(shelf, recordId, folded) {
57
57
  await conn.execute('DELETE FROM "_search" WHERE shelf = ? AND record_id = ?', [shelf, recordId]);
58
58
  if (!folded)
59
59
  return;
60
- await conn.execute('INSERT INTO "_search" (shelf, record_id, folded) VALUES (?, ?, ?)', [
61
- shelf,
62
- recordId,
63
- folded,
64
- ]);
60
+ await conn.execute('INSERT INTO "_search" (shelf, record_id, folded) VALUES (?, ?, ?)', [shelf, recordId, folded]);
65
61
  }
66
62
  export async function deleteSearchRow(shelf, recordId) {
67
63
  if (!ftsAvailable())
@@ -81,9 +77,7 @@ export async function ftsCandidates(tokens) {
81
77
  const rows = (await getEm()
82
78
  .fork()
83
79
  .getConnection()
84
- .execute('SELECT shelf, record_id FROM "_search" WHERE "_search" MATCH ?', [
85
- buildFtsMatchQuery(tokens),
86
- ]));
80
+ .execute('SELECT shelf, record_id FROM "_search" WHERE "_search" MATCH ?', [buildFtsMatchQuery(tokens)]));
87
81
  for (const r of rows) {
88
82
  const list = out.get(r.shelf);
89
83
  if (list)
@@ -1,5 +1,8 @@
1
1
  import type { ShelfDef } from '@coffer-org/sdk/shelf';
2
+ import type { LayoutEl } from '@coffer-org/sdk/fields';
2
3
  export declare function dtStringToDate(s: string): Date;
3
4
  export declare function dateToDtString(d: Date): string;
4
- export declare function encodeTemporal(m: ShelfDef, row: Record<string, unknown>): Record<string, unknown>;
5
- export declare function decodeTemporal(m: ShelfDef, row: Record<string, unknown>): Record<string, unknown>;
5
+ export declare function encodeTemporalAt(fields: LayoutEl[], row: Record<string, unknown>): Record<string, unknown>;
6
+ export declare function decodeTemporalAt(fields: LayoutEl[], row: Record<string, unknown>): Record<string, unknown>;
7
+ export declare const encodeTemporal: (m: ShelfDef, row: Record<string, unknown>) => Record<string, unknown>;
8
+ export declare const decodeTemporal: (m: ShelfDef, row: Record<string, unknown>) => Record<string, unknown>;
package/dist/temporal.js CHANGED
@@ -7,22 +7,22 @@ export function dateToDtString(d) {
7
7
  return (`${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}` +
8
8
  `T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`);
9
9
  }
10
- function datetimeKeys(m) {
11
- const fm = fieldMap(m.fields);
10
+ function datetimeKeysAt(fields) {
11
+ const fm = fieldMap(fields);
12
12
  return Object.keys(fm).filter((k) => fm[k]?.column === 'datetime');
13
13
  }
14
- export function encodeTemporal(m, row) {
14
+ export function encodeTemporalAt(fields, row) {
15
15
  const out = { ...row };
16
- for (const k of datetimeKeys(m)) {
16
+ for (const k of datetimeKeysAt(fields)) {
17
17
  const v = out[k];
18
18
  if (typeof v === 'string' && v)
19
19
  out[k] = dtStringToDate(v);
20
20
  }
21
21
  return out;
22
22
  }
23
- export function decodeTemporal(m, row) {
23
+ export function decodeTemporalAt(fields, row) {
24
24
  const out = { ...row };
25
- for (const k of datetimeKeys(m)) {
25
+ for (const k of datetimeKeysAt(fields)) {
26
26
  const v = out[k];
27
27
  if (v instanceof Date)
28
28
  out[k] = dateToDtString(v);
@@ -31,3 +31,5 @@ export function decodeTemporal(m, row) {
31
31
  }
32
32
  return out;
33
33
  }
34
+ export const encodeTemporal = (m, row) => encodeTemporalAt(m.fields, row);
35
+ export const decodeTemporal = (m, row) => decodeTemporalAt(m.fields, row);
@@ -0,0 +1,8 @@
1
+ export interface ThreadSelection {
2
+ agentId: string | null;
3
+ presetId: string | null;
4
+ }
5
+ export declare function getThreadState(connector: string, chatId: string): Promise<ThreadSelection>;
6
+ export declare function setThreadState(connector: string, chatId: string, patch: Partial<ThreadSelection>): Promise<void>;
7
+ export declare function readAndTouchThreadState(connector: string, chatId: string): Promise<ThreadSelection>;
8
+ export declare function pruneThreadState(connector: string, cutoffIso: string): Promise<void>;
@@ -0,0 +1,39 @@
1
+ import { getEm } from "./db.js";
2
+ const EMPTY = { agentId: null, presetId: null };
3
+ export async function getThreadState(connector, chatId) {
4
+ const em = getEm().fork();
5
+ const row = (await em.findOne('_ThreadState', { connector, chat_id: chatId }));
6
+ if (!row)
7
+ return { ...EMPTY };
8
+ return { agentId: row.agent_id, presetId: row.preset_id };
9
+ }
10
+ export async function setThreadState(connector, chatId, patch) {
11
+ const em = getEm().fork();
12
+ const current = await getThreadState(connector, chatId);
13
+ const next = {
14
+ agentId: 'agentId' in patch ? (patch.agentId ?? null) : current.agentId,
15
+ presetId: 'presetId' in patch ? (patch.presetId ?? null) : current.presetId,
16
+ };
17
+ const existing = await em.findOne('_ThreadState', { connector, chat_id: chatId });
18
+ const data = { agent_id: next.agentId, preset_id: next.presetId, updated_at: new Date().toISOString() };
19
+ if (existing) {
20
+ em.assign(existing, data);
21
+ }
22
+ else {
23
+ em.persist(em.create('_ThreadState', { connector, chat_id: chatId, ...data }));
24
+ }
25
+ await em.flush();
26
+ }
27
+ export async function readAndTouchThreadState(connector, chatId) {
28
+ const em = getEm().fork();
29
+ const row = (await em.findOne('_ThreadState', { connector, chat_id: chatId }));
30
+ if (!row)
31
+ return { ...EMPTY };
32
+ em.assign(row, { updated_at: new Date().toISOString() });
33
+ await em.flush();
34
+ return { agentId: row.agent_id, presetId: row.preset_id };
35
+ }
36
+ export async function pruneThreadState(connector, cutoffIso) {
37
+ const em = getEm().fork();
38
+ await em.nativeDelete('_ThreadState', { connector, updated_at: { $lt: cutoffIso } });
39
+ }
@@ -51,7 +51,9 @@ export async function listThreadMessages(connector, chatId, limit = 200) {
51
51
  const em = getEm().fork();
52
52
  const visible = (await em.find('_ThreadMessage', { connector, chat_id: chatId, role: { $ne: 'reasoning' } }, { orderBy: { ts: 'desc', msg_id: 'desc' }, limit }));
53
53
  const oldest = visible.at(-1)?.ts;
54
- const reasoning = oldest === undefined ? [] : (await em.find('_ThreadMessage', { connector, chat_id: chatId, role: 'reasoning', ts: { $gte: oldest - 1 } }, { orderBy: { ts: 'desc', msg_id: 'desc' } }));
54
+ const reasoning = oldest === undefined
55
+ ? []
56
+ : (await em.find('_ThreadMessage', { connector, chat_id: chatId, role: 'reasoning', ts: { $gte: oldest - 1 } }, { orderBy: { ts: 'desc', msg_id: 'desc' } }));
55
57
  return [...visible, ...reasoning]
56
58
  .sort((a, b) => b.ts - a.ts || (a.msg_id < b.msg_id ? 1 : a.msg_id > b.msg_id ? -1 : 0))
57
59
  .reverse()
package/dist/uploads.js CHANGED
@@ -28,7 +28,7 @@ export async function saveUploadBytes(bytes, opts = {}) {
28
28
  if (bytes.byteLength > maxBytes)
29
29
  throw new Error(`upload exceeds ${maxBytes} bytes`);
30
30
  const originalExt = opts.originalName ? extname(basename(opts.originalName)).toLowerCase() : '';
31
- const ext = originalExt && /^[.][a-z0-9]{1,10}$/.test(originalExt) ? originalExt : MIME_EXT[opts.mime ?? ''] ?? '';
31
+ const ext = originalExt && /^[.][a-z0-9]{1,10}$/.test(originalExt) ? originalExt : (MIME_EXT[opts.mime ?? ''] ?? '');
32
32
  const name = `${randomUUID()}${ext}`;
33
33
  await writeFile(join(uploadsDir(), name), bytes);
34
34
  const mime = opts.mime === 'application/octet-stream' ? undefined : opts.mime;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffer-org/server",
3
- "version": "2.6.1",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -24,8 +24,8 @@
24
24
  "postpack": "node ../../scripts/swap-exports.mjs src"
25
25
  },
26
26
  "dependencies": {
27
- "@coffer-org/core": "^2.1.4",
28
- "@coffer-org/sdk": "^2.2.0",
27
+ "@coffer-org/core": "^3.0.0",
28
+ "@coffer-org/sdk": "^3.0.0",
29
29
  "@extractus/oembed-extractor": "^4.1.0",
30
30
  "@fastify/cors": "^11.2.0",
31
31
  "@fastify/multipart": "^10.0.0",