@coffer-org/server 3.0.0 → 3.2.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/condition-where.d.ts +6 -0
- package/dist/condition-where.js +92 -0
- package/dist/index.js +50 -0
- package/dist/mcp-http.test-helpers.d.ts +1 -0
- package/dist/mcp-http.test-helpers.js +8 -0
- package/dist/records-api.d.ts +2 -1
- package/dist/records-api.js +2 -2
- package/dist/showcase-api.d.ts +21 -0
- package/dist/showcase-api.js +30 -0
- package/package.json +3 -3
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Condition } from '@coffer-org/sdk/condition';
|
|
2
|
+
import type { ShelfDef } from '@coffer-org/sdk/shelf';
|
|
3
|
+
export declare class ConditionCompileError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function conditionToWhere(m: ShelfDef, cond: Condition): Record<string, unknown>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { COMPILABLE_SCALAR_OPS as SCALAR_OPS, COMPILABLE_ARRAY_OPS as ARRAY_OPS, COMPILABLE_MEMBER_OPS as MEMBER_OPS, } from '@coffer-org/sdk/condition';
|
|
2
|
+
import { fieldMap } from '@coffer-org/sdk/shelf';
|
|
3
|
+
import { isJsonStored } from '@coffer-org/sdk/fields';
|
|
4
|
+
import { coerceFilter } from "./records-api.js";
|
|
5
|
+
export class ConditionCompileError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ConditionCompileError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const coerce = (f, v) => typeof v === 'boolean' ? v : coerceFilter(String(v), f.column);
|
|
12
|
+
function memberOperator(pred, key, where) {
|
|
13
|
+
if (typeof pred !== 'object' || pred === null)
|
|
14
|
+
return undefined;
|
|
15
|
+
const ops = Object.keys(pred);
|
|
16
|
+
const member = ops.filter((op) => MEMBER_OPS.has(op));
|
|
17
|
+
if (!member.length)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (ops.length > 1) {
|
|
20
|
+
throw new ConditionCompileError(`${where}: '${key}' mixes '${member[0]}' with other operators`);
|
|
21
|
+
}
|
|
22
|
+
return member[0];
|
|
23
|
+
}
|
|
24
|
+
function compileMember(key, operand, where) {
|
|
25
|
+
if (typeof operand === 'object' && operand !== null) {
|
|
26
|
+
throw new ConditionCompileError(`${where}: '${key}' $contains expects a single scalar element`);
|
|
27
|
+
}
|
|
28
|
+
const token = String(operand);
|
|
29
|
+
if (/[%_\\]/.test(token)) {
|
|
30
|
+
throw new ConditionCompileError(`${where}: '${key}' $contains operand '${token}' contains a LIKE metacharacter`);
|
|
31
|
+
}
|
|
32
|
+
return { $like: `%${JSON.stringify(token)}%` };
|
|
33
|
+
}
|
|
34
|
+
function compilePredicate(key, f, pred, where) {
|
|
35
|
+
if (typeof pred !== 'object' || pred === null)
|
|
36
|
+
return coerce(f, pred);
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [op, operand] of Object.entries(pred)) {
|
|
39
|
+
if (operand === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
if (ARRAY_OPS.has(op)) {
|
|
42
|
+
if (!Array.isArray(operand))
|
|
43
|
+
throw new ConditionCompileError(`${where}: '${key}' ${op} expects an array`);
|
|
44
|
+
out[op] = operand.map((v) => coerce(f, v));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!SCALAR_OPS.has(op)) {
|
|
48
|
+
throw new ConditionCompileError(`${where}: operator '${op}' on '${key}' does not compile to SQL`);
|
|
49
|
+
}
|
|
50
|
+
out[op] = coerce(f, operand);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
export function conditionToWhere(m, cond) {
|
|
55
|
+
const fm = fieldMap(m.fields);
|
|
56
|
+
const where = `${m.library}/${m.shelf}`;
|
|
57
|
+
const walk = (c) => {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const [key, spec] of Object.entries(c)) {
|
|
60
|
+
if (spec === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
if (key === '$and' || key === '$or') {
|
|
63
|
+
if (!Array.isArray(spec))
|
|
64
|
+
throw new ConditionCompileError(`${where}: '${key}' expects an array`);
|
|
65
|
+
out[key] = spec.map(walk);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const f = fm[key];
|
|
69
|
+
if (!f)
|
|
70
|
+
throw new ConditionCompileError(`${where}: unknown field '${key}'`);
|
|
71
|
+
if (f.virtual)
|
|
72
|
+
throw new ConditionCompileError(`${where}: '${key}' is virtual and has no column`);
|
|
73
|
+
const memberOp = memberOperator(spec, key, where);
|
|
74
|
+
if (memberOp && !f.hints['multiple']) {
|
|
75
|
+
throw new ConditionCompileError(`${where}: '${key}' is not a multiple field — '${memberOp}' does not apply`);
|
|
76
|
+
}
|
|
77
|
+
if (isJsonStored(f)) {
|
|
78
|
+
if (!memberOp) {
|
|
79
|
+
throw new ConditionCompileError(`${where}: '${key}' is stored as JSON — only ${[...MEMBER_OPS].join('/')} applies`);
|
|
80
|
+
}
|
|
81
|
+
out[key] = compileMember(key, spec[memberOp], where);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (f.columns) {
|
|
85
|
+
throw new ConditionCompileError(`${where}: '${key}' is a composite scalar stored across sub-columns — scalar operators do not apply`);
|
|
86
|
+
}
|
|
87
|
+
out[key] = compilePredicate(key, f, spec, where);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|
|
91
|
+
return walk(cond);
|
|
92
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,8 @@ 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
27
|
import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, ensureSingle, SingleShelfError, } from "./records-api.js";
|
|
28
|
+
import { showcaseList, showcaseListAll, showcaseCount, UnknownShowcaseError } from "./showcase-api.js";
|
|
29
|
+
import { getActiveRegistry } from "./registry-context.js";
|
|
28
30
|
import { maskTree, preserveTree } from "./field-masking.js";
|
|
29
31
|
import { writePluginSettings } from "./settings-write.js";
|
|
30
32
|
import { rootLogger, getLogger } from "./log.js";
|
|
@@ -446,6 +448,54 @@ app.get('/api/:library/:shelf', async (req, reply) => {
|
|
|
446
448
|
throw e;
|
|
447
449
|
}
|
|
448
450
|
});
|
|
451
|
+
app.get('/api/showcase/:library/:id', async (req, reply) => {
|
|
452
|
+
const { library, id } = req.params;
|
|
453
|
+
const { q, limit, offset } = req.query;
|
|
454
|
+
const paged = isPagedRequest(limit, offset);
|
|
455
|
+
try {
|
|
456
|
+
const s = getActiveRegistry().getShowcase(library, id);
|
|
457
|
+
if (!s)
|
|
458
|
+
return reply.code(404).send({ error: 'unknown_showcase' });
|
|
459
|
+
const { library: srcLibrary, shelf: srcShelf } = s.source;
|
|
460
|
+
if (!paged) {
|
|
461
|
+
let rows = await showcaseListAll(library, id, { q });
|
|
462
|
+
if (req.user && tracksRecordActivity(req.headers)) {
|
|
463
|
+
rows = await applySmartRanking(req.user.id, srcLibrary, srcShelf, rows);
|
|
464
|
+
}
|
|
465
|
+
return { rows: rows.map((r) => listRow(srcLibrary, srcShelf, r)), total: rows.length };
|
|
466
|
+
}
|
|
467
|
+
const pageOpts = {
|
|
468
|
+
limit: limit === undefined ? undefined : Number(limit),
|
|
469
|
+
offset: offset === undefined ? undefined : Number(offset),
|
|
470
|
+
};
|
|
471
|
+
let page = await showcaseList(library, id, { q }, pageOpts);
|
|
472
|
+
if (req.user && tracksRecordActivity(req.headers)) {
|
|
473
|
+
const allRows = await showcaseListAll(library, id, { q });
|
|
474
|
+
const ranked = await applySmartRanking(req.user.id, srcLibrary, srcShelf, allRows);
|
|
475
|
+
const rawLimit = Number.isFinite(pageOpts.limit) ? Math.trunc(pageOpts.limit) : MAX_PAGE;
|
|
476
|
+
const pageLimit = Math.min(Math.max(1, rawLimit), MAX_PAGE);
|
|
477
|
+
const pageOffset = Math.max(0, Number.isFinite(pageOpts.offset) ? Math.trunc(pageOpts.offset) : 0);
|
|
478
|
+
page = { rows: ranked.slice(pageOffset, pageOffset + pageLimit), total: ranked.length };
|
|
479
|
+
}
|
|
480
|
+
return { rows: page.rows.map((r) => listRow(srcLibrary, srcShelf, r)), total: page.total };
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
if (e instanceof UnknownShowcaseError)
|
|
484
|
+
return reply.code(404).send({ error: 'unknown_showcase' });
|
|
485
|
+
throw e;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
app.get('/api/showcase/:library/:id/count', async (req, reply) => {
|
|
489
|
+
const { library, id } = req.params;
|
|
490
|
+
try {
|
|
491
|
+
return { total: await showcaseCount(library, id) };
|
|
492
|
+
}
|
|
493
|
+
catch (e) {
|
|
494
|
+
if (e instanceof UnknownShowcaseError)
|
|
495
|
+
return reply.code(404).send({ error: 'unknown_showcase' });
|
|
496
|
+
throw e;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
449
499
|
app.get('/api/:library/:shelf/:id/activity', async (req, reply) => {
|
|
450
500
|
const { library, shelf, id } = req.params;
|
|
451
501
|
const rid = Number(id);
|
|
@@ -7,11 +7,19 @@ import { createUser, createApiToken } from "./auth-store.js";
|
|
|
7
7
|
import { pluginHooks } from "./plugin-hooks.js";
|
|
8
8
|
let currentAdminToken;
|
|
9
9
|
let currentMemberToken;
|
|
10
|
+
export function completeInjectedSocket(app) {
|
|
11
|
+
app.addHook('onRequest', async (req) => {
|
|
12
|
+
const socket = req.raw.socket;
|
|
13
|
+
if (socket && typeof socket.destroySoon !== 'function')
|
|
14
|
+
socket.destroySoon = () => { };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
10
17
|
export async function freshMcpApp(opts = {}) {
|
|
11
18
|
process.env['DB_PATH'] = ':memory:';
|
|
12
19
|
const orm = await initDb(systemEntities);
|
|
13
20
|
await orm.schema.update({ safe: false, dropTables: false });
|
|
14
21
|
const app = Fastify();
|
|
22
|
+
completeInjectedSocket(app);
|
|
15
23
|
app.addHook('onRequest', async (req, reply) => {
|
|
16
24
|
const gated = req.url.startsWith('/api/') ||
|
|
17
25
|
req.url.startsWith('/uploads/') ||
|
package/dist/records-api.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type RecordListOpts = {
|
|
|
16
16
|
offset?: number;
|
|
17
17
|
orderBy?: Record<string, 'ASC' | 'DESC'>;
|
|
18
18
|
deleted?: 'active' | 'deleted' | 'all';
|
|
19
|
+
where?: Record<string, unknown>;
|
|
19
20
|
};
|
|
20
21
|
export declare const MAX_PAGE = 500;
|
|
21
22
|
export declare function coerceFilter(v: string, column: string): unknown;
|
|
@@ -25,7 +26,7 @@ export declare function rowMatch(mdef: ShelfDef, row: Record<string, unknown>, t
|
|
|
25
26
|
score: number;
|
|
26
27
|
snippet: string;
|
|
27
28
|
} | null;
|
|
28
|
-
export declare function recordCount(library: string, shelf: string, query?: RecordListQuery, opts?: Pick<RecordListOpts, 'deleted'>): Promise<number>;
|
|
29
|
+
export declare function recordCount(library: string, shelf: string, query?: RecordListQuery, opts?: Pick<RecordListOpts, 'deleted' | 'where'>): Promise<number>;
|
|
29
30
|
export declare function recordList(library: string, shelf: string, query?: RecordListQuery, opts?: RecordListOpts): Promise<Record<string, unknown>[]>;
|
|
30
31
|
export declare function isPagedRequest(limit?: string, offset?: string): boolean;
|
|
31
32
|
export declare function recordListPage(library: string, shelf: string, query?: RecordListQuery, opts?: RecordListOpts): Promise<{
|
package/dist/records-api.js
CHANGED
|
@@ -108,7 +108,7 @@ function buildWhere(m, filterParams) {
|
|
|
108
108
|
export async function recordCount(library, shelf, query = {}, opts = {}) {
|
|
109
109
|
const { m, ename } = resolve(library, shelf);
|
|
110
110
|
const { q: _q, ...filterParams } = query;
|
|
111
|
-
const where = buildWhere(m, filterParams);
|
|
111
|
+
const where = { ...buildWhere(m, filterParams), ...(opts.where ?? {}) };
|
|
112
112
|
const deleted = opts.deleted ?? 'active';
|
|
113
113
|
if (deleted === 'active')
|
|
114
114
|
where.deleted_at = null;
|
|
@@ -124,7 +124,7 @@ async function listRecords(library, shelf, query = {}, opts = {}) {
|
|
|
124
124
|
const { view = 'full', extends: withExt = true, deleted = 'active' } = opts;
|
|
125
125
|
const { m, ename } = resolve(library, shelf);
|
|
126
126
|
const { q, ...filterParams } = query;
|
|
127
|
-
const where = buildWhere(m, filterParams);
|
|
127
|
+
const where = { ...buildWhere(m, filterParams), ...(opts.where ?? {}) };
|
|
128
128
|
if (deleted === 'active')
|
|
129
129
|
where.deleted_at = null;
|
|
130
130
|
else if (deleted === 'deleted')
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare class UnknownShowcaseError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function resolveShowcase(library: string, id: string): {
|
|
5
|
+
s: import("@coffer-org/sdk/showcase").ShowcaseDef;
|
|
6
|
+
m: import("@coffer-org/sdk/shelf").ShelfDef;
|
|
7
|
+
where: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
export declare function showcaseList(library: string, id: string, query?: {
|
|
10
|
+
q?: string;
|
|
11
|
+
}, opts?: {
|
|
12
|
+
limit?: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
rows: Record<string, unknown>[];
|
|
16
|
+
total: number;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function showcaseListAll(library: string, id: string, query?: {
|
|
19
|
+
q?: string;
|
|
20
|
+
}): Promise<Record<string, unknown>[]>;
|
|
21
|
+
export declare function showcaseCount(library: string, id: string): Promise<number>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getActiveRegistry, getShelf } from "./registry-context.js";
|
|
2
|
+
import { conditionToWhere } from "./condition-where.js";
|
|
3
|
+
import { recordList, recordListPage, recordCount, UnknownShelfError } from "./records-api.js";
|
|
4
|
+
export class UnknownShowcaseError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'UnknownShowcaseError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function resolveShowcase(library, id) {
|
|
11
|
+
const s = getActiveRegistry().getShowcase(library, id);
|
|
12
|
+
if (!s)
|
|
13
|
+
throw new UnknownShowcaseError(`unknown_showcase ${library}/${id}`);
|
|
14
|
+
const m = getShelf(s.source.library, s.source.shelf);
|
|
15
|
+
if (!m)
|
|
16
|
+
throw new UnknownShelfError(`unknown_shelf ${s.source.library}/${s.source.shelf}`);
|
|
17
|
+
return { s, m, where: conditionToWhere(m, s.filter) };
|
|
18
|
+
}
|
|
19
|
+
export async function showcaseList(library, id, query = {}, opts = {}) {
|
|
20
|
+
const { s, where } = resolveShowcase(library, id);
|
|
21
|
+
return recordListPage(s.source.library, s.source.shelf, { q: query.q }, { ...opts, view: 'list', extends: false, where });
|
|
22
|
+
}
|
|
23
|
+
export async function showcaseListAll(library, id, query = {}) {
|
|
24
|
+
const { s, where } = resolveShowcase(library, id);
|
|
25
|
+
return recordList(s.source.library, s.source.shelf, { q: query.q }, { view: 'list', extends: false, where });
|
|
26
|
+
}
|
|
27
|
+
export async function showcaseCount(library, id) {
|
|
28
|
+
const { s, where } = resolveShowcase(library, id);
|
|
29
|
+
return recordCount(s.source.library, s.source.shelf, {}, { where });
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffer-org/server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.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": "^3.
|
|
28
|
-
"@coffer-org/sdk": "^3.
|
|
27
|
+
"@coffer-org/core": "^3.2.0",
|
|
28
|
+
"@coffer-org/sdk": "^3.2.0",
|
|
29
29
|
"@extractus/oembed-extractor": "^4.1.0",
|
|
30
30
|
"@fastify/cors": "^11.2.0",
|
|
31
31
|
"@fastify/multipart": "^10.0.0",
|