@kumiai/hub-server 0.12.0 → 0.12.2

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/README.md CHANGED
@@ -13,6 +13,7 @@ client registry for push fan-out, and an in-memory `HubStore` for development an
13
13
  development; it is also what runs the `@kumiai/hub-conformance` suites in this repo.
14
14
  - `HubClientRegistry` — the live-connection table.
15
15
  - `createRateLimiter`, `DEFAULT_RATE_LIMITS`, `DEFAULT_KEYPACKAGE_FETCH_LIMITS`,
16
+ `DEFAULT_RECEIVE_WINDOW`, `DEFAULT_RECEIVE_WINDOW_BYTES`, `DEFAULT_RECEIVE_ACK_TIMEOUT`,
16
17
  `DEFAULT_HUB_ACCESS_RULES`.
17
18
  - `HubStoreErrorEvent`, `HubStoreErrorHook` — the `onStoreError` event and hook types.
18
19
 
@@ -74,6 +75,34 @@ than assigned, because `logPosition: undefined` becomes a present key with a fal
74
75
  off the wire — and the entire point of the field is that a reader can tell "no place in any log" from
75
76
  a place.
76
77
 
78
+ ## The receive channel is paced by acks
79
+
80
+ `hub/v1/receive` writes a frame only while the frames in flight on that channel stay under a window:
81
+ `receiveWindow` frames (`DEFAULT_RECEIVE_WINDOW`, 256) and `receiveWindowBytes` bytes
82
+ (`DEFAULT_RECEIVE_WINDOW_BYTES`, 512 KiB, counting the base64 payload plus an envelope estimate; one
83
+ frame always goes through when nothing else is in flight). The backlog drain and live pushes share
84
+ the window, and acks are read from the moment the channel opens, so the drain advances as the client
85
+ acks it.
86
+
87
+ The transport cannot do this pacing: `writer.ready` on a channel says nothing about the HTTP
88
+ bridge's per-session buffer, which `@enkaku/http-serve` drops whole once it passes
89
+ `maxSessionBufferBytes`. Unpaced, a backlog larger than that buffer, read by a slow client, ended
90
+ the session on every connect before any ack reached the hub, and the next connect redelivered the
91
+ same backlog. Keep the byte window well below the transport's session buffer.
92
+
93
+ The channel is ordered, so an ack for a frame proves the client has read every frame written before
94
+ it: for the window, an ack releases that frame and all earlier ones. The store still frees only the
95
+ deliveries actually acked. A client can leave frames unacked for good — a topic it subscribed to but
96
+ reads with `hub/v1/topic/fetch`, or one no consumer on the device claims — and those frames must not
97
+ hold the window, or they would come back first on every connect and stall it.
98
+
99
+ A full window that sees no ack at all for `receiveAckTimeout` ms (`DEFAULT_RECEIVE_ACK_TIMEOUT`,
100
+ 15 s; each ack restarts it) is released whole and the drain goes on; the channel is not ended,
101
+ because a window made only of never-acked frames would come back first on the next connect too. A
102
+ client that has stopped reading cannot grow a buffer without bound: the HTTP bridge drops its
103
+ session, and a transport with real backpressure stalls the write until `receiveBufferLimit` tears
104
+ the channel down.
105
+
77
106
  ## Retention: the store refuses, the hub schedules
78
107
 
79
108
  Two different knobs, easily confused. `createHub`'s `purge.olderThan` (default 7 days) is the **age
package/lib/handlers.d.ts CHANGED
@@ -166,6 +166,34 @@ export declare const DEFAULT_RECEIVE_BUFFER_LIMIT = 256;
166
166
  * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
167
167
  */
168
168
  export declare const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
169
+ /**
170
+ * Max frames in flight on a receive channel: written, and not yet followed by an ack for that frame
171
+ * or a later one. The hub writes the next frame only once acks bring the count back under this
172
+ * window.
173
+ *
174
+ * Without it the drain writes a whole backlog at once, and a transport that buffers per session
175
+ * (the SSE bridge drops a session whose unread buffer passes `maxSessionBufferBytes`) ends the
176
+ * session before the slow client's acks reach the hub: every connect then redelivers the same
177
+ * backlog. The window must stay well above the client's ack batch (10 for Kubun's device client)
178
+ * so batched acks keep the pipe full.
179
+ */
180
+ export declare const DEFAULT_RECEIVE_WINDOW = 256;
181
+ /**
182
+ * Max bytes (base64 payload plus an estimate of the frame's envelope) in flight on a receive
183
+ * channel. Kept well below the HTTP transport's per-session buffer (1 MiB by default in `@enkaku/http-serve`, 8 MiB
184
+ * on Kubun's relay) so a window of large frames cannot overflow it. A single frame is always let
185
+ * through when nothing else is in flight, whatever its size.
186
+ */
187
+ export declare const DEFAULT_RECEIVE_WINDOW_BYTES: number;
188
+ /**
189
+ * How long (ms) a receive channel with a full window waits without any ack before it releases the
190
+ * whole window and writes on. Each ack restarts the wait. The channel is NOT ended: a window full
191
+ * of frames the client will never ack (topics no sink on the device claims) would come back first
192
+ * on every reconnect and livelock it. Long enough for a busy phone to open the first frames of a
193
+ * window and flush an ack batch (10 frames or 500 ms on Kubun's device client); short, because it
194
+ * is the delay a window of never-acked frames costs per connect.
195
+ */
196
+ export declare const DEFAULT_RECEIVE_ACK_TIMEOUT = 15000;
169
197
  export type CreateHandlersParams = {
170
198
  registry: HubClientRegistry;
171
199
  store: HubStore;
@@ -175,6 +203,19 @@ export type CreateHandlersParams = {
175
203
  /** Max frames queued-but-unflushed on a receive channel. See {@link DEFAULT_RECEIVE_BUFFER_LIMIT}
176
204
  * for the >= 50-frame-page floor this should respect. Default: 256 */
177
205
  receiveBufferLimit?: number;
206
+ /** Max frames in flight on a receive channel. Default: {@link DEFAULT_RECEIVE_WINDOW} */
207
+ receiveWindow?: number;
208
+ /**
209
+ * Max bytes (payload plus envelope estimate) in flight on a receive channel. Default:
210
+ * {@link DEFAULT_RECEIVE_WINDOW_BYTES}
211
+ */
212
+ receiveWindowBytes?: number;
213
+ /**
214
+ * How long (ms) a receive channel with a full window waits for an ack before it releases the
215
+ * window and writes on.
216
+ * Default: {@link DEFAULT_RECEIVE_ACK_TIMEOUT}
217
+ */
218
+ receiveAckTimeout?: number;
178
219
  /**
179
220
  * TTL (ms) for the per-(did, topicID) receive delivery-authorization cache. `0` disables reuse
180
221
  * (the hook is consulted for every frame). Non-finite or negative values fall back to
package/lib/handlers.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { HandlerError } from '@enkaku/server';
2
2
  import { HUB_ERROR_CODES, hubErrorCodeOf, InvalidPayloadError, KeyPackageFetchLimitError, keyPackageDigest, wakeRecipientKeyProblem } from '@kumiai/hub-protocol';
3
3
  import { fromB64, toB64 } from '@sozai/codec';
4
- import { getReporter } from '@sozai/log';
4
+ import { getLogger, getReporter } from '@sozai/log';
5
5
  import { createRateLimiter } from './rateLimit.js';
6
6
  function normalizeAuthorizeDecision(decision) {
7
7
  return typeof decision === 'boolean' ? {
@@ -29,6 +29,11 @@ const reportStoreError = getReporter([
29
29
  'kumiai',
30
30
  'hub-server'
31
31
  ], '@kumiai/hub-server');
32
+ const receiveLogger = getLogger([
33
+ 'kumiai',
34
+ 'hub-server',
35
+ 'receive'
36
+ ]);
32
37
  /** What the failed operation was about, for the default log line: a DID for the per-recipient
33
38
  * methods, a topic for fan-out, nothing for a store-wide purge. */ function subjectOf(event) {
34
39
  switch(event.method){
@@ -91,6 +96,31 @@ export const DEFAULT_KEYPACKAGE_FETCH_LIMITS = {
91
96
  * Bounds how long an already-resolved allow is reused before the hook is re-consulted — i.e. the
92
97
  * maximum window a removed member can keep draining a topic. See the receive handler's `gate`.
93
98
  */ export const DEFAULT_RECEIVE_AUTH_CACHE_TTL = 5000;
99
+ /**
100
+ * Max frames in flight on a receive channel: written, and not yet followed by an ack for that frame
101
+ * or a later one. The hub writes the next frame only once acks bring the count back under this
102
+ * window.
103
+ *
104
+ * Without it the drain writes a whole backlog at once, and a transport that buffers per session
105
+ * (the SSE bridge drops a session whose unread buffer passes `maxSessionBufferBytes`) ends the
106
+ * session before the slow client's acks reach the hub: every connect then redelivers the same
107
+ * backlog. The window must stay well above the client's ack batch (10 for Kubun's device client)
108
+ * so batched acks keep the pipe full.
109
+ */ export const DEFAULT_RECEIVE_WINDOW = 256;
110
+ /**
111
+ * Max bytes (base64 payload plus an estimate of the frame's envelope) in flight on a receive
112
+ * channel. Kept well below the HTTP transport's per-session buffer (1 MiB by default in `@enkaku/http-serve`, 8 MiB
113
+ * on Kubun's relay) so a window of large frames cannot overflow it. A single frame is always let
114
+ * through when nothing else is in flight, whatever its size.
115
+ */ export const DEFAULT_RECEIVE_WINDOW_BYTES = 512 * 1024;
116
+ /**
117
+ * How long (ms) a receive channel with a full window waits without any ack before it releases the
118
+ * whole window and writes on. Each ack restarts the wait. The channel is NOT ended: a window full
119
+ * of frames the client will never ack (topics no sink on the device claims) would come back first
120
+ * on every reconnect and livelock it. Long enough for a busy phone to open the first frames of a
121
+ * window and flush an ack batch (10 frames or 500 ms on Kubun's device client); short, because it
122
+ * is the delay a window of never-acked frames costs per connect.
123
+ */ export const DEFAULT_RECEIVE_ACK_TIMEOUT = 15_000;
94
124
  function getClientDID(ctx) {
95
125
  const iss = ctx.message.payload.iss;
96
126
  if (typeof iss !== 'string' || iss.length === 0) {
@@ -149,6 +179,9 @@ export function createHandlers(params) {
149
179
  const topicLimiter = createRateLimiter(rateLimits.perTopic);
150
180
  const receiveBufferLimit = params.receiveBufferLimit ?? DEFAULT_RECEIVE_BUFFER_LIMIT;
151
181
  const receiveAuthCacheTTL = resolveReceiveAuthCacheTTL(params.receiveAuthCacheTTL);
182
+ const receiveWindow = Math.max(1, params.receiveWindow ?? DEFAULT_RECEIVE_WINDOW);
183
+ const receiveWindowBytes = params.receiveWindowBytes ?? DEFAULT_RECEIVE_WINDOW_BYTES;
184
+ const receiveAckTimeout = params.receiveAckTimeout ?? DEFAULT_RECEIVE_ACK_TIMEOUT;
152
185
  const storeErrorReporter = createStoreErrorReporter(params.onStoreError);
153
186
  const fetchLimits = {
154
187
  ...DEFAULT_KEYPACKAGE_FETCH_LIMITS,
@@ -494,15 +527,103 @@ export function createHandlers(params) {
494
527
  ;
495
528
  let tornDown = false;
496
529
  let writeChain = Promise.resolve();
530
+ // Ack window (flow control). `writer.ready` says nothing about the transport's per-session
531
+ // buffer (the HTTP bridge's writable is shared by every session and resolves at once), so the
532
+ // hub paces itself on acks instead: at most `receiveWindow` frames and `receiveWindowBytes`
533
+ // bytes are in flight, i.e. written and not yet known to have reached the client. Denied frames
534
+ // are never written, so never counted.
535
+ //
536
+ // The channel is ordered, so an ack for one frame proves the client has read every frame
537
+ // written before it: an ack releases that frame and all earlier ones from the window. A
538
+ // client may leave some frames unacked for good (a topic no sink on the device claims, a sink
539
+ // torn down mid-session); they stay pending in the store but cannot hold the window. This is
540
+ // window accounting only — the store still frees exactly the deliveries acked.
541
+ const inFlight = new Map() // sequenceID -> counted bytes, in write order
542
+ ;
543
+ let inFlightBytes = 0;
544
+ let onWindowChange = null;
545
+ const hasRoom = (bytes)=>inFlight.size === 0 || inFlight.size < receiveWindow && inFlightBytes + bytes <= receiveWindowBytes;
546
+ function releaseAll() {
547
+ inFlight.clear();
548
+ inFlightBytes = 0;
549
+ }
550
+ // Resolves once the frame fits or the channel is torn down. A full window that sees no ack for
551
+ // `receiveAckTimeout` ms is released whole and the drain goes on: the frames in it may be ones
552
+ // the client will never ack, and ending the channel would only redeliver them first on the
553
+ // next connect. A client that has stopped reading is not left to grow a buffer: the HTTP
554
+ // bridge drops a session that falls `maxSessionBufferBytes` behind, and a transport with real
555
+ // backpressure stalls `writer.write` until `receiveBufferLimit` tears the channel down.
556
+ function waitForRoom(bytes) {
557
+ if (tornDown || hasRoom(bytes)) return Promise.resolve();
558
+ receiveLogger.debug('receive window full for {recipientDID}: paused with {frames} frames ({bytes} bytes) unacked', {
559
+ recipientDID: clientDID,
560
+ frames: inFlight.size,
561
+ bytes: inFlightBytes
562
+ });
563
+ return new Promise((resolve)=>{
564
+ let timer;
565
+ const arm = ()=>{
566
+ clearTimeout(timer);
567
+ timer = setTimeout(()=>{
568
+ receiveLogger.warn('receive ack timeout for {recipientDID}: released {frames} frames ({bytes} bytes) after {timeoutMs} ms', {
569
+ recipientDID: clientDID,
570
+ frames: inFlight.size,
571
+ bytes: inFlightBytes,
572
+ timeoutMs: receiveAckTimeout
573
+ });
574
+ releaseAll();
575
+ notifyWindow();
576
+ }, receiveAckTimeout);
577
+ };
578
+ onWindowChange = ()=>{
579
+ if (tornDown || hasRoom(bytes)) {
580
+ clearTimeout(timer);
581
+ onWindowChange = null;
582
+ resolve();
583
+ } else {
584
+ arm();
585
+ }
586
+ };
587
+ arm();
588
+ });
589
+ }
590
+ function notifyWindow() {
591
+ onWindowChange?.();
592
+ }
593
+ function release(sequenceIDs) {
594
+ const acked = new Set(sequenceIDs);
595
+ // The acked frame written last, in write order (an ack batch need not be sorted).
596
+ let last;
597
+ for (const id of inFlight.keys()){
598
+ if (acked.has(id)) last = id;
599
+ }
600
+ if (last === undefined) return;
601
+ for (const [id, bytes] of inFlight){
602
+ inFlight.delete(id);
603
+ inFlightBytes -= bytes;
604
+ if (id === last) break;
605
+ }
606
+ notifyWindow();
607
+ }
497
608
  let resolveDone = ()=>{};
498
609
  const done = new Promise((resolve)=>{
499
610
  resolveDone = resolve;
500
611
  });
612
+ function logUnackedFrames() {
613
+ if (inFlight.size === 0) return;
614
+ receiveLogger.debug('receive stream ended for {recipientDID} with {frames} frames ({bytes} bytes) unacked', {
615
+ recipientDID: clientDID,
616
+ frames: inFlight.size,
617
+ bytes: inFlightBytes
618
+ });
619
+ }
501
620
  // Idempotent teardown, shared by every failure and end path. Frames stay pending in the store
502
621
  // and redeliver on the next connect (store-and-forward).
503
622
  function finish() {
504
623
  if (tornDown) return;
624
+ logUnackedFrames();
505
625
  tornDown = true;
626
+ notifyWindow();
506
627
  if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
507
628
  registry.unregisterIfIdle(clientDID);
508
629
  reader.cancel().catch(()=>{});
@@ -522,7 +643,13 @@ export function createHandlers(params) {
522
643
  if (tornDown) return;
523
644
  try {
524
645
  if (!await gate(frame.topicID)) return; // denied: skip, leave pending, no ack
646
+ // The payload plus the frame's other fields and the transport's envelope (~256 chars).
647
+ const bytes = frame.payload.length + frame.senderDID.length + frame.topicID.length + 256;
648
+ await waitForRoom(bytes);
649
+ if (tornDown) return;
525
650
  await writer.ready;
651
+ inFlight.set(frame.sequenceID, bytes);
652
+ inFlightBytes += bytes;
526
653
  await writer.write(frame);
527
654
  if (frame.sequenceID > lastServed) lastServed = frame.sequenceID;
528
655
  } catch {
@@ -555,6 +682,38 @@ export function createHandlers(params) {
555
682
  pushWrite(frame);
556
683
  }
557
684
  };
685
+ // Ack loop (M1): a store.ack failure must not stop later acks; only a reader error closes.
686
+ // It runs from connect, alongside the drain: the drain waits on the ack window, so acks for the
687
+ // backlog must be read while it is still being served.
688
+ void (async ()=>{
689
+ while(true){
690
+ let result;
691
+ try {
692
+ result = await reader.read();
693
+ } catch {
694
+ break;
695
+ }
696
+ if (result.done) break;
697
+ const ack = result.value?.ack;
698
+ if (ack != null) {
699
+ // The client has the frames, whatever the store does with the ack.
700
+ release(ack);
701
+ try {
702
+ await store.ack({
703
+ recipientDID: clientDID,
704
+ sequenceIDs: ack
705
+ });
706
+ } catch (error) {
707
+ // Frame stays pending; the client re-acks next round. Do NOT break.
708
+ storeErrorReporter({
709
+ method: 'ack',
710
+ did: clientDID,
711
+ error
712
+ });
713
+ }
714
+ }
715
+ }
716
+ })();
558
717
  try {
559
718
  // Binding evicts whatever held the lane: a reconnect means the old connection broke and the
560
719
  // server learns last, so the stale writer must give way to the live one. See
@@ -565,6 +724,17 @@ export function createHandlers(params) {
565
724
  evicted?.();
566
725
  // The device is draining; a trailing summary would be noise it has already outrun.
567
726
  params.wake?.dispatcher?.online(clientDID);
727
+ // Teardown triggers are armed before the drain: the drain waits on the ack window, so it
728
+ // can last as long as the client takes to ack, and an abort or eviction must end it.
729
+ // H2: an already-aborted signal never fires 'abort', so run teardown now.
730
+ if (ctx.signal.aborted) {
731
+ finish();
732
+ } else {
733
+ void evictedHere.then(finish);
734
+ ctx.signal.addEventListener('abort', finish, {
735
+ once: true
736
+ });
737
+ }
568
738
  // Drain the backlog. Await each page so lastServed is exact before the flush.
569
739
  let cursor = after;
570
740
  while(!tornDown){
@@ -597,50 +767,15 @@ export function createHandlers(params) {
597
767
  }
598
768
  } catch (error) {
599
769
  // A synchronous drain error (e.g. store.fetch threw): clean up and reject the handler.
770
+ if (!tornDown) logUnackedFrames();
600
771
  tornDown = true;
772
+ notifyWindow();
601
773
  if (receiveToken != null) registry.releaseReceiveWriter(clientDID, receiveToken);
602
774
  registry.unregisterIfIdle(clientDID);
603
775
  reader.cancel().catch(()=>{});
604
776
  writer.abort(error).catch(()=>{});
605
777
  throw error;
606
778
  }
607
- // Ack loop (M1): a store.ack failure must not stop later acks; only a reader error closes.
608
- void (async ()=>{
609
- while(true){
610
- let result;
611
- try {
612
- result = await reader.read();
613
- } catch {
614
- break;
615
- }
616
- if (result.done) break;
617
- const ack = result.value?.ack;
618
- if (ack != null) {
619
- try {
620
- await store.ack({
621
- recipientDID: clientDID,
622
- sequenceIDs: ack
623
- });
624
- } catch (error) {
625
- // Frame stays pending; the client re-acks next round. Do NOT break.
626
- storeErrorReporter({
627
- method: 'ack',
628
- did: clientDID,
629
- error
630
- });
631
- }
632
- }
633
- }
634
- })();
635
- // H2: an already-aborted signal never fires 'abort', so run teardown now.
636
- if (ctx.signal.aborted) {
637
- finish();
638
- return done;
639
- }
640
- void evictedHere.then(finish);
641
- ctx.signal.addEventListener('abort', finish, {
642
- once: true
643
- });
644
779
  return done;
645
780
  },
646
781
  'hub/v1/keypackage/upload': async (ctx)=>{
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest, CreateHandlersParams, HubRateLimits, HubStoreErrorEvent, HubStoreErrorHook, KeyPackageFetchLimits, } from './handlers.js';
2
- export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT, } from './handlers.js';
2
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_ACK_TIMEOUT, DEFAULT_RECEIVE_BUFFER_LIMIT, DEFAULT_RECEIVE_WINDOW, DEFAULT_RECEIVE_WINDOW_BYTES, } from './handlers.js';
3
3
  export type { CreateHubParams, HubInstance, HubPurgeOptions, HubReplayOptions } from './hub.js';
4
4
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
5
5
  export { createMemoryStore, type MemoryStoreOptions } from './memoryStore.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_BUFFER_LIMIT } from './handlers.js';
1
+ export { createHandlers, DEFAULT_KEYPACKAGE_FETCH_LIMITS, DEFAULT_RATE_LIMITS, DEFAULT_RECEIVE_ACK_TIMEOUT, DEFAULT_RECEIVE_BUFFER_LIMIT, DEFAULT_RECEIVE_WINDOW, DEFAULT_RECEIVE_WINDOW_BYTES } from './handlers.js';
2
2
  export { createHub, DEFAULT_HUB_ACCESS_RULES } from './hub.js';
3
3
  export { createMemoryStore } from './memoryStore.js';
4
4
  export { createRateLimiter } from './rateLimit.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumiai/hub-server",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Hub server for Enkaku E2EE group message routing",
5
5
  "keywords": [
6
6
  "hub",
@@ -25,18 +25,18 @@
25
25
  "lib/*"
26
26
  ],
27
27
  "dependencies": {
28
- "@enkaku/protocol": "^0.22.2",
29
- "@enkaku/server": "^0.22.2",
28
+ "@enkaku/protocol": "^0.22.5",
29
+ "@enkaku/server": "^0.22.5",
30
30
  "@kokuin/capability": "^0.5.2",
31
- "@kokuin/token": "^0.6.2",
31
+ "@kokuin/token": "^0.6.4",
32
32
  "@kumiai/hub-protocol": "^0.12.0",
33
- "@sozai/codec": "^0.4.3",
33
+ "@sozai/codec": "^0.4.4",
34
34
  "@sozai/event": "^0.1.6",
35
35
  "@sozai/log": "^0.4.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@enkaku/client": "^0.22.2",
39
- "@enkaku/transport": "^0.22.2",
38
+ "@enkaku/client": "^0.22.5",
39
+ "@enkaku/transport": "^0.22.5",
40
40
  "@kumiai/hub-conformance": "^0.12.0",
41
41
  "@kumiai/hub-wake": "^0.12.0"
42
42
  },