@coffer-org/server 3.3.0 → 4.0.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 (112) hide show
  1. package/dist/auth-api.js +4 -5
  2. package/dist/cache-clock.d.ts +8 -0
  3. package/dist/cache-clock.js +103 -0
  4. package/dist/cache-refresh.d.ts +7 -0
  5. package/dist/cache-refresh.js +322 -0
  6. package/dist/collection-io.d.ts +13 -0
  7. package/dist/collection-io.js +42 -28
  8. package/dist/compute-unit.d.ts +66 -0
  9. package/dist/compute-unit.js +369 -0
  10. package/dist/condition-where.js +1 -3
  11. package/dist/data-dir.d.ts +3 -0
  12. package/dist/data-dir.js +39 -0
  13. package/dist/db.js +2 -0
  14. package/dist/entity-schema.d.ts +1 -0
  15. package/dist/entity-schema.js +25 -2
  16. package/dist/extend-io.d.ts +3 -2
  17. package/dist/extend-io.js +4 -4
  18. package/dist/extend-table.d.ts +3 -2
  19. package/dist/extend-table.js +48 -11
  20. package/dist/file-fields.js +50 -23
  21. package/dist/global-search.js +1 -1
  22. package/dist/http-errors.d.ts +7 -0
  23. package/dist/http-errors.js +24 -0
  24. package/dist/index.js +31 -35
  25. package/dist/mcp-contract/client.d.ts +24 -0
  26. package/dist/mcp-contract/client.js +12 -0
  27. package/dist/mcp-contract/schema.d.ts +46 -0
  28. package/dist/mcp-contract/schema.js +90 -0
  29. package/dist/mcp-contract/tools.d.ts +19 -0
  30. package/dist/mcp-contract/tools.js +160 -0
  31. package/dist/mcp-local.d.ts +1 -1
  32. package/dist/mcp-local.js +4 -2
  33. package/dist/mcp-tools.d.ts +12 -1
  34. package/dist/mcp-tools.js +41 -8
  35. package/dist/migrations.d.ts +30 -2
  36. package/dist/migrations.js +368 -19
  37. package/dist/mutate.js +86 -57
  38. package/dist/orchestrator/agent-capabilities.d.ts +2 -0
  39. package/dist/orchestrator/agent-capabilities.js +201 -0
  40. package/dist/orchestrator/allow.d.ts +16 -0
  41. package/dist/orchestrator/allow.js +65 -0
  42. package/dist/orchestrator/attachments.d.ts +2 -0
  43. package/dist/orchestrator/attachments.js +11 -0
  44. package/dist/orchestrator/config.d.ts +4 -0
  45. package/dist/orchestrator/config.js +19 -0
  46. package/dist/orchestrator/db.d.ts +17 -0
  47. package/dist/orchestrator/db.js +12 -0
  48. package/dist/orchestrator/diagnostics.d.ts +9 -0
  49. package/dist/orchestrator/diagnostics.js +13 -0
  50. package/dist/orchestrator/environment.d.ts +3 -0
  51. package/dist/orchestrator/environment.js +30 -0
  52. package/dist/orchestrator/file-inspection.d.ts +3 -0
  53. package/dist/orchestrator/file-inspection.js +61 -0
  54. package/dist/orchestrator/format.d.ts +1 -0
  55. package/dist/orchestrator/format.js +6 -0
  56. package/dist/orchestrator/index.d.ts +30 -0
  57. package/dist/orchestrator/index.js +56 -0
  58. package/dist/orchestrator/live-message.d.ts +14 -0
  59. package/dist/orchestrator/live-message.js +100 -0
  60. package/dist/orchestrator/pipeline.d.ts +11 -0
  61. package/dist/orchestrator/pipeline.js +198 -0
  62. package/dist/orchestrator/registry.d.ts +13 -0
  63. package/dist/orchestrator/registry.js +74 -0
  64. package/dist/orchestrator/starters.d.ts +19 -0
  65. package/dist/orchestrator/starters.js +81 -0
  66. package/dist/orchestrator/suggestion-capabilities.d.ts +5 -0
  67. package/dist/orchestrator/suggestion-capabilities.js +33 -0
  68. package/dist/orchestrator/system-assembly.d.ts +7 -0
  69. package/dist/orchestrator/system-assembly.js +13 -0
  70. package/dist/orchestrator/system-capabilities.d.ts +2 -0
  71. package/dist/orchestrator/system-capabilities.js +63 -0
  72. package/dist/orchestrator/types.d.ts +144 -0
  73. package/dist/orchestrator/types.js +1 -0
  74. package/dist/part-errors.d.ts +3 -0
  75. package/dist/part-errors.js +6 -0
  76. package/dist/part-injection.d.ts +11 -3
  77. package/dist/part-injection.js +72 -92
  78. package/dist/part-ref.d.ts +13 -0
  79. package/dist/part-ref.js +109 -0
  80. package/dist/plugin-hooks.d.ts +1 -0
  81. package/dist/plugin-i18n.js +2 -1
  82. package/dist/plugin-runtime.d.ts +2 -2
  83. package/dist/plugin-runtime.js +21 -11
  84. package/dist/plugin-updates.d.ts +2 -2
  85. package/dist/plugin-updates.js +6 -12
  86. package/dist/public-url.js +2 -1
  87. package/dist/recompute-derived.d.ts +2 -1
  88. package/dist/recompute-derived.js +123 -26
  89. package/dist/records-api.d.ts +5 -1
  90. package/dist/records-api.js +47 -15
  91. package/dist/registry-context.d.ts +1 -1
  92. package/dist/registry-context.js +3 -0
  93. package/dist/rename-guard.d.ts +1 -0
  94. package/dist/rename-guard.js +9 -0
  95. package/dist/schema-sync.js +38 -1
  96. package/dist/search-index.js +1 -1
  97. package/dist/search-indexer.d.ts +1 -1
  98. package/dist/search-indexer.js +3 -3
  99. package/dist/settings-groups.d.ts +14 -0
  100. package/dist/settings-groups.js +25 -0
  101. package/dist/settings-write.d.ts +4 -4
  102. package/dist/settings-write.js +23 -26
  103. package/dist/system-settings.d.ts +3 -0
  104. package/dist/system-settings.js +27 -0
  105. package/dist/thread-store.d.ts +3 -2
  106. package/dist/thread-store.js +6 -5
  107. package/dist/trigger-queue.d.ts +33 -0
  108. package/dist/trigger-queue.js +432 -0
  109. package/dist/uploads.js +6 -3
  110. package/dist/value-compare.d.ts +1 -0
  111. package/dist/value-compare.js +7 -0
  112. package/package.json +17 -3
@@ -1,6 +1,8 @@
1
1
  import { DatabaseSchema } from '@mikro-orm/sql';
2
+ import { fieldEntries } from '@coffer-org/sdk/shelf';
2
3
  import { dialectOf } from "./dialect.js";
3
4
  import { getLogger } from '@coffer-org/sdk/logger';
5
+ import { SYSTEM_SETTINGS } from "./system-settings.js";
4
6
  const log = getLogger('migrations');
5
7
  function sqlType(col) {
6
8
  if (col === 'integer' || col === 'boolean')
@@ -25,6 +27,13 @@ function pgType(col) {
25
27
  return 'text';
26
28
  }
27
29
  const q = (id) => `"${id.replace(/"/g, '""')}"`;
30
+ const handedOverColumns = new Set();
31
+ export function markColumnHandedOver(table, column) {
32
+ handedOverColumns.add(`${table}.${column}`);
33
+ }
34
+ export function isColumnHandedOver(table, column) {
35
+ return handedOverColumns.has(`${table}.${column}`);
36
+ }
28
37
  export async function introspectTable(em, table) {
29
38
  const conn = em.getConnection();
30
39
  const platform = em.getPlatform();
@@ -38,31 +47,122 @@ export async function introspectTable(em, table) {
38
47
  : [],
39
48
  };
40
49
  }
41
- export function makeTable(em, table) {
50
+ export async function introspectAllColumns(em, tables) {
51
+ const out = new Map();
52
+ if (!tables.length)
53
+ return out;
54
+ const conn = em.getConnection();
55
+ const platform = em.getPlatform();
56
+ const schema = await DatabaseSchema.create(conn, platform, em.config, undefined, undefined, tables);
57
+ for (const t of schema.getTables()) {
58
+ out.set(t.name, new Set(t.getColumns().map((c) => c.name)));
59
+ }
60
+ return out;
61
+ }
62
+ export const CONCERNING_CODES = new Set([
63
+ 'no-source-column',
64
+ 'target-column-exists',
65
+ 'converted-nothing',
66
+ ]);
67
+ export function describeMigrationConcerns(plugin, version, ops) {
68
+ const concerning = ops.filter((o) => !o.performed && o.code && CONCERNING_CODES.has(o.code));
69
+ const lopsided = ops.filter((o) => o.performed && o.emptyTargets?.length);
70
+ const stranded = ops.filter((o) => o.strandedSources?.length);
71
+ const performedAny = ops.some((o) => o.performed);
72
+ const describe = (o) => {
73
+ const bits = [];
74
+ if (!o.performed)
75
+ bits.push(`changed nothing — ${o.reason ?? 'unknown reason'}`);
76
+ if (o.emptyTargets?.length)
77
+ bits.push(`wrote nothing into ${o.emptyTargets.join(', ')} on any of ${o.scanned ?? 0} row(s)`);
78
+ if (o.strandedSources?.length)
79
+ bits.push(`left ${o.strandedSources.join(', ')} holding data that reached no target column`);
80
+ return `${o.op}(${o.table}) ${bits.join(', and ')}`;
81
+ };
82
+ const tail = `The journal is bumped to v${version}, so it will never run again on this database — ` +
83
+ `if data was supposed to move, it did not, and a later schema sync will drop the columns it still lives in.`;
84
+ if (!ops.length)
85
+ return (`${plugin} v${version}: the migration completed without performing a single table operation. ` +
86
+ `The journal is bumped to v${version} regardless, so if its body swallowed an error or was ` +
87
+ `written for a state this database is not in, it will never run again.`);
88
+ if (!concerning.length && !lopsided.length && !stranded.length)
89
+ return null;
90
+ if (!performedAny)
91
+ return `${plugin} v${version}: the migration completed WITHOUT changing anything (${ops
92
+ .map(describe)
93
+ .join('; ')}). ${tail}`;
94
+ const flagged = [...new Set([...concerning, ...lopsided, ...stranded])];
95
+ return (`${plugin} v${version}: the migration changed some data but ${flagged.length} of its operations did not ` +
96
+ `(${flagged.map(describe).join('; ')}). ${tail}`);
97
+ }
98
+ function sameCell(a, b) {
99
+ const na = a === undefined ? null : a;
100
+ const nb = b === undefined ? null : b;
101
+ if (na === null || nb === null)
102
+ return na === nb;
103
+ return String(na) === String(nb);
104
+ }
105
+ export function makeTable(em, table, record) {
42
106
  const conn = em.getConnection();
43
107
  const T = q(table);
108
+ const countRows = async (where = '') => {
109
+ const rows = (await conn.execute(`SELECT COUNT(*) AS n FROM ${T}${where ? ` WHERE ${where}` : ''}`));
110
+ return Number(rows[0]?.n ?? 0);
111
+ };
112
+ const done = (op, rows, scanned) => record?.({ op, table, performed: true, rows, ...(scanned === undefined ? {} : { scanned }) });
113
+ const nothing = (op, code, reason, extra) => record?.({ op, table, performed: false, code, reason, ...extra });
44
114
  return {
45
115
  async renameColumn(from, to) {
46
116
  const info = await introspectTable(em, table);
47
- if (!info.exists || !info.columns.has(from))
48
- return;
117
+ if (!info.exists)
118
+ return nothing('renameColumn', 'no-table', `table ${table} does not exist`);
119
+ if (!info.columns.has(from))
120
+ return nothing('renameColumn', 'no-source-column', `source column ${from} does not exist on a table that does — an earlier schema sync already dropped it`);
121
+ if (info.columns.has(to)) {
122
+ const stranded = await countRows(`${q(from)} IS NOT NULL`);
123
+ log.warn(`${table}: renameColumn(${from} → ${to}) skipped — target column already exists`);
124
+ return nothing('renameColumn', 'target-column-exists', `target column ${to} already exists, so the rename was skipped while ${stranded} row(s) still hold data in ${from}`, { rows: 0, scanned: stranded });
125
+ }
126
+ const moved = await countRows(`${q(from)} IS NOT NULL`);
49
127
  await conn.execute(`ALTER TABLE ${T} RENAME COLUMN ${q(from)} TO ${q(to)}`);
128
+ done('renameColumn', moved, await countRows());
50
129
  },
51
130
  async fill(column, value, opts) {
52
131
  const info = await introspectTable(em, table);
53
- if (!info.exists || !info.columns.has(column))
54
- return;
132
+ if (!info.exists)
133
+ return nothing('fill', 'no-table', `table ${table} does not exist`);
134
+ if (!info.columns.has(column))
135
+ return nothing('fill', 'no-source-column', `column ${column} does not exist on a table that does — an earlier schema sync already dropped it`);
55
136
  const where = opts?.where ?? {};
56
137
  const keys = Object.keys(where);
57
138
  const clauses = keys.map((k) => (where[k] === null ? `${q(k)} IS NULL` : `${q(k)} = ?`));
58
- const params = [value, ...keys.filter((k) => where[k] !== null).map((k) => where[k])];
139
+ const whereParams = keys.filter((k) => where[k] !== null).map((k) => where[k]);
140
+ const params = [value, ...whereParams];
141
+ const matchSql = clauses.join(' AND ');
142
+ const differs = `(${q(column)} IS NULL OR ${q(column)} <> ?)`;
143
+ const countWhere = [matchSql, differs].filter(Boolean).join(' AND ');
144
+ const changing = (await conn.execute(`SELECT COUNT(*) AS n FROM ${T} WHERE ${countWhere}`, [
145
+ ...whereParams,
146
+ value,
147
+ ]));
148
+ const changed = Number(changing[0]?.n ?? 0);
59
149
  const sql = `UPDATE ${T} SET ${q(column)} = ?` + (clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '');
60
150
  await conn.execute(sql, params);
151
+ const scanned = await countRows();
152
+ if (changed)
153
+ return done('fill', changed, scanned);
154
+ if (!scanned)
155
+ return nothing('fill', 'no-rows', `table ${table} has no rows`, { rows: 0, scanned });
156
+ nothing('fill', 'no-change', `every matching row in ${column} already held that value`, { rows: 0, scanned });
61
157
  },
62
158
  async changeType(column, newType, opts) {
63
159
  const info = await introspectTable(em, table);
64
- if (!info.exists || !info.columns.has(column))
65
- return;
160
+ if (!info.exists)
161
+ return nothing('changeType', 'no-table', `table ${table} does not exist`);
162
+ if (!info.columns.has(column))
163
+ return nothing('changeType', 'no-source-column', `column ${column} does not exist on a table that does — an earlier schema sync already dropped it`);
164
+ const carried = await countRows(`${q(column)} IS NOT NULL`);
165
+ const total = await countRows();
66
166
  const tmp = `__ct_${column}`;
67
167
  const def = opts?.default;
68
168
  if (dialectOf(em) === 'sqlite') {
@@ -108,6 +208,7 @@ export function makeTable(em, table) {
108
208
  await conn.execute(`ROLLBACK`);
109
209
  throw e;
110
210
  }
211
+ done('changeType', carried, total);
111
212
  return;
112
213
  }
113
214
  await conn.execute(`BEGIN`);
@@ -135,13 +236,14 @@ export function makeTable(em, table) {
135
236
  await conn.execute(`ROLLBACK`);
136
237
  throw e;
137
238
  }
239
+ done('changeType', carried, total);
138
240
  },
139
241
  async convert(c) {
140
242
  const info = await introspectTable(em, table);
141
243
  if (!info.exists)
142
- return;
244
+ return nothing('convert', 'no-table', `table ${table} does not exist`);
143
245
  if (!c.from.some((f) => info.columns.has(f)))
144
- return;
246
+ return nothing('convert', 'no-source-column', `none of the source columns (${c.from.join(', ')}) exist on a table that does — an earlier schema sync already dropped them`);
145
247
  const sources = new Set(c.from);
146
248
  for (const t of c.to) {
147
249
  if (info.columns.has(t.name) && !sources.has(t.name)) {
@@ -152,24 +254,77 @@ export function makeTable(em, table) {
152
254
  const idCol = sqlite ? 'rowid' : 'id';
153
255
  const colType = sqlite ? sqlType : pgType;
154
256
  const present = c.from.filter((f) => info.columns.has(f));
257
+ const readable = [...new Set([...present, ...c.to.filter((t) => info.columns.has(t.name)).map((t) => t.name)])];
258
+ let scanned = 0;
259
+ let changed = 0;
260
+ let filled = 0;
261
+ const filledPerTarget = new Map(c.to.map((t) => [t.name, 0]));
262
+ const strandedPerSource = new Map(present.filter((f) => !c.to.some((t) => t.name === f)).map((f) => [f, 0]));
263
+ const inPlaceTargets = c.to.filter((t) => sources.has(t.name)).map((t) => t.name);
264
+ const blankTargets = Object.fromEntries(inPlaceTargets.map((n) => [n, null]));
265
+ const normalize = (dst) => c.to.map((t) => {
266
+ const v = dst[t.name];
267
+ return v === undefined || (typeof v === 'number' && Number.isNaN(v)) ? null : v;
268
+ });
155
269
  await conn.execute(`BEGIN`);
156
270
  try {
157
271
  for (const t of c.to) {
158
272
  if (!info.columns.has(t.name))
159
273
  await conn.execute(`ALTER TABLE ${T} ADD COLUMN ${q(t.name)} ${colType(t.type)}`);
160
274
  }
161
- const select = present.map((f) => q(f)).join(', ');
275
+ const select = readable.map((f) => q(f)).join(', ');
162
276
  const rows = (await conn.execute(`SELECT ${idCol} AS __rid, ${select} FROM ${T}`));
163
277
  const setSql = c.to.map((t) => `${q(t.name)} = ?`).join(', ');
164
278
  for (const r of rows) {
279
+ scanned++;
165
280
  const src = {};
166
281
  for (const f of c.from)
167
282
  src[f] = r[f] ?? null;
168
- const dst = c.map(src);
169
- const params = c.to.map((t) => {
170
- const v = dst[t.name];
171
- return v === undefined || (typeof v === 'number' && Number.isNaN(v)) ? null : v;
283
+ const params = normalize(c.map(src));
284
+ if (params.some((v) => v !== null))
285
+ filled++;
286
+ c.to.forEach((t, i) => {
287
+ if (params[i] !== null)
288
+ filledPerTarget.set(t.name, filledPerTarget.get(t.name) + 1);
172
289
  });
290
+ const differs = c.to.some((t, i) => !sameCell(params[i], r[t.name] ?? null));
291
+ if (strandedPerSource.size) {
292
+ const probe = (over) => {
293
+ try {
294
+ return normalize(c.map({ ...src, ...over }));
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ };
300
+ let fresh;
301
+ const carriedAcross = (f, sv) => {
302
+ if (params.some((v) => v !== null && sameCell(v, sv)))
303
+ return true;
304
+ const without = probe({ [f]: null });
305
+ if (without && params.some((v, i) => v !== null && !sameCell(v, without[i])))
306
+ return true;
307
+ if (!inPlaceTargets.length)
308
+ return false;
309
+ if (fresh === undefined)
310
+ fresh = probe(blankTargets);
311
+ const freshWithout = fresh ? probe({ ...blankTargets, [f]: null }) : null;
312
+ if (!fresh || !freshWithout)
313
+ return false;
314
+ return fresh.some((v, i) => v !== null && !sameCell(v, freshWithout[i]) && sameCell(params[i], v));
315
+ };
316
+ for (const [f, n] of strandedPerSource) {
317
+ const sv = src[f];
318
+ if (sv === null)
319
+ continue;
320
+ if (carriedAcross(f, sv))
321
+ continue;
322
+ strandedPerSource.set(f, n + 1);
323
+ }
324
+ }
325
+ if (!differs)
326
+ continue;
327
+ changed++;
173
328
  await conn.execute(`UPDATE ${T} SET ${setSql} WHERE ${idCol} = ?`, [...params, r['__rid']]);
174
329
  }
175
330
  await conn.execute(`COMMIT`);
@@ -178,11 +333,43 @@ export function makeTable(em, table) {
178
333
  await conn.execute(`ROLLBACK`);
179
334
  throw e;
180
335
  }
336
+ for (const [f, left] of strandedPerSource) {
337
+ if (left === 0) {
338
+ markColumnHandedOver(table, f);
339
+ continue;
340
+ }
341
+ log.warn(`${table}.${f}: convert left ${left} row(s) unconverted — their value in ${f} reached no target column ` +
342
+ `(the map declined them, or a target-first map kept a value already stored). ` +
343
+ `The column is NOT handed over to schema sync: it stays under the drop warning until the rows are repaired or removed.`);
344
+ }
345
+ const emptyTargets = [...filledPerTarget].filter(([, n]) => n === 0).map(([name]) => name);
346
+ const lopsided = emptyTargets.length && emptyTargets.length < c.to.length ? emptyTargets : [];
347
+ const strandedSources = [...strandedPerSource].filter(([, left]) => left > 0).map(([f]) => f);
348
+ const stranded = strandedSources.length ? { strandedSources } : {};
349
+ if (changed)
350
+ return record?.({
351
+ op: 'convert',
352
+ table,
353
+ performed: true,
354
+ rows: changed,
355
+ scanned,
356
+ ...(lopsided.length ? { emptyTargets: lopsided } : {}),
357
+ ...stranded,
358
+ });
359
+ if (!scanned)
360
+ return nothing('convert', 'no-rows', `table ${table} has no rows`, { rows: 0, scanned });
361
+ if (!filled)
362
+ return nothing('convert', 'converted-nothing', `no row has a value in any target column (${c.to.map((t) => t.name).join(', ')}) across ${scanned} row(s). ` +
363
+ `Column state cannot tell the two causes apart: the field may simply never have been used on this ` +
364
+ `install, or its source data was dropped before this migration ran. Check one record before assuming either`, { rows: 0, scanned, ...stranded });
365
+ nothing('convert', 'no-change', `every one of ${scanned} row(s) already held the converted value in ${c.to.map((t) => t.name).join(', ')}`, { rows: 0, scanned, ...stranded });
181
366
  },
182
367
  async dropColumn(column) {
183
368
  const info = await introspectTable(em, table);
184
- if (!info.exists || !info.columns.has(column))
185
- return;
369
+ if (!info.exists)
370
+ return nothing('dropColumn', 'no-table', `table ${table} does not exist`);
371
+ if (!info.columns.has(column))
372
+ return nothing('dropColumn', 'no-change', `column ${column} is already absent`, { rows: 0 });
186
373
  if (dialectOf(em) === 'sqlite') {
187
374
  const idxList = (await conn.execute(`PRAGMA index_list(${q(table)})`));
188
375
  for (const idx of idxList) {
@@ -197,13 +384,136 @@ export function makeTable(em, table) {
197
384
  await conn.execute(`DROP INDEX ${q(idx.name)}`);
198
385
  }
199
386
  }
387
+ const held = await countRows(`${q(column)} IS NOT NULL`);
388
+ const total = await countRows();
200
389
  await conn.execute(`ALTER TABLE ${T} DROP COLUMN ${q(column)}`);
390
+ done('dropColumn', held, total);
201
391
  },
202
392
  };
203
393
  }
204
394
  export async function renameSystemShelfKey(em) {
205
395
  await makeTable(em, '_embeddings').renameColumn('type', 'shelf_key');
206
396
  }
397
+ const LEGACY_ORCHESTRATOR_FIELDS = ['agent_id', 'access_password', 'trigger_prefix', 'reply_window'];
398
+ export const SYSTEM_MIGRATIONS = [
399
+ {
400
+ name: 'embeddings-shelf-key',
401
+ up: renameSystemShelfKey,
402
+ },
403
+ {
404
+ name: 'core-settings-to-system',
405
+ up: async (em) => {
406
+ const conn = em.getConnection();
407
+ const src = await introspectTable(em, '_settings__core');
408
+ const dst = await introspectTable(em, '_settings__system');
409
+ if (!src.exists || !dst.exists)
410
+ return;
411
+ const already = (await conn.execute(`SELECT 1 FROM "_settings__system" WHERE "plugin_id" = 'system' LIMIT 1`));
412
+ if (already.length > 0)
413
+ return;
414
+ const rows = (await conn.execute(`SELECT * FROM "_settings__core" WHERE "plugin_id" = 'core' LIMIT 1`));
415
+ const row = rows[0];
416
+ if (!row)
417
+ return;
418
+ const carried = ['publicUrl', 'language'].filter((c) => src.columns.has(c) && dst.columns.has(c));
419
+ if (carried.length === 0)
420
+ return;
421
+ const cols = ['plugin_id', ...carried];
422
+ const values = ['system', ...carried.map((c) => row[c] ?? null)];
423
+ await conn.execute(`INSERT INTO "_settings__system" (${cols.map((c) => `"${c}"`).join(', ')}) ` +
424
+ `VALUES (${cols.map(() => '?').join(', ')})`, values);
425
+ log.info(`[system-migration] settings moved from _settings__core to _settings__system`);
426
+ },
427
+ },
428
+ {
429
+ name: 'core-state-to-system',
430
+ up: async (em) => {
431
+ const conn = em.getConnection();
432
+ if (!(await introspectTable(em, '_plugin_state')).exists)
433
+ return;
434
+ await conn.execute(`UPDATE "_plugin_state" SET "plugin" = 'system' ` +
435
+ `WHERE "plugin" = 'core' ` +
436
+ `AND "key" NOT IN (SELECT "key" FROM "_plugin_state" WHERE "plugin" = 'system')`);
437
+ await conn.execute(`DELETE FROM "_plugin_state" WHERE "plugin" = 'core'`);
438
+ },
439
+ },
440
+ {
441
+ name: 'drop-legacy-core-settings',
442
+ up: async (em) => {
443
+ const conn = em.getConnection();
444
+ if (!(await introspectTable(em, '_settings__core')).exists)
445
+ return;
446
+ if (!(await introspectTable(em, '_settings__system')).exists)
447
+ return;
448
+ const landed = (await conn.execute(`SELECT 1 FROM "_settings__system" WHERE "plugin_id" = 'system' LIMIT 1`));
449
+ if (landed.length === 0)
450
+ return;
451
+ await conn.execute(`DROP TABLE IF EXISTS "_settings__core"`);
452
+ log.info('[system-migration] dropped the legacy _settings__core table');
453
+ },
454
+ },
455
+ {
456
+ name: 'orchestrator-settings-to-system',
457
+ up: async (em) => {
458
+ const DEFAULTS = Object.fromEntries(fieldEntries(SYSTEM_SETTINGS.fields)
459
+ .filter(([k]) => LEGACY_ORCHESTRATOR_FIELDS.includes(k))
460
+ .map(([k, f]) => [k, f.default]));
461
+ const conn = em.getConnection();
462
+ const src = await introspectTable(em, '_settings__orchestrator');
463
+ const dst = await introspectTable(em, '_settings__system');
464
+ if (!src.exists || !dst.exists)
465
+ return;
466
+ const carried = LEGACY_ORCHESTRATOR_FIELDS.filter((c) => src.columns.has(c) && dst.columns.has(c));
467
+ if (carried.length === 0)
468
+ return;
469
+ const rows = (await conn.execute(`SELECT * FROM "_settings__orchestrator" WHERE "plugin_id" = 'orchestrator' LIMIT 1`));
470
+ const row = rows[0];
471
+ if (!row)
472
+ return;
473
+ let wrote = false;
474
+ for (const c of carried) {
475
+ const value = row[c];
476
+ if (value === undefined || value === null)
477
+ continue;
478
+ const def = DEFAULTS[c];
479
+ const whereExtra = def === undefined ? `${q(c)} IS NULL` : `(${q(c)} IS NULL OR ${q(c)} = ?)`;
480
+ const params = def === undefined ? [value] : [value, def];
481
+ await conn.execute(`UPDATE "_settings__system" SET ${q(c)} = ? WHERE "plugin_id" = 'system' AND ${whereExtra}`, params);
482
+ wrote = true;
483
+ }
484
+ if (wrote)
485
+ log.info('[system-migration] orchestrator settings carried into _settings__system');
486
+ },
487
+ },
488
+ {
489
+ name: 'drop-legacy-orchestrator-settings',
490
+ up: async (em) => {
491
+ const conn = em.getConnection();
492
+ const src = await introspectTable(em, '_settings__orchestrator');
493
+ const dst = await introspectTable(em, '_settings__system');
494
+ if (!src.exists || !dst.exists)
495
+ return;
496
+ const fields = LEGACY_ORCHESTRATOR_FIELDS;
497
+ const srcRows = (await conn.execute(`SELECT * FROM "_settings__orchestrator" WHERE "plugin_id" = 'orchestrator' LIMIT 1`));
498
+ const srcRow = srcRows[0];
499
+ const needsLanding = fields.filter((c) => src.columns.has(c) && srcRow?.[c] !== undefined && srcRow?.[c] !== null);
500
+ if (needsLanding.length > 0) {
501
+ const dstRows = (await conn.execute(`SELECT * FROM "_settings__system" WHERE "plugin_id" = 'system' LIMIT 1`));
502
+ const dstRow = dstRows[0];
503
+ const landed = needsLanding.every((c) => dst.columns.has(c) && dstRow?.[c] !== undefined && dstRow?.[c] !== null);
504
+ if (!landed)
505
+ return;
506
+ }
507
+ await conn.execute(`DROP TABLE IF EXISTS "_settings__orchestrator"`);
508
+ log.info('[system-migration] dropped the legacy _settings__orchestrator table');
509
+ },
510
+ },
511
+ ];
512
+ export async function runSystemMigrations(em) {
513
+ for (const m of SYSTEM_MIGRATIONS) {
514
+ await m.up(em);
515
+ }
516
+ }
207
517
  export function validateMigrations(pluginId, list) {
208
518
  const versions = list.map((m) => m.version).sort((a, b) => a - b);
209
519
  versions.forEach((v, i) => {
@@ -212,13 +522,46 @@ export function validateMigrations(pluginId, list) {
212
522
  }
213
523
  });
214
524
  }
525
+ async function persistOps(em, plugin, version, ops) {
526
+ if (!ops.length)
527
+ return;
528
+ if (!(await introspectTable(em, '_migration_ops')).exists) {
529
+ log.debug(`${plugin} v${version}: _migration_ops is absent — the accounting is logged but not persisted`);
530
+ return;
531
+ }
532
+ const conn = em.getConnection();
533
+ const at = new Date().toISOString();
534
+ for (const [seq, o] of ops.entries()) {
535
+ await conn.execute(`INSERT INTO _migration_ops (plugin_id, version, seq, op, table_name, performed, rows, scanned, code, reason, empty_targets, stranded_sources, applied_at)
536
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
537
+ ON CONFLICT(plugin_id, version, seq) DO UPDATE SET
538
+ op = excluded.op, table_name = excluded.table_name, performed = excluded.performed,
539
+ rows = excluded.rows, scanned = excluded.scanned, code = excluded.code,
540
+ reason = excluded.reason, empty_targets = excluded.empty_targets,
541
+ stranded_sources = excluded.stranded_sources, applied_at = excluded.applied_at`, [
542
+ plugin,
543
+ version,
544
+ seq,
545
+ o.op,
546
+ o.table,
547
+ o.performed ? 1 : 0,
548
+ o.rows ?? null,
549
+ o.scanned ?? null,
550
+ o.code ?? null,
551
+ o.reason ?? null,
552
+ o.emptyTargets?.length ? o.emptyTargets.join(', ') : null,
553
+ o.strandedSources?.length ? o.strandedSources.join(', ') : null,
554
+ at,
555
+ ]);
556
+ }
557
+ }
215
558
  async function anyTableExists(em, tables) {
216
559
  for (const t of tables)
217
560
  if ((await introspectTable(em, t)).exists)
218
561
  return true;
219
562
  return false;
220
563
  }
221
- export async function runMigrations({ em, plugins, hooks }) {
564
+ export async function runMigrations({ em, plugins, hooks, onApplied }) {
222
565
  const conn = em.getConnection();
223
566
  for (const p of plugins) {
224
567
  const list = hooks[p.id]?.migrations ?? [];
@@ -237,9 +580,15 @@ export async function runMigrations({ em, plugins, hooks }) {
237
580
  continue;
238
581
  }
239
582
  const pending = [...list].filter((m) => m.version > stored).sort((a, b) => a.version - b.version);
240
- const ctx = { table: (name) => makeTable(em, name) };
241
583
  for (const m of pending) {
584
+ const ops = [];
585
+ const ctx = { table: (name) => makeTable(em, name, (o) => ops.push(o)) };
242
586
  await m.up(ctx);
587
+ const concern = describeMigrationConcerns(p.id, m.version, ops);
588
+ if (concern)
589
+ log.warn(concern);
590
+ await persistOps(em, p.id, m.version, ops);
591
+ onApplied?.({ plugin: p.id, version: m.version, ops });
243
592
  await conn.execute(`INSERT INTO _migrations (plugin_id, version) VALUES (?, ?)
244
593
  ON CONFLICT(plugin_id) DO UPDATE SET version = excluded.version`, [p.id, m.version]);
245
594
  }