akanjs 3.0.0-alpha.90 → 3.0.0-alpha.92
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/common/types.ts +7 -0
- package/fetch/client/fetchClient.ts +21 -7
- 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/diLifecycle.ts +1 -1
- package/server/resolver/signal.resolver.ts +56 -14
- package/signal/endpointInfo.ts +16 -0
- package/signal/serializer/fetch.serializer.ts +8 -1
- package/signal/sliceInfo.ts +19 -2
- package/signal/types.ts +5 -2
- package/store/action.ts +22 -18
- package/types/common/types.d.ts +7 -0
- package/types/signal/endpointInfo.d.ts +10 -0
- package/types/signal/sliceInfo.d.ts +18 -2
- package/types/signal/types.d.ts +5 -1
- package/ui/Load/Units.tsx +3 -2
package/common/types.ts
CHANGED
|
@@ -6,6 +6,13 @@ export interface FetchPolicy<Returns = unknown> {
|
|
|
6
6
|
token?: string;
|
|
7
7
|
partial?: string[];
|
|
8
8
|
timeout?: number;
|
|
9
|
+
/**
|
|
10
|
+
* A `pubsub` subscription only: called after the room has been resubscribed following a dropped connection.
|
|
11
|
+
*
|
|
12
|
+
* Whatever was published while the socket was down is gone, and a room cannot say which messages those were, so
|
|
13
|
+
* a subscriber that has to stay correct reloads here instead of carrying on from a gap it cannot see.
|
|
14
|
+
*/
|
|
15
|
+
onResync?: () => void;
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
export type SnakeCase<S extends string> = S extends `${infer T}_${infer U}` ? `${Lowercase<T>}_${SnakeCase<U>}` : S;
|
|
@@ -373,12 +373,15 @@ export class FetchClient {
|
|
|
373
373
|
};
|
|
374
374
|
wrappedListeners.set(handleEvent, wrapped);
|
|
375
375
|
const ws = this.#resolveWs(fetchPolicy?.origin);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
376
|
+
const handleResync = fetchPolicy?.onResync;
|
|
377
|
+
ws.subscribe({ key, data, handleEvent: wrapped, handleResync });
|
|
378
|
+
return () =>
|
|
379
|
+
ws.unsubscribe({
|
|
380
|
+
key,
|
|
381
|
+
data,
|
|
382
|
+
handleEvent: wrappedListeners.get(handleEvent) ?? handleEvent,
|
|
383
|
+
handleResync,
|
|
384
|
+
});
|
|
382
385
|
};
|
|
383
386
|
});
|
|
384
387
|
return;
|
|
@@ -624,6 +627,7 @@ export class FetchClient {
|
|
|
624
627
|
const names = {
|
|
625
628
|
list: `${refName}List${capSuffix}`,
|
|
626
629
|
insight: `${refName}Insight${capSuffix}`,
|
|
630
|
+
live: `${refName}Live${capSuffix}`,
|
|
627
631
|
};
|
|
628
632
|
|
|
629
633
|
const mcp = slice.mcp === false ? { mcp: false as const } : {};
|
|
@@ -643,6 +647,15 @@ export class FetchClient {
|
|
|
643
647
|
...mcp,
|
|
644
648
|
},
|
|
645
649
|
};
|
|
650
|
+
|
|
651
|
+
if (slice.live)
|
|
652
|
+
endpoint[names.live] = {
|
|
653
|
+
type: "pubsub",
|
|
654
|
+
args: slice.args.map((arg) => ({ ...arg, type: "room" as const })),
|
|
655
|
+
returns: { refName: "Any" },
|
|
656
|
+
guards: slice.guards,
|
|
657
|
+
mcp: false,
|
|
658
|
+
};
|
|
646
659
|
return endpoint;
|
|
647
660
|
}
|
|
648
661
|
#registerSlice(refName: string, suffix: string, slice: SerializedSlice, prefix?: string) {
|
|
@@ -657,8 +670,9 @@ export class FetchClient {
|
|
|
657
670
|
};
|
|
658
671
|
|
|
659
672
|
const endpoint = FetchClient.getEndpointFromSlice(refName, suffix, slice);
|
|
673
|
+
|
|
660
674
|
Object.entries(endpoint).forEach(([key, value]) => {
|
|
661
|
-
this.#
|
|
675
|
+
this.#registerEndpoint(key, value, prefix);
|
|
662
676
|
});
|
|
663
677
|
|
|
664
678
|
const argLength = slice.args.length;
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/di/diLifecycle.ts
CHANGED
|
@@ -662,7 +662,7 @@ export class DiLifecycle {
|
|
|
662
662
|
},
|
|
663
663
|
})),
|
|
664
664
|
);
|
|
665
|
-
if (liveKeys.length) this.logger.info(`Live sync: ${liveKeys.length}
|
|
665
|
+
if (liveKeys.length) this.logger.info(`Live sync: ${liveKeys.length} live slice(s) — ${liveKeys.join(", ")}`);
|
|
666
666
|
return { routes, wsRoutes, routeOptions };
|
|
667
667
|
}
|
|
668
668
|
|
|
@@ -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}`;
|
|
378
|
-
|
|
379
|
-
|
|
379
|
+
|
|
380
|
+
const liveBuilder = (builder as any).pubsub(Any, {
|
|
381
|
+
...sliceInfo.signalOption,
|
|
380
382
|
mcp: false,
|
|
381
383
|
live: {
|
|
382
384
|
refName,
|
|
@@ -384,20 +386,20 @@ 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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
...requestArgs: any
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
);
|
|
400
|
-
});
|
|
392
|
+
endpointObj[liveKey] = liveBuilder
|
|
393
|
+
._addRoomArgs(sliceInfo.args)
|
|
394
|
+
._addInternalArgs(sliceInfo.internalArgs)
|
|
395
|
+
.exec(async function (this: any, ...requestArgs: any) {
|
|
396
|
+
const args = requestArgs.slice(0, argLength);
|
|
397
|
+
const internalArgs = requestArgs.slice(argLength);
|
|
398
|
+
return assertSliceQuery(
|
|
399
|
+
await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]),
|
|
400
|
+
key,
|
|
401
|
+
);
|
|
402
|
+
});
|
|
401
403
|
}
|
|
402
404
|
|
|
403
405
|
const insightKey = `${refName}Insight${capitalizedKey}`;
|
|
@@ -483,6 +485,44 @@ export class SignalResolver {
|
|
|
483
485
|
}
|
|
484
486
|
}
|
|
485
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
|
+
}
|
|
486
526
|
/**
|
|
487
527
|
* The model's field metadata, which membership routing reads for one thing: whether a path is an array, because
|
|
488
528
|
* a bare value on an array field means membership rather than equality.
|
|
@@ -650,6 +690,8 @@ export class SignalResolver {
|
|
|
650
690
|
|
|
651
691
|
const requestRoomId = context.getRoomId(key);
|
|
652
692
|
if (subscribe) {
|
|
693
|
+
|
|
694
|
+
if (liveOption) SignalResolver.#assertNotPaused(key, liveOption, endpointInfo, context);
|
|
653
695
|
const query = await context.exec();
|
|
654
696
|
const roomId = liveOption ? context.getLiveRoomId(key) : requestRoomId;
|
|
655
697
|
if (liveOption)
|
package/signal/endpointInfo.ts
CHANGED
|
@@ -299,6 +299,22 @@ export class EndpointInfo<
|
|
|
299
299
|
Nullable
|
|
300
300
|
>;
|
|
301
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Retypes a slice's own arguments as this room's arguments, keeping each one's nullability.
|
|
304
|
+
*
|
|
305
|
+
* Not `_addArgs`, which would route a `search` argument back to `.search()`. And not `.room()` per argument:
|
|
306
|
+
* that refuses a nullable argument in anything but the last position, which is the right rule for a URL and a
|
|
307
|
+
* meaningless one for a room — the arguments travel as a positional array with explicit nulls, so a missing one
|
|
308
|
+
* is unambiguous. Nullability is preserved because dropping it would make an absent optional argument fail to
|
|
309
|
+
* deserialize on the way in.
|
|
310
|
+
*/
|
|
311
|
+
_addRoomArgs(args: ArgInfo<EndpointArgProps<boolean>>[]) {
|
|
312
|
+
for (const arg of args) {
|
|
313
|
+
this.argNames.push(arg.name);
|
|
314
|
+
this.args.push({ ...arg, type: "room" });
|
|
315
|
+
}
|
|
316
|
+
return this;
|
|
317
|
+
}
|
|
302
318
|
_addInternalArgs(args: InternalArgInfo<boolean>[]) {
|
|
303
319
|
for (const arg of args) this.with(arg.argRef, arg.option);
|
|
304
320
|
return this;
|
|
@@ -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
|
@@ -1017,12 +1017,11 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1017
1017
|
|
|
1018
1018
|
const requests = new SliceRequest();
|
|
1019
1019
|
|
|
1020
|
-
let liveWatch: {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
} | null = null;
|
|
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);
|
|
1026
1025
|
const namesOfSlice: { [key in SliceActionKey | SliceStateKey | "modelList"]: string } = {
|
|
1027
1026
|
defaultModel: SliceName.replace(names.Model, names.defaultModel),
|
|
1028
1027
|
modelInsight: sliceName.replace(names.model, names.modelInsight),
|
|
@@ -1399,24 +1398,29 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
|
|
|
1399
1398
|
*/
|
|
1400
1399
|
[namesOfSlice.watchLiveModel]: function (this: SetGet, queryArgs: unknown[] | null) {
|
|
1401
1400
|
if (!slice.live) return;
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1401
|
+
const args = queryArgs ? expandQueryArgs(normalizeQueryArgs(queryArgs, slice.args), slice.args) : null;
|
|
1402
|
+
|
|
1403
|
+
const paused = !!args && livePauseIdxs.some((idx) => args[idx] != null);
|
|
1404
|
+
const signature = args && !paused ? JSON.stringify(args) : null;
|
|
1405
|
+
if (liveWatch && signature !== liveWatch.signature) {
|
|
1406
|
+
liveWatch.dispose();
|
|
1406
1407
|
liveWatch = null;
|
|
1407
1408
|
}
|
|
1408
|
-
if (!
|
|
1409
|
+
if (!args || paused || liveWatch) return;
|
|
1409
1410
|
const self = this as unknown as DynamicRecord;
|
|
1410
1411
|
const apply = self[namesOfSlice.applyLiveModel] as (event: unknown) => void;
|
|
1411
1412
|
const refresh = self[namesOfSlice.refreshModel] as (form: object) => Promise<void>;
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1413
|
+
|
|
1414
|
+
const subscribe = fetch[`subscribe${capitalize(sliceName.replace(names.model, `${names.model}Live`))}`] as
|
|
1415
|
+
| ((...args: unknown[]) => () => void)
|
|
1416
|
+
| undefined;
|
|
1417
|
+
if (!subscribe) return;
|
|
1418
|
+
const dispose = subscribe(...args, (event: unknown) => apply(event), {
|
|
1419
|
+
crystalize: false,
|
|
1416
1420
|
|
|
1417
|
-
|
|
1418
|
-
};
|
|
1419
|
-
|
|
1421
|
+
onResync: () => void refresh({ invalidate: true }),
|
|
1422
|
+
});
|
|
1423
|
+
liveWatch = { signature: signature ?? "", dispose };
|
|
1420
1424
|
},
|
|
1421
1425
|
};
|
|
1422
1426
|
return Object.assign(acc, singleSliceAction);
|
package/types/common/types.d.ts
CHANGED
|
@@ -6,6 +6,13 @@ export interface FetchPolicy<Returns = unknown> {
|
|
|
6
6
|
token?: string;
|
|
7
7
|
partial?: string[];
|
|
8
8
|
timeout?: number;
|
|
9
|
+
/**
|
|
10
|
+
* A `pubsub` subscription only: called after the room has been resubscribed following a dropped connection.
|
|
11
|
+
*
|
|
12
|
+
* Whatever was published while the socket was down is gone, and a room cannot say which messages those were, so
|
|
13
|
+
* a subscriber that has to stay correct reloads here instead of carrying on from a gap it cannot see.
|
|
14
|
+
*/
|
|
15
|
+
onResync?: () => void;
|
|
9
16
|
}
|
|
10
17
|
export type SnakeCase<S extends string> = S extends `${infer T}_${infer U}` ? `${Lowercase<T>}_${SnakeCase<U>}` : S;
|
|
11
18
|
export type SnakeCaseObj<T> = {
|
|
@@ -64,6 +64,16 @@ export declare class EndpointInfo<ReqType extends EndpointType = EndpointType, S
|
|
|
64
64
|
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: string, arg: Arg, option?: Omit<EndpointArgProps, "nullable">): EndpointInfo<ReqType, Srvs, [...ArgNames, ArgName], [...Args, arg?: _ClientArg | null], InternalArgs, [...ServerArgs, arg: _ServerArg | undefined], Returns, ClientReturns, ServerReturns, Nullable>;
|
|
65
65
|
_addArgs(args: ArgInfo<EndpointArgProps<boolean>>[]): this;
|
|
66
66
|
with<ArgType, Optional extends boolean = false>(argRef: InternalArgCls<ArgType>, option?: InternalArgProps<Optional>): EndpointInfo<ReqType, Srvs, ArgNames, Args, [...InternalArgs, arg: NonNullable<ArgType> | (Optional extends true ? null : never)], ServerArgs, Returns, ClientReturns, ServerReturns, Nullable>;
|
|
67
|
+
/**
|
|
68
|
+
* Retypes a slice's own arguments as this room's arguments, keeping each one's nullability.
|
|
69
|
+
*
|
|
70
|
+
* Not `_addArgs`, which would route a `search` argument back to `.search()`. And not `.room()` per argument:
|
|
71
|
+
* that refuses a nullable argument in anything but the last position, which is the right rule for a URL and a
|
|
72
|
+
* meaningless one for a room — the arguments travel as a positional array with explicit nulls, so a missing one
|
|
73
|
+
* is unambiguous. Nullability is preserved because dropping it would make an absent optional argument fail to
|
|
74
|
+
* deserialize on the way in.
|
|
75
|
+
*/
|
|
76
|
+
_addRoomArgs(args: ArgInfo<EndpointArgProps<boolean>>[]): this;
|
|
67
77
|
_addInternalArgs(args: InternalArgInfo<boolean>[]): this;
|
|
68
78
|
exec<ExecFn extends (this: Srvs, ...args: [...ServerArgs, ...InternalArgs]) => ReqType extends "pubsub" ? Promise<void> | void : ReqType extends "prompt" ? PromiseOrObject<PromptResult> : PromiseOrObject<DocumentModel<FieldToValue<Returns>> | (Nullable extends true ? null | undefined : never)>>(execFn: ExecFn): EndpointInfo<ReqType, Srvs, ArgNames, Args, InternalArgs, ServerArgs, Returns, FieldToValue<Returns>, ReturnType<ExecFn>, Nullable>;
|
|
69
79
|
getPath(key: string): string;
|
|
@@ -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];
|