@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.
@@ -1,93 +1,392 @@
1
1
  import { isField, isGroup, isCollectionGroup } from '@coffer-org/sdk/fields';
2
- async function partValue(p, ctx, self) {
3
- return typeof p.value === 'function' ? await p.value({ ...ctx, self }) : p.value;
2
+ import { mandatedClientParts } from '@coffer-org/sdk/parts';
3
+ import { PartContractError } from "./part-errors.js";
4
+ import { createRefLoader } from "./part-ref.js";
5
+ export const newPartMemo = () => new Map();
6
+ class NeedsCompletion extends Error {
7
+ scope;
8
+ key;
9
+ constructor(scope, key) {
10
+ super(`needs completion: ${key}`);
11
+ this.scope = scope;
12
+ this.key = key;
13
+ }
4
14
  }
5
- async function completeOne(key, fm, value, ctx) {
6
- if (value == null || typeof value !== 'object' || Array.isArray(value))
7
- return value;
8
- const self = { ...value };
9
- for (const p of fm.parts) {
10
- if (p.mode !== 'pinned' && p.mode !== 'computed')
11
- continue;
12
- try {
13
- self[p.role] = await partValue(p, ctx, self);
15
+ let scopeUid = 0;
16
+ async function partValue(p, ctx) {
17
+ return typeof p.value === 'function' ? await p.value(ctx) : p.value;
18
+ }
19
+ function rethrowLoud(e) {
20
+ if (e instanceof PartContractError || e instanceof NeedsCompletion)
21
+ throw e;
22
+ }
23
+ function isComputeUnit(fm) {
24
+ return Boolean(fm.parts?.length) || (fm.virtual === true && fm.compute !== undefined);
25
+ }
26
+ const unitsCache = new WeakMap();
27
+ function scopeUnits(fields) {
28
+ const cached = unitsCache.get(fields);
29
+ if (cached)
30
+ return cached;
31
+ const out = new Map();
32
+ const scan = (list) => {
33
+ for (const it of list) {
34
+ if (isField(it)) {
35
+ if (isComputeUnit(it.type))
36
+ out.set(it.key, it.type);
37
+ }
38
+ else if (isGroup(it) && !it.key)
39
+ scan(it.fields);
14
40
  }
15
- catch (e) {
16
- console.warn(`[part-injection] '${key}.${p.role}' threw`, e);
41
+ };
42
+ scan(fields);
43
+ unitsCache.set(fields, out);
44
+ return out;
45
+ }
46
+ const embeddedCache = new WeakMap();
47
+ function scopeEmbedded(fields) {
48
+ const cached = embeddedCache.get(fields);
49
+ if (cached)
50
+ return cached;
51
+ const out = new Map();
52
+ const scan = (list) => {
53
+ for (const it of list) {
54
+ if (!isGroup(it))
55
+ continue;
56
+ if (!it.key)
57
+ scan(it.fields);
58
+ else if (!isCollectionGroup(it))
59
+ out.set(it.key, it.fields);
60
+ }
61
+ };
62
+ scan(fields);
63
+ embeddedCache.set(fields, out);
64
+ return out;
65
+ }
66
+ const relationsCache = new WeakMap();
67
+ function levelRelations(fields) {
68
+ const cached = relationsCache.get(fields);
69
+ if (cached)
70
+ return cached;
71
+ const out = new Map();
72
+ const scan = (list) => {
73
+ for (const it of list) {
74
+ if (isField(it)) {
75
+ if (it.type.relation)
76
+ out.set(it.key, it.type);
77
+ }
78
+ else if (isGroup(it) && !it.key)
79
+ scan(it.fields);
17
80
  }
81
+ };
82
+ scan(fields);
83
+ relationsCache.set(fields, out);
84
+ return out;
85
+ }
86
+ function childScope(scope, key) {
87
+ const existing = scope.groups.get(key);
88
+ if (existing)
89
+ return existing;
90
+ const sub = scope.embedded.get(key);
91
+ if (!sub)
92
+ return null;
93
+ const cur = scope.obj[key];
94
+ if (!isPlainObject(cur))
95
+ return null;
96
+ const copy = { ...cur };
97
+ scope.obj[key] = copy;
98
+ const child = newScope(scope.st, copy, sub, `${scope.path}${key}.`, scope.level, scope.level.label);
99
+ scope.groups.set(key, child);
100
+ return child;
101
+ }
102
+ function makeProxy(scope) {
103
+ return new Proxy(scope.obj, {
104
+ get(target, prop, receiver) {
105
+ if (typeof prop === 'string') {
106
+ if (scope.units.has(prop) && scope.status.get(prop) !== 'done')
107
+ throw new NeedsCompletion(scope, prop);
108
+ const child = childScope(scope, prop);
109
+ if (child)
110
+ return child.proxy;
111
+ }
112
+ return Reflect.get(target, prop, receiver);
113
+ },
114
+ });
115
+ }
116
+ function newScope(st, obj, fields, path, level, label) {
117
+ const scope = {
118
+ st,
119
+ uid: ++scopeUid,
120
+ obj,
121
+ units: scopeUnits(fields),
122
+ embedded: scopeEmbedded(fields),
123
+ status: new Map(),
124
+ inflight: new Map(),
125
+ path,
126
+ level: level,
127
+ proxy: null,
128
+ groups: new Map(),
129
+ };
130
+ scope.proxy = makeProxy(scope);
131
+ if (!level)
132
+ scope.level = { fields, scope, proxy: scope.proxy, label };
133
+ return scope;
134
+ }
135
+ function refsOf(st) {
136
+ if (!st.refs) {
137
+ st.refs = {
138
+ chain: st.selfKey ? [st.selfKey] : [],
139
+ loader: createRefLoader((m, record, refs) => injectParts(m.fields, record, {
140
+ settings: {},
141
+ global: {},
142
+ meta: { library: m.library, shelf: m.shelf },
143
+ ...(st.ctx.em ? { em: st.ctx.em } : {}),
144
+ refs,
145
+ }), st.ctx.em),
146
+ };
18
147
  }
19
- return self;
148
+ return st.refs;
149
+ }
150
+ function siteOf(field, role) {
151
+ return role ? `'${field}.${role}'` : `'${field}'`;
152
+ }
153
+ function refFor(scope, key, field, role) {
154
+ const level = scope.level;
155
+ const fm = levelRelations(level.fields).get(key);
156
+ if (!fm?.relation) {
157
+ const known = [...levelRelations(level.fields).keys()];
158
+ throw new PartContractError(`ctx.ref('${key}') in ${scope.st.ctx.meta.library}/${scope.st.ctx.meta.shelf} ${siteOf(field, role)}: ` +
159
+ `'${key}' is not a relation field of ${level.label} ` +
160
+ `(relation fields there: ${known.length ? known.map((k) => `'${k}'`).join(', ') : 'none'}). ` +
161
+ `ctx.ref takes the KEY of a relation declared next to this field, not a path.`);
162
+ }
163
+ if (fm.hints?.['multi'] === true || fm.hints?.['multiple'] === true) {
164
+ throw new PartContractError(`ctx.ref('${key}') in ${scope.st.ctx.meta.library}/${scope.st.ctx.meta.shelf} ${siteOf(field, role)}: ` +
165
+ `'${key}' is a MULTIPLE relation — ctx.ref resolves one record, not a list.`);
166
+ }
167
+ const raw = level.scope.obj[key];
168
+ if (raw == null || raw === '')
169
+ return Promise.resolve(null);
170
+ const id = typeof raw === 'number' ? raw : Number(raw);
171
+ if (!Number.isInteger(id))
172
+ return Promise.resolve(null);
173
+ const refs = refsOf(scope.st);
174
+ return refs.loader.load(fm.relation.library, fm.relation.shelf, id, refs.chain);
20
175
  }
21
- async function computeOne(key, fm, value, ctx) {
176
+ function storedRefError(scope, key, field, role) {
177
+ const { library, shelf } = scope.st.ctx.meta;
178
+ return new PartContractError(`ctx.ref('${key}') in ${library}/${shelf} ${siteOf(field, role)}: a computed-AND-STORED part must not read ` +
179
+ `another record. Its column is written once, at write time, and nothing recomputes it when the record it ` +
180
+ `read changes — the value would be stale and look authoritative. Drop the part's 'key' so the value is ` +
181
+ `computed on every read instead.`);
182
+ }
183
+ function computeCtxFor(scope, self, stored, field, role) {
184
+ const st = scope.st;
185
+ return {
186
+ record: st.recordLevel.proxy,
187
+ parent: scope.level.proxy,
188
+ settings: st.ctx.settings,
189
+ global: st.ctx.global,
190
+ meta: st.ctx.meta,
191
+ self,
192
+ ref: stored
193
+ ? (key) => {
194
+ throw storedRefError(scope, key, field, role);
195
+ }
196
+ : (key) => refFor(scope, key, field, role),
197
+ };
198
+ }
199
+ async function completeValue(scope, key, fm, value, idx) {
22
200
  if (value == null || typeof value !== 'object' || Array.isArray(value))
23
201
  return value;
202
+ const st = scope.st;
203
+ const write = st.mode === 'write';
24
204
  const self = { ...value };
205
+ const scopeId = `${st.ctx.meta.library}/${st.ctx.meta.shelf}`;
206
+ const path = idx === undefined ? `${scopeId}|${scope.path}${key}` : `${scopeId}|${scope.path}${key}.${idx}`;
25
207
  const contextOnly = [];
26
208
  for (const p of fm.parts) {
27
209
  if (p.mode !== 'pinned' && p.mode !== 'computed')
28
210
  continue;
29
- contextOnly.push(p.role);
211
+ if (write)
212
+ contextOnly.push(p.role);
213
+ const memoKey = `${path}|${p.role}`;
214
+ if (st.memo.has(memoKey)) {
215
+ self[p.role] = st.memo.get(memoKey);
216
+ continue;
217
+ }
30
218
  try {
31
- self[p.role] = await partValue(p, ctx, self);
219
+ const v = await partValue(p, computeCtxFor(scope, self, false, key, p.role));
220
+ self[p.role] = v;
221
+ st.memo.set(memoKey, v);
32
222
  }
33
223
  catch (e) {
34
- console.warn(`[part-compute] '${key}.${p.role}' threw`, e);
224
+ rethrowLoud(e);
225
+ console.warn(`[${write ? 'part-compute' : 'part-injection'}] '${key}.${p.role}' threw`, e);
35
226
  }
36
227
  }
37
- for (const p of fm.parts) {
38
- if (p.mode !== 'computedStored')
39
- continue;
40
- try {
41
- self[p.key] = await partValue(p, ctx, self);
42
- }
43
- catch (e) {
44
- console.warn(`[part-compute] '${key}.${p.role}' threw`, e);
228
+ if (write) {
229
+ for (const p of fm.parts) {
230
+ if (p.mode !== 'computedStored')
231
+ continue;
232
+ try {
233
+ self[p.key] = await partValue(p, computeCtxFor(scope, self, true, key, p.role));
234
+ }
235
+ catch (e) {
236
+ rethrowLoud(e);
237
+ console.warn(`[part-compute] '${key}.${p.role}' threw`, e);
238
+ }
45
239
  }
240
+ if (contextOnly.length)
241
+ st.strip.push({ value: self, roles: contextOnly });
46
242
  }
47
- for (const role of contextOnly)
48
- delete self[role];
49
243
  return self;
50
244
  }
51
- async function walkFields(fields, obj, ctx, complete) {
52
- const out = { ...obj };
245
+ function materializable(fm) {
246
+ const parts = fm.parts ?? [];
247
+ if (!parts.some((p) => p.mode === 'computedStored'))
248
+ return false;
249
+ if (mandatedClientParts(fm.required, parts).length > 0)
250
+ return false;
251
+ return true;
252
+ }
253
+ function storedFilled(fm, value) {
254
+ if (value == null || typeof value !== 'object')
255
+ return false;
256
+ const v = value;
257
+ return (fm.parts ?? []).some((p) => p.key && v[p.key] != null && v[p.key] !== '');
258
+ }
259
+ function isPlainObject(v) {
260
+ return v != null && typeof v === 'object' && !Array.isArray(v);
261
+ }
262
+ async function runVirtualField(scope, key, fm) {
263
+ if (scope.st.mode === 'write') {
264
+ delete scope.obj[key];
265
+ return;
266
+ }
267
+ try {
268
+ scope.obj[key] = await partValue({ value: fm.compute }, computeCtxFor(scope, {}, false, key, ''));
269
+ }
270
+ catch (e) {
271
+ rethrowLoud(e);
272
+ console.warn(`[part-injection] '${key}' threw`, e);
273
+ }
274
+ }
275
+ async function runFieldUnit(scope, key, fm) {
276
+ if (fm.virtual === true && fm.compute !== undefined)
277
+ return runVirtualField(scope, key, fm);
278
+ if (!fm.parts?.length)
279
+ return;
280
+ const value = scope.obj[key];
281
+ if (value == null) {
282
+ if (scope.st.mode !== 'write' || fm.hints?.['multiple'] === true || !materializable(fm))
283
+ return;
284
+ const made = await completeValue(scope, key, fm, {}, undefined);
285
+ if (storedFilled(fm, made))
286
+ scope.obj[key] = made;
287
+ return;
288
+ }
289
+ if (fm.hints?.['multiple'] === true) {
290
+ if (!Array.isArray(value))
291
+ return;
292
+ const out = [];
293
+ for (const [i, v] of value.entries())
294
+ out.push(await completeValue(scope, key, fm, v, i));
295
+ scope.obj[key] = out;
296
+ return;
297
+ }
298
+ scope.obj[key] = await completeValue(scope, key, fm, value, undefined);
299
+ }
300
+ async function completeUnit(scope, key, chain) {
301
+ if (scope.status.get(key) === 'done')
302
+ return;
303
+ const fm = scope.units.get(key);
304
+ if (!fm)
305
+ return;
306
+ const hop = `${scope.uid}:${key}`;
307
+ const repeat = chain.indexOf(hop);
308
+ if (repeat !== -1) {
309
+ const names = [...chain.slice(repeat), hop].map((h) => `'${h.slice(h.indexOf(':') + 1)}'`);
310
+ throw new PartContractError(`[part-injection] ${scope.st.ctx.meta.library}/${scope.st.ctx.meta.shelf}: completion cycle ${names.join(' → ')}` +
311
+ ` — these composite parts read each other, so neither can be completed first.`);
312
+ }
313
+ const inflight = scope.inflight.get(key);
314
+ if (inflight)
315
+ return inflight;
316
+ const next = [...chain, hop];
317
+ const run = (async () => {
318
+ const resolved = new Set();
319
+ for (;;) {
320
+ try {
321
+ await runFieldUnit(scope, key, fm);
322
+ break;
323
+ }
324
+ catch (e) {
325
+ if (!(e instanceof NeedsCompletion))
326
+ throw e;
327
+ const dep = `${e.scope.uid}:${e.key}`;
328
+ if (resolved.has(dep))
329
+ throw new PartContractError(`[part-injection] ${scope.st.ctx.meta.library}/${scope.st.ctx.meta.shelf}: '${key}' demands '${e.key}' ` +
330
+ `again after it was completed — its compute function cannot make progress (a stale suspension held ` +
331
+ `across invocations, or a value object read outside the context it was handed).`);
332
+ resolved.add(dep);
333
+ await completeUnit(e.scope, e.key, next);
334
+ }
335
+ }
336
+ scope.status.set(key, 'done');
337
+ })();
338
+ scope.inflight.set(key, run);
339
+ try {
340
+ await run;
341
+ }
342
+ finally {
343
+ scope.inflight.delete(key);
344
+ }
345
+ }
346
+ async function driveScope(scope, fields) {
53
347
  for (const it of fields) {
54
348
  if (isField(it)) {
55
- const fm = it.type;
56
- if (!fm.parts?.length)
57
- continue;
58
- const value = out[it.key];
59
- if (value == null)
349
+ if (!isComputeUnit(it.type))
60
350
  continue;
61
- if (fm.hints?.['multiple'] === true) {
62
- if (!Array.isArray(value))
63
- continue;
64
- out[it.key] = await Promise.all(value.map((v) => complete(it.key, fm, v, ctx)));
65
- }
66
- else {
67
- out[it.key] = await complete(it.key, fm, value, ctx);
68
- }
351
+ await completeUnit(scope, it.key, []);
69
352
  }
70
353
  else if (isGroup(it)) {
354
+ const sub = it.fields;
71
355
  if (!it.key) {
72
- Object.assign(out, await walkFields(it.fields, out, ctx, complete));
356
+ await driveScope(scope, sub);
73
357
  }
74
358
  else if (isCollectionGroup(it)) {
75
- const rows = out[it.key];
76
- if (Array.isArray(rows)) {
77
- out[it.key] = await Promise.all(rows.map((row) => row != null && typeof row === 'object'
78
- ? walkFields(it.fields, row, ctx, complete)
79
- : row));
80
- }
359
+ const rows = scope.obj[it.key];
360
+ if (!Array.isArray(rows))
361
+ continue;
362
+ const copies = rows.map((row) => (isPlainObject(row) ? { ...row } : row));
363
+ scope.obj[it.key] = copies;
364
+ await Promise.all(copies.map((row, i) => isPlainObject(row)
365
+ ? runScope(scope.st, row, sub, `${scope.path}${it.key}.${i}.`, null, `collection row '${it.key}'`)
366
+ : Promise.resolve(row)));
81
367
  }
82
368
  else {
83
- const sub = out[it.key];
84
- if (sub != null && typeof sub === 'object' && !Array.isArray(sub)) {
85
- out[it.key] = await walkFields(it.fields, sub, ctx, complete);
369
+ const cur = scope.obj[it.key];
370
+ const child = childScope(scope, it.key);
371
+ if (child) {
372
+ await driveScope(child, sub);
373
+ }
374
+ else if (cur == null && scope.st.mode === 'write' && partWork(sub).computedStored) {
375
+ const made = newScope(scope.st, {}, sub, `${scope.path}${it.key}.`, scope.level, scope.level.label);
376
+ await driveScope(made, sub);
377
+ if (Object.keys(made.obj).length) {
378
+ scope.obj[it.key] = made.obj;
379
+ scope.groups.set(it.key, made);
380
+ }
86
381
  }
87
382
  }
88
383
  }
89
384
  }
90
- return out;
385
+ }
386
+ async function runScope(st, obj, fields, path, level, label) {
387
+ const scope = newScope(st, obj, fields, path, level, label);
388
+ await driveScope(scope, fields);
389
+ return scope.obj;
91
390
  }
92
391
  const partWorkCache = new WeakMap();
93
392
  function partWork(fields) {
@@ -97,6 +396,8 @@ function partWork(fields) {
97
396
  const found = { unstored: false, computedStored: false };
98
397
  for (const it of fields) {
99
398
  if (isField(it)) {
399
+ if (it.type.virtual === true && it.type.compute !== undefined)
400
+ found.unstored = true;
100
401
  for (const p of it.type.parts ?? []) {
101
402
  if (p.mode === 'pinned' || p.mode === 'computed')
102
403
  found.unstored = true;
@@ -113,13 +414,35 @@ function partWork(fields) {
113
414
  partWorkCache.set(fields, found);
114
415
  return found;
115
416
  }
417
+ async function runWalk(fields, row, ctx, mode) {
418
+ const id = row['id'] ?? row['base_id'];
419
+ const st = {
420
+ ctx,
421
+ mode,
422
+ memo: ctx.memo ?? newPartMemo(),
423
+ refs: ctx.refs ?? null,
424
+ strip: [],
425
+ selfKey: typeof id === 'number' ? `${ctx.meta.library}/${ctx.meta.shelf}#${id}` : null,
426
+ recordLevel: null,
427
+ };
428
+ const scope = newScope(st, { ...row }, fields, '', null, `${ctx.meta.library}/${ctx.meta.shelf}`);
429
+ st.recordLevel = scope.level;
430
+ await driveScope(scope, fields);
431
+ for (const s of st.strip)
432
+ for (const role of s.roles)
433
+ delete s.value[role];
434
+ return scope.obj;
435
+ }
116
436
  export async function injectParts(fields, row, ctx) {
117
437
  if (!partWork(fields).unstored)
118
438
  return row;
119
- return walkFields(fields, row, ctx, completeOne);
439
+ return runWalk(fields, row, ctx, 'read');
440
+ }
441
+ export function hasStoredPartWork(fields) {
442
+ return partWork(fields).computedStored;
120
443
  }
121
444
  export async function applyStoredParts(fields, row, ctx) {
122
445
  if (!partWork(fields).computedStored)
123
446
  return row;
124
- return walkFields(fields, row, ctx, computeOne);
447
+ return runWalk(fields, row, ctx, 'write');
125
448
  }
@@ -0,0 +1,13 @@
1
+ import type { EntityManager } from '@mikro-orm/core';
2
+ import type { ShelfDef } from '@coffer-org/sdk/shelf';
3
+ type Row = Record<string, unknown>;
4
+ export type CompleteRef = (m: ShelfDef, record: Row, refs: RefState) => Promise<Row>;
5
+ export interface RefLoader {
6
+ load(library: string, shelf: string, id: number, chain: readonly string[]): Promise<Row | null>;
7
+ }
8
+ export interface RefState {
9
+ loader: RefLoader;
10
+ chain: readonly string[];
11
+ }
12
+ export declare function createRefLoader(complete: CompleteRef, em?: EntityManager): RefLoader;
13
+ export {};
@@ -0,0 +1,109 @@
1
+ import { getEm } from "./db.js";
2
+ import { selectRows } from "./read-rows.js";
3
+ import { nestEmbedded, readAtMany } from "./collection-io.js";
4
+ import { decodeTemporal } from "./temporal.js";
5
+ import { shelfTableName } from "./entity-schema.js";
6
+ import { getShelf } from "./registry-context.js";
7
+ import { chunk } from "./batch.js";
8
+ import { PartContractError } from "./part-errors.js";
9
+ const MAX_REF_DEPTH = 8;
10
+ export function createRefLoader(complete, em) {
11
+ const cache = new Map();
12
+ const waiters = new Map();
13
+ const pending = new Map();
14
+ let scheduled = false;
15
+ const loader = { load };
16
+ function load(library, shelf, id, chain) {
17
+ const key = `${library}/${shelf}#${id}`;
18
+ if (chain.includes(key)) {
19
+ console.warn(`[part-ref] ref loop ${[...chain, key].join(' → ')} — resolved as null. Two records reach each other ` +
20
+ `through ctx.ref; the value at the far end of the loop cannot be computed.`);
21
+ return Promise.resolve(null);
22
+ }
23
+ if (chain.length >= MAX_REF_DEPTH) {
24
+ console.warn(`[part-ref] ref chain deeper than ${MAX_REF_DEPTH} hops (${[...chain, key].join(' → ')}) — resolved as null.`);
25
+ return Promise.resolve(null);
26
+ }
27
+ const hit = cache.get(key);
28
+ if (hit)
29
+ return hit;
30
+ const p = new Promise((resolve, reject) => {
31
+ waiters.set(key, { resolve, reject, chain: [...chain, key] });
32
+ });
33
+ p.catch(() => { });
34
+ cache.set(key, p);
35
+ const shelfKey = `${library}/${shelf}`;
36
+ const ids = pending.get(shelfKey);
37
+ if (ids)
38
+ ids.push(id);
39
+ else
40
+ pending.set(shelfKey, [id]);
41
+ if (!scheduled) {
42
+ scheduled = true;
43
+ queueMicrotask(flush);
44
+ }
45
+ return p;
46
+ }
47
+ function flush() {
48
+ scheduled = false;
49
+ const batches = [...pending];
50
+ pending.clear();
51
+ for (const [shelfKey, ids] of batches)
52
+ void runBatch(shelfKey, ids);
53
+ }
54
+ function settle(key, fn) {
55
+ const w = waiters.get(key);
56
+ if (!w)
57
+ return;
58
+ waiters.delete(key);
59
+ fn(w);
60
+ }
61
+ async function runBatch(shelfKey, ids) {
62
+ const slash = shelfKey.indexOf('/');
63
+ const library = shelfKey.slice(0, slash);
64
+ const shelf = shelfKey.slice(slash + 1);
65
+ const keyOf = (id) => `${shelfKey}#${id}`;
66
+ const unique = [...new Set(ids)];
67
+ try {
68
+ const m = getShelf(library, shelf);
69
+ if (!m)
70
+ throw new PartContractError(`ctx.ref: no shelf '${shelfKey}' in the registry`);
71
+ const fork = em ?? getEm().fork();
72
+ const table = shelfTableName(library, shelf);
73
+ const flats = [];
74
+ for (const part of chunk(unique)) {
75
+ flats.push(...(await selectRows(fork, table, { id: { $in: part }, deleted_at: null })));
76
+ }
77
+ const foundIds = flats.map((r) => Number(r['id']));
78
+ const collectionsById = await readAtMany(fork, table, m.fields, foundIds);
79
+ const seen = new Set();
80
+ await Promise.all(flats.map(async (flat) => {
81
+ const id = Number(flat['id']);
82
+ seen.add(id);
83
+ const key = keyOf(id);
84
+ const w = waiters.get(key);
85
+ if (!w)
86
+ return;
87
+ const record = {
88
+ ...nestEmbedded(m, decodeTemporal(m, flat)),
89
+ ...(collectionsById.get(id) ?? {}),
90
+ };
91
+ try {
92
+ const done = await complete(m, record, { loader, chain: w.chain });
93
+ settle(key, (x) => x.resolve(done));
94
+ }
95
+ catch (e) {
96
+ settle(key, (x) => x.reject(e));
97
+ }
98
+ }));
99
+ for (const id of unique)
100
+ if (!seen.has(id))
101
+ settle(keyOf(id), (w) => w.resolve(null));
102
+ }
103
+ catch (e) {
104
+ for (const id of unique)
105
+ settle(keyOf(id), (w) => w.reject(e));
106
+ }
107
+ }
108
+ return loader;
109
+ }
@@ -185,6 +185,7 @@ export async function purgePluginData(p, actor) {
185
185
  .transactional(async (tx) => {
186
186
  const now = new Date().toISOString();
187
187
  await tx.nativeDelete('_Migration', { plugin_id: p.id });
188
+ await tx.nativeDelete('_MigrationOp', { plugin_id: p.id });
188
189
  await tx.nativeDelete('_Seed', { plugin_id: p.id });
189
190
  const row = await tx.findOne('_Plugin', { id: p.id });
190
191
  if (row)
@@ -1,4 +1,6 @@
1
+ import { collectionGroups } from '@coffer-org/sdk/shelf';
1
2
  import { derivedEntries, applyDerived } from '@coffer-org/sdk/derive';
3
+ import { applyStoredParts, hasStoredPartWork } from "./part-injection.js";
2
4
  import { getEm } from "./db.js";
3
5
  import { selectRows } from "./read-rows.js";
4
6
  import { encodeJson } from "./mutate.js";
@@ -11,8 +13,23 @@ function sameValue(a, b) {
11
13
  return false;
12
14
  return a === b;
13
15
  }
16
+ function partCtx(m) {
17
+ return { settings: {}, global: {}, meta: { library: m.library, shelf: m.shelf } };
18
+ }
19
+ function changedTopLevel(m, before, after) {
20
+ const collKeys = new Set(collectionGroups(m.fields).map((c) => c.key));
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(after)) {
23
+ if (collKeys.has(k))
24
+ continue;
25
+ if (JSON.stringify(before[k] ?? null) !== JSON.stringify(v ?? null))
26
+ out[k] = v;
27
+ }
28
+ return out;
29
+ }
14
30
  export async function recomputeDerivedFields(m, entityName) {
15
- if (derivedEntries(m.fields).length === 0)
31
+ const storedParts = hasStoredPartWork(m.fields);
32
+ if (derivedEntries(m.fields).length === 0 && !storedParts)
16
33
  return { scanned: 0, changed: 0 };
17
34
  const fork = getEm().fork();
18
35
  const rows = await selectRows(fork, entityName, {}, { orderBy: { id: 'asc' } });
@@ -22,10 +39,13 @@ export async function recomputeDerivedFields(m, entityName) {
22
39
  const flat = decodeTemporal(m, row);
23
40
  const nested = nestEmbedded(m, flat);
24
41
  const collections = await readCollections(fork, m, id);
25
- const derived = await applyDerived(m.fields, { ...nested, ...collections });
26
- if (Object.keys(derived).length === 0)
42
+ const hydrated = { ...nested, ...collections };
43
+ const withParts = storedParts ? await applyStoredParts(m.fields, hydrated, partCtx(m)) : hydrated;
44
+ const partPatch = storedParts ? changedTopLevel(m, hydrated, withParts) : {};
45
+ const derived = await applyDerived(m.fields, withParts);
46
+ if (Object.keys(derived).length === 0 && Object.keys(partPatch).length === 0)
27
47
  continue;
28
- const encoded = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, derived)));
48
+ const encoded = encodeJson(m, encodeTemporal(m, flattenEmbedded(m, { ...partPatch, ...derived })));
29
49
  const dirty = {};
30
50
  for (const [k, v] of Object.entries(encoded)) {
31
51
  if (!sameValue(row[k], v))