@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.
@@ -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 makeTable(em, table) {
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 || !info.columns.has(from))
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 || !info.columns.has(column))
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 params = [value, ...keys.filter((k) => where[k] !== null).map((k) => where[k])];
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 || !info.columns.has(column))
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 = present.map((f) => q(f)).join(', ');
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 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;
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 || !info.columns.has(column))
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
- return { ...nested, ...collections };
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
- return { ...parsedData, id, created_at: ts, updated_at: ts };
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
- result = { ...nested, ...collections, ...(_extends ? { _extends } : {}) };
195
- writeEvent(tx, ctx.actor, 'restore', `${m.library}/${m.shelf}`, id, null, result);
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,3 @@
1
+ export declare class PartContractError extends Error {
2
+ constructor(message: string);
3
+ }
@@ -0,0 +1,6 @@
1
+ export class PartContractError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'PartContractError';
5
+ }
6
+ }
@@ -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 {};