@coffer-org/server 2.5.2 → 2.6.1
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/collection-io.js +3 -4
- package/dist/embeddings.d.ts +4 -1
- package/dist/embeddings.js +6 -5
- package/dist/extend-table.js +4 -5
- package/dist/global-search.js +3 -8
- package/dist/index.js +19 -3
- package/dist/mcp-local.js +3 -1
- package/dist/mcp-tools.d.ts +15 -2
- package/dist/mcp-tools.js +84 -10
- package/dist/mutate.js +4 -7
- package/dist/plugin-runtime.js +3 -5
- package/dist/rag-search.d.ts +40 -0
- package/dist/rag-search.js +95 -0
- package/dist/read-rows.d.ts +8 -0
- package/dist/read-rows.js +11 -0
- package/dist/records-api.d.ts +4 -0
- package/dist/records-api.js +53 -6
- package/package.json +2 -2
package/dist/collection-io.js
CHANGED
|
@@ -2,6 +2,7 @@ import { serialize } from '@mikro-orm/core';
|
|
|
2
2
|
import { collectionGroups, fieldEntries, storageColumns } from '@coffer-org/sdk/shelf';
|
|
3
3
|
import { isGroup, isEmbeddedGroup, isJsonStored } from '@coffer-org/sdk/fields';
|
|
4
4
|
import { chunk } from "./batch.js";
|
|
5
|
+
import { selectRows } from "./read-rows.js";
|
|
5
6
|
function flattenLayout(fields) {
|
|
6
7
|
const out = [];
|
|
7
8
|
for (const f of fields) {
|
|
@@ -68,7 +69,7 @@ export async function readAt(em, prefix, fields, parentId) {
|
|
|
68
69
|
for (const c of collectionGroups(fields)) {
|
|
69
70
|
const table = `${prefix}__${c.key}`;
|
|
70
71
|
const childFields = c.group.fields;
|
|
71
|
-
const rows =
|
|
72
|
+
const rows = (await selectRows(em, table, { parent_id: parentId }, { orderBy: { position: 'asc' } }));
|
|
72
73
|
const acc = [];
|
|
73
74
|
for (const row of rows) {
|
|
74
75
|
const { id, parent_id: _pid, position: _pos, ...rest } = row;
|
|
@@ -90,9 +91,7 @@ export async function readAtMany(em, prefix, fields, parentIds) {
|
|
|
90
91
|
const childFields = c.group.fields;
|
|
91
92
|
const rows = [];
|
|
92
93
|
for (const ids of chunk(parentIds)) {
|
|
93
|
-
rows.push(...
|
|
94
|
-
orderBy: { position: 'asc' },
|
|
95
|
-
})));
|
|
94
|
+
rows.push(...(await selectRows(em, table, { parent_id: { $in: ids } }, { orderBy: { position: 'asc' } })));
|
|
96
95
|
}
|
|
97
96
|
const nestedByChild = await readAtMany(em, table, childFields, rows.map((r) => r.id));
|
|
98
97
|
for (const id of parentIds)
|
package/dist/embeddings.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export declare function cosine(a: ArrayLike<number>, b: ArrayLike<number>): numb
|
|
|
25
25
|
export declare function twoPassSearch(rows: EmbeddingRow[], queryVec: ArrayLike<number>, k: number, opts?: {
|
|
26
26
|
coarseDims?: number;
|
|
27
27
|
poolMinFactor?: number;
|
|
28
|
+
shelfKeys?: Set<string>;
|
|
28
29
|
}): EmbeddingHit[];
|
|
29
30
|
export declare function upsertEmbedding(args: {
|
|
30
31
|
shelfKey: string;
|
|
@@ -34,5 +35,7 @@ export declare function upsertEmbedding(args: {
|
|
|
34
35
|
model: string;
|
|
35
36
|
}): Promise<void>;
|
|
36
37
|
export declare function deleteEmbedding(shelfKey: string, recordId: number): Promise<void>;
|
|
37
|
-
export declare function searchEmbeddings(queryVec: number[], k: number
|
|
38
|
+
export declare function searchEmbeddings(queryVec: number[], k: number, opts?: {
|
|
39
|
+
shelfKeys?: Set<string>;
|
|
40
|
+
}): Promise<EmbeddingHit[]>;
|
|
38
41
|
export declare function listEventsSince(lastId: number, limit: number): Promise<EventRow[]>;
|
package/dist/embeddings.js
CHANGED
|
@@ -59,7 +59,8 @@ export function cosine(a, b) {
|
|
|
59
59
|
return denom ? dot / denom : 0;
|
|
60
60
|
}
|
|
61
61
|
export function twoPassSearch(rows, queryVec, k, opts = {}) {
|
|
62
|
-
const
|
|
62
|
+
const scope = opts.shelfKeys ? rows.filter((r) => opts.shelfKeys.has(r.shelfKey)) : rows;
|
|
63
|
+
const N = scope.length;
|
|
63
64
|
if (N === 0)
|
|
64
65
|
return [];
|
|
65
66
|
const coarseDims = opts.coarseDims ?? COARSE_DIMS;
|
|
@@ -68,13 +69,13 @@ export function twoPassSearch(rows, queryVec, k, opts = {}) {
|
|
|
68
69
|
const m = Math.min(coarseDims, qFull.length);
|
|
69
70
|
const qSmall = qFull.subarray(0, m);
|
|
70
71
|
const poolSize = Math.min(N, Math.max(k * poolMinFactor, Math.ceil(N * 0.1)));
|
|
71
|
-
const pool =
|
|
72
|
+
const pool = scope
|
|
72
73
|
.map((r, i) => ({ i, d: 1 - cosine(r.full.subarray(0, m), qSmall) }))
|
|
73
74
|
.sort((a, b) => a.d - b.d)
|
|
74
75
|
.slice(0, poolSize);
|
|
75
76
|
return pool
|
|
76
77
|
.map(({ i }) => {
|
|
77
|
-
const r =
|
|
78
|
+
const r = scope[i];
|
|
78
79
|
return { shelfKey: r.shelfKey, recordId: r.recordId, snippet: r.snippet, distance: 1 - cosine(r.full, qFull) };
|
|
79
80
|
})
|
|
80
81
|
.sort((a, b) => a.distance - b.distance)
|
|
@@ -99,8 +100,8 @@ export async function deleteEmbedding(shelfKey, recordId) {
|
|
|
99
100
|
await em.nativeDelete('_Embedding', { shelf_key: shelfKey, record_id: recordId });
|
|
100
101
|
invalidateEmbeddingCache();
|
|
101
102
|
}
|
|
102
|
-
export async function searchEmbeddings(queryVec, k) {
|
|
103
|
-
return twoPassSearch(await loadCache(), queryVec, k);
|
|
103
|
+
export async function searchEmbeddings(queryVec, k, opts = {}) {
|
|
104
|
+
return twoPassSearch(await loadCache(), queryVec, k, opts);
|
|
104
105
|
}
|
|
105
106
|
export async function listEventsSince(lastId, limit) {
|
|
106
107
|
const em = getEm().fork();
|
package/dist/extend-table.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import { serialize } from '@mikro-orm/core';
|
|
2
1
|
import { getEm } from "./db.js";
|
|
3
2
|
import { splitAt, writeAt, readAt, readAtMany, deleteAt } from "./collection-io.js";
|
|
4
3
|
import { chunk } from "./batch.js";
|
|
4
|
+
import { selectRows } from "./read-rows.js";
|
|
5
5
|
export function extendEntityName(e) {
|
|
6
6
|
return `extend__${e.id}`;
|
|
7
7
|
}
|
|
8
8
|
export async function getExtendRecord(e, baseId, em) {
|
|
9
9
|
const fork = em ?? getEm().fork();
|
|
10
10
|
const name = extendEntityName(e);
|
|
11
|
-
const
|
|
12
|
-
if (!
|
|
11
|
+
const [flat] = (await selectRows(fork, name, { base_id: baseId }, { limit: 1 }));
|
|
12
|
+
if (!flat)
|
|
13
13
|
return undefined;
|
|
14
|
-
const flat = serialize(row);
|
|
15
14
|
const collections = await readAt(fork, name, e.fields, baseId);
|
|
16
15
|
return { ...flat, ...collections };
|
|
17
16
|
}
|
|
@@ -23,7 +22,7 @@ export async function getExtendRecords(e, baseIds, em) {
|
|
|
23
22
|
const name = extendEntityName(e);
|
|
24
23
|
const flats = [];
|
|
25
24
|
for (const ids of chunk(baseIds)) {
|
|
26
|
-
flats.push(...
|
|
25
|
+
flats.push(...(await selectRows(fork, name, { base_id: { $in: ids } })));
|
|
27
26
|
}
|
|
28
27
|
if (flats.length === 0)
|
|
29
28
|
return out;
|
package/dist/global-search.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { textSearchKeys, titleKey, recordTitle, storageColumnsFor } from '@coffer-org/sdk/shelf';
|
|
2
|
-
import { serialize } from '@mikro-orm/core';
|
|
3
2
|
import { tokenize } from '@coffer-org/core/search';
|
|
4
3
|
import { getLogger } from '@coffer-org/sdk/logger';
|
|
5
4
|
import { getEm } from "./db.js";
|
|
6
5
|
import { rowMatch } from "./records-api.js";
|
|
7
6
|
import { ftsAvailable, ftsCandidates, MIN_FTS_QUERY_LENGTH } from "./search-index.js";
|
|
7
|
+
import { selectRows } from "./read-rows.js";
|
|
8
8
|
const log = getLogger('search');
|
|
9
9
|
export const SEARCH_SCAN_LIMIT = 1000;
|
|
10
10
|
function matchCols(m) {
|
|
@@ -44,10 +44,7 @@ export async function globalSearchByScan(shelves, q, limit) {
|
|
|
44
44
|
continue;
|
|
45
45
|
if (!textSearchKeys(shelf.def).length)
|
|
46
46
|
continue;
|
|
47
|
-
const rows =
|
|
48
|
-
limit: SEARCH_SCAN_LIMIT,
|
|
49
|
-
fields: matchCols(shelf.def),
|
|
50
|
-
})).map((r) => serialize(r));
|
|
47
|
+
const rows = await selectRows(fork, shelf.table, {}, { limit: SEARCH_SCAN_LIMIT, fields: matchCols(shelf.def) });
|
|
51
48
|
if (rows.length === SEARCH_SCAN_LIMIT) {
|
|
52
49
|
log.warn('scan cap reached', { shelf: shelf.key, limit: SEARCH_SCAN_LIMIT });
|
|
53
50
|
}
|
|
@@ -77,9 +74,7 @@ export async function globalSearch(shelves, q, limit) {
|
|
|
77
74
|
continue;
|
|
78
75
|
if (!textSearchKeys(shelf.def).length)
|
|
79
76
|
continue;
|
|
80
|
-
const rows =
|
|
81
|
-
fields: matchCols(shelf.def),
|
|
82
|
-
})).map((r) => serialize(r));
|
|
77
|
+
const rows = await selectRows(fork, shelf.table, { id: { $in: ids } }, { fields: matchCols(shelf.def) });
|
|
83
78
|
scored.push(...scoreRows(shelf, rows, tokens));
|
|
84
79
|
}
|
|
85
80
|
scored.sort((a, b) => b.score - a.score || tieKey(a.result).localeCompare(tieKey(b.result)));
|
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, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, } from "./records-api.js";
|
|
27
|
+
import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, ensureSingle, SingleShelfError, } 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";
|
|
@@ -125,6 +125,8 @@ async function guard(reply, fn) {
|
|
|
125
125
|
catch (e) {
|
|
126
126
|
if (e instanceof ValidationError)
|
|
127
127
|
return reply.code(422).send({ error: 'validation', issues: e.issues });
|
|
128
|
+
if (e instanceof SingleShelfError)
|
|
129
|
+
return reply.code(409).send({ error: 'single_shelf', message: e.message });
|
|
128
130
|
if (e instanceof NotFoundError)
|
|
129
131
|
return reply.code(404).send({ error: 'not_found' });
|
|
130
132
|
app.log.error(e);
|
|
@@ -365,6 +367,11 @@ function maskRecordRow(library, shelf, row) {
|
|
|
365
367
|
}
|
|
366
368
|
return out;
|
|
367
369
|
}
|
|
370
|
+
function listRow(library, shelf, row) {
|
|
371
|
+
const out = maskRecordRow(library, shelf, row);
|
|
372
|
+
const activity = out._activity;
|
|
373
|
+
return activity ? { ...out, _activity: { favorite: activity.favorite === true } } : out;
|
|
374
|
+
}
|
|
368
375
|
app.get('/api/trash', async (_req, reply) => {
|
|
369
376
|
const rows = await recordTrashList();
|
|
370
377
|
return rows.map((entry) => ({ ...entry, record: maskRecordRow(entry.library, entry.shelf, entry.record) }));
|
|
@@ -415,7 +422,7 @@ app.get('/api/:library/:shelf', async (req, reply) => {
|
|
|
415
422
|
let rows = await recordList(library, shelf, query, opts);
|
|
416
423
|
if (req.user && tracksRecordActivity(req.headers))
|
|
417
424
|
rows = await applySmartRanking(req.user.id, library, shelf, rows);
|
|
418
|
-
return rows.map((r) =>
|
|
425
|
+
return rows.map((r) => listRow(library, shelf, r));
|
|
419
426
|
}
|
|
420
427
|
const pageOpts = {
|
|
421
428
|
...opts,
|
|
@@ -431,7 +438,7 @@ app.get('/api/:library/:shelf', async (req, reply) => {
|
|
|
431
438
|
const pageOffset = Math.max(0, Number.isFinite(pageOpts.offset) ? Math.trunc(pageOpts.offset) : 0);
|
|
432
439
|
page = { rows: ranked.slice(pageOffset, pageOffset + pageLimit), total: ranked.length };
|
|
433
440
|
}
|
|
434
|
-
return { rows: page.rows.map((r) =>
|
|
441
|
+
return { rows: page.rows.map((r) => listRow(library, shelf, r)), total: page.total };
|
|
435
442
|
}
|
|
436
443
|
catch (e) {
|
|
437
444
|
if (e instanceof UnknownShelfError)
|
|
@@ -476,6 +483,15 @@ app.patch('/api/:library/:shelf/:id/activity', async (req, reply) => {
|
|
|
476
483
|
throw e;
|
|
477
484
|
}
|
|
478
485
|
});
|
|
486
|
+
app.get('/api/:library/:shelf/single', async (req, reply) => {
|
|
487
|
+
const { library, shelf } = req.params;
|
|
488
|
+
const m = getShelf(library, shelf);
|
|
489
|
+
if (!m)
|
|
490
|
+
return reply.code(404).send({ error: 'unknown_shelf' });
|
|
491
|
+
if (!m.single)
|
|
492
|
+
return reply.code(404).send({ error: 'not_single_shelf' });
|
|
493
|
+
return maskRecordRow(library, shelf, await ensureSingle(library, shelf));
|
|
494
|
+
});
|
|
479
495
|
app.get('/api/:library/:shelf/:id', async (req, reply) => {
|
|
480
496
|
const { library, shelf, id } = req.params;
|
|
481
497
|
const rid = Number(id);
|
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, recordTrashList, recordRestore, recordPurge, } from "./records-api.js";
|
|
2
|
+
import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, recordTrashList, recordRestore, recordPurge, SingleShelfError, } 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) {
|
|
@@ -7,6 +7,8 @@ export function mapError(e) {
|
|
|
7
7
|
throw new ValidationError(e.issues ?? []);
|
|
8
8
|
if (e instanceof UnknownShelfError)
|
|
9
9
|
throw new NotFoundError('not_found');
|
|
10
|
+
if (e instanceof SingleShelfError)
|
|
11
|
+
throw new ValidationError([{ field: 'shelf', code: 'single_shelf', message: e.message }]);
|
|
10
12
|
throw e;
|
|
11
13
|
}
|
|
12
14
|
export function splitListQuery(raw) {
|
package/dist/mcp-tools.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { type ToolResult } from '@coffer-org/mcp';
|
|
4
4
|
import { type AuthRole, type PluginHooks } from './plugin-hooks.ts';
|
|
5
5
|
import { type Condition } from '@coffer-org/sdk/condition';
|
|
6
|
-
import { type
|
|
6
|
+
import { type RagHit } from './rag-search.ts';
|
|
7
7
|
export interface McpToolDef {
|
|
8
8
|
server: string;
|
|
9
9
|
bareName: string;
|
|
@@ -17,7 +17,7 @@ export interface McpToolDef {
|
|
|
17
17
|
export interface RagDeps {
|
|
18
18
|
embeddingApiKey: string;
|
|
19
19
|
}
|
|
20
|
-
export declare function formatHits(hits:
|
|
20
|
+
export declare function formatHits(hits: RagHit[]): string;
|
|
21
21
|
export declare function resolveRagDeps(): Promise<RagDeps | null>;
|
|
22
22
|
export declare function collectMcpTools(opts?: {
|
|
23
23
|
rag?: RagDeps | null;
|
|
@@ -28,6 +28,19 @@ export declare function collectPluginInstructions(hooks?: Record<string, PluginH
|
|
|
28
28
|
id: string;
|
|
29
29
|
instructions: string;
|
|
30
30
|
}[]>;
|
|
31
|
+
type SingleShelf = {
|
|
32
|
+
library: string;
|
|
33
|
+
shelf: string;
|
|
34
|
+
claude: string;
|
|
35
|
+
};
|
|
36
|
+
export declare function collectSingleShelves(reg?: {
|
|
37
|
+
shelves: {
|
|
38
|
+
library: string;
|
|
39
|
+
shelf: string;
|
|
40
|
+
single?: boolean;
|
|
41
|
+
claude?: string;
|
|
42
|
+
}[];
|
|
43
|
+
}): SingleShelf[];
|
|
31
44
|
type LibraryPurpose = {
|
|
32
45
|
id: string;
|
|
33
46
|
agent: string;
|
package/dist/mcp-tools.js
CHANGED
|
@@ -10,7 +10,8 @@ import { countTargetsFor, recordCounts } from "./counts.js";
|
|
|
10
10
|
import { describeCondition } from '@coffer-org/sdk/condition';
|
|
11
11
|
import { getEm } from "./db.js";
|
|
12
12
|
import { getPluginSettings } from "./plugin-runtime.js";
|
|
13
|
-
import {
|
|
13
|
+
import { configuredPublicUrl } from "./public-url.js";
|
|
14
|
+
import { hybridSearch, buildSearchShelves } from "./rag-search.js";
|
|
14
15
|
import { embedOne, embeddingFailureMessage } from "./embed-openai.js";
|
|
15
16
|
import { getLogger } from "./log.js";
|
|
16
17
|
import { writePluginSettings, listSettings } from "./settings-write.js";
|
|
@@ -21,7 +22,14 @@ export function formatHits(hits) {
|
|
|
21
22
|
if (hits.length === 0)
|
|
22
23
|
return 'No matching records.';
|
|
23
24
|
return hits
|
|
24
|
-
.map((h) =>
|
|
25
|
+
.map((h) => {
|
|
26
|
+
const legs = [];
|
|
27
|
+
if (h.distance != null)
|
|
28
|
+
legs.push(`vector ${h.distance.toFixed(3)} #${h.vectorRank}`);
|
|
29
|
+
if (h.textRank != null)
|
|
30
|
+
legs.push(`text #${h.textRank}`);
|
|
31
|
+
return `[${h.shelfKey}/${h.recordId}] (${legs.join(' | ')})\n${h.snippet}`;
|
|
32
|
+
})
|
|
25
33
|
.join('\n\n');
|
|
26
34
|
}
|
|
27
35
|
const ok = (data) => ({
|
|
@@ -64,11 +72,16 @@ export async function collectMcpTools(opts = {}) {
|
|
|
64
72
|
role: 'member',
|
|
65
73
|
handler: async () => {
|
|
66
74
|
const { token, expiresInSec } = mintUploadTicket(actor);
|
|
75
|
+
const base = await configuredPublicUrl();
|
|
76
|
+
const uploadUrl = base ? `${base}/api/upload` : '/api/upload';
|
|
67
77
|
return ok({
|
|
68
78
|
token,
|
|
69
|
-
upload_url:
|
|
79
|
+
upload_url: uploadUrl,
|
|
70
80
|
expires_in: expiresInSec,
|
|
71
|
-
how_to:
|
|
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
|
+
(base
|
|
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.'),
|
|
72
85
|
});
|
|
73
86
|
},
|
|
74
87
|
});
|
|
@@ -132,18 +145,55 @@ export async function collectMcpTools(opts = {}) {
|
|
|
132
145
|
server: 'rag',
|
|
133
146
|
bareName: 'search_records',
|
|
134
147
|
httpName: 'search_records',
|
|
135
|
-
description: "
|
|
136
|
-
|
|
148
|
+
description: "Hybrid search over the user's coffer records: semantic similarity plus exact-token matching. " +
|
|
149
|
+
'Returns the most relevant records as library/shelf/id refs with a text snippet. Narrow it with `library` ' +
|
|
150
|
+
'(and optionally `shelf`) when you already know where the answer lives. An empty result means nothing ' +
|
|
151
|
+
'relevant is stored — say so instead of answering from a weak match. If the first call returns nothing ' +
|
|
152
|
+
'useful, retry with a rephrasing, or with the exact literal string (a model number, a name, an error code): ' +
|
|
153
|
+
'the exact-token leg finds those where a paraphrase cannot.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
query: z.string(),
|
|
156
|
+
k: z.number().int().positive().optional(),
|
|
157
|
+
library: z.string().optional(),
|
|
158
|
+
shelf: z.string().optional(),
|
|
159
|
+
},
|
|
137
160
|
scope: 'rag',
|
|
138
161
|
role: 'member',
|
|
139
162
|
handler: async (args) => {
|
|
163
|
+
const library = args.library;
|
|
164
|
+
const shelf = args.shelf;
|
|
165
|
+
if (shelf && !library) {
|
|
166
|
+
return fail('Error: `shelf` needs `library` (a shelf name is only unique inside its library).');
|
|
167
|
+
}
|
|
168
|
+
if (library) {
|
|
169
|
+
const known = buildSearchShelves({ library, shelf });
|
|
170
|
+
if (known.length === 0) {
|
|
171
|
+
const all = buildSearchShelves();
|
|
172
|
+
const valid = shelf
|
|
173
|
+
? all.filter((s) => s.key.startsWith(`${library}/`)).map((s) => s.key)
|
|
174
|
+
: [...new Set(all.map((s) => s.key.split('/')[0]))];
|
|
175
|
+
return fail(`Error: unknown ${shelf ? 'shelf' : 'library'}. Valid: ${valid.join(', ')}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
let vector = null;
|
|
179
|
+
try {
|
|
180
|
+
vector = (await embedOne(args.query, embeddingApiKey)).vector;
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
log.warn(`embedding failed, text-only search — ${embeddingFailureMessage(e)}`);
|
|
184
|
+
}
|
|
140
185
|
try {
|
|
141
|
-
const
|
|
142
|
-
|
|
186
|
+
const hits = await hybridSearch({
|
|
187
|
+
query: args.query,
|
|
188
|
+
vector,
|
|
189
|
+
k: args.k ?? DEFAULT_RAG_TOP_K,
|
|
190
|
+
library,
|
|
191
|
+
shelf,
|
|
192
|
+
});
|
|
143
193
|
return { content: [{ type: 'text', text: formatHits(hits) }] };
|
|
144
194
|
}
|
|
145
195
|
catch (e) {
|
|
146
|
-
return fail(`
|
|
196
|
+
return fail(`Search unavailable: ${e.message} Use the regular coffer tools (list_records/get_record) instead.`);
|
|
147
197
|
}
|
|
148
198
|
},
|
|
149
199
|
});
|
|
@@ -214,6 +264,22 @@ export async function collectPluginInstructions(hooks = pluginHooks, emFactory =
|
|
|
214
264
|
}
|
|
215
265
|
return out;
|
|
216
266
|
}
|
|
267
|
+
export function collectSingleShelves(reg) {
|
|
268
|
+
let registry = reg;
|
|
269
|
+
if (!registry) {
|
|
270
|
+
try {
|
|
271
|
+
registry = getActiveRegistry();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!registry)
|
|
278
|
+
return [];
|
|
279
|
+
return registry.shelves
|
|
280
|
+
.filter((s) => s.single && s.claude)
|
|
281
|
+
.map((s) => ({ library: s.library, shelf: s.shelf, claude: s.claude }));
|
|
282
|
+
}
|
|
217
283
|
export function collectLibraryPurposes(reg) {
|
|
218
284
|
let registry = reg;
|
|
219
285
|
if (!registry) {
|
|
@@ -259,6 +325,13 @@ export async function buildDomainSections() {
|
|
|
259
325
|
overview =
|
|
260
326
|
'## Libraries (what each holds / when to use it — pick the right one before searching)\n\n' + blocks.join('\n\n');
|
|
261
327
|
}
|
|
328
|
+
const singles = collectSingleShelves();
|
|
329
|
+
const singleSection = singles.length
|
|
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')
|
|
334
|
+
: null;
|
|
262
335
|
const dataModel = '## Data model\n' +
|
|
263
336
|
'Library (top-level area) → shelf (a kind of record, e.g. things/item) → record (addressed library/shelf/id) → fields. ' +
|
|
264
337
|
'Some field values are JSON (e.g. quantity {"value":2000,"unit":"ml"}); some are relations (hold another record\'s id); ' +
|
|
@@ -273,6 +346,7 @@ export async function buildDomainSections() {
|
|
|
273
346
|
return [
|
|
274
347
|
dataModel,
|
|
275
348
|
...(overview ? [overview] : []),
|
|
349
|
+
...(singleSection ? [singleSection] : []),
|
|
276
350
|
...(site ? [`## web\n${site}`] : []),
|
|
277
351
|
...rules,
|
|
278
352
|
];
|
|
@@ -280,7 +354,7 @@ export async function buildDomainSections() {
|
|
|
280
354
|
export function buildMcpInstructions(sections) {
|
|
281
355
|
const base = [
|
|
282
356
|
"Coffer is the user's personal database, organized into libraries (kitchen, people, finance, health, devices, home, garden, documents, travel, and more), each holding typed records.",
|
|
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
|
|
357
|
+
'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 lookup — it searches by meaning and by exact token at once, and takes an optional library/shelf to narrow it. State where each fact came from by citing its record as [library/shelf/id]: an answer assembled from search results without citations cannot be checked against the data.',
|
|
284
358
|
'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.',
|
|
285
359
|
'Record field values may be JSON-encoded (e.g. quantity {"value":2000,"unit":"ml"}) — parse them.',
|
|
286
360
|
'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.',
|
package/dist/mutate.js
CHANGED
|
@@ -3,6 +3,7 @@ import { buildZodObject, buildZodObjectPartial, fieldEntries } from '@coffer-org
|
|
|
3
3
|
import { isJsonStored, decodeVmsg } from '@coffer-org/sdk/fields';
|
|
4
4
|
import { resolveFlag } from '@coffer-org/sdk/condition';
|
|
5
5
|
import { getEm } from "./db.js";
|
|
6
|
+
import { selectRows } from "./read-rows.js";
|
|
6
7
|
import { normalizeFileFields, dropUnchangedFileFields, touchesFileFields } from "./file-fields.js";
|
|
7
8
|
import { notifyRecordsChanged } from "./index-signal.js";
|
|
8
9
|
import { encodeTemporal, decodeTemporal } from "./temporal.js";
|
|
@@ -54,18 +55,14 @@ export async function listRecords(entityName, opts = {}) {
|
|
|
54
55
|
const fork = getEm().fork();
|
|
55
56
|
const deleted = opts.deleted ?? 'active';
|
|
56
57
|
const where = deleted === 'active' ? { deleted_at: null } : deleted === 'deleted' ? { deleted_at: { $ne: null } } : {};
|
|
57
|
-
|
|
58
|
-
return serialize(rows);
|
|
58
|
+
return selectRows(fork, entityName, where, { orderBy: { id: 'asc' } });
|
|
59
59
|
}
|
|
60
60
|
export async function getRecord(m, entityName, id, opts = {}) {
|
|
61
61
|
const fork = getEm().fork();
|
|
62
|
-
const row = await fork
|
|
63
|
-
id,
|
|
64
|
-
...(opts.includeDeleted ? {} : { deleted_at: null }),
|
|
65
|
-
});
|
|
62
|
+
const [row] = await selectRows(fork, entityName, { id, ...(opts.includeDeleted ? {} : { deleted_at: null }) }, { limit: 1 });
|
|
66
63
|
if (!row)
|
|
67
64
|
return undefined;
|
|
68
|
-
const flat = decodeTemporal(m,
|
|
65
|
+
const flat = decodeTemporal(m, row);
|
|
69
66
|
const nested = nestEmbedded(m, flat);
|
|
70
67
|
const collections = await readCollections(fork, m, id);
|
|
71
68
|
return { ...nested, ...collections };
|
package/dist/plugin-runtime.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { serialize } from '@mikro-orm/core';
|
|
2
1
|
import { composeRegistry } from '@coffer-org/core/compose';
|
|
3
2
|
import { getLogger } from '@coffer-org/sdk/logger';
|
|
4
3
|
import { initDb, getOrm, getEm } from "./db.js";
|
|
@@ -6,6 +5,7 @@ import { syncSchema } from "./schema-sync.js";
|
|
|
6
5
|
import { systemEntities, buildPluginEntities, shelfTableName } from "./entity-schema.js";
|
|
7
6
|
import { pluginHooks, pluginCtx, HttpError } from "./plugin-hooks.js";
|
|
8
7
|
import { discoverPlugins, loadServerHooks } from "./plugin-discovery.js";
|
|
8
|
+
import { selectRows } from "./read-rows.js";
|
|
9
9
|
import { runMigrations, assertSafeRequired, renameSystemShelfKey } from "./migrations.js";
|
|
10
10
|
import { runSeeds } from "./seeds.js";
|
|
11
11
|
import { setActiveRegistry } from "./registry-context.js";
|
|
@@ -36,12 +36,10 @@ export function getDisabled() {
|
|
|
36
36
|
export async function getPluginSettings(pluginId) {
|
|
37
37
|
try {
|
|
38
38
|
const fork = getEm().fork();
|
|
39
|
-
const row = await fork
|
|
40
|
-
plugin_id: pluginId,
|
|
41
|
-
});
|
|
39
|
+
const [row] = await selectRows(fork, `_settings__${pluginId}`, { plugin_id: pluginId }, { limit: 1 });
|
|
42
40
|
if (!row)
|
|
43
41
|
return {};
|
|
44
|
-
return
|
|
42
|
+
return row;
|
|
45
43
|
}
|
|
46
44
|
catch {
|
|
47
45
|
return {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type EmbeddingHit } from './embeddings.ts';
|
|
2
|
+
import { type GlobalSearchHit, type SearchShelf } from './global-search.ts';
|
|
3
|
+
export declare const RRF_K = 60;
|
|
4
|
+
export declare const MAX_VECTOR_DISTANCE = 0.85;
|
|
5
|
+
export declare const SEARCH_POOL = 50;
|
|
6
|
+
export interface FusedEntry {
|
|
7
|
+
score: number;
|
|
8
|
+
ranks: (number | null)[];
|
|
9
|
+
}
|
|
10
|
+
export declare function fuseRrf<T>(lists: T[][], keyOf: (item: T) => string, k?: number): Map<string, FusedEntry>;
|
|
11
|
+
export interface RagHit {
|
|
12
|
+
shelfKey: string;
|
|
13
|
+
recordId: number;
|
|
14
|
+
snippet: string;
|
|
15
|
+
distance: number | null;
|
|
16
|
+
vectorRank: number | null;
|
|
17
|
+
textRank: number | null;
|
|
18
|
+
score: number;
|
|
19
|
+
}
|
|
20
|
+
export interface HybridOpts {
|
|
21
|
+
query: string;
|
|
22
|
+
vector: number[] | null;
|
|
23
|
+
k: number;
|
|
24
|
+
library?: string;
|
|
25
|
+
shelf?: string;
|
|
26
|
+
maxDistance?: number;
|
|
27
|
+
rrfK?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface HybridDeps {
|
|
30
|
+
vectorSearch?: (vec: number[], k: number, o?: {
|
|
31
|
+
shelfKeys?: Set<string>;
|
|
32
|
+
}) => Promise<EmbeddingHit[]>;
|
|
33
|
+
textSearch?: (shelves: SearchShelf[], q: string, k: number) => Promise<GlobalSearchHit[]>;
|
|
34
|
+
shelves?: SearchShelf[];
|
|
35
|
+
}
|
|
36
|
+
export declare function buildSearchShelves(filter?: {
|
|
37
|
+
library?: string;
|
|
38
|
+
shelf?: string;
|
|
39
|
+
}): SearchShelf[];
|
|
40
|
+
export declare function hybridSearch(opts: HybridOpts, deps?: HybridDeps): Promise<RagHit[]>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { searchEmbeddings } from "./embeddings.js";
|
|
2
|
+
import { globalSearch } from "./global-search.js";
|
|
3
|
+
import { getActiveRegistry } from "./registry-context.js";
|
|
4
|
+
import { shelfTableName } from "./entity-schema.js";
|
|
5
|
+
import { getLogger } from "./log.js";
|
|
6
|
+
const log = getLogger('rag-search');
|
|
7
|
+
export const RRF_K = 60;
|
|
8
|
+
export const MAX_VECTOR_DISTANCE = 0.85;
|
|
9
|
+
export const SEARCH_POOL = 50;
|
|
10
|
+
export function fuseRrf(lists, keyOf, k = RRF_K) {
|
|
11
|
+
const out = new Map();
|
|
12
|
+
lists.forEach((list, listIndex) => {
|
|
13
|
+
list.forEach((item, i) => {
|
|
14
|
+
const key = keyOf(item);
|
|
15
|
+
let entry = out.get(key);
|
|
16
|
+
if (!entry) {
|
|
17
|
+
entry = { score: 0, ranks: lists.map(() => null) };
|
|
18
|
+
out.set(key, entry);
|
|
19
|
+
}
|
|
20
|
+
if (entry.ranks[listIndex] !== null)
|
|
21
|
+
return;
|
|
22
|
+
entry.ranks[listIndex] = i + 1;
|
|
23
|
+
entry.score += 1 / (k + i + 1);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
export function buildSearchShelves(filter = {}) {
|
|
29
|
+
return getActiveRegistry()
|
|
30
|
+
.shelves.filter((m) => (filter.library ? m.library === filter.library : true))
|
|
31
|
+
.filter((m) => (filter.shelf ? m.shelf === filter.shelf : true))
|
|
32
|
+
.map((m) => ({
|
|
33
|
+
key: `${m.library}/${m.shelf}`,
|
|
34
|
+
table: shelfTableName(m.library, m.shelf),
|
|
35
|
+
def: m,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
const hitKey = (shelfKey, recordId) => `${shelfKey}/${recordId}`;
|
|
39
|
+
async function leg(name, run) {
|
|
40
|
+
try {
|
|
41
|
+
return await run();
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
log.warn(`${name} leg failed, degrading — ${e.message}`);
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function hybridSearch(opts, deps = {}) {
|
|
49
|
+
const vectorSearch = deps.vectorSearch ?? searchEmbeddings;
|
|
50
|
+
const textSearch = deps.textSearch ?? globalSearch;
|
|
51
|
+
const narrowed = Boolean(opts.library || opts.shelf);
|
|
52
|
+
const shelves = deps.shelves
|
|
53
|
+
? deps.shelves
|
|
54
|
+
.filter((s) => (opts.library ? s.key.startsWith(`${opts.library}/`) : true))
|
|
55
|
+
.filter((s) => (opts.shelf ? s.key.endsWith(`/${opts.shelf}`) : true))
|
|
56
|
+
: buildSearchShelves({ library: opts.library, shelf: opts.shelf });
|
|
57
|
+
const shelfKeys = narrowed ? new Set(shelves.map((s) => s.key)) : undefined;
|
|
58
|
+
const maxDistance = opts.maxDistance ?? MAX_VECTOR_DISTANCE;
|
|
59
|
+
const [vectorHits, textHits] = await Promise.all([
|
|
60
|
+
opts.vector
|
|
61
|
+
? leg('vector', () => vectorSearch(opts.vector, SEARCH_POOL, { shelfKeys }))
|
|
62
|
+
: Promise.resolve([]),
|
|
63
|
+
leg('text', () => textSearch(shelves, opts.query, SEARCH_POOL)),
|
|
64
|
+
]);
|
|
65
|
+
const near = vectorHits.filter((h) => h.distance <= maxDistance);
|
|
66
|
+
const keyedText = textHits.map((h) => ({
|
|
67
|
+
shelfKey: `${h.library}/${h.shelf}`,
|
|
68
|
+
recordId: Number(h.id),
|
|
69
|
+
snippet: h.snippet,
|
|
70
|
+
}));
|
|
71
|
+
const fused = fuseRrf([near, keyedText], (h) => hitKey(h.shelfKey, h.recordId), opts.rrfK);
|
|
72
|
+
const snippets = new Map();
|
|
73
|
+
for (const h of keyedText)
|
|
74
|
+
snippets.set(hitKey(h.shelfKey, h.recordId), h.snippet);
|
|
75
|
+
for (const h of near)
|
|
76
|
+
snippets.set(hitKey(h.shelfKey, h.recordId), h.snippet);
|
|
77
|
+
const distances = new Map(near.map((h) => [hitKey(h.shelfKey, h.recordId), h.distance]));
|
|
78
|
+
return [...fused.entries()]
|
|
79
|
+
.map(([key, entry]) => {
|
|
80
|
+
const slash = key.lastIndexOf('/');
|
|
81
|
+
const shelfKey = key.slice(0, slash);
|
|
82
|
+
return {
|
|
83
|
+
shelfKey,
|
|
84
|
+
recordId: Number(key.slice(slash + 1)),
|
|
85
|
+
snippet: snippets.get(key) ?? '',
|
|
86
|
+
distance: distances.get(key) ?? null,
|
|
87
|
+
vectorRank: entry.ranks[0] ?? null,
|
|
88
|
+
textRank: entry.ranks[1] ?? null,
|
|
89
|
+
score: entry.score,
|
|
90
|
+
};
|
|
91
|
+
})
|
|
92
|
+
.sort((a, b) => b.score - a.score ||
|
|
93
|
+
hitKey(a.shelfKey, a.recordId).localeCompare(hitKey(b.shelfKey, b.recordId)))
|
|
94
|
+
.slice(0, opts.k);
|
|
95
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/core';
|
|
2
|
+
export type SelectOpts = {
|
|
3
|
+
fields?: string[];
|
|
4
|
+
orderBy?: Record<string, 'ASC' | 'DESC' | 'asc' | 'desc'>;
|
|
5
|
+
limit?: number;
|
|
6
|
+
offset?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function selectRows(em: EntityManager, entityName: string, where?: Record<string, unknown>, opts?: SelectOpts): Promise<Record<string, unknown>[]>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export async function selectRows(em, entityName, where = {}, opts = {}) {
|
|
2
|
+
const qb = em.createQueryBuilder(entityName);
|
|
3
|
+
qb.select(opts.fields ?? '*').where(where);
|
|
4
|
+
if (opts.orderBy)
|
|
5
|
+
qb.orderBy(opts.orderBy);
|
|
6
|
+
if (opts.limit !== undefined)
|
|
7
|
+
qb.limit(opts.limit, opts.offset);
|
|
8
|
+
else if (opts.offset !== undefined)
|
|
9
|
+
qb.offset(opts.offset);
|
|
10
|
+
return (await qb.execute('all', true));
|
|
11
|
+
}
|
package/dist/records-api.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ShelfDef } from '@coffer-org/sdk/shelf';
|
|
2
2
|
export declare class UnknownShelfError extends Error {
|
|
3
3
|
}
|
|
4
|
+
export declare class SingleShelfError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
4
7
|
export type RecordListQuery = {
|
|
5
8
|
q?: string;
|
|
6
9
|
id?: string;
|
|
@@ -33,6 +36,7 @@ export declare function recordGet(library: string, shelf: string, id: number, op
|
|
|
33
36
|
includeDeleted?: boolean;
|
|
34
37
|
}): Promise<Record<string, unknown> | null>;
|
|
35
38
|
export declare function recordCreate(library: string, shelf: string, body: unknown, actor?: string): Promise<Record<string, unknown>>;
|
|
39
|
+
export declare function ensureSingle(library: string, shelf: string): Promise<Record<string, unknown>>;
|
|
36
40
|
export declare function recordUpdate(library: string, shelf: string, id: number, body: unknown, actor?: string): Promise<Record<string, unknown>>;
|
|
37
41
|
export declare function recordDelete(library: string, shelf: string, id: number, actor?: string): Promise<void>;
|
|
38
42
|
export declare function recordRestore(library: string, shelf: string, id: number, actor?: string): Promise<void>;
|
package/dist/records-api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
import { serialize } from '@mikro-orm/core';
|
|
4
3
|
import { getActiveRegistry, getShelf, getExtendsFor } from "./registry-context.js";
|
|
4
|
+
import { selectRows } from "./read-rows.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";
|
|
@@ -11,11 +11,17 @@ import { createRecord, updateRecord, getRecord, deleteRecord, restoreRecord, pur
|
|
|
11
11
|
import { deleteRecordActivity } from "./record-activity.js";
|
|
12
12
|
export class UnknownShelfError extends Error {
|
|
13
13
|
}
|
|
14
|
+
export class SingleShelfError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'SingleShelfError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
14
20
|
export const MAX_PAGE = 500;
|
|
15
21
|
function pickCols(row, cols) {
|
|
16
22
|
const out = {};
|
|
17
23
|
for (const c of cols)
|
|
18
|
-
if (c in row)
|
|
24
|
+
if (c in row && row[c] !== null)
|
|
19
25
|
out[c] = row[c];
|
|
20
26
|
return out;
|
|
21
27
|
}
|
|
@@ -112,7 +118,7 @@ export async function recordCount(library, shelf, query = {}, opts = {}) {
|
|
|
112
118
|
return 0;
|
|
113
119
|
return getEm().fork().count(ename, where);
|
|
114
120
|
}
|
|
115
|
-
|
|
121
|
+
async function listRecords(library, shelf, query = {}, opts = {}) {
|
|
116
122
|
const { view = 'full', extends: withExt = true, deleted = 'active' } = opts;
|
|
117
123
|
const { m, ename } = resolve(library, shelf);
|
|
118
124
|
const { q, ...filterParams } = query;
|
|
@@ -137,7 +143,7 @@ export async function recordList(library, shelf, query = {}, opts = {}) {
|
|
|
137
143
|
...(opts.orderBy ? { orderBy: opts.orderBy } : {}),
|
|
138
144
|
...(sqlSlice ? { limit: opts.limit, offset: Math.max(0, opts.offset ?? 0) } : {}),
|
|
139
145
|
};
|
|
140
|
-
let rows =
|
|
146
|
+
let rows = await selectRows(fork, ename, where, findOpts);
|
|
141
147
|
if (tokens.length > 0)
|
|
142
148
|
rows = rows.filter((row) => rowMatch(m, row, tokens) !== null);
|
|
143
149
|
if (projected)
|
|
@@ -147,6 +153,12 @@ export async function recordList(library, shelf, query = {}, opts = {}) {
|
|
|
147
153
|
return decoded;
|
|
148
154
|
return withExtendsMany(decoded, library, shelf);
|
|
149
155
|
}
|
|
156
|
+
export async function recordList(library, shelf, query = {}, opts = {}) {
|
|
157
|
+
const { m } = resolve(library, shelf);
|
|
158
|
+
if (m.single)
|
|
159
|
+
await ensureSingle(library, shelf);
|
|
160
|
+
return listRecords(library, shelf, query, opts);
|
|
161
|
+
}
|
|
150
162
|
export function isPagedRequest(limit, offset) {
|
|
151
163
|
return [limit, offset].some((v) => v !== undefined && v.trim() !== '' && Number.isFinite(Number(v)));
|
|
152
164
|
}
|
|
@@ -179,7 +191,7 @@ export async function recordGet(library, shelf, id, opts = {}) {
|
|
|
179
191
|
return null;
|
|
180
192
|
return withExtends(row, library, shelf);
|
|
181
193
|
}
|
|
182
|
-
|
|
194
|
+
async function createOne(library, shelf, body, actor = 'gui') {
|
|
183
195
|
const { m, ename } = resolve(library, shelf);
|
|
184
196
|
const { base, extData } = splitBody(body);
|
|
185
197
|
validateExtends(library, shelf, extData);
|
|
@@ -189,6 +201,37 @@ export async function recordCreate(library, shelf, body, actor = 'gui') {
|
|
|
189
201
|
});
|
|
190
202
|
return withExtends(row, library, shelf);
|
|
191
203
|
}
|
|
204
|
+
export async function recordCreate(library, shelf, body, actor = 'gui') {
|
|
205
|
+
const { m } = resolve(library, shelf);
|
|
206
|
+
if (m.single) {
|
|
207
|
+
await ensureSingle(library, shelf);
|
|
208
|
+
throw new SingleShelfError(`single_shelf ${library}/${shelf}: the record already exists — update it instead`);
|
|
209
|
+
}
|
|
210
|
+
return createOne(library, shelf, body, actor);
|
|
211
|
+
}
|
|
212
|
+
const singleInFlight = new Map();
|
|
213
|
+
export async function ensureSingle(library, shelf) {
|
|
214
|
+
const { m } = resolve(library, shelf);
|
|
215
|
+
if (!m.single)
|
|
216
|
+
throw new Error(`not a single shelf: ${library}/${shelf}`);
|
|
217
|
+
const key = `${library}/${shelf}`;
|
|
218
|
+
const pending = singleInFlight.get(key);
|
|
219
|
+
if (pending)
|
|
220
|
+
return pending;
|
|
221
|
+
const task = (async () => {
|
|
222
|
+
const rows = await listRecords(library, shelf, {}, { limit: 1, orderBy: { id: 'ASC' } });
|
|
223
|
+
if (rows[0])
|
|
224
|
+
return rows[0];
|
|
225
|
+
return createOne(library, shelf, {}, 'system');
|
|
226
|
+
})();
|
|
227
|
+
singleInFlight.set(key, task);
|
|
228
|
+
try {
|
|
229
|
+
return await task;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
singleInFlight.delete(key);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
192
235
|
export async function recordUpdate(library, shelf, id, body, actor = 'gui') {
|
|
193
236
|
const { m, ename } = resolve(library, shelf);
|
|
194
237
|
const { base, extData } = splitBody(body);
|
|
@@ -201,6 +244,8 @@ export async function recordUpdate(library, shelf, id, body, actor = 'gui') {
|
|
|
201
244
|
}
|
|
202
245
|
export async function recordDelete(library, shelf, id, actor = 'gui') {
|
|
203
246
|
const { m, ename } = resolve(library, shelf);
|
|
247
|
+
if (m.single)
|
|
248
|
+
throw new SingleShelfError(`single_shelf ${library}/${shelf}: the record cannot be deleted — clear its fields instead`);
|
|
204
249
|
await deleteRecord(m, ename, id, { actor });
|
|
205
250
|
}
|
|
206
251
|
export async function recordRestore(library, shelf, id, actor = 'gui') {
|
|
@@ -209,6 +254,8 @@ export async function recordRestore(library, shelf, id, actor = 'gui') {
|
|
|
209
254
|
}
|
|
210
255
|
export async function recordPurge(library, shelf, id, actor = 'gui') {
|
|
211
256
|
const { m, ename } = resolve(library, shelf);
|
|
257
|
+
if (m.single)
|
|
258
|
+
throw new SingleShelfError(`single_shelf ${library}/${shelf}: the record cannot be deleted — clear its fields instead`);
|
|
212
259
|
await purgeRecord(m, ename, id, { actor }, (tx, recordId) => deleteExtends(tx, library, shelf, recordId));
|
|
213
260
|
await deleteRecordActivity(library, shelf, id);
|
|
214
261
|
}
|
|
@@ -216,7 +263,7 @@ export async function recordTrashList() {
|
|
|
216
263
|
const shelves = getActiveRegistry().shelves.filter((s) => s.standalone !== false);
|
|
217
264
|
const out = [];
|
|
218
265
|
for (const m of shelves) {
|
|
219
|
-
const rows = await
|
|
266
|
+
const rows = await listRecords(m.library, m.shelf, {}, { view: 'full', extends: true, deleted: 'deleted' });
|
|
220
267
|
for (const record of rows)
|
|
221
268
|
out.push({ library: m.library, shelf: m.shelf, label: m.label, record });
|
|
222
269
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffer-org/server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@coffer-org/core": "^2.1.4",
|
|
28
|
-
"@coffer-org/sdk": "^2.
|
|
28
|
+
"@coffer-org/sdk": "^2.2.0",
|
|
29
29
|
"@extractus/oembed-extractor": "^4.1.0",
|
|
30
30
|
"@fastify/cors": "^11.2.0",
|
|
31
31
|
"@fastify/multipart": "^10.0.0",
|