@coffer-org/server 3.3.0 → 3.4.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.
@@ -151,11 +151,12 @@ export function flattenEmbeddedAt(fields, input) {
151
151
  }
152
152
  }
153
153
  for (const [key, subs] of columnsTargets(fields)) {
154
+ if (!(key in out))
155
+ continue;
154
156
  const obj = out[key];
155
157
  delete out[key];
156
- if (obj && typeof obj === 'object')
157
- for (const s of subs)
158
- out[`${key}__${s}`] = obj[s];
158
+ for (const s of subs)
159
+ out[`${key}__${s}`] = obj && typeof obj === 'object' ? obj[s] : null;
159
160
  }
160
161
  return out;
161
162
  }
@@ -163,16 +164,18 @@ export function nestEmbeddedAt(fields, row) {
163
164
  const out = { ...row };
164
165
  for (const [key, subs] of columnsTargets(fields)) {
165
166
  const nested = {};
166
- let any = false;
167
+ let filled = false;
167
168
  for (const s of subs) {
168
169
  const col = `${key}__${s}`;
169
- if (col in out) {
170
- nested[s] = out[col];
171
- delete out[col];
172
- any = true;
173
- }
170
+ if (!(col in out))
171
+ continue;
172
+ const v = out[col];
173
+ nested[s] = v;
174
+ delete out[col];
175
+ if (v != null && v !== '')
176
+ filled = true;
174
177
  }
175
- if (any)
178
+ if (filled)
176
179
  out[key] = nested;
177
180
  }
178
181
  for (const [key, g] of embeddedGroups(fields)) {
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
+ import { disableColumnRenameDetection } from "./rename-guard.js";
5
6
  const SKIP_TABLES = [/^_search(_|$)/];
6
7
  let _orm;
7
8
  export function getOrm() {
@@ -21,6 +22,7 @@ export async function closeDb() {
21
22
  _orm = undefined;
22
23
  }
23
24
  export async function initDb(entities) {
25
+ disableColumnRenameDetection();
24
26
  const driverName = process.env['DB_DRIVER'] ?? 'sqlite';
25
27
  const common = {
26
28
  entities,
@@ -13,6 +13,7 @@ export declare function buildExtendCollectionEntities(e: ExtendDef): EntitySchem
13
13
  export declare const EventSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
14
14
  export declare const PluginRowSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
15
15
  export declare const MigrationRowSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
16
+ export declare const MigrationOpRowSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
16
17
  export declare const SeedRowSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
17
18
  export declare const EmbeddingSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
18
19
  export declare const PluginStateSchema: EntitySchema<any, never, import("@mikro-orm/core").EntityCtor<any>>;
@@ -122,6 +122,25 @@ export const MigrationRowSchema = new EntitySchema({
122
122
  version: { type: 'integer', nullable: false },
123
123
  },
124
124
  });
125
+ export const MigrationOpRowSchema = new EntitySchema({
126
+ name: '_MigrationOp',
127
+ tableName: '_migration_ops',
128
+ properties: {
129
+ plugin_id: { type: 'text', primary: true },
130
+ version: { type: 'integer', primary: true },
131
+ seq: { type: 'integer', primary: true },
132
+ op: { type: 'text', nullable: false },
133
+ table_name: { type: 'text', nullable: false },
134
+ performed: { type: 'integer', nullable: false },
135
+ rows: { type: 'integer', nullable: true },
136
+ scanned: { type: 'integer', nullable: true },
137
+ code: { type: 'text', nullable: true },
138
+ reason: { type: 'text', nullable: true },
139
+ empty_targets: { type: 'text', nullable: true },
140
+ stranded_sources: { type: 'text', nullable: true },
141
+ applied_at: { type: 'text', nullable: true },
142
+ },
143
+ });
125
144
  export const SeedRowSchema = new EntitySchema({
126
145
  name: '_Seed',
127
146
  tableName: '_seeds',
@@ -296,6 +315,7 @@ export const systemEntities = [
296
315
  EventSchema,
297
316
  PluginRowSchema,
298
317
  MigrationRowSchema,
318
+ MigrationOpRowSchema,
299
319
  SeedRowSchema,
300
320
  EmbeddingSchema,
301
321
  PluginStateSchema,
@@ -1,9 +1,10 @@
1
1
  import type { EntityManager } from '@mikro-orm/core';
2
+ import type { PartMemo } from './part-injection.ts';
2
3
  export declare function splitBody(body: unknown): {
3
4
  base: Record<string, unknown>;
4
5
  extData: Record<string, Record<string, unknown>>;
5
6
  };
6
7
  export declare function deleteExtends(em: EntityManager, library: string, shelf: string, id: number): Promise<void>;
7
- export declare function readExtends(em: EntityManager, library: string, shelf: string, id: number): Promise<Record<string, unknown>>;
8
+ export declare function readExtends(em: EntityManager, library: string, shelf: string, id: number, memo?: PartMemo): Promise<Record<string, unknown>>;
8
9
  export declare function validateExtends(library: string, shelf: string, extData: Record<string, Record<string, unknown>>): void;
9
- export declare function saveExtends(em: EntityManager, library: string, shelf: string, baseId: number, extData: Record<string, Record<string, unknown>>): Promise<void>;
10
+ export declare function saveExtends(em: EntityManager, library: string, shelf: string, baseId: number, extData: Record<string, Record<string, unknown>>, memo?: PartMemo): Promise<void>;
package/dist/extend-io.js CHANGED
@@ -20,10 +20,10 @@ export async function deleteExtends(em, library, shelf, id) {
20
20
  await deleteExtendRecord(em, e, id);
21
21
  }
22
22
  }
23
- export async function readExtends(em, library, shelf, id) {
23
+ export async function readExtends(em, library, shelf, id, memo) {
24
24
  const out = {};
25
25
  for (const e of getExtendsFor(library, shelf)) {
26
- out[e.id] = (await getExtendRecord(e, id, em)) ?? null;
26
+ out[e.id] = (await getExtendRecord(e, id, em, memo)) ?? null;
27
27
  }
28
28
  return out;
29
29
  }
@@ -41,7 +41,7 @@ export function validateExtends(library, shelf, extData) {
41
41
  if (issues.length)
42
42
  throw new ValidationError(issues);
43
43
  }
44
- export async function saveExtends(em, library, shelf, baseId, extData) {
44
+ export async function saveExtends(em, library, shelf, baseId, extData, memo) {
45
45
  validateExtends(library, shelf, extData);
46
46
  for (const e of getExtendsFor(library, shelf)) {
47
47
  const data = extData[e.id];
@@ -53,7 +53,7 @@ export async function saveExtends(em, library, shelf, baseId, extData) {
53
53
  const fileIssues = normalizeFileFieldsAt(e.fields, row);
54
54
  if (fileIssues.length)
55
55
  throw new ValidationError(fileIssues);
56
- await upsertExtendRecord(em, e, baseId, row);
56
+ await upsertExtendRecord(em, e, baseId, row, memo);
57
57
  }
58
58
  }
59
59
  }
@@ -1,9 +1,10 @@
1
1
  import type { EntityManager } from '@mikro-orm/core';
2
2
  import type { ExtendDef } from '@coffer-org/sdk/extend';
3
+ import { type PartMemo } from './part-injection.ts';
3
4
  type Row = Record<string, unknown>;
4
5
  export declare function extendEntityName(e: ExtendDef): string;
5
- export declare function getExtendRecord(e: ExtendDef, baseId: number, em?: EntityManager): Promise<Row | undefined>;
6
+ export declare function getExtendRecord(e: ExtendDef, baseId: number, em?: EntityManager, memo?: PartMemo): Promise<Row | undefined>;
6
7
  export declare function getExtendRecords(e: ExtendDef, baseIds: number[], em?: EntityManager): Promise<Map<number, Row>>;
7
- export declare function upsertExtendRecord(em: EntityManager, e: ExtendDef, baseId: number, data: Row): Promise<void>;
8
+ export declare function upsertExtendRecord(em: EntityManager, e: ExtendDef, baseId: number, data: Row, memo?: PartMemo): Promise<void>;
8
9
  export declare function deleteExtendRecord(em: EntityManager, e: ExtendDef, baseId: number): Promise<void>;
9
10
  export {};
@@ -4,14 +4,20 @@ import { encodeJsonAt } from "./mutate.js";
4
4
  import { encodeTemporalAt, decodeTemporalAt } from "./temporal.js";
5
5
  import { chunk } from "./batch.js";
6
6
  import { selectRows } from "./read-rows.js";
7
- import { injectParts, applyStoredParts } from "./part-injection.js";
7
+ import { injectParts, applyStoredParts, hasStoredPartWork, } from "./part-injection.js";
8
8
  export function extendEntityName(e) {
9
9
  return `extend__${e.id}`;
10
10
  }
11
- function extendPartCtx(e, record) {
12
- return { record, settings: {}, global: {}, meta: { library: 'extend', shelf: e.id } };
11
+ function extendPartCtx(e, em, memo) {
12
+ return {
13
+ settings: {},
14
+ global: {},
15
+ meta: { library: 'extend', shelf: e.id },
16
+ ...(em ? { em } : {}),
17
+ ...(memo ? { memo } : {}),
18
+ };
13
19
  }
14
- export async function getExtendRecord(e, baseId, em) {
20
+ export async function getExtendRecord(e, baseId, em, memo) {
15
21
  const fork = em ?? getEm().fork();
16
22
  const name = extendEntityName(e);
17
23
  const [flat] = (await selectRows(fork, name, { base_id: baseId }, { limit: 1 }));
@@ -20,7 +26,7 @@ export async function getExtendRecord(e, baseId, em) {
20
26
  const decoded = nestEmbeddedAt(e.fields, decodeTemporalAt(e.fields, flat));
21
27
  const collections = await readAt(fork, name, e.fields, baseId);
22
28
  const record = { ...decoded, ...collections };
23
- return injectParts(e.fields, record, extendPartCtx(e, record));
29
+ return injectParts(e.fields, record, extendPartCtx(e, em, memo));
24
30
  }
25
31
  export async function getExtendRecords(e, baseIds, em) {
26
32
  const out = new Map();
@@ -40,14 +46,31 @@ export async function getExtendRecords(e, baseIds, em) {
40
46
  const id = Number(flat.base_id);
41
47
  const decoded = nestEmbeddedAt(e.fields, decodeTemporalAt(e.fields, flat));
42
48
  const record = { ...decoded, ...(collectionsById.get(id) ?? {}) };
43
- out.set(id, await injectParts(e.fields, record, extendPartCtx(e, record)));
49
+ out.set(id, await injectParts(e.fields, record, extendPartCtx(e, em)));
44
50
  }
45
51
  return out;
46
52
  }
47
- export async function upsertExtendRecord(em, e, baseId, data) {
53
+ async function storedExtendState(em, e, baseId) {
48
54
  const name = extendEntityName(e);
49
- const withParts = await applyStoredParts(e.fields, data, extendPartCtx(e, data));
50
- const { base, collections } = splitAt(e.fields, withParts);
55
+ const [flat] = (await selectRows(em, name, { base_id: baseId }, { limit: 1 }));
56
+ if (!flat)
57
+ return {};
58
+ const decoded = nestEmbeddedAt(e.fields, decodeTemporalAt(e.fields, flat));
59
+ return { ...decoded, ...(await readAt(em, name, e.fields, baseId)) };
60
+ }
61
+ export async function upsertExtendRecord(em, e, baseId, data, memo) {
62
+ const name = extendEntityName(e);
63
+ const needParts = hasStoredPartWork(e.fields);
64
+ const target = needParts ? { ...(await storedExtendState(em, e, baseId)), ...data } : data;
65
+ const withParts = await applyStoredParts(e.fields, target, extendPartCtx(e, em, memo));
66
+ const write = {};
67
+ for (const [k, v] of Object.entries(withParts))
68
+ if (k in data || v !== target[k])
69
+ write[k] = v;
70
+ const { base, collections } = splitAt(e.fields, write);
71
+ for (const k of Object.keys(collections))
72
+ if (!(k in data))
73
+ delete collections[k];
51
74
  const flat = encodeJsonAt(e.fields, encodeTemporalAt(e.fields, flattenEmbeddedAt(e.fields, base)));
52
75
  await em.upsert(name, { base_id: baseId, ...flat });
53
76
  await writeAt(em, name, e.fields, baseId, collections);
@@ -0,0 +1,7 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export interface HttpError {
3
+ code: number;
4
+ body: Record<string, unknown>;
5
+ }
6
+ export declare function httpErrorFor(e: unknown): HttpError | null;
7
+ export declare function registerErrorHandler(app: FastifyInstance): void;
@@ -0,0 +1,24 @@
1
+ import { ValidationError, NotFoundError } from "./mutate.js";
2
+ import { SingleShelfError } from "./records-api.js";
3
+ import { PartContractError } from "./part-errors.js";
4
+ export function httpErrorFor(e) {
5
+ if (e instanceof ValidationError)
6
+ return { code: 422, body: { error: 'validation', issues: e.issues } };
7
+ if (e instanceof SingleShelfError)
8
+ return { code: 409, body: { error: 'single_shelf', message: e.message } };
9
+ if (e instanceof NotFoundError)
10
+ return { code: 404, body: { error: 'not_found' } };
11
+ if (e instanceof PartContractError)
12
+ return { code: 500, body: { error: 'part_contract', message: e.message } };
13
+ return null;
14
+ }
15
+ export function registerErrorHandler(app) {
16
+ app.setErrorHandler((error, req, reply) => {
17
+ const mapped = httpErrorFor(error);
18
+ if (!mapped)
19
+ return reply.send(error);
20
+ if (mapped.code >= 500)
21
+ req.log.error(error);
22
+ return reply.code(mapped.code).send(mapped.body);
23
+ });
24
+ }
package/dist/index.js CHANGED
@@ -11,7 +11,8 @@ import fastifyStatic from '@fastify/static';
11
11
  import { shelfTableName } from "./entity-schema.js";
12
12
  import { getEm, closeDb } from "./db.js";
13
13
  import { recordCounts } from "./counts.js";
14
- import { ValidationError, NotFoundError } from "./mutate.js";
14
+ import { NotFoundError } from "./mutate.js";
15
+ import { httpErrorFor, registerErrorHandler } from "./http-errors.js";
15
16
  import { resolveEmbed } from "./embed.js";
16
17
  import { resolveWithinHome, filterSort, listDir } from "./fs-list.js";
17
18
  import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from "./plugin-runtime.js";
@@ -24,7 +25,7 @@ import { baseUrl } from "./public-url.js";
24
25
  import { discoverPluginAssets, discoverRuntime } from "./plugin-discovery.js";
25
26
  import { checkLatestVersion, resolveUpdateTarget, resolveAllUpdateTargets, resolveBaseTargets, resolveRuntimeTarget, runNpmInstall, } from "./plugin-updates.js";
26
27
  import { buildClientSchema } from "./schema-api.js";
27
- import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, ensureSingle, SingleShelfError, } from "./records-api.js";
28
+ import { recordList, recordListPage, isPagedRequest, recordGet, recordCreate, recordUpdate, recordDelete, recordRestore, recordPurge, recordTrashList, MAX_PAGE, UnknownShelfError, FilterError, ensureSingle, } from "./records-api.js";
28
29
  import { showcaseList, showcaseListAll, showcaseCount, UnknownShowcaseError } from "./showcase-api.js";
29
30
  import { getActiveRegistry } from "./registry-context.js";
30
31
  import { maskTree, preserveTree } from "./field-masking.js";
@@ -117,6 +118,7 @@ app.post('/api/embed', async (req, reply) => {
117
118
  return reply.code(422).send({ error: 'unresolved' });
118
119
  return reply.send(rec);
119
120
  });
121
+ registerErrorHandler(app);
120
122
  function key(library, shelf) {
121
123
  return `${library}/${shelf}`;
122
124
  }
@@ -125,13 +127,11 @@ async function guard(reply, fn) {
125
127
  return await fn();
126
128
  }
127
129
  catch (e) {
128
- if (e instanceof ValidationError)
129
- return reply.code(422).send({ error: 'validation', issues: e.issues });
130
- if (e instanceof SingleShelfError)
131
- return reply.code(409).send({ error: 'single_shelf', message: e.message });
132
- if (e instanceof NotFoundError)
133
- return reply.code(404).send({ error: 'not_found' });
134
- app.log.error(e);
130
+ const mapped = httpErrorFor(e);
131
+ if (!mapped || mapped.code >= 500)
132
+ app.log.error(e);
133
+ if (mapped)
134
+ return reply.code(mapped.code).send(mapped.body);
135
135
  return reply.code(500).send({ error: 'internal' });
136
136
  }
137
137
  }
@@ -445,6 +445,8 @@ app.get('/api/:library/:shelf', async (req, reply) => {
445
445
  catch (e) {
446
446
  if (e instanceof UnknownShelfError)
447
447
  return reply.code(404).send({ error: 'unknown_shelf' });
448
+ if (e instanceof FilterError)
449
+ return reply.code(400).send({ error: 'bad_filter', message: e.message });
448
450
  throw e;
449
451
  }
450
452
  });
package/dist/mcp-local.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ValidationError, NotFoundError } from '@coffer-org/mcp/client';
2
- import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, recordTrashList, recordRestore, recordPurge, SingleShelfError, } from "./records-api.js";
2
+ import { recordList, recordListPage, recordGet, recordCreate, recordUpdate, recordDelete, UnknownShelfError, recordTrashList, recordRestore, recordPurge, SingleShelfError, FilterError, } from "./records-api.js";
3
3
  import { ValidationError as ServerValidationError } from "./mutate.js";
4
4
  import { buildClientSchema } from "./schema-api.js";
5
5
  export function mapError(e) {
@@ -9,6 +9,8 @@ export function mapError(e) {
9
9
  throw new NotFoundError('not_found');
10
10
  if (e instanceof SingleShelfError)
11
11
  throw new ValidationError([{ field: 'shelf', code: 'single_shelf', message: e.message }]);
12
+ if (e instanceof FilterError)
13
+ throw new ValidationError([{ field: 'query', code: 'bad_filter', message: e.message }]);
12
14
  throw e;
13
15
  }
14
16
  export function splitListQuery(raw) {
@@ -1,5 +1,7 @@
1
1
  import type { EntityManager, EntitySchema } from '@mikro-orm/core';
2
2
  import type { Migration, PluginHooks, TableOps } from './plugin-hooks.ts';
3
+ export declare function markColumnHandedOver(table: string, column: string): void;
4
+ export declare function isColumnHandedOver(table: string, column: string): boolean;
3
5
  export declare function introspectTable(em: EntityManager, table: string): Promise<{
4
6
  exists: boolean;
5
7
  columns: Set<string>;
@@ -9,7 +11,23 @@ export declare function introspectTable(em: EntityManager, table: string): Promi
9
11
  primary: boolean;
10
12
  }[];
11
13
  }>;
12
- export declare function makeTable(em: EntityManager, table: string): TableOps;
14
+ export declare function introspectAllColumns(em: EntityManager, tables: string[]): Promise<Map<string, Set<string>>>;
15
+ export type MigrationOpCode = 'no-table' | 'no-rows' | 'no-change' | 'no-source-column' | 'target-column-exists' | 'converted-nothing';
16
+ export declare const CONCERNING_CODES: ReadonlySet<MigrationOpCode>;
17
+ export interface MigrationOp {
18
+ op: 'renameColumn' | 'fill' | 'changeType' | 'convert' | 'dropColumn';
19
+ table: string;
20
+ performed: boolean;
21
+ rows?: number;
22
+ scanned?: number;
23
+ emptyTargets?: string[];
24
+ strandedSources?: string[];
25
+ code?: MigrationOpCode;
26
+ reason?: string;
27
+ }
28
+ export type OpRecorder = (op: MigrationOp) => void;
29
+ export declare function describeMigrationConcerns(plugin: string, version: number, ops: MigrationOp[]): string | null;
30
+ export declare function makeTable(em: EntityManager, table: string, record?: OpRecorder): TableOps;
13
31
  export declare function renameSystemShelfKey(em: EntityManager): Promise<void>;
14
32
  export declare function validateMigrations(pluginId: string, list: Migration[]): void;
15
33
  interface RunArgs {
@@ -19,7 +37,12 @@ interface RunArgs {
19
37
  tables?: string[];
20
38
  }[];
21
39
  hooks: Record<string, PluginHooks>;
40
+ onApplied?: (r: {
41
+ plugin: string;
42
+ version: number;
43
+ ops: MigrationOp[];
44
+ }) => void;
22
45
  }
23
- export declare function runMigrations({ em, plugins, hooks }: RunArgs): Promise<void>;
46
+ export declare function runMigrations({ em, plugins, hooks, onApplied }: RunArgs): Promise<void>;
24
47
  export declare function assertSafeRequired(em: EntityManager, entities: EntitySchema[]): Promise<void>;
25
48
  export {};