@coffer-org/server 1.14.0 → 2.0.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/counts.d.ts +6 -0
- package/dist/counts.js +31 -0
- package/dist/db.js +2 -0
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +6 -6
- package/dist/entity-schema.js +1 -1
- package/dist/file-fields.d.ts +6 -0
- package/dist/file-fields.js +121 -0
- package/dist/frontend-agent.d.ts +1 -0
- package/dist/frontend-agent.js +37 -0
- package/dist/global-search.d.ts +16 -0
- package/dist/global-search.js +87 -0
- package/dist/index-signal.d.ts +3 -2
- package/dist/index-signal.js +22 -8
- package/dist/index.js +78 -103
- package/dist/local-api.d.ts +5 -5
- package/dist/local-api.js +9 -9
- package/dist/mcp-http.js +16 -1
- package/dist/mcp-local.d.ts +5 -5
- package/dist/mcp-local.js +12 -12
- package/dist/mcp-tools.js +21 -10
- package/dist/migrations.d.ts +1 -0
- package/dist/migrations.js +23 -0
- package/dist/mutate.d.ts +1 -0
- package/dist/mutate.js +18 -2
- package/dist/plugin-hooks.d.ts +13 -0
- package/dist/plugin-runtime.d.ts +1 -0
- package/dist/plugin-runtime.js +24 -1
- package/dist/plugin-user-api.d.ts +4 -0
- package/dist/plugin-user-api.js +92 -0
- package/dist/public-url.d.ts +1 -0
- package/dist/public-url.js +4 -1
- package/dist/records-api.d.ts +16 -6
- package/dist/records-api.js +76 -32
- package/dist/search-index.d.ts +10 -0
- package/dist/search-index.js +100 -0
- package/dist/search-indexer.d.ts +4 -0
- package/dist/search-indexer.js +88 -0
- package/dist/thread-store.d.ts +10 -2
- package/dist/thread-store.js +40 -0
- package/package.json +3 -3
package/dist/mutate.js
CHANGED
|
@@ -3,13 +3,14 @@ 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 { normalizeFileFields, dropUnchangedFileFields, touchesFileFields } from "./file-fields.js";
|
|
6
7
|
import { notifyRecordsChanged } from "./index-signal.js";
|
|
7
8
|
import { encodeTemporal, decodeTemporal } from "./temporal.js";
|
|
8
9
|
import { splitCollections, writeCollections, deleteCollections, flattenEmbedded, nestEmbedded, readCollections, } from "./collection-io.js";
|
|
9
10
|
function nowIso() {
|
|
10
11
|
return new Date().toISOString();
|
|
11
12
|
}
|
|
12
|
-
function encodeJson(m, data) {
|
|
13
|
+
export function encodeJson(m, data) {
|
|
13
14
|
const out = { ...data };
|
|
14
15
|
for (const [k, f] of fieldEntries(m.fields)) {
|
|
15
16
|
if (isJsonStored(f) && out[k] !== null && out[k] !== undefined && typeof out[k] === 'object') {
|
|
@@ -70,6 +71,9 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
|
70
71
|
throw new ValidationError(parsed.error.issues.map(toIssue));
|
|
71
72
|
const ts = nowIso();
|
|
72
73
|
const parsedData = parsed.data;
|
|
74
|
+
const fileIssues = normalizeFileFields(m, parsedData);
|
|
75
|
+
if (fileIssues.length)
|
|
76
|
+
throw new ValidationError(fileIssues);
|
|
73
77
|
const { base, collections } = splitCollections(m, { ...parsedData });
|
|
74
78
|
const data = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, { ...base, created_at: ts, updated_at: ts })));
|
|
75
79
|
let id;
|
|
@@ -91,7 +95,16 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
|
91
95
|
return { ...parsedData, id, created_at: ts, updated_at: ts };
|
|
92
96
|
}
|
|
93
97
|
export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
94
|
-
|
|
98
|
+
let patch = input;
|
|
99
|
+
if (touchesFileFields(m, input)) {
|
|
100
|
+
const prior = await getEm()
|
|
101
|
+
.fork()
|
|
102
|
+
.findOne(entityName, { id });
|
|
103
|
+
if (!prior)
|
|
104
|
+
throw new NotFoundError();
|
|
105
|
+
patch = dropUnchangedFileFields(m, input, serialize(prior));
|
|
106
|
+
}
|
|
107
|
+
const parsed = buildZodObjectPartial(m).safeParse(patch);
|
|
95
108
|
if (!parsed.success)
|
|
96
109
|
throw new ValidationError(parsed.error.issues.map(toIssue));
|
|
97
110
|
let result;
|
|
@@ -105,6 +118,9 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
|
105
118
|
const existing = nestEmbedded(m, existingFlat);
|
|
106
119
|
const ts = nowIso();
|
|
107
120
|
const { base, collections } = splitCollections(m, { ...parsed.data });
|
|
121
|
+
const fileIssues = normalizeFileFields(m, base);
|
|
122
|
+
if (fileIssues.length)
|
|
123
|
+
throw new ValidationError(fileIssues);
|
|
108
124
|
const merged = { ...existing, ...base };
|
|
109
125
|
const reqIssues = [];
|
|
110
126
|
for (const [key, f] of fieldEntries(m.fields)) {
|
package/dist/plugin-hooks.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface TableOps {
|
|
|
13
13
|
transform?: (old: unknown) => unknown;
|
|
14
14
|
}): Promise<void>;
|
|
15
15
|
convert(c: ColumnConversion): Promise<void>;
|
|
16
|
+
dropColumn(column: string): Promise<void>;
|
|
16
17
|
}
|
|
17
18
|
export interface MigrationCtx {
|
|
18
19
|
table(name: string): TableOps;
|
|
@@ -47,6 +48,16 @@ export declare class HttpError extends Error {
|
|
|
47
48
|
status: number;
|
|
48
49
|
constructor(status: number, message: string);
|
|
49
50
|
}
|
|
51
|
+
export interface ActionCaller {
|
|
52
|
+
id: string;
|
|
53
|
+
role: AuthRole;
|
|
54
|
+
}
|
|
55
|
+
export type PluginUserAction = (body: Record<string, unknown>, caller: ActionCaller) => Promise<unknown>;
|
|
56
|
+
export type PluginStreamAction = (body: Record<string, unknown>, ctx: {
|
|
57
|
+
caller: ActionCaller;
|
|
58
|
+
emit: (event: string, data: unknown) => void;
|
|
59
|
+
signal: AbortSignal;
|
|
60
|
+
}) => Promise<void>;
|
|
50
61
|
export interface PluginHooks {
|
|
51
62
|
migrations?: Migration[];
|
|
52
63
|
seed?: Seed[];
|
|
@@ -55,5 +66,7 @@ export interface PluginHooks {
|
|
|
55
66
|
agent?: AgentContribution;
|
|
56
67
|
backgroundTasks?: import('./background-scheduler.ts').BackgroundTask[];
|
|
57
68
|
actions?: Record<string, PluginAction>;
|
|
69
|
+
userActions?: Record<string, PluginUserAction>;
|
|
70
|
+
streamActions?: Record<string, PluginStreamAction>;
|
|
58
71
|
}
|
|
59
72
|
export declare const pluginHooks: Record<string, PluginHooks>;
|
package/dist/plugin-runtime.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Registry } from '@coffer-org/core/compose';
|
|
|
2
2
|
import type { PluginManifest } from '@coffer-org/sdk/plugin';
|
|
3
3
|
export declare function getPlugins(): Promise<PluginManifest[]>;
|
|
4
4
|
export declare function readDisabled(): Promise<Set<string>>;
|
|
5
|
+
export declare function getDisabled(): Promise<Set<string>>;
|
|
5
6
|
export declare function getPluginSettings(pluginId: string): Promise<Record<string, unknown>>;
|
|
6
7
|
export declare function requireSettings<K extends string>(pluginId: string, keys: readonly K[]): Promise<Record<K, string>>;
|
|
7
8
|
export declare function initStorage(): Promise<Set<string>>;
|
package/dist/plugin-runtime.js
CHANGED
|
@@ -6,12 +6,15 @@ import { syncSchema } from "./schema-sync.js";
|
|
|
6
6
|
import { systemEntities, buildPluginEntities, shelfTableName } from "./entity-schema.js";
|
|
7
7
|
import { pluginHooks, pluginCtx, HttpError } from "./plugin-hooks.js";
|
|
8
8
|
import { discoverPlugins, loadServerHooks } from "./plugin-discovery.js";
|
|
9
|
-
import { runMigrations, assertSafeRequired } from "./migrations.js";
|
|
9
|
+
import { runMigrations, assertSafeRequired, renameSystemShelfKey } from "./migrations.js";
|
|
10
10
|
import { runSeeds } from "./seeds.js";
|
|
11
11
|
import { setActiveRegistry } from "./registry-context.js";
|
|
12
12
|
import { migrateEmbeddingVectorsToBlob } from "./embeddings.js";
|
|
13
|
+
import { ensureSearchTable } from "./search-index.js";
|
|
13
14
|
import { startScheduler, stopScheduler } from "./background-scheduler.js";
|
|
15
|
+
import { startSearchIndexer, indexSearchOnce } from "./search-indexer.js";
|
|
14
16
|
const log = getLogger('plugins');
|
|
17
|
+
let stopSearchIndexer;
|
|
15
18
|
let _plugins = null;
|
|
16
19
|
export async function getPlugins() {
|
|
17
20
|
return (_plugins ??= await discoverPlugins());
|
|
@@ -23,6 +26,13 @@ export async function readDisabled() {
|
|
|
23
26
|
disabled.delete('core');
|
|
24
27
|
return disabled;
|
|
25
28
|
}
|
|
29
|
+
let _disabled = null;
|
|
30
|
+
export function getDisabled() {
|
|
31
|
+
return (_disabled ??= readDisabled().catch((e) => {
|
|
32
|
+
_disabled = null;
|
|
33
|
+
throw e;
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
26
36
|
export async function getPluginSettings(pluginId) {
|
|
27
37
|
try {
|
|
28
38
|
const fork = getEm().fork();
|
|
@@ -66,11 +76,13 @@ async function seedPluginRows() {
|
|
|
66
76
|
}
|
|
67
77
|
export async function initStorage() {
|
|
68
78
|
await initDb(systemEntities);
|
|
79
|
+
await renameSystemShelfKey(getEm().fork());
|
|
69
80
|
await syncSchema();
|
|
70
81
|
await migrateEmbeddingVectorsToBlob();
|
|
71
82
|
await getEm().fork().getConnection().execute('DROP TABLE IF EXISTS "_folders"');
|
|
72
83
|
await seedPluginRows();
|
|
73
84
|
const disabled = await readDisabled();
|
|
85
|
+
_disabled = Promise.resolve(disabled);
|
|
74
86
|
const active = (await getPlugins()).filter((p) => !disabled.has(p.id));
|
|
75
87
|
const hooks = await loadServerHooks();
|
|
76
88
|
await runMigrations({
|
|
@@ -83,6 +95,7 @@ export async function initStorage() {
|
|
|
83
95
|
if (pluginEntities.length)
|
|
84
96
|
getOrm().discoverEntity(pluginEntities);
|
|
85
97
|
await syncSchema();
|
|
98
|
+
await ensureSearchTable();
|
|
86
99
|
return disabled;
|
|
87
100
|
}
|
|
88
101
|
export async function initPlugins() {
|
|
@@ -107,6 +120,14 @@ export async function initPlugins() {
|
|
|
107
120
|
throw err;
|
|
108
121
|
}
|
|
109
122
|
}
|
|
123
|
+
stopSearchIndexer = startSearchIndexer();
|
|
124
|
+
bgTasks.push({
|
|
125
|
+
name: 'core:search-index',
|
|
126
|
+
intervalMs: 300_000,
|
|
127
|
+
run: async () => {
|
|
128
|
+
await indexSearchOnce();
|
|
129
|
+
},
|
|
130
|
+
});
|
|
110
131
|
startScheduler(bgTasks);
|
|
111
132
|
if (bgTasks.length)
|
|
112
133
|
log.debug(`scheduler: ${bgTasks.length} background task(s): ${bgTasks.map((t) => t.name).join(' ')}`);
|
|
@@ -115,6 +136,8 @@ export async function initPlugins() {
|
|
|
115
136
|
return reg;
|
|
116
137
|
}
|
|
117
138
|
export async function teardownPlugins() {
|
|
139
|
+
stopSearchIndexer?.();
|
|
140
|
+
stopSearchIndexer = undefined;
|
|
118
141
|
stopScheduler();
|
|
119
142
|
Object.assign(pluginHooks, await loadServerHooks());
|
|
120
143
|
const disabled = await readDisabled();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { type PluginHooks } from './plugin-hooks.ts';
|
|
3
|
+
export declare function registerPluginAdminApi(app: FastifyInstance, requireAdmin: (req: FastifyRequest, reply: FastifyReply) => boolean, hooks?: Record<string, PluginHooks>, disabledSet?: () => Promise<Set<string>>): void;
|
|
4
|
+
export declare function registerPluginUserApi(app: FastifyInstance, hooks?: Record<string, PluginHooks>, disabledSet?: () => Promise<Set<string>>): void;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { pluginHooks, HttpError } from "./plugin-hooks.js";
|
|
2
|
+
import { getDisabled } from "./plugin-runtime.js";
|
|
3
|
+
import { ValidationError } from "./mutate.js";
|
|
4
|
+
import { getLogger } from '@coffer-org/sdk/logger';
|
|
5
|
+
const log = getLogger('plugin-user-api');
|
|
6
|
+
const HEARTBEAT_MS = 15_000;
|
|
7
|
+
function caller(req) {
|
|
8
|
+
return { id: String(req.user.id), role: req.user.role };
|
|
9
|
+
}
|
|
10
|
+
function openSse(raw, signal) {
|
|
11
|
+
raw.writeHead(200, {
|
|
12
|
+
'content-type': 'text/event-stream',
|
|
13
|
+
'cache-control': 'no-cache, no-transform',
|
|
14
|
+
connection: 'keep-alive',
|
|
15
|
+
'x-accel-buffering': 'no',
|
|
16
|
+
});
|
|
17
|
+
return (event, data) => {
|
|
18
|
+
if (signal.aborted || raw.destroyed)
|
|
19
|
+
return;
|
|
20
|
+
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function actionError(e, reply) {
|
|
24
|
+
if (e instanceof ValidationError) {
|
|
25
|
+
const detail = e.issues.map((i) => `${i.path.join('.') || i.field}: ${i.code}`).join('; ');
|
|
26
|
+
return reply.code(422).send({ error: `Validation error — ${detail}`, issues: e.issues });
|
|
27
|
+
}
|
|
28
|
+
if (e instanceof HttpError)
|
|
29
|
+
return reply.code(e.status).send({ error: e.message });
|
|
30
|
+
return reply.code(400).send({ error: e.message });
|
|
31
|
+
}
|
|
32
|
+
export function registerPluginAdminApi(app, requireAdmin, hooks = pluginHooks, disabledSet = getDisabled) {
|
|
33
|
+
app.post('/api/plugins/:id/:action', async (req, reply) => {
|
|
34
|
+
if (!requireAdmin(req, reply))
|
|
35
|
+
return;
|
|
36
|
+
const { id, action } = req.params;
|
|
37
|
+
const fn = (await disabledSet()).has(id) ? undefined : hooks[id]?.actions?.[action];
|
|
38
|
+
if (!fn)
|
|
39
|
+
return reply.code(404).send({ error: `unknown action ${id}/${action}` });
|
|
40
|
+
try {
|
|
41
|
+
return await fn((req.body ?? {}));
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return actionError(e, reply);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function registerPluginUserApi(app, hooks = pluginHooks, disabledSet = getDisabled) {
|
|
49
|
+
async function isOff(id) {
|
|
50
|
+
return (await disabledSet()).has(id);
|
|
51
|
+
}
|
|
52
|
+
app.post('/api/plugins/:id/user/:action', async (req, reply) => {
|
|
53
|
+
const { id, action } = req.params;
|
|
54
|
+
const fn = (await isOff(id)) ? undefined : hooks[id]?.userActions?.[action];
|
|
55
|
+
if (!fn)
|
|
56
|
+
return reply.code(404).send({ error: `unknown user action ${id}/${action}` });
|
|
57
|
+
try {
|
|
58
|
+
return await fn((req.body ?? {}), caller(req));
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
return actionError(e, reply);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
app.post('/api/plugins/:id/user/:action/stream', async (req, reply) => {
|
|
65
|
+
const { id, action } = req.params;
|
|
66
|
+
const fn = (await isOff(id)) ? undefined : hooks[id]?.streamActions?.[action];
|
|
67
|
+
if (!fn)
|
|
68
|
+
return reply.code(404).send({ error: `unknown stream action ${id}/${action}` });
|
|
69
|
+
reply.hijack();
|
|
70
|
+
const ctl = new AbortController();
|
|
71
|
+
reply.raw.on('close', () => ctl.abort());
|
|
72
|
+
const emit = openSse(reply.raw, ctl.signal);
|
|
73
|
+
const beat = setInterval(() => {
|
|
74
|
+
if (ctl.signal.aborted || reply.raw.destroyed)
|
|
75
|
+
return clearInterval(beat);
|
|
76
|
+
reply.raw.write(':\n\n');
|
|
77
|
+
}, HEARTBEAT_MS);
|
|
78
|
+
beat.unref?.();
|
|
79
|
+
try {
|
|
80
|
+
await fn((req.body ?? {}), { caller: caller(req), emit, signal: ctl.signal });
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
log.error(`stream action ${id}/${action} failed: ${e.message}`);
|
|
84
|
+
emit('error', { message: e.message });
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
clearInterval(beat);
|
|
88
|
+
if (!ctl.signal.aborted && !reply.raw.destroyed)
|
|
89
|
+
reply.raw.end();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
package/dist/public-url.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FastifyRequest } from 'fastify';
|
|
2
2
|
export declare function invalidatePublicUrlCache(): void;
|
|
3
|
+
export declare function configuredPublicUrl(): Promise<string>;
|
|
3
4
|
export declare function baseUrl(req: FastifyRequest): Promise<string>;
|
|
4
5
|
export declare function mcpResource(req: FastifyRequest): Promise<string>;
|
package/dist/public-url.js
CHANGED
|
@@ -22,8 +22,11 @@ async function configuredBase() {
|
|
|
22
22
|
cached = { value, readAt: Date.now() };
|
|
23
23
|
return value;
|
|
24
24
|
}
|
|
25
|
+
export async function configuredPublicUrl() {
|
|
26
|
+
return configuredBase();
|
|
27
|
+
}
|
|
25
28
|
export async function baseUrl(req) {
|
|
26
|
-
const configured = await
|
|
29
|
+
const configured = await configuredPublicUrl();
|
|
27
30
|
if (configured)
|
|
28
31
|
return configured;
|
|
29
32
|
const proto = req.headers['x-forwarded-proto']?.split(',')[0]?.trim() ?? req.protocol;
|
package/dist/records-api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ShelfDef } from '@coffer-org/sdk/shelf';
|
|
2
|
-
export declare class
|
|
2
|
+
export declare class UnknownShelfError extends Error {
|
|
3
3
|
}
|
|
4
4
|
export type RecordListQuery = {
|
|
5
5
|
q?: string;
|
|
@@ -9,7 +9,11 @@ export type RecordListQuery = {
|
|
|
9
9
|
export type RecordListOpts = {
|
|
10
10
|
view?: 'list' | 'full';
|
|
11
11
|
extends?: boolean;
|
|
12
|
+
limit?: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
orderBy?: Record<string, 'ASC' | 'DESC'>;
|
|
12
15
|
};
|
|
16
|
+
export declare const MAX_PAGE = 500;
|
|
13
17
|
export declare function coerceFilter(v: string, column: string): unknown;
|
|
14
18
|
export declare function withExtends(record: Record<string, unknown>, library: string, shelf: string): Promise<Record<string, unknown>>;
|
|
15
19
|
export declare function withExtendsMany(rows: Record<string, unknown>[], library: string, shelf: string): Promise<Record<string, unknown>[]>;
|
|
@@ -17,8 +21,14 @@ export declare function rowMatch(mdef: ShelfDef, row: Record<string, unknown>, t
|
|
|
17
21
|
score: number;
|
|
18
22
|
snippet: string;
|
|
19
23
|
} | null;
|
|
20
|
-
export declare function
|
|
21
|
-
export declare function
|
|
22
|
-
export declare function
|
|
23
|
-
export declare function
|
|
24
|
-
|
|
24
|
+
export declare function recordCount(library: string, shelf: string, query?: RecordListQuery): Promise<number>;
|
|
25
|
+
export declare function recordList(library: string, shelf: string, query?: RecordListQuery, opts?: RecordListOpts): Promise<Record<string, unknown>[]>;
|
|
26
|
+
export declare function isPagedRequest(limit?: string, offset?: string): boolean;
|
|
27
|
+
export declare function recordListPage(library: string, shelf: string, query?: RecordListQuery, opts?: RecordListOpts): Promise<{
|
|
28
|
+
rows: Record<string, unknown>[];
|
|
29
|
+
total: number;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function recordGet(library: string, shelf: string, id: number): Promise<Record<string, unknown> | null>;
|
|
32
|
+
export declare function recordCreate(library: string, shelf: string, body: unknown, actor?: string): Promise<Record<string, unknown>>;
|
|
33
|
+
export declare function recordUpdate(library: string, shelf: string, id: number, body: unknown, actor?: string): Promise<Record<string, unknown>>;
|
|
34
|
+
export declare function recordDelete(library: string, shelf: string, id: number, actor?: string): Promise<void>;
|
package/dist/records-api.js
CHANGED
|
@@ -8,8 +8,9 @@ import { getEm } from "./db.js";
|
|
|
8
8
|
import { decodeTemporal, dtStringToDate } from "./temporal.js";
|
|
9
9
|
import { splitBody, saveExtends, deleteExtends, validateExtends, readExtends } from "./extend-io.js";
|
|
10
10
|
import { createRecord, updateRecord, getRecord, deleteRecord } from "./mutate.js";
|
|
11
|
-
export class
|
|
11
|
+
export class UnknownShelfError extends Error {
|
|
12
12
|
}
|
|
13
|
+
export const MAX_PAGE = 500;
|
|
13
14
|
function pickCols(row, cols) {
|
|
14
15
|
const out = {};
|
|
15
16
|
for (const c of cols)
|
|
@@ -17,10 +18,10 @@ function pickCols(row, cols) {
|
|
|
17
18
|
out[c] = row[c];
|
|
18
19
|
return out;
|
|
19
20
|
}
|
|
20
|
-
function resolve(library,
|
|
21
|
-
const m = getShelf(library,
|
|
21
|
+
function resolve(library, shelf) {
|
|
22
|
+
const m = getShelf(library, shelf);
|
|
22
23
|
if (!m)
|
|
23
|
-
throw new
|
|
24
|
+
throw new UnknownShelfError(`unknown_shelf ${library}/${shelf}`);
|
|
24
25
|
return { m, ename: shelfTableName(m.library, m.shelf) };
|
|
25
26
|
}
|
|
26
27
|
export function coerceFilter(v, column) {
|
|
@@ -77,10 +78,7 @@ export function rowMatch(mdef, row, tokens) {
|
|
|
77
78
|
const snippet = raw.length > 120 ? raw.slice(0, 120) + '…' : raw;
|
|
78
79
|
return { score, snippet };
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
-
const { view = 'full', extends: withExt = true } = opts;
|
|
82
|
-
const { m, ename } = resolve(library, type);
|
|
83
|
-
const { q, ...filterParams } = query;
|
|
81
|
+
function buildWhere(m, filterParams) {
|
|
84
82
|
const where = {};
|
|
85
83
|
const fm = fieldMap(m.fields);
|
|
86
84
|
for (const [k, v] of Object.entries(filterParams)) {
|
|
@@ -90,23 +88,46 @@ export async function recordList(library, type, query = {}, opts = {}) {
|
|
|
90
88
|
if (fieldDef && !fieldDef.virtual)
|
|
91
89
|
where[k] = coerceFilter(String(v), fieldDef.column);
|
|
92
90
|
}
|
|
93
|
-
if (filterParams
|
|
94
|
-
const ids = String(filterParams
|
|
91
|
+
if (filterParams['id']) {
|
|
92
|
+
const ids = String(filterParams['id'])
|
|
95
93
|
.split(',')
|
|
96
94
|
.map((s) => Number(s.trim()))
|
|
97
95
|
.filter((n) => !Number.isNaN(n));
|
|
98
96
|
if (ids.length)
|
|
99
97
|
where['id'] = ids.length === 1 ? ids[0] : { $in: ids };
|
|
100
98
|
}
|
|
99
|
+
return where;
|
|
100
|
+
}
|
|
101
|
+
export async function recordCount(library, shelf, query = {}) {
|
|
102
|
+
const { m, ename } = resolve(library, shelf);
|
|
103
|
+
const { q: _q, ...filterParams } = query;
|
|
104
|
+
const where = buildWhere(m, filterParams);
|
|
105
|
+
if (m.standalone === false && Object.keys(where).length === 0)
|
|
106
|
+
return 0;
|
|
107
|
+
return getEm().fork().count(ename, where);
|
|
108
|
+
}
|
|
109
|
+
export async function recordList(library, shelf, query = {}, opts = {}) {
|
|
110
|
+
const { view = 'full', extends: withExt = true } = opts;
|
|
111
|
+
const { m, ename } = resolve(library, shelf);
|
|
112
|
+
const { q, ...filterParams } = query;
|
|
113
|
+
const where = buildWhere(m, filterParams);
|
|
101
114
|
if (m.standalone === false && Object.keys(where).length === 0)
|
|
102
115
|
return [];
|
|
103
116
|
const fork = getEm().fork();
|
|
104
117
|
const tokens = typeof q === 'string' && q ? tokenize(q) : [];
|
|
105
118
|
const projected = view === 'list' ? storageColumnsFor(m, listKeys(m)) : null;
|
|
106
|
-
const
|
|
107
|
-
?
|
|
119
|
+
const fields = projected
|
|
120
|
+
? tokens.length
|
|
121
|
+
? [...new Set([...projected, ...storageColumnsFor(m, textSearchKeys(m))])]
|
|
122
|
+
: projected
|
|
108
123
|
: undefined;
|
|
109
|
-
|
|
124
|
+
const sqlSlice = tokens.length === 0 && opts.limit !== undefined;
|
|
125
|
+
const findOpts = {
|
|
126
|
+
...(fields ? { fields } : {}),
|
|
127
|
+
...(opts.orderBy ? { orderBy: opts.orderBy } : {}),
|
|
128
|
+
...(sqlSlice ? { limit: opts.limit, offset: Math.max(0, opts.offset ?? 0) } : {}),
|
|
129
|
+
};
|
|
130
|
+
let rows = (await fork.find(ename, where, (Object.keys(findOpts).length ? findOpts : undefined))).map((r) => serialize(r));
|
|
110
131
|
if (tokens.length > 0)
|
|
111
132
|
rows = rows.filter((row) => rowMatch(m, row, tokens) !== null);
|
|
112
133
|
if (projected)
|
|
@@ -114,38 +135,61 @@ export async function recordList(library, type, query = {}, opts = {}) {
|
|
|
114
135
|
const decoded = rows.map((r) => decodeTemporal(m, r));
|
|
115
136
|
if (!withExt)
|
|
116
137
|
return decoded;
|
|
117
|
-
return withExtendsMany(decoded, library,
|
|
138
|
+
return withExtendsMany(decoded, library, shelf);
|
|
139
|
+
}
|
|
140
|
+
export function isPagedRequest(limit, offset) {
|
|
141
|
+
return [limit, offset].some((v) => v !== undefined && v.trim() !== '' && Number.isFinite(Number(v)));
|
|
142
|
+
}
|
|
143
|
+
export async function recordListPage(library, shelf, query = {}, opts = {}) {
|
|
144
|
+
const rawLimit = Number.isFinite(opts.limit) ? Math.trunc(opts.limit) : MAX_PAGE;
|
|
145
|
+
const limit = Math.min(Math.max(1, rawLimit), MAX_PAGE);
|
|
146
|
+
const rawOffset = Number.isFinite(opts.offset) ? Math.trunc(opts.offset) : 0;
|
|
147
|
+
const offset = Math.max(0, rawOffset);
|
|
148
|
+
const hasQ = typeof query.q === 'string' && query.q.length > 0;
|
|
149
|
+
const orderBy = opts.orderBy ?? { id: 'ASC' };
|
|
150
|
+
if (hasQ) {
|
|
151
|
+
const all = await recordList(library, shelf, query, {
|
|
152
|
+
...opts,
|
|
153
|
+
orderBy,
|
|
154
|
+
limit: undefined,
|
|
155
|
+
offset: undefined,
|
|
156
|
+
});
|
|
157
|
+
return { rows: all.slice(offset, offset + limit), total: all.length };
|
|
158
|
+
}
|
|
159
|
+
const rows = await recordList(library, shelf, query, { ...opts, orderBy, limit, offset });
|
|
160
|
+
const total = await recordCount(library, shelf, query);
|
|
161
|
+
return { rows, total };
|
|
118
162
|
}
|
|
119
|
-
export async function recordGet(library,
|
|
120
|
-
const { m, ename } = resolve(library,
|
|
163
|
+
export async function recordGet(library, shelf, id) {
|
|
164
|
+
const { m, ename } = resolve(library, shelf);
|
|
121
165
|
if (m.standalone === false)
|
|
122
166
|
return null;
|
|
123
167
|
const row = await getRecord(m, ename, id);
|
|
124
168
|
if (!row)
|
|
125
169
|
return null;
|
|
126
|
-
return withExtends(row, library,
|
|
170
|
+
return withExtends(row, library, shelf);
|
|
127
171
|
}
|
|
128
|
-
export async function recordCreate(library,
|
|
129
|
-
const { m, ename } = resolve(library,
|
|
172
|
+
export async function recordCreate(library, shelf, body, actor = 'gui') {
|
|
173
|
+
const { m, ename } = resolve(library, shelf);
|
|
130
174
|
const { base, extData } = splitBody(body);
|
|
131
|
-
validateExtends(library,
|
|
175
|
+
validateExtends(library, shelf, extData);
|
|
132
176
|
const row = await createRecord(m, ename, base, { actor }, async (tx, id) => {
|
|
133
|
-
await saveExtends(tx, library,
|
|
134
|
-
return readExtends(tx, library,
|
|
177
|
+
await saveExtends(tx, library, shelf, id, extData);
|
|
178
|
+
return readExtends(tx, library, shelf, id);
|
|
135
179
|
});
|
|
136
|
-
return withExtends(row, library,
|
|
180
|
+
return withExtends(row, library, shelf);
|
|
137
181
|
}
|
|
138
|
-
export async function recordUpdate(library,
|
|
139
|
-
const { m, ename } = resolve(library,
|
|
182
|
+
export async function recordUpdate(library, shelf, id, body, actor = 'gui') {
|
|
183
|
+
const { m, ename } = resolve(library, shelf);
|
|
140
184
|
const { base, extData } = splitBody(body);
|
|
141
|
-
validateExtends(library,
|
|
185
|
+
validateExtends(library, shelf, extData);
|
|
142
186
|
const row = await updateRecord(m, ename, id, base, { actor }, async (tx) => {
|
|
143
|
-
await saveExtends(tx, library,
|
|
144
|
-
return readExtends(tx, library,
|
|
187
|
+
await saveExtends(tx, library, shelf, id, extData);
|
|
188
|
+
return readExtends(tx, library, shelf, id);
|
|
145
189
|
});
|
|
146
|
-
return withExtends(row, library,
|
|
190
|
+
return withExtends(row, library, shelf);
|
|
147
191
|
}
|
|
148
|
-
export async function recordDelete(library,
|
|
149
|
-
const { m, ename } = resolve(library,
|
|
150
|
-
await deleteRecord(m, ename, id, { actor }, (tx) => deleteExtends(tx, library,
|
|
192
|
+
export async function recordDelete(library, shelf, id, actor = 'gui') {
|
|
193
|
+
const { m, ename } = resolve(library, shelf);
|
|
194
|
+
await deleteRecord(m, ename, id, { actor }, (tx) => deleteExtends(tx, library, shelf, id));
|
|
151
195
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ShelfDef } from '@coffer-org/sdk/shelf';
|
|
2
|
+
export declare const SEARCH_SEPARATOR = "\n";
|
|
3
|
+
export declare const MIN_FTS_QUERY_LENGTH = 3;
|
|
4
|
+
export declare function ftsAvailable(): boolean;
|
|
5
|
+
export declare function foldedTextFor(m: ShelfDef, record: Record<string, unknown>): string;
|
|
6
|
+
export declare function buildFtsMatchQuery(tokens: string[]): string;
|
|
7
|
+
export declare function ensureSearchTable(): Promise<void>;
|
|
8
|
+
export declare function upsertSearchRow(shelf: string, recordId: number, folded: string): Promise<void>;
|
|
9
|
+
export declare function deleteSearchRow(shelf: string, recordId: number): Promise<void>;
|
|
10
|
+
export declare function ftsCandidates(tokens: string[]): Promise<Map<string, number[]> | null>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { textSearchKeys, titleKey } from '@coffer-org/sdk/shelf';
|
|
2
|
+
import { foldText } from '@coffer-org/core/search';
|
|
3
|
+
import { getLogger } from '@coffer-org/sdk/logger';
|
|
4
|
+
import { getEm } from "./db.js";
|
|
5
|
+
import { getDialect } from "./dialect.js";
|
|
6
|
+
import { flattenEmbedded } from "./collection-io.js";
|
|
7
|
+
import { encodeJson } from "./mutate.js";
|
|
8
|
+
const log = getLogger('search-index');
|
|
9
|
+
export const SEARCH_SEPARATOR = '\n';
|
|
10
|
+
export const MIN_FTS_QUERY_LENGTH = 3;
|
|
11
|
+
let ftsUnsupported = false;
|
|
12
|
+
export function ftsAvailable() {
|
|
13
|
+
if (ftsUnsupported)
|
|
14
|
+
return false;
|
|
15
|
+
try {
|
|
16
|
+
return getDialect() === 'sqlite';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function foldedTextFor(m, record) {
|
|
23
|
+
const row = encodeJson(m, flattenEmbedded(m, record));
|
|
24
|
+
const keys = [...new Set([...textSearchKeys(m), titleKey(m)])].filter(Boolean);
|
|
25
|
+
return keys
|
|
26
|
+
.map((k) => String(row[k] ?? ''))
|
|
27
|
+
.filter((s) => s.length > 0)
|
|
28
|
+
.map(foldText)
|
|
29
|
+
.join(SEARCH_SEPARATOR);
|
|
30
|
+
}
|
|
31
|
+
export function buildFtsMatchQuery(tokens) {
|
|
32
|
+
return tokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(' AND ');
|
|
33
|
+
}
|
|
34
|
+
export async function ensureSearchTable() {
|
|
35
|
+
if (!ftsAvailable())
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
await getEm()
|
|
39
|
+
.fork()
|
|
40
|
+
.getConnection()
|
|
41
|
+
.execute(`CREATE VIRTUAL TABLE IF NOT EXISTS "_search" USING fts5(
|
|
42
|
+
shelf UNINDEXED,
|
|
43
|
+
record_id UNINDEXED,
|
|
44
|
+
folded,
|
|
45
|
+
tokenize='trigram'
|
|
46
|
+
)`);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
ftsUnsupported = true;
|
|
50
|
+
log.warn(`FTS5 index unavailable, global search falls back to the scan — ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function upsertSearchRow(shelf, recordId, folded) {
|
|
54
|
+
if (!ftsAvailable())
|
|
55
|
+
return;
|
|
56
|
+
const conn = getEm().fork().getConnection();
|
|
57
|
+
await conn.execute('DELETE FROM "_search" WHERE shelf = ? AND record_id = ?', [shelf, recordId]);
|
|
58
|
+
if (!folded)
|
|
59
|
+
return;
|
|
60
|
+
await conn.execute('INSERT INTO "_search" (shelf, record_id, folded) VALUES (?, ?, ?)', [
|
|
61
|
+
shelf,
|
|
62
|
+
recordId,
|
|
63
|
+
folded,
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
export async function deleteSearchRow(shelf, recordId) {
|
|
67
|
+
if (!ftsAvailable())
|
|
68
|
+
return;
|
|
69
|
+
await getEm()
|
|
70
|
+
.fork()
|
|
71
|
+
.getConnection()
|
|
72
|
+
.execute('DELETE FROM "_search" WHERE shelf = ? AND record_id = ?', [shelf, recordId]);
|
|
73
|
+
}
|
|
74
|
+
export async function ftsCandidates(tokens) {
|
|
75
|
+
const out = new Map();
|
|
76
|
+
if (!ftsAvailable() || tokens.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
if (tokens.some((t) => t.length < MIN_FTS_QUERY_LENGTH))
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const rows = (await getEm()
|
|
82
|
+
.fork()
|
|
83
|
+
.getConnection()
|
|
84
|
+
.execute('SELECT shelf, record_id FROM "_search" WHERE "_search" MATCH ?', [
|
|
85
|
+
buildFtsMatchQuery(tokens),
|
|
86
|
+
]));
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
const list = out.get(r.shelf);
|
|
89
|
+
if (list)
|
|
90
|
+
list.push(Number(r.record_id));
|
|
91
|
+
else
|
|
92
|
+
out.set(r.shelf, [Number(r.record_id)]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
log.warn(`MATCH failed — ${e.message}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|