@coffer-org/server 3.2.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.
- package/dist/collection-io.js +13 -10
- package/dist/db.js +2 -0
- package/dist/entity-schema.d.ts +1 -0
- package/dist/entity-schema.js +20 -0
- package/dist/extend-io.d.ts +3 -2
- package/dist/extend-io.js +4 -4
- package/dist/extend-table.d.ts +3 -2
- package/dist/extend-table.js +35 -5
- package/dist/http-errors.d.ts +7 -0
- package/dist/http-errors.js +24 -0
- package/dist/index.js +11 -9
- package/dist/mcp-local.js +3 -1
- package/dist/migrations.d.ts +25 -2
- package/dist/migrations.js +246 -19
- package/dist/mutate.js +43 -7
- package/dist/part-errors.d.ts +3 -0
- package/dist/part-errors.js +6 -0
- package/dist/part-injection.d.ts +21 -0
- package/dist/part-injection.js +448 -0
- package/dist/part-ref.d.ts +13 -0
- package/dist/part-ref.js +109 -0
- package/dist/plugin-runtime.js +1 -0
- package/dist/recompute-derived.js +24 -4
- package/dist/records-api.d.ts +5 -1
- package/dist/records-api.js +47 -13
- package/dist/rename-guard.d.ts +1 -0
- package/dist/rename-guard.js +9 -0
- package/dist/schema-sync.js +38 -1
- package/package.json +3 -3
package/dist/migrations.js
CHANGED
|
@@ -25,6 +25,13 @@ function pgType(col) {
|
|
|
25
25
|
return 'text';
|
|
26
26
|
}
|
|
27
27
|
const q = (id) => `"${id.replace(/"/g, '""')}"`;
|
|
28
|
+
const handedOverColumns = new Set();
|
|
29
|
+
export function markColumnHandedOver(table, column) {
|
|
30
|
+
handedOverColumns.add(`${table}.${column}`);
|
|
31
|
+
}
|
|
32
|
+
export function isColumnHandedOver(table, column) {
|
|
33
|
+
return handedOverColumns.has(`${table}.${column}`);
|
|
34
|
+
}
|
|
28
35
|
export async function introspectTable(em, table) {
|
|
29
36
|
const conn = em.getConnection();
|
|
30
37
|
const platform = em.getPlatform();
|
|
@@ -38,31 +45,122 @@ export async function introspectTable(em, table) {
|
|
|
38
45
|
: [],
|
|
39
46
|
};
|
|
40
47
|
}
|
|
41
|
-
export function
|
|
48
|
+
export async function introspectAllColumns(em, tables) {
|
|
49
|
+
const out = new Map();
|
|
50
|
+
if (!tables.length)
|
|
51
|
+
return out;
|
|
52
|
+
const conn = em.getConnection();
|
|
53
|
+
const platform = em.getPlatform();
|
|
54
|
+
const schema = await DatabaseSchema.create(conn, platform, em.config, undefined, undefined, tables);
|
|
55
|
+
for (const t of schema.getTables()) {
|
|
56
|
+
out.set(t.name, new Set(t.getColumns().map((c) => c.name)));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
export const CONCERNING_CODES = new Set([
|
|
61
|
+
'no-source-column',
|
|
62
|
+
'target-column-exists',
|
|
63
|
+
'converted-nothing',
|
|
64
|
+
]);
|
|
65
|
+
export function describeMigrationConcerns(plugin, version, ops) {
|
|
66
|
+
const concerning = ops.filter((o) => !o.performed && o.code && CONCERNING_CODES.has(o.code));
|
|
67
|
+
const lopsided = ops.filter((o) => o.performed && o.emptyTargets?.length);
|
|
68
|
+
const stranded = ops.filter((o) => o.strandedSources?.length);
|
|
69
|
+
const performedAny = ops.some((o) => o.performed);
|
|
70
|
+
const describe = (o) => {
|
|
71
|
+
const bits = [];
|
|
72
|
+
if (!o.performed)
|
|
73
|
+
bits.push(`changed nothing — ${o.reason ?? 'unknown reason'}`);
|
|
74
|
+
if (o.emptyTargets?.length)
|
|
75
|
+
bits.push(`wrote nothing into ${o.emptyTargets.join(', ')} on any of ${o.scanned ?? 0} row(s)`);
|
|
76
|
+
if (o.strandedSources?.length)
|
|
77
|
+
bits.push(`left ${o.strandedSources.join(', ')} holding data that reached no target column`);
|
|
78
|
+
return `${o.op}(${o.table}) ${bits.join(', and ')}`;
|
|
79
|
+
};
|
|
80
|
+
const tail = `The journal is bumped to v${version}, so it will never run again on this database — ` +
|
|
81
|
+
`if data was supposed to move, it did not, and a later schema sync will drop the columns it still lives in.`;
|
|
82
|
+
if (!ops.length)
|
|
83
|
+
return (`${plugin} v${version}: the migration completed without performing a single table operation. ` +
|
|
84
|
+
`The journal is bumped to v${version} regardless, so if its body swallowed an error or was ` +
|
|
85
|
+
`written for a state this database is not in, it will never run again.`);
|
|
86
|
+
if (!concerning.length && !lopsided.length && !stranded.length)
|
|
87
|
+
return null;
|
|
88
|
+
if (!performedAny)
|
|
89
|
+
return `${plugin} v${version}: the migration completed WITHOUT changing anything (${ops
|
|
90
|
+
.map(describe)
|
|
91
|
+
.join('; ')}). ${tail}`;
|
|
92
|
+
const flagged = [...new Set([...concerning, ...lopsided, ...stranded])];
|
|
93
|
+
return (`${plugin} v${version}: the migration changed some data but ${flagged.length} of its operations did not ` +
|
|
94
|
+
`(${flagged.map(describe).join('; ')}). ${tail}`);
|
|
95
|
+
}
|
|
96
|
+
function sameCell(a, b) {
|
|
97
|
+
const na = a === undefined ? null : a;
|
|
98
|
+
const nb = b === undefined ? null : b;
|
|
99
|
+
if (na === null || nb === null)
|
|
100
|
+
return na === nb;
|
|
101
|
+
return String(na) === String(nb);
|
|
102
|
+
}
|
|
103
|
+
export function makeTable(em, table, record) {
|
|
42
104
|
const conn = em.getConnection();
|
|
43
105
|
const T = q(table);
|
|
106
|
+
const countRows = async (where = '') => {
|
|
107
|
+
const rows = (await conn.execute(`SELECT COUNT(*) AS n FROM ${T}${where ? ` WHERE ${where}` : ''}`));
|
|
108
|
+
return Number(rows[0]?.n ?? 0);
|
|
109
|
+
};
|
|
110
|
+
const done = (op, rows, scanned) => record?.({ op, table, performed: true, rows, ...(scanned === undefined ? {} : { scanned }) });
|
|
111
|
+
const nothing = (op, code, reason, extra) => record?.({ op, table, performed: false, code, reason, ...extra });
|
|
44
112
|
return {
|
|
45
113
|
async renameColumn(from, to) {
|
|
46
114
|
const info = await introspectTable(em, table);
|
|
47
|
-
if (!info.exists
|
|
48
|
-
return;
|
|
115
|
+
if (!info.exists)
|
|
116
|
+
return nothing('renameColumn', 'no-table', `table ${table} does not exist`);
|
|
117
|
+
if (!info.columns.has(from))
|
|
118
|
+
return nothing('renameColumn', 'no-source-column', `source column ${from} does not exist on a table that does — an earlier schema sync already dropped it`);
|
|
119
|
+
if (info.columns.has(to)) {
|
|
120
|
+
const stranded = await countRows(`${q(from)} IS NOT NULL`);
|
|
121
|
+
log.warn(`${table}: renameColumn(${from} → ${to}) skipped — target column already exists`);
|
|
122
|
+
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 });
|
|
123
|
+
}
|
|
124
|
+
const moved = await countRows(`${q(from)} IS NOT NULL`);
|
|
49
125
|
await conn.execute(`ALTER TABLE ${T} RENAME COLUMN ${q(from)} TO ${q(to)}`);
|
|
126
|
+
done('renameColumn', moved, await countRows());
|
|
50
127
|
},
|
|
51
128
|
async fill(column, value, opts) {
|
|
52
129
|
const info = await introspectTable(em, table);
|
|
53
|
-
if (!info.exists
|
|
54
|
-
return;
|
|
130
|
+
if (!info.exists)
|
|
131
|
+
return nothing('fill', 'no-table', `table ${table} does not exist`);
|
|
132
|
+
if (!info.columns.has(column))
|
|
133
|
+
return nothing('fill', 'no-source-column', `column ${column} does not exist on a table that does — an earlier schema sync already dropped it`);
|
|
55
134
|
const where = opts?.where ?? {};
|
|
56
135
|
const keys = Object.keys(where);
|
|
57
136
|
const clauses = keys.map((k) => (where[k] === null ? `${q(k)} IS NULL` : `${q(k)} = ?`));
|
|
58
|
-
const
|
|
137
|
+
const whereParams = keys.filter((k) => where[k] !== null).map((k) => where[k]);
|
|
138
|
+
const params = [value, ...whereParams];
|
|
139
|
+
const matchSql = clauses.join(' AND ');
|
|
140
|
+
const differs = `(${q(column)} IS NULL OR ${q(column)} <> ?)`;
|
|
141
|
+
const countWhere = [matchSql, differs].filter(Boolean).join(' AND ');
|
|
142
|
+
const changing = (await conn.execute(`SELECT COUNT(*) AS n FROM ${T} WHERE ${countWhere}`, [
|
|
143
|
+
...whereParams,
|
|
144
|
+
value,
|
|
145
|
+
]));
|
|
146
|
+
const changed = Number(changing[0]?.n ?? 0);
|
|
59
147
|
const sql = `UPDATE ${T} SET ${q(column)} = ?` + (clauses.length ? ` WHERE ${clauses.join(' AND ')}` : '');
|
|
60
148
|
await conn.execute(sql, params);
|
|
149
|
+
const scanned = await countRows();
|
|
150
|
+
if (changed)
|
|
151
|
+
return done('fill', changed, scanned);
|
|
152
|
+
if (!scanned)
|
|
153
|
+
return nothing('fill', 'no-rows', `table ${table} has no rows`, { rows: 0, scanned });
|
|
154
|
+
nothing('fill', 'no-change', `every matching row in ${column} already held that value`, { rows: 0, scanned });
|
|
61
155
|
},
|
|
62
156
|
async changeType(column, newType, opts) {
|
|
63
157
|
const info = await introspectTable(em, table);
|
|
64
|
-
if (!info.exists
|
|
65
|
-
return;
|
|
158
|
+
if (!info.exists)
|
|
159
|
+
return nothing('changeType', 'no-table', `table ${table} does not exist`);
|
|
160
|
+
if (!info.columns.has(column))
|
|
161
|
+
return nothing('changeType', 'no-source-column', `column ${column} does not exist on a table that does — an earlier schema sync already dropped it`);
|
|
162
|
+
const carried = await countRows(`${q(column)} IS NOT NULL`);
|
|
163
|
+
const total = await countRows();
|
|
66
164
|
const tmp = `__ct_${column}`;
|
|
67
165
|
const def = opts?.default;
|
|
68
166
|
if (dialectOf(em) === 'sqlite') {
|
|
@@ -108,6 +206,7 @@ export function makeTable(em, table) {
|
|
|
108
206
|
await conn.execute(`ROLLBACK`);
|
|
109
207
|
throw e;
|
|
110
208
|
}
|
|
209
|
+
done('changeType', carried, total);
|
|
111
210
|
return;
|
|
112
211
|
}
|
|
113
212
|
await conn.execute(`BEGIN`);
|
|
@@ -135,13 +234,14 @@ export function makeTable(em, table) {
|
|
|
135
234
|
await conn.execute(`ROLLBACK`);
|
|
136
235
|
throw e;
|
|
137
236
|
}
|
|
237
|
+
done('changeType', carried, total);
|
|
138
238
|
},
|
|
139
239
|
async convert(c) {
|
|
140
240
|
const info = await introspectTable(em, table);
|
|
141
241
|
if (!info.exists)
|
|
142
|
-
return;
|
|
242
|
+
return nothing('convert', 'no-table', `table ${table} does not exist`);
|
|
143
243
|
if (!c.from.some((f) => info.columns.has(f)))
|
|
144
|
-
return;
|
|
244
|
+
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
245
|
const sources = new Set(c.from);
|
|
146
246
|
for (const t of c.to) {
|
|
147
247
|
if (info.columns.has(t.name) && !sources.has(t.name)) {
|
|
@@ -152,24 +252,77 @@ export function makeTable(em, table) {
|
|
|
152
252
|
const idCol = sqlite ? 'rowid' : 'id';
|
|
153
253
|
const colType = sqlite ? sqlType : pgType;
|
|
154
254
|
const present = c.from.filter((f) => info.columns.has(f));
|
|
255
|
+
const readable = [...new Set([...present, ...c.to.filter((t) => info.columns.has(t.name)).map((t) => t.name)])];
|
|
256
|
+
let scanned = 0;
|
|
257
|
+
let changed = 0;
|
|
258
|
+
let filled = 0;
|
|
259
|
+
const filledPerTarget = new Map(c.to.map((t) => [t.name, 0]));
|
|
260
|
+
const strandedPerSource = new Map(present.filter((f) => !c.to.some((t) => t.name === f)).map((f) => [f, 0]));
|
|
261
|
+
const inPlaceTargets = c.to.filter((t) => sources.has(t.name)).map((t) => t.name);
|
|
262
|
+
const blankTargets = Object.fromEntries(inPlaceTargets.map((n) => [n, null]));
|
|
263
|
+
const normalize = (dst) => c.to.map((t) => {
|
|
264
|
+
const v = dst[t.name];
|
|
265
|
+
return v === undefined || (typeof v === 'number' && Number.isNaN(v)) ? null : v;
|
|
266
|
+
});
|
|
155
267
|
await conn.execute(`BEGIN`);
|
|
156
268
|
try {
|
|
157
269
|
for (const t of c.to) {
|
|
158
270
|
if (!info.columns.has(t.name))
|
|
159
271
|
await conn.execute(`ALTER TABLE ${T} ADD COLUMN ${q(t.name)} ${colType(t.type)}`);
|
|
160
272
|
}
|
|
161
|
-
const select =
|
|
273
|
+
const select = readable.map((f) => q(f)).join(', ');
|
|
162
274
|
const rows = (await conn.execute(`SELECT ${idCol} AS __rid, ${select} FROM ${T}`));
|
|
163
275
|
const setSql = c.to.map((t) => `${q(t.name)} = ?`).join(', ');
|
|
164
276
|
for (const r of rows) {
|
|
277
|
+
scanned++;
|
|
165
278
|
const src = {};
|
|
166
279
|
for (const f of c.from)
|
|
167
280
|
src[f] = r[f] ?? null;
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
281
|
+
const params = normalize(c.map(src));
|
|
282
|
+
if (params.some((v) => v !== null))
|
|
283
|
+
filled++;
|
|
284
|
+
c.to.forEach((t, i) => {
|
|
285
|
+
if (params[i] !== null)
|
|
286
|
+
filledPerTarget.set(t.name, filledPerTarget.get(t.name) + 1);
|
|
172
287
|
});
|
|
288
|
+
const differs = c.to.some((t, i) => !sameCell(params[i], r[t.name] ?? null));
|
|
289
|
+
if (strandedPerSource.size) {
|
|
290
|
+
const probe = (over) => {
|
|
291
|
+
try {
|
|
292
|
+
return normalize(c.map({ ...src, ...over }));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
let fresh;
|
|
299
|
+
const carriedAcross = (f, sv) => {
|
|
300
|
+
if (params.some((v) => v !== null && sameCell(v, sv)))
|
|
301
|
+
return true;
|
|
302
|
+
const without = probe({ [f]: null });
|
|
303
|
+
if (without && params.some((v, i) => v !== null && !sameCell(v, without[i])))
|
|
304
|
+
return true;
|
|
305
|
+
if (!inPlaceTargets.length)
|
|
306
|
+
return false;
|
|
307
|
+
if (fresh === undefined)
|
|
308
|
+
fresh = probe(blankTargets);
|
|
309
|
+
const freshWithout = fresh ? probe({ ...blankTargets, [f]: null }) : null;
|
|
310
|
+
if (!fresh || !freshWithout)
|
|
311
|
+
return false;
|
|
312
|
+
return fresh.some((v, i) => v !== null && !sameCell(v, freshWithout[i]) && sameCell(params[i], v));
|
|
313
|
+
};
|
|
314
|
+
for (const [f, n] of strandedPerSource) {
|
|
315
|
+
const sv = src[f];
|
|
316
|
+
if (sv === null)
|
|
317
|
+
continue;
|
|
318
|
+
if (carriedAcross(f, sv))
|
|
319
|
+
continue;
|
|
320
|
+
strandedPerSource.set(f, n + 1);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!differs)
|
|
324
|
+
continue;
|
|
325
|
+
changed++;
|
|
173
326
|
await conn.execute(`UPDATE ${T} SET ${setSql} WHERE ${idCol} = ?`, [...params, r['__rid']]);
|
|
174
327
|
}
|
|
175
328
|
await conn.execute(`COMMIT`);
|
|
@@ -178,11 +331,43 @@ export function makeTable(em, table) {
|
|
|
178
331
|
await conn.execute(`ROLLBACK`);
|
|
179
332
|
throw e;
|
|
180
333
|
}
|
|
334
|
+
for (const [f, left] of strandedPerSource) {
|
|
335
|
+
if (left === 0) {
|
|
336
|
+
markColumnHandedOver(table, f);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
log.warn(`${table}.${f}: convert left ${left} row(s) unconverted — their value in ${f} reached no target column ` +
|
|
340
|
+
`(the map declined them, or a target-first map kept a value already stored). ` +
|
|
341
|
+
`The column is NOT handed over to schema sync: it stays under the drop warning until the rows are repaired or removed.`);
|
|
342
|
+
}
|
|
343
|
+
const emptyTargets = [...filledPerTarget].filter(([, n]) => n === 0).map(([name]) => name);
|
|
344
|
+
const lopsided = emptyTargets.length && emptyTargets.length < c.to.length ? emptyTargets : [];
|
|
345
|
+
const strandedSources = [...strandedPerSource].filter(([, left]) => left > 0).map(([f]) => f);
|
|
346
|
+
const stranded = strandedSources.length ? { strandedSources } : {};
|
|
347
|
+
if (changed)
|
|
348
|
+
return record?.({
|
|
349
|
+
op: 'convert',
|
|
350
|
+
table,
|
|
351
|
+
performed: true,
|
|
352
|
+
rows: changed,
|
|
353
|
+
scanned,
|
|
354
|
+
...(lopsided.length ? { emptyTargets: lopsided } : {}),
|
|
355
|
+
...stranded,
|
|
356
|
+
});
|
|
357
|
+
if (!scanned)
|
|
358
|
+
return nothing('convert', 'no-rows', `table ${table} has no rows`, { rows: 0, scanned });
|
|
359
|
+
if (!filled)
|
|
360
|
+
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). ` +
|
|
361
|
+
`Column state cannot tell the two causes apart: the field may simply never have been used on this ` +
|
|
362
|
+
`install, or its source data was dropped before this migration ran. Check one record before assuming either`, { rows: 0, scanned, ...stranded });
|
|
363
|
+
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
364
|
},
|
|
182
365
|
async dropColumn(column) {
|
|
183
366
|
const info = await introspectTable(em, table);
|
|
184
|
-
if (!info.exists
|
|
185
|
-
return;
|
|
367
|
+
if (!info.exists)
|
|
368
|
+
return nothing('dropColumn', 'no-table', `table ${table} does not exist`);
|
|
369
|
+
if (!info.columns.has(column))
|
|
370
|
+
return nothing('dropColumn', 'no-change', `column ${column} is already absent`, { rows: 0 });
|
|
186
371
|
if (dialectOf(em) === 'sqlite') {
|
|
187
372
|
const idxList = (await conn.execute(`PRAGMA index_list(${q(table)})`));
|
|
188
373
|
for (const idx of idxList) {
|
|
@@ -197,7 +382,10 @@ export function makeTable(em, table) {
|
|
|
197
382
|
await conn.execute(`DROP INDEX ${q(idx.name)}`);
|
|
198
383
|
}
|
|
199
384
|
}
|
|
385
|
+
const held = await countRows(`${q(column)} IS NOT NULL`);
|
|
386
|
+
const total = await countRows();
|
|
200
387
|
await conn.execute(`ALTER TABLE ${T} DROP COLUMN ${q(column)}`);
|
|
388
|
+
done('dropColumn', held, total);
|
|
201
389
|
},
|
|
202
390
|
};
|
|
203
391
|
}
|
|
@@ -212,13 +400,46 @@ export function validateMigrations(pluginId, list) {
|
|
|
212
400
|
}
|
|
213
401
|
});
|
|
214
402
|
}
|
|
403
|
+
async function persistOps(em, plugin, version, ops) {
|
|
404
|
+
if (!ops.length)
|
|
405
|
+
return;
|
|
406
|
+
if (!(await introspectTable(em, '_migration_ops')).exists) {
|
|
407
|
+
log.debug(`${plugin} v${version}: _migration_ops is absent — the accounting is logged but not persisted`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const conn = em.getConnection();
|
|
411
|
+
const at = new Date().toISOString();
|
|
412
|
+
for (const [seq, o] of ops.entries()) {
|
|
413
|
+
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)
|
|
414
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
415
|
+
ON CONFLICT(plugin_id, version, seq) DO UPDATE SET
|
|
416
|
+
op = excluded.op, table_name = excluded.table_name, performed = excluded.performed,
|
|
417
|
+
rows = excluded.rows, scanned = excluded.scanned, code = excluded.code,
|
|
418
|
+
reason = excluded.reason, empty_targets = excluded.empty_targets,
|
|
419
|
+
stranded_sources = excluded.stranded_sources, applied_at = excluded.applied_at`, [
|
|
420
|
+
plugin,
|
|
421
|
+
version,
|
|
422
|
+
seq,
|
|
423
|
+
o.op,
|
|
424
|
+
o.table,
|
|
425
|
+
o.performed ? 1 : 0,
|
|
426
|
+
o.rows ?? null,
|
|
427
|
+
o.scanned ?? null,
|
|
428
|
+
o.code ?? null,
|
|
429
|
+
o.reason ?? null,
|
|
430
|
+
o.emptyTargets?.length ? o.emptyTargets.join(', ') : null,
|
|
431
|
+
o.strandedSources?.length ? o.strandedSources.join(', ') : null,
|
|
432
|
+
at,
|
|
433
|
+
]);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
215
436
|
async function anyTableExists(em, tables) {
|
|
216
437
|
for (const t of tables)
|
|
217
438
|
if ((await introspectTable(em, t)).exists)
|
|
218
439
|
return true;
|
|
219
440
|
return false;
|
|
220
441
|
}
|
|
221
|
-
export async function runMigrations({ em, plugins, hooks }) {
|
|
442
|
+
export async function runMigrations({ em, plugins, hooks, onApplied }) {
|
|
222
443
|
const conn = em.getConnection();
|
|
223
444
|
for (const p of plugins) {
|
|
224
445
|
const list = hooks[p.id]?.migrations ?? [];
|
|
@@ -237,9 +458,15 @@ export async function runMigrations({ em, plugins, hooks }) {
|
|
|
237
458
|
continue;
|
|
238
459
|
}
|
|
239
460
|
const pending = [...list].filter((m) => m.version > stored).sort((a, b) => a.version - b.version);
|
|
240
|
-
const ctx = { table: (name) => makeTable(em, name) };
|
|
241
461
|
for (const m of pending) {
|
|
462
|
+
const ops = [];
|
|
463
|
+
const ctx = { table: (name) => makeTable(em, name, (o) => ops.push(o)) };
|
|
242
464
|
await m.up(ctx);
|
|
465
|
+
const concern = describeMigrationConcerns(p.id, m.version, ops);
|
|
466
|
+
if (concern)
|
|
467
|
+
log.warn(concern);
|
|
468
|
+
await persistOps(em, p.id, m.version, ops);
|
|
469
|
+
onApplied?.({ plugin: p.id, version: m.version, ops });
|
|
243
470
|
await conn.execute(`INSERT INTO _migrations (plugin_id, version) VALUES (?, ?)
|
|
244
471
|
ON CONFLICT(plugin_id) DO UPDATE SET version = excluded.version`, [p.id, m.version]);
|
|
245
472
|
}
|
package/dist/mutate.js
CHANGED
|
@@ -9,9 +9,28 @@ import { normalizeFileFields, dropUnchangedFileFields, touchesFileFields } from
|
|
|
9
9
|
import { notifyRecordsChanged } from "./index-signal.js";
|
|
10
10
|
import { encodeTemporal, decodeTemporal } from "./temporal.js";
|
|
11
11
|
import { splitCollections, writeCollections, deleteCollections, flattenEmbedded, nestEmbedded, readCollections, } from "./collection-io.js";
|
|
12
|
+
import { injectParts, applyStoredParts, hasStoredPartWork, newPartMemo, } from "./part-injection.js";
|
|
13
|
+
const EMPTY_SETTINGS = {};
|
|
14
|
+
const EMPTY_GLOBAL = {};
|
|
12
15
|
function nowIso() {
|
|
13
16
|
return new Date().toISOString();
|
|
14
17
|
}
|
|
18
|
+
function partCtx(m, memo, em) {
|
|
19
|
+
return {
|
|
20
|
+
settings: EMPTY_SETTINGS,
|
|
21
|
+
global: EMPTY_GLOBAL,
|
|
22
|
+
meta: { library: m.library, shelf: m.shelf },
|
|
23
|
+
...(memo ? { memo } : {}),
|
|
24
|
+
...(em ? { em } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function mergeStoredParts(m, record, memo, em) {
|
|
28
|
+
const computed = await applyStoredParts(m.fields, record, partCtx(m, memo, em));
|
|
29
|
+
if (computed === record)
|
|
30
|
+
return false;
|
|
31
|
+
Object.assign(record, computed);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
15
34
|
export function encodeJsonAt(fields, data) {
|
|
16
35
|
const out = { ...data };
|
|
17
36
|
for (const [k, f] of fieldEntries(fields)) {
|
|
@@ -67,7 +86,8 @@ export async function getRecord(m, entityName, id, opts = {}) {
|
|
|
67
86
|
const flat = decodeTemporal(m, row);
|
|
68
87
|
const nested = nestEmbedded(m, flat);
|
|
69
88
|
const collections = await readCollections(fork, m, id);
|
|
70
|
-
|
|
89
|
+
const record = { ...nested, ...collections };
|
|
90
|
+
return injectParts(m.fields, record, partCtx(m));
|
|
71
91
|
}
|
|
72
92
|
export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
73
93
|
const parsed = buildZodObject(m).safeParse(input);
|
|
@@ -78,6 +98,8 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
|
78
98
|
const fileIssues = normalizeFileFields(m, parsedData);
|
|
79
99
|
if (fileIssues.length)
|
|
80
100
|
throw new ValidationError(fileIssues);
|
|
101
|
+
const memo = newPartMemo();
|
|
102
|
+
await mergeStoredParts(m, parsedData, memo);
|
|
81
103
|
const { base, collections } = splitCollections(m, { ...parsedData });
|
|
82
104
|
const data = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, { ...base, created_at: ts, updated_at: ts })));
|
|
83
105
|
let id;
|
|
@@ -88,7 +110,7 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
|
88
110
|
await tx.flush();
|
|
89
111
|
id = entity.id;
|
|
90
112
|
await writeCollections(tx, m, id, collections);
|
|
91
|
-
const derived = applyDerived(m.fields, { ...parsedData, id });
|
|
113
|
+
const derived = await applyDerived(m.fields, { ...parsedData, id });
|
|
92
114
|
if (Object.keys(derived).length) {
|
|
93
115
|
await tx.nativeUpdate(entityName, { id }, encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived))));
|
|
94
116
|
Object.assign(parsedData, derived);
|
|
@@ -101,7 +123,8 @@ export async function createRecord(m, entityName, input, ctx, afterBase) {
|
|
|
101
123
|
});
|
|
102
124
|
});
|
|
103
125
|
notifyRecordsChanged();
|
|
104
|
-
|
|
126
|
+
const record = { ...parsedData, id, created_at: ts, updated_at: ts };
|
|
127
|
+
return injectParts(m.fields, record, partCtx(m, memo));
|
|
105
128
|
}
|
|
106
129
|
export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
107
130
|
let patch = input;
|
|
@@ -116,6 +139,7 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
|
116
139
|
const parsed = buildZodObjectPartial(m).safeParse(patch);
|
|
117
140
|
if (!parsed.success)
|
|
118
141
|
throw new ValidationError(parsed.error.issues.map(toIssue));
|
|
142
|
+
const memo = newPartMemo();
|
|
119
143
|
let result;
|
|
120
144
|
await getEm()
|
|
121
145
|
.fork()
|
|
@@ -146,11 +170,22 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
|
146
170
|
}
|
|
147
171
|
if (reqIssues.length)
|
|
148
172
|
throw new ValidationError(reqIssues);
|
|
173
|
+
const storedCollections = hasStoredPartWork(m.fields) ? await readCollections(tx, m, id) : {};
|
|
174
|
+
const target = { ...merged, ...storedCollections, ...collections };
|
|
175
|
+
if (await mergeStoredParts(m, target, memo, tx)) {
|
|
176
|
+
const resplit = splitCollections(m, target);
|
|
177
|
+
Object.assign(merged, resplit.base);
|
|
178
|
+
for (const k of Object.keys(collections)) {
|
|
179
|
+
const rows = resplit.collections[k];
|
|
180
|
+
if (rows)
|
|
181
|
+
collections[k] = rows;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
149
184
|
const dbRow = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, { ...merged, updated_at: ts })));
|
|
150
185
|
await tx.upsert(entityName, dbRow);
|
|
151
186
|
await writeCollections(tx, m, id, collections);
|
|
152
187
|
const allCollections = await readCollections(tx, m, id);
|
|
153
|
-
const derived = applyDerived(m.fields, { ...merged, ...allCollections });
|
|
188
|
+
const derived = await applyDerived(m.fields, { ...merged, ...allCollections });
|
|
154
189
|
if (Object.keys(derived).length) {
|
|
155
190
|
await tx.nativeUpdate(entityName, { id }, encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived))));
|
|
156
191
|
Object.assign(merged, derived);
|
|
@@ -158,7 +193,7 @@ export async function updateRecord(m, entityName, id, input, ctx, afterBase) {
|
|
|
158
193
|
const _extends = afterBase ? await afterBase(tx, id) : undefined;
|
|
159
194
|
const after = { ...merged, ...allCollections, updated_at: ts, ...(_extends ? { _extends } : {}) };
|
|
160
195
|
writeEvent(tx, ctx.actor, 'update', `${m.library}/${m.shelf}`, id, existing, after);
|
|
161
|
-
result = after;
|
|
196
|
+
result = await injectParts(m.fields, after, partCtx(m, memo, tx));
|
|
162
197
|
});
|
|
163
198
|
notifyRecordsChanged();
|
|
164
199
|
return result;
|
|
@@ -191,8 +226,9 @@ export async function restoreRecord(m, entityName, id, ctx, afterBase) {
|
|
|
191
226
|
const nested = nestEmbedded(m, flat);
|
|
192
227
|
const collections = await readCollections(tx, m, id);
|
|
193
228
|
const _extends = afterBase ? await afterBase(tx, id) : undefined;
|
|
194
|
-
|
|
195
|
-
writeEvent(tx, ctx.actor, 'restore', `${m.library}/${m.shelf}`, id, null,
|
|
229
|
+
const restored = { ...nested, ...collections, ...(_extends ? { _extends } : {}) };
|
|
230
|
+
writeEvent(tx, ctx.actor, 'restore', `${m.library}/${m.shelf}`, id, null, restored);
|
|
231
|
+
result = await injectParts(m.fields, restored, partCtx(m, undefined, tx));
|
|
196
232
|
});
|
|
197
233
|
notifyRecordsChanged();
|
|
198
234
|
return result;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/core';
|
|
2
|
+
import { type LayoutEl } from '@coffer-org/sdk/fields';
|
|
3
|
+
import { type RefState } from './part-ref.ts';
|
|
4
|
+
type Row = Record<string, unknown>;
|
|
5
|
+
export type PartMemo = Map<string, unknown>;
|
|
6
|
+
export declare const newPartMemo: () => PartMemo;
|
|
7
|
+
export interface PartCtx {
|
|
8
|
+
settings: Record<string, unknown>;
|
|
9
|
+
global: Record<string, unknown>;
|
|
10
|
+
meta: {
|
|
11
|
+
library: string;
|
|
12
|
+
shelf: string;
|
|
13
|
+
};
|
|
14
|
+
em?: EntityManager;
|
|
15
|
+
memo?: PartMemo;
|
|
16
|
+
refs?: RefState;
|
|
17
|
+
}
|
|
18
|
+
export declare function injectParts(fields: LayoutEl[], row: Row, ctx: PartCtx): Promise<Row>;
|
|
19
|
+
export declare function hasStoredPartWork(fields: LayoutEl[]): boolean;
|
|
20
|
+
export declare function applyStoredParts(fields: LayoutEl[], row: Row, ctx: PartCtx): Promise<Row>;
|
|
21
|
+
export {};
|