akanjs 3.0.0-alpha.91 → 3.0.0-alpha.93
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/constant/cascadePaths.ts +43 -9
- package/constant/fieldInfo.ts +10 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/di/utils.ts +3 -2
- package/server/resolver/CascadeRunner.ts +23 -2
- package/server/resolver/signal.resolver.ts +44 -1
- package/service/injectInfo.ts +10 -9
- package/signal/serializer/fetch.serializer.ts +8 -1
- package/signal/sliceInfo.ts +19 -2
- package/signal/types.ts +5 -2
- package/store/action.ts +8 -2
- package/types/constant/cascadePaths.d.ts +3 -1
- package/types/constant/fieldInfo.d.ts +7 -0
- package/types/server/di/utils.d.ts +2 -1
- package/types/signal/sliceInfo.d.ts +18 -2
- package/types/signal/types.d.ts +5 -1
- package/ui/Load/Units.tsx +3 -2
package/constant/cascadePaths.ts
CHANGED
|
@@ -19,8 +19,10 @@ export interface CascadeWithPath {
|
|
|
19
19
|
readonly refName: string | null;
|
|
20
20
|
/** Set when the field declares `refPath`: the sibling field holding the owner's refName. */
|
|
21
21
|
readonly typeKey: string | null;
|
|
22
|
-
/** The refNames `typeKey` may hold. Empty unless the field is polymorphic. */
|
|
22
|
+
/** The refNames `typeKey` may hold. Empty unless the field is polymorphic over an enum. */
|
|
23
23
|
readonly typeValues: readonly string[];
|
|
24
|
+
/** Set by `polymorphic: "any"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
|
|
25
|
+
readonly anyOwner: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
const idNames = new Set(["ID", "String"]);
|
|
@@ -33,6 +35,7 @@ export class CascadePaths {
|
|
|
33
35
|
|
|
34
36
|
collect(fieldMap: FieldObject) {
|
|
35
37
|
for (const [key, field] of Object.entries(fieldMap)) {
|
|
38
|
+
this.#assertPolymorphicIsWiredUp(key, field);
|
|
36
39
|
if (!field.cascade) continue;
|
|
37
40
|
this.#assertKnownAction(key, field.cascade);
|
|
38
41
|
if (field.cascade === "removeRef") this.removeRef.set(key, this.#readOwnedRelation(key, field));
|
|
@@ -41,6 +44,16 @@ export class CascadePaths {
|
|
|
41
44
|
return this;
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/** `polymorphic` widens one thing and nothing else, so anywhere else it is a declaration that does nothing. */
|
|
48
|
+
#assertPolymorphicIsWiredUp(key: string, field: ConstantField) {
|
|
49
|
+
if (!field.polymorphic) return;
|
|
50
|
+
if (field.cascade === "removeWith" && field.refPath) return;
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Field "${key}" declares polymorphic: "${field.polymorphic}", which only widens a ` +
|
|
53
|
+
`cascade: "removeWith" field that names its owner type with refPath`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
#assertKnownAction(key: string, action: CascadeAction) {
|
|
45
58
|
|
|
46
59
|
if (!cascadeActions.includes(action)) {
|
|
@@ -64,10 +77,10 @@ export class CascadePaths {
|
|
|
64
77
|
if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap);
|
|
65
78
|
if (field.ref) {
|
|
66
79
|
this.#assertHoldsId(key, field);
|
|
67
|
-
return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [] };
|
|
80
|
+
return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [], anyOwner: false };
|
|
68
81
|
}
|
|
69
82
|
if (field.isClass && !field.isScalar) {
|
|
70
|
-
return { key, modelRef: field.modelRef, refName: null, typeKey: null, typeValues: [] };
|
|
83
|
+
return { key, modelRef: field.modelRef, refName: null, typeKey: null, typeValues: [], anyOwner: false };
|
|
71
84
|
}
|
|
72
85
|
throw new Error(
|
|
73
86
|
`Cascade field "${key}" declares cascade: "removeWith" but names no owner; make it a model reference, ` +
|
|
@@ -81,23 +94,44 @@ export class CascadePaths {
|
|
|
81
94
|
const typeKey = field.refPath as string;
|
|
82
95
|
const typeField = fieldMap[typeKey];
|
|
83
96
|
if (!typeField) throw new Error(`Cascade field "${key}" declares refPath: "${typeKey}", which is not a field`);
|
|
97
|
+
if (field.polymorphic === "any") return this.#readAnyOwner(key, typeKey, typeField);
|
|
84
98
|
|
|
85
99
|
if (!typeField.enum) {
|
|
86
100
|
throw new Error(
|
|
87
101
|
`Cascade field "${key}" declares refPath: "${typeKey}", which must be an enumOf(...) naming the owner ` +
|
|
88
|
-
`refNames it may hold`,
|
|
102
|
+
`refNames it may hold; declare polymorphic: "any" to pay for a sweep on every removal instead`,
|
|
89
103
|
);
|
|
90
104
|
}
|
|
91
105
|
const typeValues = typeField.enum.values.map((value) => String(value));
|
|
92
|
-
return { key, modelRef: null, refName: null, typeKey, typeValues };
|
|
106
|
+
return { key, modelRef: null, refName: null, typeKey, typeValues, anyOwner: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#readAnyOwner(key: string, typeKey: string, typeField: ConstantField): CascadeWithPath {
|
|
110
|
+
|
|
111
|
+
if (typeField.enum) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Cascade field "${key}" declares polymorphic: "any" and a refPath naming an enumOf(...); keep one`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (this.#primitiveNameOf(typeField) !== "String") {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Cascade field "${key}" declares polymorphic: "any", so refPath: "${typeKey}" must be a String field ` +
|
|
120
|
+
`holding the owner's refName`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return { key, modelRef: null, refName: null, typeKey, typeValues: [], anyOwner: true };
|
|
93
124
|
}
|
|
94
125
|
|
|
95
126
|
#assertHoldsId(key: string, field: ConstantField) {
|
|
96
|
-
const
|
|
97
|
-
const refName = PrimitiveRegistry.has(modelRef)
|
|
98
|
-
? PrimitiveRegistry.getName(modelRef as unknown as typeof PrimitiveScalar)
|
|
99
|
-
: null;
|
|
127
|
+
const refName = this.#primitiveNameOf(field);
|
|
100
128
|
if (refName && idNames.has(refName)) return;
|
|
101
129
|
throw new Error(`Cascade field "${key}" declares ref or refPath and must hold an ID`);
|
|
102
130
|
}
|
|
131
|
+
|
|
132
|
+
#primitiveNameOf(field: ConstantField) {
|
|
133
|
+
const modelRef = field.modelRef as unknown as Cls;
|
|
134
|
+
if (!PrimitiveRegistry.has(modelRef)) return null;
|
|
135
|
+
return PrimitiveRegistry.getName(modelRef as unknown as typeof PrimitiveScalar);
|
|
136
|
+
}
|
|
103
137
|
}
|
package/constant/fieldInfo.ts
CHANGED
|
@@ -106,6 +106,11 @@ export interface ConstantFieldProps<
|
|
|
106
106
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
107
107
|
text?: TextFieldRole;
|
|
108
108
|
cascade?: CascadeAction;
|
|
109
|
+
/**
|
|
110
|
+
* Widens a `cascade: "removeWith"` field whose `refPath` names a free-form `String` instead of an `enumOf`:
|
|
111
|
+
* the owner is whatever refName the row happens to hold, found by sweeping at removal time.
|
|
112
|
+
*/
|
|
113
|
+
polymorphic?: "any";
|
|
109
114
|
/**
|
|
110
115
|
* Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
|
|
111
116
|
* in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
|
|
@@ -204,6 +209,7 @@ interface ConstantFieldBuildProps<
|
|
|
204
209
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
205
210
|
text?: TextFieldRole;
|
|
206
211
|
cascade?: CascadeAction;
|
|
212
|
+
polymorphic?: "any";
|
|
207
213
|
visual: boolean;
|
|
208
214
|
modelRef: ConstantModelRef;
|
|
209
215
|
arrDepth: number;
|
|
@@ -329,6 +335,7 @@ export class ConstantField<
|
|
|
329
335
|
readonly validate?: (value: FieldValue, model: any) => boolean;
|
|
330
336
|
readonly text?: TextFieldRole;
|
|
331
337
|
readonly cascade?: CascadeAction;
|
|
338
|
+
readonly polymorphic?: "any";
|
|
332
339
|
readonly visual: boolean;
|
|
333
340
|
readonly modelRef: ConstantModelRef;
|
|
334
341
|
readonly arrDepth: number;
|
|
@@ -362,6 +369,7 @@ export class ConstantField<
|
|
|
362
369
|
this.validate = props.validate;
|
|
363
370
|
this.text = props.text;
|
|
364
371
|
this.cascade = props.cascade;
|
|
372
|
+
this.polymorphic = props.polymorphic;
|
|
365
373
|
this.visual = props.visual;
|
|
366
374
|
this.modelRef = props.modelRef;
|
|
367
375
|
this.arrDepth = props.arrDepth;
|
|
@@ -442,6 +450,7 @@ export class ConstantField<
|
|
|
442
450
|
validate: option.validate,
|
|
443
451
|
text: option.text,
|
|
444
452
|
cascade: option.cascade,
|
|
453
|
+
polymorphic: option.polymorphic,
|
|
445
454
|
visual: option.visual ?? false,
|
|
446
455
|
modelRef,
|
|
447
456
|
arrDepth: arrDepth,
|
|
@@ -489,6 +498,7 @@ export class ConstantField<
|
|
|
489
498
|
validate: this.validate,
|
|
490
499
|
text: this.text,
|
|
491
500
|
cascade: this.cascade,
|
|
501
|
+
polymorphic: this.polymorphic,
|
|
492
502
|
visual: this.visual,
|
|
493
503
|
modelRef: this.modelRef,
|
|
494
504
|
arrDepth: this.arrDepth,
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/di/utils.ts
CHANGED
|
@@ -71,7 +71,8 @@ export const getModuleDependencyRefNames = (mod: DatabaseModule | ServiceModule)
|
|
|
71
71
|
/**
|
|
72
72
|
* The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
|
|
73
73
|
* `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
|
|
74
|
-
* inject graph cannot see. A polymorphic owner
|
|
74
|
+
* inject graph cannot see. A polymorphic owner is exempt — an enum list spans optional modules by design, and
|
|
75
|
+
* `polymorphic: "any"` names no module at all.
|
|
75
76
|
*/
|
|
76
77
|
export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) => {
|
|
77
78
|
const dependencies = new Set<string>();
|
|
@@ -79,7 +80,7 @@ export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) =>
|
|
|
79
80
|
const { cascade } = mod.constant.full;
|
|
80
81
|
for (const modelRef of cascade.removeRef.values()) dependencies.add(ConstantRegistry.getRefName(modelRef));
|
|
81
82
|
for (const path of cascade.removeWith.values()) {
|
|
82
|
-
if (path.typeValues.length) continue;
|
|
83
|
+
if (path.anyOwner || path.typeValues.length) continue;
|
|
83
84
|
dependencies.add(path.refName ?? ConstantRegistry.getRefName(path.modelRef as never));
|
|
84
85
|
}
|
|
85
86
|
return dependencies;
|
|
@@ -43,6 +43,8 @@ const drainSize = 200;
|
|
|
43
43
|
export class CascadeRunner {
|
|
44
44
|
readonly #modules = new Map<string, CascadeModule>();
|
|
45
45
|
readonly #plans = new Map<string, CascadePlan>();
|
|
46
|
+
/** `polymorphic: "any"` edges: the owner is unknowable at boot, so every model's removal has to sweep them. */
|
|
47
|
+
readonly #anyEdges: WithEdge[] = [];
|
|
46
48
|
readonly #bulk = new Set<string>();
|
|
47
49
|
readonly #context = new AsyncLocalStorage<CascadeContext>();
|
|
48
50
|
readonly #logger = new Logger("Cascade");
|
|
@@ -71,7 +73,8 @@ export class CascadeRunner {
|
|
|
71
73
|
|
|
72
74
|
async run(refName: string, doc: Record<string, unknown>) {
|
|
73
75
|
const plan = this.#plans.get(refName);
|
|
74
|
-
if (!plan
|
|
76
|
+
if (!plan) return;
|
|
77
|
+
if (!plan.refEdges.length && !plan.withEdges.length && !this.#anyEdges.length) return;
|
|
75
78
|
const parent = this.#context.getStore();
|
|
76
79
|
const seen = parent?.seen ?? new Set<string>();
|
|
77
80
|
const depth = (parent?.depth ?? 0) + 1;
|
|
@@ -83,7 +86,9 @@ export class CascadeRunner {
|
|
|
83
86
|
}
|
|
84
87
|
await this.#context.run({ seen, depth }, async () => {
|
|
85
88
|
for (const edge of plan.refEdges) await this.#removeRef(edge, doc, seen);
|
|
86
|
-
if (id)
|
|
89
|
+
if (!id) return;
|
|
90
|
+
for (const edge of plan.withEdges) await this.#removeWith(edge, refName, id, seen);
|
|
91
|
+
for (const edge of this.#anyEdges) await this.#removeWith(edge, refName, id, seen);
|
|
87
92
|
});
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -136,6 +141,10 @@ export class CascadeRunner {
|
|
|
136
141
|
|
|
137
142
|
#collectWithEdges(childRef: string, mod: CascadeModule) {
|
|
138
143
|
for (const [key, path] of mod.constant.full.cascade.removeWith) {
|
|
144
|
+
if (path.anyOwner) {
|
|
145
|
+
this.#anyEdges.push({ refName: childRef, key, typeKey: path.typeKey });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
139
148
|
for (const owner of this.#resolveOwners(childRef, key, path)) {
|
|
140
149
|
this.#plans.get(owner)?.withEdges.push({ refName: childRef, key, typeKey: path.typeKey });
|
|
141
150
|
}
|
|
@@ -163,6 +172,8 @@ export class CascadeRunner {
|
|
|
163
172
|
#hasRemoveSideEffect(refName: string) {
|
|
164
173
|
const mod = this.#modules.get(refName);
|
|
165
174
|
if (!mod) return true;
|
|
175
|
+
|
|
176
|
+
if (this.#anyEdges.length) return true;
|
|
166
177
|
if (mod.schema.preHooks.get("remove")?.length || mod.schema.postHooks.get("remove")?.length) return true;
|
|
167
178
|
if ((mod.srvRef as unknown as { [LIBS_REMOVE_HOOK]?: boolean })[LIBS_REMOVE_HOOK]) return true;
|
|
168
179
|
const proto = mod.srvRef.prototype as { _preRemove?: unknown; _postRemove?: unknown };
|
|
@@ -184,10 +195,20 @@ export class CascadeRunner {
|
|
|
184
195
|
lines.push(`${refName} removeWith ${edge.refName}.${path} (${this.#strategy(edge.refName)})`);
|
|
185
196
|
}
|
|
186
197
|
}
|
|
198
|
+
for (const edge of this.#anyEdges) {
|
|
199
|
+
lines.push(`<any> removeWith ${edge.refName}.${edge.key}+${edge.typeKey} (${this.#strategy(edge.refName)})`);
|
|
200
|
+
}
|
|
187
201
|
if (!lines.length) return;
|
|
188
202
|
const bulk = lines.filter((line) => line.endsWith("(bulk)")).length;
|
|
189
203
|
this.#logger.verbose(`${lines.length} cascade edge(s), ${bulk} in one query`);
|
|
190
204
|
for (const line of lines) this.#logger.verbose(line);
|
|
205
|
+
|
|
206
|
+
if (!this.#anyEdges.length) return;
|
|
207
|
+
const wildcards = this.#anyEdges.map((edge) => `${edge.refName}.${edge.key}`).join(", ");
|
|
208
|
+
this.#logger.info(
|
|
209
|
+
`${this.#anyEdges.length} wildcard removeWith edge(s) (${wildcards}): every removal probes them, ` +
|
|
210
|
+
`and no cascade removes in one query`,
|
|
211
|
+
);
|
|
191
212
|
}
|
|
192
213
|
|
|
193
214
|
#strategy(refName: string) {
|
|
@@ -374,9 +374,11 @@ export class SignalResolver {
|
|
|
374
374
|
`and opening it would put every model on a live socket by default.`,
|
|
375
375
|
);
|
|
376
376
|
SignalResolver.#assertLiveSort(refName, key, sliceInfo);
|
|
377
|
+
SignalResolver.#assertLivePauseOn(refName, key, sliceInfo);
|
|
377
378
|
const liveKey = `${refName}Live${capitalizedKey}`;
|
|
379
|
+
|
|
378
380
|
const liveBuilder = (builder as any).pubsub(Any, {
|
|
379
|
-
|
|
381
|
+
...sliceInfo.signalOption,
|
|
380
382
|
mcp: false,
|
|
381
383
|
live: {
|
|
382
384
|
refName,
|
|
@@ -384,6 +386,7 @@ export class SignalResolver {
|
|
|
384
386
|
sort: sliceInfo.liveOption.sort,
|
|
385
387
|
fallback: sliceInfo.liveOption.fallback,
|
|
386
388
|
payload: sliceInfo.liveOption.payload,
|
|
389
|
+
pauseOn: sliceInfo.liveOption.pauseOn,
|
|
387
390
|
} satisfies LiveEndpointOption,
|
|
388
391
|
});
|
|
389
392
|
endpointObj[liveKey] = liveBuilder
|
|
@@ -482,6 +485,44 @@ export class SignalResolver {
|
|
|
482
485
|
}
|
|
483
486
|
}
|
|
484
487
|
}
|
|
488
|
+
/** Refuses a subscribe carrying an argument the slice named in `pauseOn`, naming the argument it refused on. */
|
|
489
|
+
static #assertNotPaused(
|
|
490
|
+
key: string,
|
|
491
|
+
liveOption: LiveEndpointOption,
|
|
492
|
+
endpointInfo: EndpointInfo,
|
|
493
|
+
context: SignalContext,
|
|
494
|
+
) {
|
|
495
|
+
for (const name of liveOption.pauseOn) {
|
|
496
|
+
const idx = endpointInfo.args.findIndex((arg) => arg.name === name);
|
|
497
|
+
if (idx < 0 || context.args[idx] == null) continue;
|
|
498
|
+
throw new Error(
|
|
499
|
+
`Live room "${key}" is paused while "${name}" carries a value: the slice declared it in ` +
|
|
500
|
+
`.live({ pauseOn }), so this window updates by refetching instead of subscribing.`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* That every argument `pauseOn` names is one this slice has, and one that can actually be empty.
|
|
506
|
+
*
|
|
507
|
+
* A name that is not an argument would do nothing at all, and a `param` — which is never nullable, so it is
|
|
508
|
+
* present on every call — would switch the room off for good rather than while a box is filled. Both are the
|
|
509
|
+
* kind of mistake whose only symptom is a list that never updates, so neither is allowed to boot.
|
|
510
|
+
*/
|
|
511
|
+
static #assertLivePauseOn(refName: string, key: string, sliceInfo: SliceInfo) {
|
|
512
|
+
for (const name of sliceInfo.liveOption?.pauseOn ?? []) {
|
|
513
|
+
const arg = sliceInfo.args.find((candidate) => candidate.name === name);
|
|
514
|
+
if (!arg)
|
|
515
|
+
throw new Error(
|
|
516
|
+
`Live slice "${refName}.${key}" declares pauseOn "${name}", which is not one of its arguments ` +
|
|
517
|
+
`(${sliceInfo.args.map((candidate) => candidate.name).join(", ") || "none"}).`,
|
|
518
|
+
);
|
|
519
|
+
if (!arg.option?.nullable)
|
|
520
|
+
throw new Error(
|
|
521
|
+
`Live slice "${refName}.${key}" declares pauseOn "${name}", which is a required ${arg.type} and is ` +
|
|
522
|
+
`therefore always present — the room would never open. Only a nullable argument can pause live sync.`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
485
526
|
/**
|
|
486
527
|
* The model's field metadata, which membership routing reads for one thing: whether a path is an array, because
|
|
487
528
|
* a bare value on an array field means membership rather than equality.
|
|
@@ -649,6 +690,8 @@ export class SignalResolver {
|
|
|
649
690
|
|
|
650
691
|
const requestRoomId = context.getRoomId(key);
|
|
651
692
|
if (subscribe) {
|
|
693
|
+
|
|
694
|
+
if (liveOption) SignalResolver.#assertNotPaused(key, liveOption, endpointInfo, context);
|
|
652
695
|
const query = await context.exec();
|
|
653
696
|
const roomId = liveOption ? context.getLiveRoomId(key) : requestRoomId;
|
|
654
697
|
if (liveOption)
|
package/service/injectInfo.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BackendEnv, type Cls, INJECT_META } from "akanjs/base";
|
|
1
|
+
import { type BackendEnv, type Cls, INJECT_META, PrimitiveRegistry } from "akanjs/base";
|
|
2
2
|
import {
|
|
3
3
|
type ConstantFieldTypeInput,
|
|
4
4
|
ConstantRegistry,
|
|
@@ -389,6 +389,9 @@ export const injectionBuilder = (parentRefName: string) => ({
|
|
|
389
389
|
throw new Error("get and set should be both provided or not provided");
|
|
390
390
|
const isMap = modelRef === Map;
|
|
391
391
|
if (isMap && !opts.of) throw new Error("of should be provided when modelRef is Map");
|
|
392
|
+
const valueRef = (isMap ? opts.of : modelRef) as Cls;
|
|
393
|
+
|
|
394
|
+
const isStructured = Array.isArray(valueRef) || !PrimitiveRegistry.has(valueRef);
|
|
392
395
|
type FieldValue = never extends GetFn ? GetFieldValue<ValueRef, ExplicitType, MapValue> : ReturnType<GetFn>;
|
|
393
396
|
type MapFieldValue = never extends GetFn ? FieldToValue<MapValue> : ReturnType<GetFn>;
|
|
394
397
|
type IsNullable = DefaultValue extends never ? true : false;
|
|
@@ -425,16 +428,14 @@ export const injectionBuilder = (parentRefName: string) => ({
|
|
|
425
428
|
>("memory", {
|
|
426
429
|
local: opts.local,
|
|
427
430
|
get: (serializedValue: never) => {
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
);
|
|
431
|
+
const stored = serializedValue as unknown;
|
|
432
|
+
|
|
433
|
+
const rawValue = isStructured && typeof stored === "string" ? JSON.parse(stored) : stored;
|
|
434
|
+
return ConstantRegistry.deserialize(valueRef, (rawValue as object | null) ?? opts.default, true) ?? null;
|
|
433
435
|
},
|
|
434
436
|
set: (value: never) => {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
);
|
|
437
|
+
const serialized = ConstantRegistry.serialize(valueRef, value, true) ?? opts.default ?? null;
|
|
438
|
+
return isStructured ? JSON.stringify(serialized) : serialized;
|
|
438
439
|
},
|
|
439
440
|
default: opts.default as unknown,
|
|
440
441
|
isMap,
|
|
@@ -110,7 +110,14 @@ export class FetchSerializer {
|
|
|
110
110
|
...(sliceInfo.signalOption.path ? { path: sliceInfo.signalOption.path } : {}),
|
|
111
111
|
...(guards?.length ? { guards } : {}),
|
|
112
112
|
...(sliceInfo.signalOption.mcp === false ? { mcp: false as const } : {}),
|
|
113
|
-
...(sliceInfo.liveOption
|
|
113
|
+
...(sliceInfo.liveOption
|
|
114
|
+
? {
|
|
115
|
+
live: {
|
|
116
|
+
sort: sliceInfo.liveOption.sort,
|
|
117
|
+
...(sliceInfo.liveOption.pauseOn.length ? { pauseOn: sliceInfo.liveOption.pauseOn } : {}),
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
114
121
|
};
|
|
115
122
|
}
|
|
116
123
|
|
package/signal/sliceInfo.ts
CHANGED
|
@@ -27,7 +27,7 @@ import type { CnstFull, CnstInput, CnstInsight, CnstLight, DbFilter, SignalOptio
|
|
|
27
27
|
* What a slice declares when it opts into live sync. Nothing here is required — `.live()` on its own is the whole
|
|
28
28
|
* opt-in, and every default below is the conservative reading.
|
|
29
29
|
*/
|
|
30
|
-
export interface LiveSliceOption {
|
|
30
|
+
export interface LiveSliceOption<ArgName extends string = string> {
|
|
31
31
|
/**
|
|
32
32
|
* The sort keys a client may reproduce well enough to place a row itself. Anything else falls back to a refetch,
|
|
33
33
|
* because the client would otherwise have to guess where a new row goes.
|
|
@@ -45,12 +45,28 @@ export interface LiveSliceOption {
|
|
|
45
45
|
fallback?: "invalidate";
|
|
46
46
|
/** `light` sends the row and costs nothing to apply; `id` sends the id alone for a room where the row is bulky. */
|
|
47
47
|
payload?: "light" | "id";
|
|
48
|
+
/**
|
|
49
|
+
* The arguments that switch live sync off for as long as they carry a value, named for this slice.
|
|
50
|
+
*
|
|
51
|
+
* A filter often builds a different query shape depending on what it was handed — `search ? q.search(text) : {}`
|
|
52
|
+
* is the common one — so the same slice is routable blank and unroutable with text in the box. Naming the
|
|
53
|
+
* argument makes that explicit: the client opens no room while it is filled and the window updates by
|
|
54
|
+
* refetching, which is what a slice with no `.live()` at all does. Clearing it opens the room again.
|
|
55
|
+
*
|
|
56
|
+
* Only a nullable argument can be named, which in practice means a `search`: a `param` is always present, so
|
|
57
|
+
* naming one would switch live off for good. That is refused at boot rather than left to be discovered.
|
|
58
|
+
*
|
|
59
|
+
* This is not `fallback`. `fallback: "invalidate"` keeps the room and gives up precision — every write on the
|
|
60
|
+
* model tells the room to refetch. `pauseOn` gives up the room and keeps precision everywhere else.
|
|
61
|
+
*/
|
|
62
|
+
pauseOn?: ArgName[];
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
export interface ResolvedLiveSliceOption {
|
|
51
66
|
sort: string[];
|
|
52
67
|
fallback: "invalidate" | null;
|
|
53
68
|
payload: "light" | "id";
|
|
69
|
+
pauseOn: string[];
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
export class SliceInfo<
|
|
@@ -193,13 +209,14 @@ export class SliceInfo<
|
|
|
193
209
|
>;
|
|
194
210
|
}
|
|
195
211
|
/** Opts this slice into live sync. Declaring nothing at all is what keeps a slice out of it entirely. */
|
|
196
|
-
live(option: LiveSliceOption = {}) {
|
|
212
|
+
live(option: LiveSliceOption<ArgNames[number]> = {}) {
|
|
197
213
|
if (this.execFn) throw new Error("Query function is already set");
|
|
198
214
|
if (this.liveOption) throw new Error("Live option is already set");
|
|
199
215
|
this.liveOption = {
|
|
200
216
|
sort: option.sort ?? ["latest"],
|
|
201
217
|
fallback: option.fallback ?? null,
|
|
202
218
|
payload: option.payload ?? "light",
|
|
219
|
+
pauseOn: (option.pauseOn as string[] | undefined) ?? [],
|
|
203
220
|
};
|
|
204
221
|
return this;
|
|
205
222
|
}
|
package/signal/types.ts
CHANGED
|
@@ -69,6 +69,8 @@ export interface LiveEndpointOption {
|
|
|
69
69
|
sort: string[];
|
|
70
70
|
fallback: "invalidate" | null;
|
|
71
71
|
payload: "light" | "id";
|
|
72
|
+
/** Room arguments that must be empty for the room to exist at all. Enforced here as well as in the client. */
|
|
73
|
+
pauseOn: string[];
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export interface SignalOption<Response = any, Nullable extends boolean = false, _Key = keyof UnCls<Response>>
|
|
@@ -143,9 +145,10 @@ interface SerializedSignalOption {
|
|
|
143
145
|
export interface SerializedSlice extends SerializedSignalOption {
|
|
144
146
|
/**
|
|
145
147
|
* Present when the slice declared `.live()`. `sort` is the allowlist of sort keys a subscriber may place a new
|
|
146
|
-
* row under itself; on any other sort an insertion refetches instead of guessing where the row goes.
|
|
148
|
+
* row under itself; on any other sort an insertion refetches instead of guessing where the row goes. `pauseOn`
|
|
149
|
+
* names the arguments that switch the room off while they carry a value, and travels only when there are any.
|
|
147
150
|
*/
|
|
148
|
-
live?: { sort: string[] };
|
|
151
|
+
live?: { sort: string[]; pauseOn?: string[] };
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
export interface SerializedReturns {
|
package/store/action.ts
CHANGED
|
@@ -1018,6 +1018,10 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1018
1018
|
const requests = new SliceRequest();
|
|
1019
1019
|
|
|
1020
1020
|
let liveWatch: { signature: string; dispose: () => void } | null = null;
|
|
1021
|
+
|
|
1022
|
+
const livePauseIdxs = (slice.live?.pauseOn ?? [])
|
|
1023
|
+
.map((name) => slice.args.findIndex((arg) => arg.name === name))
|
|
1024
|
+
.filter((idx) => idx >= 0);
|
|
1021
1025
|
const namesOfSlice: { [key in SliceActionKey | SliceStateKey | "modelList"]: string } = {
|
|
1022
1026
|
defaultModel: SliceName.replace(names.Model, names.defaultModel),
|
|
1023
1027
|
modelInsight: sliceName.replace(names.model, names.modelInsight),
|
|
@@ -1395,12 +1399,14 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1395
1399
|
[namesOfSlice.watchLiveModel]: function (this: SetGet, queryArgs: unknown[] | null) {
|
|
1396
1400
|
if (!slice.live) return;
|
|
1397
1401
|
const args = queryArgs ? expandQueryArgs(normalizeQueryArgs(queryArgs, slice.args), slice.args) : null;
|
|
1398
|
-
|
|
1402
|
+
|
|
1403
|
+
const paused = !!args && livePauseIdxs.some((idx) => args[idx] != null);
|
|
1404
|
+
const signature = args && !paused ? JSON.stringify(args) : null;
|
|
1399
1405
|
if (liveWatch && signature !== liveWatch.signature) {
|
|
1400
1406
|
liveWatch.dispose();
|
|
1401
1407
|
liveWatch = null;
|
|
1402
1408
|
}
|
|
1403
|
-
if (!args || liveWatch) return;
|
|
1409
|
+
if (!args || paused || liveWatch) return;
|
|
1404
1410
|
const self = this as unknown as DynamicRecord;
|
|
1405
1411
|
const apply = self[namesOfSlice.applyLiveModel] as (event: unknown) => void;
|
|
1406
1412
|
const refresh = self[namesOfSlice.refreshModel] as (form: object) => Promise<void>;
|
|
@@ -16,8 +16,10 @@ export interface CascadeWithPath {
|
|
|
16
16
|
readonly refName: string | null;
|
|
17
17
|
/** Set when the field declares `refPath`: the sibling field holding the owner's refName. */
|
|
18
18
|
readonly typeKey: string | null;
|
|
19
|
-
/** The refNames `typeKey` may hold. Empty unless the field is polymorphic. */
|
|
19
|
+
/** The refNames `typeKey` may hold. Empty unless the field is polymorphic over an enum. */
|
|
20
20
|
readonly typeValues: readonly string[];
|
|
21
|
+
/** Set by `polymorphic: "any"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
|
|
22
|
+
readonly anyOwner: boolean;
|
|
21
23
|
}
|
|
22
24
|
export declare class CascadePaths {
|
|
23
25
|
#private;
|
|
@@ -47,6 +47,11 @@ export interface ConstantFieldProps<FieldType extends ConstantFieldKind = Consta
|
|
|
47
47
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
48
48
|
text?: TextFieldRole;
|
|
49
49
|
cascade?: CascadeAction;
|
|
50
|
+
/**
|
|
51
|
+
* Widens a `cascade: "removeWith"` field whose `refPath` names a free-form `String` instead of an `enumOf`:
|
|
52
|
+
* the owner is whatever refName the row happens to hold, found by sweeping at removal time.
|
|
53
|
+
*/
|
|
54
|
+
polymorphic?: "any";
|
|
50
55
|
/**
|
|
51
56
|
* Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
|
|
52
57
|
* in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
|
|
@@ -101,6 +106,7 @@ interface ConstantFieldBuildProps<FieldType extends ConstantFieldKind = any, Fie
|
|
|
101
106
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
102
107
|
text?: TextFieldRole;
|
|
103
108
|
cascade?: CascadeAction;
|
|
109
|
+
polymorphic?: "any";
|
|
104
110
|
visual: boolean;
|
|
105
111
|
modelRef: ConstantModelRef;
|
|
106
112
|
arrDepth: number;
|
|
@@ -147,6 +153,7 @@ export declare class ConstantField<FieldType extends ConstantFieldKind = Constan
|
|
|
147
153
|
readonly validate?: (value: FieldValue, model: any) => boolean;
|
|
148
154
|
readonly text?: TextFieldRole;
|
|
149
155
|
readonly cascade?: CascadeAction;
|
|
156
|
+
readonly polymorphic?: "any";
|
|
150
157
|
readonly visual: boolean;
|
|
151
158
|
readonly modelRef: ConstantModelRef;
|
|
152
159
|
readonly arrDepth: number;
|
|
@@ -18,7 +18,8 @@ export declare const getModuleDependencyRefNames: (mod: DatabaseModule | Service
|
|
|
18
18
|
/**
|
|
19
19
|
* The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
|
|
20
20
|
* `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
|
|
21
|
-
* inject graph cannot see. A polymorphic owner
|
|
21
|
+
* inject graph cannot see. A polymorphic owner is exempt — an enum list spans optional modules by design, and
|
|
22
|
+
* `polymorphic: "any"` names no module at all.
|
|
22
23
|
*/
|
|
23
24
|
export declare const getModuleCascadeRefNames: (mod: DatabaseModule | ServiceModule) => Set<string>;
|
|
24
25
|
export interface Registration {
|
|
@@ -9,7 +9,7 @@ import type { CnstFull, CnstInput, CnstInsight, CnstLight, DbFilter, SignalOptio
|
|
|
9
9
|
* What a slice declares when it opts into live sync. Nothing here is required — `.live()` on its own is the whole
|
|
10
10
|
* opt-in, and every default below is the conservative reading.
|
|
11
11
|
*/
|
|
12
|
-
export interface LiveSliceOption {
|
|
12
|
+
export interface LiveSliceOption<ArgName extends string = string> {
|
|
13
13
|
/**
|
|
14
14
|
* The sort keys a client may reproduce well enough to place a row itself. Anything else falls back to a refetch,
|
|
15
15
|
* because the client would otherwise have to guess where a new row goes.
|
|
@@ -27,11 +27,27 @@ export interface LiveSliceOption {
|
|
|
27
27
|
fallback?: "invalidate";
|
|
28
28
|
/** `light` sends the row and costs nothing to apply; `id` sends the id alone for a room where the row is bulky. */
|
|
29
29
|
payload?: "light" | "id";
|
|
30
|
+
/**
|
|
31
|
+
* The arguments that switch live sync off for as long as they carry a value, named for this slice.
|
|
32
|
+
*
|
|
33
|
+
* A filter often builds a different query shape depending on what it was handed — `search ? q.search(text) : {}`
|
|
34
|
+
* is the common one — so the same slice is routable blank and unroutable with text in the box. Naming the
|
|
35
|
+
* argument makes that explicit: the client opens no room while it is filled and the window updates by
|
|
36
|
+
* refetching, which is what a slice with no `.live()` at all does. Clearing it opens the room again.
|
|
37
|
+
*
|
|
38
|
+
* Only a nullable argument can be named, which in practice means a `search`: a `param` is always present, so
|
|
39
|
+
* naming one would switch live off for good. That is refused at boot rather than left to be discovered.
|
|
40
|
+
*
|
|
41
|
+
* This is not `fallback`. `fallback: "invalidate"` keeps the room and gives up precision — every write on the
|
|
42
|
+
* model tells the room to refetch. `pauseOn` gives up the room and keeps precision everywhere else.
|
|
43
|
+
*/
|
|
44
|
+
pauseOn?: ArgName[];
|
|
30
45
|
}
|
|
31
46
|
export interface ResolvedLiveSliceOption {
|
|
32
47
|
sort: string[];
|
|
33
48
|
fallback: "invalidate" | null;
|
|
34
49
|
payload: "light" | "id";
|
|
50
|
+
pauseOn: string[];
|
|
35
51
|
}
|
|
36
52
|
export declare class SliceInfo<RefName extends string = string, Input = any, Full = any, Light = any, Insight = any, Filter extends FilterInstance = any, Srvs extends {
|
|
37
53
|
[key: string]: any;
|
|
@@ -56,7 +72,7 @@ export declare class SliceInfo<RefName extends string = string, Input = any, Ful
|
|
|
56
72
|
search<ArgName extends string, ExplicitType = unknown, Arg extends ConstantFieldTypeInput = PlainTypeToFieldType<ExplicitType>, _ArgType = unknown extends ExplicitType ? FieldToValue<Arg> : ExplicitType, _ClientArg = PurifiedModel<_ArgType>, _ServerArg = DocumentModel<_ArgType>>(name: ArgName, arg: Arg, option?: Omit<EndpointArgProps, "nullable">): SliceInfo<RefName, Input, Full, Light, Insight, Filter, Srvs, [...ArgNames, ArgName], [...Args, arg?: _ClientArg | null], InternalArgs, [...ServerArgs, arg: _ServerArg | undefined]>;
|
|
57
73
|
with<ArgType, Optional extends boolean = false>(argRef: InternalArgCls<ArgType>, option?: InternalArgProps<Optional>): SliceInfo<RefName, Input, Full, Light, Insight, Filter, Srvs, ArgNames, Args, [...InternalArgs, arg: NonNullable<ArgType> | (Optional extends true ? null : never)], ServerArgs>;
|
|
58
74
|
/** Opts this slice into live sync. Declaring nothing at all is what keeps a slice out of it entirely. */
|
|
59
|
-
live(option?: LiveSliceOption): this;
|
|
75
|
+
live(option?: LiveSliceOption<ArgNames[number]>): this;
|
|
60
76
|
exec(query: (this: {
|
|
61
77
|
[K in keyof Srvs as K extends string ? Uncapitalize<K> : never]: Srvs[K];
|
|
62
78
|
}, ...args: [...ServerArgs, ...InternalArgs]) => PromiseOrObject<QueryOf<DocumentModel<Full>>>): this;
|
package/types/signal/types.d.ts
CHANGED
|
@@ -60,6 +60,8 @@ export interface LiveEndpointOption {
|
|
|
60
60
|
sort: string[];
|
|
61
61
|
fallback: "invalidate" | null;
|
|
62
62
|
payload: "light" | "id";
|
|
63
|
+
/** Room arguments that must be empty for the room to exist at all. Enforced here as well as in the client. */
|
|
64
|
+
pauseOn: string[];
|
|
63
65
|
}
|
|
64
66
|
export interface SignalOption<Response = any, Nullable extends boolean = false, _Key = keyof UnCls<Response>> extends InitOption, TimerOption {
|
|
65
67
|
nullable?: Nullable;
|
|
@@ -129,10 +131,12 @@ interface SerializedSignalOption {
|
|
|
129
131
|
export interface SerializedSlice extends SerializedSignalOption {
|
|
130
132
|
/**
|
|
131
133
|
* Present when the slice declared `.live()`. `sort` is the allowlist of sort keys a subscriber may place a new
|
|
132
|
-
* row under itself; on any other sort an insertion refetches instead of guessing where the row goes.
|
|
134
|
+
* row under itself; on any other sort an insertion refetches instead of guessing where the row goes. `pauseOn`
|
|
135
|
+
* names the arguments that switch the room off while they carry a value, and travels only when there are any.
|
|
133
136
|
*/
|
|
134
137
|
live?: {
|
|
135
138
|
sort: string[];
|
|
139
|
+
pauseOn?: string[];
|
|
136
140
|
};
|
|
137
141
|
}
|
|
138
142
|
export interface SerializedReturns {
|
package/ui/Load/Units.tsx
CHANGED
|
@@ -161,12 +161,13 @@ function Render<RefName extends string, Light extends { id: string }>({
|
|
|
161
161
|
loadedQueryArgs.current = initQueryArgs;
|
|
162
162
|
}, [initSignature]);
|
|
163
163
|
|
|
164
|
+
const queryArgsSignature = JSON.stringify(storeUse[namesOfSlice.queryArgsOfModel]());
|
|
164
165
|
useEffect(() => {
|
|
165
|
-
void storeDo[namesOfSlice.watchLiveModel](initQueryArgs);
|
|
166
|
+
void storeDo[namesOfSlice.watchLiveModel](storeGet<object[]>()[namesOfSlice.queryArgsOfModel] ?? initQueryArgs);
|
|
166
167
|
return () => {
|
|
167
168
|
void storeDo[namesOfSlice.watchLiveModel](null);
|
|
168
169
|
};
|
|
169
|
-
}, [initSignature]);
|
|
170
|
+
}, [initSignature, queryArgsSignature]);
|
|
170
171
|
|
|
171
172
|
useEffect(() => {
|
|
172
173
|
const modelStaleAt = storeGet<Date>()[namesOfSlice.modelStaleAt];
|