akanjs 3.0.0-alpha.91 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.91",
3
+ "version": "3.0.0-alpha.92",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -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
- guards: sliceCls.getGuards,
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)
@@ -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 ? { live: { sort: sliceInfo.liveOption.sort } } : {}),
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
 
@@ -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
- const signature = args ? JSON.stringify(args) : null;
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>;
@@ -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;
@@ -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];