@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.
- 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 +32 -9
- 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 +21 -33
- package/dist/part-errors.d.ts +3 -0
- package/dist/part-errors.js +6 -0
- package/dist/part-injection.d.ts +12 -3
- package/dist/part-injection.js +379 -56
- 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 +2 -2
package/dist/part-injection.js
CHANGED
|
@@ -1,93 +1,392 @@
|
|
|
1
1
|
import { isField, isGroup, isCollectionGroup } from '@coffer-org/sdk/fields';
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
rethrowLoud(e);
|
|
225
|
+
console.warn(`[${write ? 'part-compute' : 'part-injection'}] '${key}.${p.role}' threw`, e);
|
|
35
226
|
}
|
|
36
227
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
await driveScope(scope, sub);
|
|
73
357
|
}
|
|
74
358
|
else if (isCollectionGroup(it)) {
|
|
75
|
-
const rows =
|
|
76
|
-
if (Array.isArray(rows))
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {};
|
package/dist/part-ref.js
ADDED
|
@@ -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
|
+
}
|
package/dist/plugin-runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
26
|
-
|
|
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))
|