@coffer-org/server 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/collection-io.d.ts +17 -13
  2. package/dist/collection-io.js +99 -43
  3. package/dist/db.d.ts +1 -1
  4. package/dist/db.js +22 -25
  5. package/dist/dialect.d.ts +4 -0
  6. package/dist/dialect.js +14 -0
  7. package/dist/embed.d.ts +14 -0
  8. package/dist/embed.js +46 -0
  9. package/dist/embeddings.d.ts +38 -0
  10. package/dist/embeddings.js +109 -0
  11. package/dist/entity-schema.d.ts +6 -17
  12. package/dist/entity-schema.js +103 -68
  13. package/dist/extend-io.d.ts +3 -4
  14. package/dist/extend-io.js +24 -11
  15. package/dist/extend-table.d.ts +5 -4
  16. package/dist/extend-table.js +26 -17
  17. package/dist/fs-list.d.ts +11 -0
  18. package/dist/fs-list.js +18 -0
  19. package/dist/index.d.ts +0 -1
  20. package/dist/index.js +268 -190
  21. package/dist/local-api.d.ts +4 -5
  22. package/dist/local-api.js +14 -42
  23. package/dist/migrations.d.ts +24 -0
  24. package/dist/migrations.js +198 -0
  25. package/dist/msg-log.d.ts +12 -0
  26. package/dist/msg-log.js +5 -0
  27. package/dist/mutate.d.ts +6 -4
  28. package/dist/mutate.js +47 -37
  29. package/dist/plugin-discovery.d.ts +1 -12
  30. package/dist/plugin-discovery.js +15 -28
  31. package/dist/plugin-hooks.d.ts +36 -26
  32. package/dist/plugin-hooks.js +0 -2
  33. package/dist/plugin-runtime.d.ts +0 -23
  34. package/dist/plugin-runtime.js +33 -67
  35. package/dist/plugin-state.d.ts +2 -0
  36. package/dist/plugin-state.js +11 -0
  37. package/dist/plugins-api.d.ts +0 -1
  38. package/dist/plugins-api.js +2 -10
  39. package/dist/records-api.d.ts +19 -0
  40. package/dist/records-api.js +116 -0
  41. package/dist/registry-context.d.ts +1 -7
  42. package/dist/registry-context.js +4 -3
  43. package/dist/schema-api.d.ts +1 -0
  44. package/dist/schema-api.js +11 -0
  45. package/dist/schema-sync.d.ts +0 -1
  46. package/dist/schema-sync.js +1 -10
  47. package/dist/secrets.d.ts +7 -0
  48. package/dist/secrets.js +60 -0
  49. package/dist/seeds.d.ts +14 -0
  50. package/dist/seeds.js +23 -0
  51. package/dist/temporal.d.ts +0 -16
  52. package/dist/temporal.js +0 -6
  53. package/dist/uploads.d.ts +4 -0
  54. package/dist/uploads.js +12 -0
  55. package/package.json +12 -4
  56. package/dist/collection-io.d.ts.map +0 -1
  57. package/dist/collection-io.js.map +0 -1
  58. package/dist/db.d.ts.map +0 -1
  59. package/dist/db.js.map +0 -1
  60. package/dist/entity-schema.d.ts.map +0 -1
  61. package/dist/entity-schema.js.map +0 -1
  62. package/dist/extend-io.d.ts.map +0 -1
  63. package/dist/extend-io.js.map +0 -1
  64. package/dist/extend-table.d.ts.map +0 -1
  65. package/dist/extend-table.js.map +0 -1
  66. package/dist/index.d.ts.map +0 -1
  67. package/dist/index.js.map +0 -1
  68. package/dist/local-api.d.ts.map +0 -1
  69. package/dist/local-api.js.map +0 -1
  70. package/dist/mutate.d.ts.map +0 -1
  71. package/dist/mutate.js.map +0 -1
  72. package/dist/plugin-discovery.d.ts.map +0 -1
  73. package/dist/plugin-discovery.js.map +0 -1
  74. package/dist/plugin-hooks.d.ts.map +0 -1
  75. package/dist/plugin-hooks.js.map +0 -1
  76. package/dist/plugin-runtime.d.ts.map +0 -1
  77. package/dist/plugin-runtime.js.map +0 -1
  78. package/dist/plugins-api.d.ts.map +0 -1
  79. package/dist/plugins-api.js.map +0 -1
  80. package/dist/registry-context.d.ts.map +0 -1
  81. package/dist/registry-context.js.map +0 -1
  82. package/dist/schema-sync.d.ts.map +0 -1
  83. package/dist/schema-sync.js.map +0 -1
  84. package/dist/temporal.d.ts.map +0 -1
  85. package/dist/temporal.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,49 +1,59 @@
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
+ };
1
9
  import { randomUUID } from 'node:crypto';
2
- import { createWriteStream, mkdirSync, existsSync } from 'node:fs';
10
+ import { createWriteStream, existsSync } from 'node:fs';
11
+ import { realpath } from 'node:fs/promises';
3
12
  import { pipeline } from 'node:stream/promises';
4
- import { extname, join } from 'node:path';
13
+ import { extname, join, sep } from 'node:path';
14
+ import { homedir } from 'node:os';
5
15
  import Fastify from 'fastify';
6
16
  import cors from '@fastify/cors';
7
17
  import multipart from '@fastify/multipart';
8
18
  import fastifyStatic from '@fastify/static';
9
19
  import { serialize } from '@mikro-orm/core';
10
- import { moduleToClient, fieldMap, textSearchKeys, titleKey, recordTitle, buildZodObject } from '@coffer-org/sdk/module';
11
- import { tokenize, matchScoreFolded, foldText } from '@coffer-org/core/search';
12
- import { extendToClient } from '@coffer-org/sdk/extend';
13
- import { extendEntityName, getExtendRecord } from './extend-table.js';
14
- import { moduleTableName } from './entity-schema.js';
15
- import { splitBody, saveExtends } from './extend-io.js';
16
- import { getEm } from './db.js';
17
- import { createRecord, getRecord, updateRecord, deleteRecord, ValidationError, NotFoundError } from './mutate.js';
18
- import { decodeTemporal, dtStringToDate } from './temporal.js';
19
- import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from './plugin-runtime.js';
20
- import { registerPluginsApi } from './plugins-api.js';
21
- // Завантажити app/.env (креденшели Telegram-бота тощо) ДО будь-чого, що читає env.
22
- // Робить app незалежним від ~/.profile. Наявні env-змінні мають пріоритет.
20
+ import { textSearchKeys, recordTitle, buildZodObject } from '@coffer-org/sdk/module';
21
+ import { tokenize } from '@coffer-org/core/search';
22
+ import { moduleTableName } from "./entity-schema.js";
23
+ import { getEm, closeDb } from "./db.js";
24
+ import { ValidationError, NotFoundError, toIssue } from "./mutate.js";
25
+ import { resolveEmbed } from "./embed.js";
26
+ import { resolveWithinHome, filterSort, listDir } from "./fs-list.js";
27
+ import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from "./plugin-runtime.js";
28
+ import { registerPluginsApi } from "./plugins-api.js";
29
+ import { buildClientSchema } from "./schema-api.js";
30
+ import { recordList, recordGet, recordCreate, recordUpdate, recordDelete, rowMatch, UnknownTypeError } from "./records-api.js";
31
+ import { maskTree, preserveTree } from "./secrets.js";
32
+ import { uploadsDir } from "./uploads.js";
23
33
  const ENV_FILE = join(process.cwd(), '.env');
24
34
  if (existsSync(ENV_FILE))
25
35
  process.loadEnvFile(ENV_FILE);
26
36
  const PORT = Number(process.env.PORT ?? 7023);
27
- // initPlugins() сам ініціалізує ORM (двофазний bootstrap: системні таблиці →
28
- // _plugins → discoverEntity активних плагінів → syncSchema).
29
- // Життєвий цикл плагінів → АКТИВНИЙ реєстр (набір з БД _plugins).
30
37
  const reg = await initPlugins();
31
38
  const { vaults, modules, getModule, extends_, getExtendsFor } = reg;
32
- // Мапи: vault/type → ім'я сутності MikroORM (== ім'я таблиці) + ModuleDef.
33
39
  const entityNames = new Map();
34
40
  const moduleDefs = new Map();
35
41
  for (const m of modules) {
36
42
  entityNames.set(`${m.vault}/${m.module}`, moduleTableName(m.vault, m.module));
37
43
  moduleDefs.set(`${m.vault}/${m.module}`, m);
38
44
  }
39
- // Сховище медіа
40
- const UPLOADS = process.env.UPLOADS ?? join(process.cwd(), 'data', 'uploads');
41
- mkdirSync(UPLOADS, { recursive: true });
45
+ const UPLOADS = uploadsDir();
42
46
  const app = Fastify({ logger: true });
43
47
  await app.register(cors, { origin: true });
44
48
  await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } });
45
- await app.register(fastifyStatic, { root: UPLOADS, prefix: '/uploads/' });
46
- // Завантаження файлу → повертає ім'я в /uploads
49
+ await app.register(fastifyStatic, {
50
+ root: UPLOADS,
51
+ prefix: '/uploads/',
52
+ setHeaders: (res) => {
53
+ res.setHeader('Content-Security-Policy', 'sandbox');
54
+ res.setHeader('X-Content-Type-Options', 'nosniff');
55
+ },
56
+ });
47
57
  app.post('/api/upload', async (req, reply) => {
48
58
  const data = await req.file();
49
59
  if (!data)
@@ -53,10 +63,18 @@ app.post('/api/upload', async (req, reply) => {
53
63
  await pipeline(data.file, createWriteStream(join(UPLOADS, name)));
54
64
  return reply.send({ filename: name });
55
65
  });
66
+ app.post('/api/embed', async (req, reply) => {
67
+ const url = req.body?.url;
68
+ if (!url || typeof url !== 'string')
69
+ return reply.code(400).send({ error: 'no_url' });
70
+ const rec = await resolveEmbed(url);
71
+ if (!rec)
72
+ return reply.code(422).send({ error: 'unresolved' });
73
+ return reply.send(rec);
74
+ });
56
75
  function key(vault, type) {
57
76
  return `${vault}/${type}`;
58
77
  }
59
- // ─── Error helper ─────────────────────────────────────────────────────────────
60
78
  async function guard(reply, fn) {
61
79
  try {
62
80
  return await fn();
@@ -70,25 +88,16 @@ async function guard(reply, fn) {
70
88
  return reply.code(500).send({ error: 'internal' });
71
89
  }
72
90
  }
73
- // ─── Helper: додати _extends до запису ────────────────────────────────────────
74
- async function withExtends(record, vault, module) {
75
- const matchedExtends = getExtendsFor(vault, module);
76
- if (!matchedExtends.length)
77
- return record;
78
- const _extends = {};
79
- await Promise.all(matchedExtends.map(async (e) => {
80
- const data = await getExtendRecord(extendEntityName(e), String(record.id));
81
- _extends[e.id] = data ?? null;
82
- }));
83
- return { ...record, _extends };
84
- }
85
- // ─── Vaults / schema ──────────────────────────────────────────────────────────
86
- app.get('/api/vaults', () => vaults.map((v) => ({
87
- ...v.meta,
88
- modules: v.modules.map(moduleToClient),
89
- extends: extends_.filter((e) => e.attachTo.some((r) => r.vault === v.meta.id)).map(extendToClient),
90
- })));
91
- // ─── Лічильники записів для Dashboard (усі standalone-модулі активних плагінів) ──
91
+ app.get('/health', async (_req, reply) => {
92
+ try {
93
+ await getEm().getConnection().execute('select 1');
94
+ return { status: 'ok' };
95
+ }
96
+ catch {
97
+ return reply.code(503).send({ status: 'db_unavailable' });
98
+ }
99
+ });
100
+ app.get('/api/vaults', () => buildClientSchema());
92
101
  app.get('/api/counts', async () => {
93
102
  const fork = getEm().fork();
94
103
  const out = {};
@@ -102,30 +111,6 @@ app.get('/api/counts', async () => {
102
111
  }
103
112
  return out;
104
113
  });
105
- /**
106
- * Матч запису модуля проти токенів пошуку — спільне для глобального та спискового
107
- * пошуку. Заголовок (titleKey) важить більше за інші текстові поля. Повертає
108
- * score + сниппет (перше «інше» поле з токеном, інакше заголовок), або null якщо
109
- * не матч. Поля згортаються один раз і перевикористовуються (score + сниппет).
110
- */
111
- function rowMatch(mdef, row, tokens) {
112
- const textKeys = textSearchKeys(mdef);
113
- if (!textKeys.length)
114
- return null;
115
- const tk = titleKey(mdef);
116
- const otherKeys = textKeys.filter((key) => key !== tk);
117
- const label = recordTitle(mdef, row);
118
- const otherRaw = otherKeys.map((key) => String(row[key] ?? ''));
119
- const foldedOthers = otherRaw.map(foldText);
120
- const score = matchScoreFolded(foldText(label), foldedOthers, tokens);
121
- if (score === null)
122
- return null;
123
- const hitIdx = foldedOthers.findIndex((f) => tokens.some((t) => f.includes(t)));
124
- const raw = (hitIdx >= 0 ? otherRaw[hitIdx] : label) ?? label;
125
- const snippet = raw.length > 120 ? raw.slice(0, 120) + '…' : raw;
126
- return { score, snippet };
127
- }
128
- // ─── Глобальний пошук ─────────────────────────────────────────────────────────
129
114
  app.get('/api/search', async (req) => {
130
115
  const { q = '', limit = '20' } = req.query;
131
116
  const tokens = tokenize(q);
@@ -143,7 +128,6 @@ app.get('/api/search', async (req) => {
143
128
  if (!textSearchKeys(mdef).length)
144
129
  continue;
145
130
  const [vault, type] = k.split('/');
146
- // Cap на модуль — захист від великих таблиць (персональний масштаб).
147
131
  const rows = (await fork.find(ename, {}, { limit: 1000 })).map((r) => serialize(r));
148
132
  for (const row of rows) {
149
133
  const m = rowMatch(mdef, row, tokens);
@@ -159,9 +143,6 @@ app.get('/api/search', async (req) => {
159
143
  scored.sort((a, b) => b.score - a.score);
160
144
  return scored.slice(0, lim).map((s) => s.result);
161
145
  });
162
- // ─── Plugins (дашборд: набір активних плагінів живе в _plugins) ───────────────
163
- // NB: GET /api/plugins тепер постачає registerPluginsApi (web-federation маніфест
164
- // з asset-URL). Решта plugin-management ендпоінтів (PATCH/DELETE/settings) лишаються тут.
165
146
  app.patch('/api/plugins/:id', async (req, reply) => {
166
147
  const { id } = req.params;
167
148
  const { enabled } = (req.body ?? {});
@@ -192,11 +173,8 @@ app.patch('/api/plugins/:id', async (req, reply) => {
192
173
  fork.create('_Plugin', { id, enabled: enabled ? 1 : 0, updated_at: new Date().toISOString() });
193
174
  }
194
175
  await fork.flush();
195
- // Зміна набору активних плагінів застосовується на наступному старті сервера.
196
176
  return { ok: true, restartRequired: true };
197
177
  });
198
- // ЯВНЕ видалення даних плагіна (безповоротне): DROP таблиць + enabled=0.
199
- // Toggle (PATCH) даних не чіпає — це окрема свідома дія.
200
178
  app.delete('/api/plugins/:id/data', async (req, reply) => {
201
179
  const { id } = req.params;
202
180
  const plugins = await getPlugins();
@@ -221,7 +199,7 @@ app.get('/api/plugins/:id/settings', async (req, reply) => {
221
199
  return reply.code(404).send({ error: 'no_settings' });
222
200
  }
223
201
  const row = await getPluginSettings(id);
224
- return row;
202
+ return maskTree(p.settings.fields, row);
225
203
  });
226
204
  app.put('/api/plugins/:id/settings', (req, reply) => guard(reply, async () => {
227
205
  const { id } = req.params;
@@ -236,29 +214,19 @@ app.put('/api/plugins/:id/settings', (req, reply) => guard(reply, async () => {
236
214
  label: `${id}.plugin.label`,
237
215
  fields: p.settings.fields,
238
216
  };
239
- const parsed = buildZodObject(syntheticMod).safeParse(req.body ?? {});
217
+ const existing = await getPluginSettings(id);
218
+ const body = preserveTree(p.settings.fields, (req.body ?? {}), existing);
219
+ const parsed = buildZodObject(syntheticMod).safeParse(body);
240
220
  if (!parsed.success) {
241
- throw new ValidationError(parsed.error.issues.map((i) => {
242
- const field = String(i.path[0] ?? '');
243
- try {
244
- const m = JSON.parse(i.message);
245
- if (typeof m.code === 'string')
246
- return { field, code: m.code, params: m.params };
247
- }
248
- catch {
249
- /* */
250
- }
251
- return { field, code: 'invalid' };
252
- }));
221
+ throw new ValidationError(parsed.error.issues.map(toIssue));
253
222
  }
254
223
  const entityName = `_settings__${id}`;
255
224
  const row = { plugin_id: id, ...parsed.data };
256
225
  await getEm()
257
226
  .fork()
258
227
  .upsert(entityName, row);
259
- return { ok: true, row };
228
+ return { ok: true, row: maskTree(p.settings.fields, row) };
260
229
  }));
261
- // ─── Nextcloud: test connection ───────────────────────────────────────────────
262
230
  app.post('/api/plugins/nextcloud/test', async (_req, reply) => {
263
231
  const settings = await getPluginSettings('nextcloud');
264
232
  const url = settings['nextcloud_url'];
@@ -267,132 +235,237 @@ app.post('/api/plugins/nextcloud/test', async (_req, reply) => {
267
235
  if (!url || !login || !password) {
268
236
  return reply.code(400).send({ error: 'Nextcloud settings not configured. Save them first.' });
269
237
  }
270
- // Змінний специфікатор (не літерал): server не тягне @coffer-org/plugin-nextcloud у свою
271
- // tsc-програму (розрив циклу server↔плагіни). Generic plugin-routes — окреме питання.
272
238
  const ncRuntime = '@coffer-org/plugin-nextcloud/runtime';
273
- const { checkNextcloudConnection } = (await import(ncRuntime));
239
+ const { checkNextcloudConnection } = (await import(__rewriteRelativeImportExtension(ncRuntime)));
274
240
  const result = await checkNextcloudConnection(url, login, password);
275
241
  if (result.ok)
276
242
  return { ok: true };
277
243
  return reply.code(400).send({ error: result.error });
278
244
  });
279
- // ─── Records CRUD ─────────────────────────────────────────────────────────────
280
- /** Привести значення фільтра (з query — завжди рядок) до типу колонки MikroORM. */
281
- function coerceFilter(v, column) {
282
- if (column === 'integer' || column === 'real') {
283
- const n = Number(v);
284
- return Number.isNaN(n) ? v : n;
285
- }
286
- if (column === 'boolean')
287
- return v === 'true' || v === '1';
288
- if (column === 'datetime')
289
- return dtStringToDate(v); // фільтр-рядок → Date під тип колонки
290
- return v; // date/time лишаються рядками
245
+ app.post('/api/plugins/finance/test', async (_req, reply) => {
246
+ const settings = await getPluginSettings('finance');
247
+ const url = settings['firefly_url'];
248
+ const token = settings['token'];
249
+ if (!url || !token) {
250
+ return reply.code(400).send({ error: 'Finance settings not configured. Save them first.' });
251
+ }
252
+ const finRuntime = '@coffer-org/plugin-finance/runtime';
253
+ const { checkFireflyConnection } = (await import(__rewriteRelativeImportExtension(finRuntime)));
254
+ const result = await checkFireflyConnection(url, token);
255
+ if (result.ok)
256
+ return { ok: true };
257
+ return reply.code(400).send({ error: result.error });
258
+ });
259
+ app.post('/api/plugins/finance/sync', async (_req, reply) => {
260
+ const finRuntime = '@coffer-org/plugin-finance/runtime';
261
+ const { runSync } = (await import(__rewriteRelativeImportExtension(finRuntime)));
262
+ try {
263
+ const counts = await runSync();
264
+ return { ok: true, ...counts };
265
+ }
266
+ catch (e) {
267
+ return reply.code(400).send({ error: e.message });
268
+ }
269
+ });
270
+ app.post('/api/plugins/finance/import-wise', async (_req, reply) => {
271
+ const finRuntime = '@coffer-org/plugin-finance/runtime';
272
+ const { importWise } = (await import(__rewriteRelativeImportExtension(finRuntime)));
273
+ try {
274
+ return await importWise();
275
+ }
276
+ catch (e) {
277
+ return reply.code(400).send({ error: e.message });
278
+ }
279
+ });
280
+ app.post('/api/plugins/devices/scan', async (_req, reply) => {
281
+ const devRuntime = '@coffer-org/plugin-devices/runtime';
282
+ const { runScan } = (await import(__rewriteRelativeImportExtension(devRuntime)));
283
+ try {
284
+ const result = await runScan({ create: true });
285
+ return { ok: true, result };
286
+ }
287
+ catch (e) {
288
+ if (e instanceof ValidationError) {
289
+ const detail = e.issues.map((i) => `${i.path.join('.') || i.field}: ${i.code}`).join('; ');
290
+ return reply.code(422).send({ error: `Validation error during scan — ${detail}`, issues: e.issues });
291
+ }
292
+ return reply.code(409).send({ error: e.message });
293
+ }
294
+ });
295
+ app.post('/api/plugins/media/test', async (_req, reply) => {
296
+ const settings = await getPluginSettings('media');
297
+ const url = settings['jellyfin_url'];
298
+ const token = settings['jellyfin_token'];
299
+ if (!url || !token) {
300
+ return reply.code(400).send({ error: 'Jellyfin settings not configured. Save them first.' });
301
+ }
302
+ const mediaRuntime = '@coffer-org/plugin-media/runtime';
303
+ const { testConnection } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
304
+ const result = await testConnection(url, token);
305
+ if (result.ok)
306
+ return { ok: true };
307
+ return reply.code(400).send({ error: result.error });
308
+ });
309
+ app.post('/api/plugins/media/sync', async (_req, reply) => {
310
+ const mediaRuntime = '@coffer-org/plugin-media/runtime';
311
+ const { runJellyfinSync } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
312
+ try {
313
+ const counts = await runJellyfinSync();
314
+ return { ok: true, ...counts };
315
+ }
316
+ catch (e) {
317
+ return reply.code(400).send({ error: e.message });
318
+ }
319
+ });
320
+ app.post('/api/plugins/media/add', async (req, reply) => {
321
+ const { query, tmdb_id, kind } = (req.body ?? {});
322
+ const mediaRuntime = '@coffer-org/plugin-media/runtime';
323
+ const { addTitle } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
324
+ try {
325
+ return await addTitle(query, tmdb_id, kind);
326
+ }
327
+ catch (e) {
328
+ return reply.code(400).send({ error: e.message });
329
+ }
330
+ });
331
+ app.get('/api/fs/list', async (req, reply) => {
332
+ const home = homedir();
333
+ const q = req.query;
334
+ const r = resolveWithinHome(q.path ?? '', home);
335
+ if ('error' in r)
336
+ return reply.code(403).send({ error: r.error });
337
+ let abs;
338
+ try {
339
+ abs = await realpath(r.abs);
340
+ }
341
+ catch {
342
+ return { dir: r.abs, entries: [] };
343
+ }
344
+ if (abs !== home && !abs.startsWith(home + sep))
345
+ return reply.code(403).send({ error: 'outside_home' });
346
+ try {
347
+ const entries = filterSort(await listDir(abs), q.exts ? q.exts.split(',') : undefined);
348
+ return { dir: abs, entries };
349
+ }
350
+ catch {
351
+ return { dir: abs, entries: [] };
352
+ }
353
+ });
354
+ function maskRecordRow(vault, type, row) {
355
+ const m = getModule(vault, type);
356
+ let out = m ? maskTree(m.fields, row) : row;
357
+ const ext = out._extends;
358
+ if (ext) {
359
+ const defs = getExtendsFor(vault, type);
360
+ const maskedExt = {};
361
+ for (const [id, sub] of Object.entries(ext)) {
362
+ const def = defs.find((e) => e.id === id);
363
+ maskedExt[id] = sub && def ? maskTree(def.fields, sub) : sub;
364
+ }
365
+ out = { ...out, _extends: maskedExt };
366
+ }
367
+ return out;
291
368
  }
292
369
  app.get('/api/:vault/:type', async (req, reply) => {
293
370
  const { vault, type } = req.params;
294
371
  const query = req.query;
295
- const { q, ...filterParams } = query;
296
- const ename = entityNames.get(key(vault, type));
297
- const mdef = moduleDefs.get(key(vault, type));
298
- if (!ename)
299
- return reply.code(404).send({ error: 'unknown_type' });
300
- // Збираємо фільтри за полями модуля (наприклад: trip_id=xxx, brand=Apple).
301
- const where = {};
302
- // Значення з query — рядки,
303
- // тому приводимо до типу колонки (boolean/number) під типи сутності.
304
- if (mdef) {
305
- const fm = fieldMap(mdef.fields);
306
- for (const [k, v] of Object.entries(filterParams)) {
307
- const fieldDef = fm[k];
308
- if (fieldDef && !fieldDef.virtual)
309
- where[k] = coerceFilter(v, fieldDef.column);
310
- }
372
+ try {
373
+ const rows = await recordList(vault, type, query);
374
+ return rows.map((r) => maskRecordRow(vault, type, r));
375
+ }
376
+ catch (e) {
377
+ if (e instanceof UnknownTypeError)
378
+ return reply.code(404).send({ error: 'unknown_type' });
379
+ throw e;
311
380
  }
312
- // Точкова вибірка за id (comma-separated): резолв relation-цілей,
313
- // зокрема standalone:false-модулів, що не перелічуються глобально.
314
- if (filterParams.id) {
315
- const ids = String(filterParams.id)
316
- .split(',')
317
- .map((s) => s.trim())
318
- .filter(Boolean);
319
- if (ids.length)
320
- where['id'] = ids.length === 1 ? ids[0] : { $in: ids };
321
- }
322
- // standalone:false-модулі не перелічуються глобально — лише точкова вибірка
323
- // (за полем-фільтром або id). Порожній where → порожній список.
324
- if (mdef?.standalone === false && Object.keys(where).length === 0)
325
- return [];
326
- const fork = getEm().fork();
327
- let rows = (await fork.find(ename, where)).map((r) => serialize(r));
328
- // Текстовий пошук — у JS (SQLite LIKE не сворачивает кириличний регістр).
329
- // Порядок/сортування лишаємо клієнту (TypeView сортує по колонках) — без ранжування.
330
- const tokens = q ? tokenize(q) : [];
331
- if (tokens.length > 0 && mdef) {
332
- rows = rows.filter((row) => rowMatch(mdef, row, tokens) !== null);
333
- }
334
- const decoded = mdef ? rows.map((r) => decodeTemporal(mdef, r)) : rows;
335
- return Promise.all(decoded.map((row) => withExtends(row, vault, type)));
336
381
  });
337
382
  app.get('/api/:vault/:type/:id', async (req, reply) => {
338
383
  const { vault, type, id } = req.params;
339
- const ename = entityNames.get(key(vault, type));
340
- const mdef = moduleDefs.get(key(vault, type));
341
- if (!ename || mdef?.standalone === false)
342
- return reply.code(404).send({ error: 'unknown_type' });
343
- if (!mdef)
344
- return reply.code(404).send({ error: 'unknown_type' });
345
- const row = await getRecord(mdef, ename, id);
346
- if (!row)
347
- return reply.code(404).send({ error: 'not_found' });
348
- return withExtends(row, vault, type);
384
+ const rid = Number(id);
385
+ if (isNaN(rid))
386
+ return reply.code(400).send({ error: 'invalid_id' });
387
+ try {
388
+ const row = await recordGet(vault, type, rid);
389
+ if (!row)
390
+ return reply.code(404).send({ error: 'not_found' });
391
+ return maskRecordRow(vault, type, row);
392
+ }
393
+ catch (e) {
394
+ if (e instanceof UnknownTypeError)
395
+ return reply.code(404).send({ error: 'unknown_type' });
396
+ throw e;
397
+ }
349
398
  });
350
399
  app.post('/api/:vault/:type', (req, reply) => guard(reply, async () => {
351
400
  const { vault, type } = req.params;
352
- const m = getModule(vault, type);
353
- const ename = entityNames.get(key(vault, type));
354
- if (!m || !ename)
355
- return reply.code(404).send({ error: 'unknown_type' });
356
- const { base, extData } = splitBody(req.body);
357
- const row = await createRecord(m, ename, base, { actor: 'gui' });
358
- await saveExtends(vault, type, String(row.id), extData);
359
- return reply.code(201).send(await withExtends(row, vault, type));
401
+ try {
402
+ const m = getModule(vault, type);
403
+ let body = (req.body ?? {});
404
+ if (m)
405
+ body = preserveTree(m.fields, body, {});
406
+ const row = await recordCreate(vault, type, body);
407
+ return reply.code(201).send(maskRecordRow(vault, type, row));
408
+ }
409
+ catch (e) {
410
+ if (e instanceof UnknownTypeError)
411
+ return reply.code(404).send({ error: 'unknown_type' });
412
+ throw e;
413
+ }
360
414
  }));
361
415
  app.patch('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
362
416
  const { vault, type, id } = req.params;
363
- const m = getModule(vault, type);
364
- const ename = entityNames.get(key(vault, type));
365
- if (!m || !ename)
366
- return reply.code(404).send({ error: 'unknown_type' });
367
- const { base, extData } = splitBody(req.body);
368
- const row = await updateRecord(m, ename, id, base, { actor: 'gui' });
369
- await saveExtends(vault, type, id, extData);
370
- return withExtends(row, vault, type);
417
+ const rid = Number(id);
418
+ if (isNaN(rid))
419
+ return reply.code(400).send({ error: 'invalid_id' });
420
+ try {
421
+ const m = getModule(vault, type);
422
+ let body = (req.body ?? {});
423
+ if (m) {
424
+ const existing = (await recordGet(vault, type, rid)) ?? {};
425
+ body = preserveTree(m.fields, body, existing);
426
+ const exDefs = getExtendsFor(vault, type);
427
+ const exRecs = (existing._extends ?? {});
428
+ for (const def of exDefs) {
429
+ const bk = `_extend_${def.id}`;
430
+ const sub = body[bk];
431
+ if (sub && typeof sub === 'object') {
432
+ body = { ...body, [bk]: preserveTree(def.fields, sub, (exRecs[def.id] ?? {})) };
433
+ }
434
+ }
435
+ }
436
+ const row = await recordUpdate(vault, type, rid, body);
437
+ return maskRecordRow(vault, type, row);
438
+ }
439
+ catch (e) {
440
+ if (e instanceof UnknownTypeError)
441
+ return reply.code(404).send({ error: 'unknown_type' });
442
+ throw e;
443
+ }
371
444
  }));
372
445
  app.delete('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
373
446
  const { vault, type, id } = req.params;
374
- const m = getModule(vault, type);
375
- const ename = entityNames.get(key(vault, type));
376
- if (!m || !ename)
377
- return reply.code(404).send({ error: 'unknown_type' });
378
- await deleteRecord(m, ename, id, { actor: 'gui' });
379
- return reply.code(204).send();
447
+ const rid = Number(id);
448
+ if (isNaN(rid))
449
+ return reply.code(400).send({ error: 'invalid_id' });
450
+ try {
451
+ await recordDelete(vault, type, rid);
452
+ return reply.code(204).send();
453
+ }
454
+ catch (e) {
455
+ if (e instanceof UnknownTypeError)
456
+ return reply.code(404).send({ error: 'unknown_type' });
457
+ throw e;
458
+ }
380
459
  }));
381
- // ─── Web-federation: маніфест активних плагінів + статичні asset'и з node_modules ─
382
460
  await registerPluginsApi(app);
383
- // ─── Serve-режим (продакшн): віддавати зібраний web + SPA-fallback ─────────────
384
- // Вмикається коли WEB_DIST вказує на packages/web/dist (через `coffer start`).
385
461
  const WEB_DIST = process.env.WEB_DIST;
386
462
  if (WEB_DIST && existsSync(join(WEB_DIST, 'index.html'))) {
387
463
  await app.register(fastifyStatic, { root: WEB_DIST, prefix: '/', decorateReply: false });
388
- // Vendor singletons (react/react-dom/jsx-runtime/i18next/web-sdk) — sibling of dist/,
389
- // served at /vendor/ so the importmap in index.html resolves plugin bare imports.
390
464
  const VENDOR_DIST = join(WEB_DIST, '..', 'dist-vendor');
391
465
  if (existsSync(VENDOR_DIST)) {
392
466
  await app.register(fastifyStatic, { root: VENDOR_DIST, prefix: '/vendor/', decorateReply: false });
393
467
  }
394
468
  app.setNotFoundHandler((req, reply) => {
395
- // API/uploads/vendor — справжні 404; решта GET → index.html (клієнтський роутинг)
396
469
  if (req.method === 'GET' &&
397
470
  !req.url.startsWith('/api') &&
398
471
  !req.url.startsWith('/uploads') &&
@@ -410,11 +483,16 @@ app
410
483
  app.log.error(e);
411
484
  process.exit(1);
412
485
  });
413
- // Graceful shutdown — дає tsx watch час закрити порт перед рестартом
414
- const shutdown = () => Promise.resolve(teardownPlugins())
415
- .then(() => app.close())
416
- .then(() => process.exit(0))
417
- .catch(() => process.exit(1));
486
+ let shuttingDown = false;
487
+ const shutdown = () => {
488
+ if (shuttingDown)
489
+ return;
490
+ shuttingDown = true;
491
+ Promise.resolve(teardownPlugins())
492
+ .then(() => app.close())
493
+ .then(() => closeDb())
494
+ .then(() => process.exit(0))
495
+ .catch(() => process.exit(1));
496
+ };
418
497
  process.on('SIGTERM', shutdown);
419
498
  process.on('SIGINT', shutdown);
420
- //# sourceMappingURL=index.js.map
@@ -1,6 +1,5 @@
1
- export declare class UnknownTypeError extends Error {
2
- }
3
- export declare function localExists(vault: string, type: string, id: string): Promise<boolean>;
1
+ import { UnknownTypeError } from './records-api.ts';
2
+ export { UnknownTypeError };
3
+ export declare function localExists(vault: string, type: string, id: number): Promise<boolean>;
4
4
  export declare function localPost(vault: string, type: string, body: unknown): Promise<Record<string, unknown>>;
5
- export declare function localPatch(vault: string, type: string, id: string, body: unknown): Promise<Record<string, unknown>>;
6
- //# sourceMappingURL=local-api.d.ts.map
5
+ export declare function localPatch(vault: string, type: string, id: number, body: unknown): Promise<Record<string, unknown>>;