@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.
@@ -0,0 +1,15 @@
1
+ export interface CountTarget {
2
+ key: string;
3
+ table: string;
4
+ }
5
+ export interface ShelfRef {
6
+ library: string;
7
+ shelf: string;
8
+ standalone?: boolean;
9
+ }
10
+ export declare function countTargetsFor(shelves: ShelfRef[], filter?: {
11
+ library?: string;
12
+ shelf?: string;
13
+ }): CountTarget[];
14
+ export declare function buildCountsSql(shelves: CountTarget[]): string[];
15
+ export declare function recordCounts(shelves: CountTarget[]): Promise<Record<string, number>>;
package/dist/counts.js ADDED
@@ -0,0 +1,39 @@
1
+ import { getEm } from "./db.js";
2
+ import { shelfTableName } from "./entity-schema.js";
3
+ const CHUNK = 500;
4
+ function quoteIdent(name) {
5
+ return `"${name.replace(/"/g, '""')}"`;
6
+ }
7
+ function quoteLiteral(value) {
8
+ return `'${value.replace(/'/g, "''")}'`;
9
+ }
10
+ export function countTargetsFor(shelves, filter = {}) {
11
+ return shelves
12
+ .filter((s) => s.standalone !== false)
13
+ .filter((s) => (filter.library ? s.library === filter.library : true))
14
+ .filter((s) => (filter.shelf ? s.shelf === filter.shelf : true))
15
+ .map((s) => ({ key: `${s.library}/${s.shelf}`, table: shelfTableName(s.library, s.shelf) }));
16
+ }
17
+ export function buildCountsSql(shelves) {
18
+ const out = [];
19
+ for (let i = 0; i < shelves.length; i += CHUNK) {
20
+ const branches = shelves
21
+ .slice(i, i + CHUNK)
22
+ .map((s) => `SELECT ${quoteLiteral(s.key)} AS k, COUNT(*) AS n FROM ${quoteIdent(s.table)}`);
23
+ out.push(branches.join(' UNION ALL '));
24
+ }
25
+ return out;
26
+ }
27
+ export async function recordCounts(shelves) {
28
+ const statements = buildCountsSql(shelves);
29
+ if (!statements.length)
30
+ return {};
31
+ const conn = getEm().fork().getConnection();
32
+ const out = {};
33
+ for (const sql of statements) {
34
+ const rows = (await conn.execute(sql));
35
+ for (const r of rows)
36
+ out[r.k] = Number(r.n);
37
+ }
38
+ return out;
39
+ }
package/dist/db.js CHANGED
@@ -2,6 +2,7 @@ import { mkdirSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { MikroORM, EntityCaseNamingStrategy } from '@mikro-orm/core';
4
4
  import { SqliteDriver } from '@mikro-orm/sqlite';
5
+ const SKIP_TABLES = [/^_search(_|$)/];
5
6
  let _orm;
6
7
  export function getOrm() {
7
8
  if (!_orm)
@@ -25,6 +26,7 @@ export async function initDb(entities) {
25
26
  entities,
26
27
  namingStrategy: EntityCaseNamingStrategy,
27
28
  discovery: { warnWhenNoEntities: false },
29
+ schemaGenerator: { skipTables: SKIP_TABLES },
28
30
  };
29
31
  if (driverName === 'postgresql') {
30
32
  const { PostgreSqlDriver } = await import('@mikro-orm/postgresql');
@@ -1,11 +1,11 @@
1
1
  export type EmbeddingHit = {
2
- type: string;
2
+ shelfKey: string;
3
3
  recordId: number;
4
4
  snippet: string;
5
5
  distance: number;
6
6
  };
7
7
  export type EmbeddingRow = {
8
- type: string;
8
+ shelfKey: string;
9
9
  recordId: number;
10
10
  snippet: string;
11
11
  full: Float32Array;
@@ -27,12 +27,12 @@ export declare function twoPassSearch(rows: EmbeddingRow[], queryVec: ArrayLike<
27
27
  poolMinFactor?: number;
28
28
  }): EmbeddingHit[];
29
29
  export declare function upsertEmbedding(args: {
30
- type: string;
30
+ shelfKey: string;
31
31
  recordId: number;
32
32
  snippet: string;
33
33
  vector: number[];
34
34
  model: string;
35
35
  }): Promise<void>;
36
- export declare function deleteEmbedding(type: string, recordId: number): Promise<void>;
36
+ export declare function deleteEmbedding(shelfKey: string, recordId: number): Promise<void>;
37
37
  export declare function searchEmbeddings(queryVec: number[], k: number): Promise<EmbeddingHit[]>;
38
38
  export declare function listEventsSince(lastId: number, limit: number): Promise<EventRow[]>;
@@ -12,7 +12,7 @@ async function loadCache() {
12
12
  const em = getEm().fork();
13
13
  const rows = (await em.find('_Embedding', {}));
14
14
  cache = rows.map((r) => ({
15
- type: r.type, recordId: r.record_id, snippet: r.snippet, full: decodeVector(r.vector),
15
+ shelfKey: r.shelf_key, recordId: r.record_id, snippet: r.snippet, full: decodeVector(r.vector),
16
16
  }));
17
17
  return cache;
18
18
  }
@@ -39,7 +39,7 @@ export async function migrateEmbeddingVectorsToBlob() {
39
39
  if (typeof r.vector !== 'string')
40
40
  continue;
41
41
  const f = decodeBase64Vector(r.vector);
42
- await em.nativeUpdate('_Embedding', { type: r.type, record_id: r.record_id }, { vector: Buffer.from(f.buffer, f.byteOffset, f.byteLength) });
42
+ await em.nativeUpdate('_Embedding', { shelf_key: r.shelf_key, record_id: r.record_id }, { vector: Buffer.from(f.buffer, f.byteOffset, f.byteLength) });
43
43
  migrated++;
44
44
  }
45
45
  if (migrated > 0) {
@@ -75,7 +75,7 @@ export function twoPassSearch(rows, queryVec, k, opts = {}) {
75
75
  return pool
76
76
  .map(({ i }) => {
77
77
  const r = rows[i];
78
- return { type: r.type, recordId: r.recordId, snippet: r.snippet, distance: 1 - cosine(r.full, qFull) };
78
+ return { shelfKey: r.shelfKey, recordId: r.recordId, snippet: r.snippet, distance: 1 - cosine(r.full, qFull) };
79
79
  })
80
80
  .sort((a, b) => a.distance - b.distance)
81
81
  .slice(0, k);
@@ -83,7 +83,7 @@ export function twoPassSearch(rows, queryVec, k, opts = {}) {
83
83
  export async function upsertEmbedding(args) {
84
84
  const em = getEm().fork();
85
85
  await em.upsert('_Embedding', {
86
- type: args.type,
86
+ shelf_key: args.shelfKey,
87
87
  record_id: args.recordId,
88
88
  snippet: args.snippet,
89
89
  vector: encodeVector(args.vector),
@@ -94,9 +94,9 @@ export async function upsertEmbedding(args) {
94
94
  await em.flush();
95
95
  invalidateEmbeddingCache();
96
96
  }
97
- export async function deleteEmbedding(type, recordId) {
97
+ export async function deleteEmbedding(shelfKey, recordId) {
98
98
  const em = getEm().fork();
99
- await em.nativeDelete('_Embedding', { type, record_id: recordId });
99
+ await em.nativeDelete('_Embedding', { shelf_key: shelfKey, record_id: recordId });
100
100
  invalidateEmbeddingCache();
101
101
  }
102
102
  export async function searchEmbeddings(queryVec, k) {
@@ -133,7 +133,7 @@ export const EmbeddingSchema = new EntitySchema({
133
133
  name: '_Embedding',
134
134
  tableName: '_embeddings',
135
135
  properties: {
136
- type: { type: 'text', primary: true },
136
+ shelf_key: { type: 'text', primary: true },
137
137
  record_id: { type: 'integer', primary: true },
138
138
  snippet: { type: 'text' },
139
139
  vector: { type: 'blob' },
@@ -0,0 +1,6 @@
1
+ import type { ShelfDef } from '@coffer-org/sdk/shelf';
2
+ import type { ValidationIssue } from './mutate.ts';
3
+ export declare function mimeForName(name: string): string | undefined;
4
+ export declare function touchesFileFields(m: ShelfDef, input: unknown): boolean;
5
+ export declare function dropUnchangedFileFields(m: ShelfDef, input: Record<string, unknown>, stored: Record<string, unknown>): Record<string, unknown>;
6
+ export declare function normalizeFileFields(m: ShelfDef, data: Record<string, unknown>): ValidationIssue[];
@@ -0,0 +1,121 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fieldEntries } from '@coffer-org/sdk/shelf';
4
+ import { jsonValue } from '@coffer-org/sdk/fields';
5
+ import { uploadsDir } from "./uploads.js";
6
+ const MIME_BY_EXT = {
7
+ jpg: 'image/jpeg',
8
+ jpeg: 'image/jpeg',
9
+ png: 'image/png',
10
+ gif: 'image/gif',
11
+ webp: 'image/webp',
12
+ avif: 'image/avif',
13
+ svg: 'image/svg+xml',
14
+ heic: 'image/heic',
15
+ bmp: 'image/bmp',
16
+ ico: 'image/x-icon',
17
+ mp4: 'video/mp4',
18
+ webm: 'video/webm',
19
+ mov: 'video/quicktime',
20
+ mkv: 'video/x-matroska',
21
+ mp3: 'audio/mpeg',
22
+ m4a: 'audio/mp4',
23
+ ogg: 'audio/ogg',
24
+ opus: 'audio/opus',
25
+ wav: 'audio/wav',
26
+ flac: 'audio/flac',
27
+ pdf: 'application/pdf',
28
+ txt: 'text/plain',
29
+ csv: 'text/csv',
30
+ json: 'application/json',
31
+ zip: 'application/zip',
32
+ doc: 'application/msword',
33
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
34
+ xls: 'application/vnd.ms-excel',
35
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
36
+ };
37
+ export function mimeForName(name) {
38
+ const i = name.lastIndexOf('.');
39
+ if (i < 0)
40
+ return undefined;
41
+ return MIME_BY_EXT[name.slice(i + 1).toLowerCase()];
42
+ }
43
+ function resolveEntry(entry) {
44
+ let size;
45
+ try {
46
+ const st = statSync(join(uploadsDir(), entry.name));
47
+ if (!st.isFile())
48
+ return null;
49
+ size = st.size;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ const mime = mimeForName(entry.name);
55
+ return mime ? { name: entry.name, mime, size } : { name: entry.name, size };
56
+ }
57
+ function sameValue(a, b) {
58
+ if (a === undefined || b === undefined)
59
+ return false;
60
+ try {
61
+ return JSON.stringify(jsonValue(a)) === JSON.stringify(jsonValue(b));
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ export function touchesFileFields(m, input) {
68
+ if (typeof input !== 'object' || input === null)
69
+ return false;
70
+ const o = input;
71
+ for (const [key, f] of fieldEntries(m.fields))
72
+ if (f.prim === 'file' && key in o)
73
+ return true;
74
+ return false;
75
+ }
76
+ export function dropUnchangedFileFields(m, input, stored) {
77
+ let out = null;
78
+ for (const [key, f] of fieldEntries(m.fields)) {
79
+ if (f.prim !== 'file')
80
+ continue;
81
+ if (!(key in input))
82
+ continue;
83
+ if (!sameValue(input[key], stored[key]))
84
+ continue;
85
+ out ??= { ...input };
86
+ delete out[key];
87
+ }
88
+ return out ?? input;
89
+ }
90
+ export function normalizeFileFields(m, data) {
91
+ const issues = [];
92
+ for (const [key, f] of fieldEntries(m.fields)) {
93
+ if (f.prim !== 'file')
94
+ continue;
95
+ if (!(key in data))
96
+ continue;
97
+ const raw = data[key];
98
+ if (raw == null || raw === '')
99
+ continue;
100
+ const parsed = jsonValue(raw);
101
+ const isArray = Array.isArray(parsed);
102
+ const items = (isArray ? parsed : [parsed]);
103
+ const out = [];
104
+ for (const it of items) {
105
+ if (typeof it !== 'object' || it === null || typeof it.name !== 'string') {
106
+ issues.push({ field: key, code: 'file_not_uploaded', path: [key] });
107
+ continue;
108
+ }
109
+ const resolved = resolveEntry(it);
110
+ if (!resolved) {
111
+ issues.push({ field: key, code: 'file_not_uploaded', params: { name: it.name }, path: [key] });
112
+ continue;
113
+ }
114
+ out.push(resolved);
115
+ }
116
+ if (issues.length)
117
+ continue;
118
+ data[key] = isArray ? out : out[0];
119
+ }
120
+ return issues;
121
+ }
@@ -0,0 +1 @@
1
+ export declare function frontendInstructions(): Promise<string | null>;
@@ -0,0 +1,37 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { getLogger } from '@coffer-org/sdk/logger';
13
+ import { configuredPublicUrl } from "./public-url.js";
14
+ const log = getLogger('frontend-agent');
15
+ export async function frontendInstructions() {
16
+ const dist = process.env['WEB_DIST'];
17
+ if (!dist)
18
+ return null;
19
+ try {
20
+ const root = join(dist, '..');
21
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
22
+ const rel = pkg.coffer?.agent;
23
+ if (!rel)
24
+ return null;
25
+ const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(join(root, rel)).href)));
26
+ if (typeof mod.agent !== 'function') {
27
+ log.warn(`frontend ${rel} exports no agent() — no site-link instructions`);
28
+ return null;
29
+ }
30
+ const text = await mod.agent({ siteUrl: await configuredPublicUrl() });
31
+ return typeof text === 'string' && text.trim() ? text : null;
32
+ }
33
+ catch (e) {
34
+ log.warn(`frontend agent instructions skipped — ${e.message}`);
35
+ return null;
36
+ }
37
+ }
@@ -0,0 +1,16 @@
1
+ import type { ShelfDef } from '@coffer-org/sdk/shelf';
2
+ export declare const SEARCH_SCAN_LIMIT = 1000;
3
+ export interface SearchShelf {
4
+ key: string;
5
+ table: string;
6
+ def: ShelfDef;
7
+ }
8
+ export interface GlobalSearchHit {
9
+ library: string;
10
+ shelf: string;
11
+ id: string;
12
+ label: string;
13
+ snippet: string;
14
+ }
15
+ export declare function globalSearchByScan(shelves: SearchShelf[], q: string, limit: number): Promise<GlobalSearchHit[]>;
16
+ export declare function globalSearch(shelves: SearchShelf[], q: string, limit: number): Promise<GlobalSearchHit[]>;
@@ -0,0 +1,87 @@
1
+ import { textSearchKeys, titleKey, recordTitle, storageColumnsFor } from '@coffer-org/sdk/shelf';
2
+ import { serialize } from '@mikro-orm/core';
3
+ import { tokenize } from '@coffer-org/core/search';
4
+ import { getLogger } from '@coffer-org/sdk/logger';
5
+ import { getEm } from "./db.js";
6
+ import { rowMatch } from "./records-api.js";
7
+ import { ftsAvailable, ftsCandidates, MIN_FTS_QUERY_LENGTH } from "./search-index.js";
8
+ const log = getLogger('search');
9
+ export const SEARCH_SCAN_LIMIT = 1000;
10
+ function matchCols(m) {
11
+ return storageColumnsFor(m, [...new Set([...textSearchKeys(m), titleKey(m)])].filter(Boolean));
12
+ }
13
+ function tieKey(hit) {
14
+ return `${hit.library}/${hit.shelf}/${hit.id}`;
15
+ }
16
+ function scoreRows(shelf, rows, tokens) {
17
+ const [library, shelfName] = shelf.key.split('/');
18
+ const out = [];
19
+ for (const row of rows) {
20
+ const m = rowMatch(shelf.def, row, tokens);
21
+ if (!m)
22
+ continue;
23
+ out.push({
24
+ score: m.score,
25
+ result: {
26
+ library,
27
+ shelf: shelfName,
28
+ id: String(row['id']),
29
+ label: recordTitle(shelf.def, row),
30
+ snippet: m.snippet,
31
+ },
32
+ });
33
+ }
34
+ return out;
35
+ }
36
+ export async function globalSearchByScan(shelves, q, limit) {
37
+ const tokens = tokenize(q);
38
+ if (tokens.length === 0)
39
+ return [];
40
+ const fork = getEm().fork();
41
+ const scored = [];
42
+ for (const shelf of shelves) {
43
+ if (shelf.def.standalone === false)
44
+ continue;
45
+ if (!textSearchKeys(shelf.def).length)
46
+ continue;
47
+ const rows = (await fork.find(shelf.table, {}, {
48
+ limit: SEARCH_SCAN_LIMIT,
49
+ fields: matchCols(shelf.def),
50
+ })).map((r) => serialize(r));
51
+ if (rows.length === SEARCH_SCAN_LIMIT) {
52
+ log.warn('scan cap reached', { shelf: shelf.key, limit: SEARCH_SCAN_LIMIT });
53
+ }
54
+ scored.push(...scoreRows(shelf, rows, tokens));
55
+ }
56
+ scored.sort((a, b) => b.score - a.score || tieKey(a.result).localeCompare(tieKey(b.result)));
57
+ return scored.slice(0, limit).map((s) => s.result);
58
+ }
59
+ export async function globalSearch(shelves, q, limit) {
60
+ const tokens = tokenize(q);
61
+ if (tokens.length === 0)
62
+ return [];
63
+ if (!ftsAvailable() || tokens.some((t) => t.length < MIN_FTS_QUERY_LENGTH)) {
64
+ return globalSearchByScan(shelves, q, limit);
65
+ }
66
+ const candidates = await ftsCandidates(tokens);
67
+ if (candidates === null)
68
+ return globalSearchByScan(shelves, q, limit);
69
+ if (candidates.size === 0)
70
+ return [];
71
+ const byKey = new Map(shelves.map((s) => [s.key, s]));
72
+ const fork = getEm().fork();
73
+ const scored = [];
74
+ for (const [key, ids] of candidates) {
75
+ const shelf = byKey.get(key);
76
+ if (!shelf || shelf.def.standalone === false)
77
+ continue;
78
+ if (!textSearchKeys(shelf.def).length)
79
+ continue;
80
+ const rows = (await fork.find(shelf.table, { id: { $in: ids } }, {
81
+ fields: matchCols(shelf.def),
82
+ })).map((r) => serialize(r));
83
+ scored.push(...scoreRows(shelf, rows, tokens));
84
+ }
85
+ scored.sort((a, b) => b.score - a.score || tieKey(a.result).localeCompare(tieKey(b.result)));
86
+ return scored.slice(0, limit).map((s) => s.result);
87
+ }
@@ -1,3 +1,4 @@
1
- export declare function onRecordsChanged(cb: () => void): void;
2
- export declare function offRecordsChanged(): void;
1
+ export declare function onRecordsChanged(cb: () => void): () => void;
2
+ export declare function offRecordsChanged(cb?: () => void): void;
3
+ export declare function recordsChangedListenerCount(): number;
3
4
  export declare function notifyRecordsChanged(): void;
@@ -1,14 +1,28 @@
1
1
  import { getLogger } from '@coffer-org/sdk/logger';
2
- const log = getLogger('rag-signal');
3
- let listener = null;
2
+ const log = getLogger('index-signal');
3
+ const listeners = new Set();
4
4
  export function onRecordsChanged(cb) {
5
- if (listener)
6
- log.warn('onRecordsChanged: overwriting an already-registered listener without offRecordsChanged');
7
- listener = cb;
5
+ listeners.add(cb);
6
+ return () => {
7
+ listeners.delete(cb);
8
+ };
8
9
  }
9
- export function offRecordsChanged() {
10
- listener = null;
10
+ export function offRecordsChanged(cb) {
11
+ if (cb)
12
+ listeners.delete(cb);
13
+ else
14
+ listeners.clear();
15
+ }
16
+ export function recordsChangedListenerCount() {
17
+ return listeners.size;
11
18
  }
12
19
  export function notifyRecordsChanged() {
13
- listener?.();
20
+ for (const cb of [...listeners]) {
21
+ try {
22
+ cb();
23
+ }
24
+ catch (e) {
25
+ log.error(`records-changed listener threw — ${e.message}`);
26
+ }
27
+ }
14
28
  }