@coffer-org/server 1.14.0 → 2.1.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.
@@ -179,8 +179,31 @@ export function makeTable(em, table) {
179
179
  throw e;
180
180
  }
181
181
  },
182
+ async dropColumn(column) {
183
+ const info = await introspectTable(em, table);
184
+ if (!info.exists || !info.columns.has(column))
185
+ return;
186
+ if (dialectOf(em) === 'sqlite') {
187
+ const idxList = (await conn.execute(`PRAGMA index_list(${q(table)})`));
188
+ for (const idx of idxList) {
189
+ const info = (await conn.execute(`PRAGMA index_info(${q(idx.name)})`));
190
+ if (!info.some((c) => c.name === column))
191
+ continue;
192
+ const meta = (await conn.execute(`SELECT sql FROM sqlite_master WHERE type='index' AND name=?`, [
193
+ idx.name,
194
+ ]));
195
+ const sql = meta[0]?.sql;
196
+ if (sql !== null)
197
+ await conn.execute(`DROP INDEX ${q(idx.name)}`);
198
+ }
199
+ }
200
+ await conn.execute(`ALTER TABLE ${T} DROP COLUMN ${q(column)}`);
201
+ },
182
202
  };
183
203
  }
204
+ export async function renameSystemShelfKey(em) {
205
+ await makeTable(em, '_embeddings').renameColumn('type', 'shelf_key');
206
+ }
184
207
  export function validateMigrations(pluginId, list) {
185
208
  const versions = list.map((m) => m.version).sort((a, b) => a - b);
186
209
  versions.forEach((v, i) => {
package/dist/mutate.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { EntityManager } from '@mikro-orm/core';
2
2
  import type { ShelfDef } from '@coffer-org/sdk/shelf';
3
3
  import { z } from 'zod';
4
+ export declare function encodeJson(m: ShelfDef, data: Record<string, unknown>): Record<string, unknown>;
4
5
  export interface MutateCtx {
5
6
  actor: string;
6
7
  }
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
- const parsed = buildZodObjectPartial(m).safeParse(input);
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)) {
@@ -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>;
@@ -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>>;
@@ -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
+ }
@@ -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>;
@@ -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 configuredBase();
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;
@@ -1,5 +1,5 @@
1
1
  import type { ShelfDef } from '@coffer-org/sdk/shelf';
2
- export declare class UnknownTypeError extends Error {
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 recordList(library: string, type: string, query?: RecordListQuery, opts?: RecordListOpts): Promise<Record<string, unknown>[]>;
21
- export declare function recordGet(library: string, type: string, id: number): Promise<Record<string, unknown> | null>;
22
- export declare function recordCreate(library: string, type: string, body: unknown, actor?: string): Promise<Record<string, unknown>>;
23
- export declare function recordUpdate(library: string, type: string, id: number, body: unknown, actor?: string): Promise<Record<string, unknown>>;
24
- export declare function recordDelete(library: string, type: string, id: number, actor?: string): Promise<void>;
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>;
@@ -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 UnknownTypeError extends Error {
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, type) {
21
- const m = getShelf(library, type);
21
+ function resolve(library, shelf) {
22
+ const m = getShelf(library, shelf);
22
23
  if (!m)
23
- throw new UnknownTypeError(`unknown_type ${library}/${type}`);
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
- export async function recordList(library, type, query = {}, opts = {}) {
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.id) {
94
- const ids = String(filterParams.id)
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 findOpts = projected
107
- ? { fields: tokens.length ? [...new Set([...projected, ...storageColumnsFor(m, textSearchKeys(m))])] : projected }
119
+ const fields = projected
120
+ ? tokens.length
121
+ ? [...new Set([...projected, ...storageColumnsFor(m, textSearchKeys(m))])]
122
+ : projected
108
123
  : undefined;
109
- let rows = (await fork.find(ename, where, findOpts)).map((r) => serialize(r));
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, type);
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, type, id) {
120
- const { m, ename } = resolve(library, type);
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, type);
170
+ return withExtends(row, library, shelf);
127
171
  }
128
- export async function recordCreate(library, type, body, actor = 'gui') {
129
- const { m, ename } = resolve(library, type);
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, type, extData);
175
+ validateExtends(library, shelf, extData);
132
176
  const row = await createRecord(m, ename, base, { actor }, async (tx, id) => {
133
- await saveExtends(tx, library, type, id, extData);
134
- return readExtends(tx, library, type, id);
177
+ await saveExtends(tx, library, shelf, id, extData);
178
+ return readExtends(tx, library, shelf, id);
135
179
  });
136
- return withExtends(row, library, type);
180
+ return withExtends(row, library, shelf);
137
181
  }
138
- export async function recordUpdate(library, type, id, body, actor = 'gui') {
139
- const { m, ename } = resolve(library, type);
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, type, extData);
185
+ validateExtends(library, shelf, extData);
142
186
  const row = await updateRecord(m, ename, id, base, { actor }, async (tx) => {
143
- await saveExtends(tx, library, type, id, extData);
144
- return readExtends(tx, library, type, id);
187
+ await saveExtends(tx, library, shelf, id, extData);
188
+ return readExtends(tx, library, shelf, id);
145
189
  });
146
- return withExtends(row, library, type);
190
+ return withExtends(row, library, shelf);
147
191
  }
148
- export async function recordDelete(library, type, id, actor = 'gui') {
149
- const { m, ename } = resolve(library, type);
150
- await deleteRecord(m, ename, id, { actor }, (tx) => deleteExtends(tx, library, type, id));
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
+ }
@@ -0,0 +1,4 @@
1
+ export declare const SEARCH_STATE_PLUGIN = "core";
2
+ export declare const SEARCH_CURSOR_KEY = "fts_last_event_id";
3
+ export declare function indexSearchOnce(batch?: number): Promise<number>;
4
+ export declare function startSearchIndexer(batch?: number): () => void;