akanjs 3.0.0-alpha.71 → 3.0.0-alpha.72

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.
Files changed (186) hide show
  1. package/client/cookie.ts +21 -34
  2. package/client/csrTypes.ts +0 -12
  3. package/client/frameConfig.ts +13 -0
  4. package/common/Logger.ts +13 -6
  5. package/common/TrustedProxy.ts +134 -0
  6. package/common/clamp.ts +2 -0
  7. package/common/dynamicRecord.ts +12 -0
  8. package/common/index.ts +5 -1
  9. package/common/isValidDate.ts +1 -1
  10. package/common/requestOrigin.ts +32 -0
  11. package/common/{httpClient.ts → restClient.ts} +16 -9
  12. package/constant/types.ts +22 -0
  13. package/dictionary/base.dictionary.ts +4 -0
  14. package/dictionary/trans.ts +24 -5
  15. package/document/dataLoader.ts +0 -4
  16. package/document/filterMeta.ts +2 -2
  17. package/document/loaderInfo.ts +4 -1
  18. package/fetch/client/fetchClient.ts +65 -88
  19. package/fetch/client/fetchHandle.ts +57 -0
  20. package/fetch/client/httpClient.ts +42 -14
  21. package/fetch/client/sliceInitHandle.ts +101 -0
  22. package/fetch/fetchType/appliedReturn.type.ts +64 -1
  23. package/fetch/fetchType/sliceFetch.type.ts +5 -5
  24. package/fetch/requestStorage.ts +19 -5
  25. package/index.ts +16 -2
  26. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  27. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  28. package/package.json +1 -1
  29. package/server/akanAppHeaders.ts +10 -4
  30. package/server/akanOption.ts +14 -1
  31. package/server/akanServer.ts +3 -0
  32. package/server/cachePolicy.ts +2 -11
  33. package/server/decorators.ts +12 -3
  34. package/server/devtools/constantSerializer.ts +8 -1
  35. package/server/di/diLifecycle.ts +21 -1
  36. package/server/di/resolveHierarchy.ts +3 -3
  37. package/server/mcp/McpDispatcher.ts +8 -2
  38. package/server/resolver/database.resolver.ts +13 -8
  39. package/server/resolver/signal.resolver.ts +3 -3
  40. package/server/routeTreeBuilder.ts +1 -15
  41. package/server/routing/apiRouter.ts +4 -5
  42. package/server/routing/appWsData.ts +9 -6
  43. package/server/staticPath.ts +39 -0
  44. package/server/webRouter.ts +35 -41
  45. package/service/adapt.ts +1 -5
  46. package/service/predefinedAdaptor/cache.adaptor.ts +8 -4
  47. package/service/predefinedAdaptor/database.adaptor.ts +37 -1766
  48. package/service/predefinedAdaptor/sql/PendingStoreEnsures.ts +22 -0
  49. package/service/predefinedAdaptor/sql/QueryCompiler.ts +225 -0
  50. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +812 -0
  51. package/service/predefinedAdaptor/sql/UpdateCompiler.ts +67 -0
  52. package/service/predefinedAdaptor/sql/dialect/postgres.ts +151 -0
  53. package/service/predefinedAdaptor/sql/dialect/sqlite.ts +134 -0
  54. package/service/predefinedAdaptor/sql/driver/bunSqlite.ts +29 -0
  55. package/service/predefinedAdaptor/sql/driver/libsql.ts +37 -0
  56. package/service/predefinedAdaptor/sql/driver/postgres.ts +42 -0
  57. package/service/predefinedAdaptor/sql/types.ts +251 -0
  58. package/service/predefinedAdaptor/sql/values.ts +91 -0
  59. package/signal/CrossSiteGuard.ts +71 -0
  60. package/signal/SignalFailure.ts +51 -0
  61. package/signal/endpoint.ts +2 -2
  62. package/signal/exception.ts +43 -6
  63. package/signal/index.ts +2 -0
  64. package/signal/intercept.ts +5 -2
  65. package/signal/internal.ts +10 -2
  66. package/signal/serializer/fetch.serializer.ts +11 -1
  67. package/signal/signalContext.ts +25 -28
  68. package/signal/slice.ts +1 -1
  69. package/store/SliceRequest.ts +23 -0
  70. package/store/action.ts +177 -114
  71. package/store/agent/AgentBridge.ts +3 -1
  72. package/store/agentic/StExposeBuilder.ts +7 -4
  73. package/store/agentic/StStateBuilder.ts +9 -4
  74. package/store/agentic/StToolBuilder.ts +21 -7
  75. package/store/store.ts +1 -1
  76. package/store/storeInstance.ts +3 -3
  77. package/store/storeRegistry.ts +1 -1
  78. package/types/client/cookie.d.ts +13 -2
  79. package/types/client/csrTypes.d.ts +0 -1
  80. package/types/client/frameConfig.d.ts +1 -0
  81. package/types/common/TrustedProxy.d.ts +24 -0
  82. package/types/common/clamp.d.ts +2 -0
  83. package/types/common/dynamicRecord.d.ts +14 -0
  84. package/types/common/index.d.ts +5 -1
  85. package/types/common/requestOrigin.d.ts +14 -0
  86. package/types/common/restClient.d.ts +27 -0
  87. package/types/constant/types.d.ts +13 -0
  88. package/types/dictionary/base.dictionary.d.ts +1 -1
  89. package/types/dictionary/dictionary.d.ts +8 -8
  90. package/types/document/dataLoader.d.ts +0 -3
  91. package/types/document/filterMeta.d.ts +2 -2
  92. package/types/document/loaderInfo.d.ts +2 -2
  93. package/types/fetch/client/fetchHandle.d.ts +24 -0
  94. package/types/fetch/client/httpClient.d.ts +2 -0
  95. package/types/fetch/client/sliceInitHandle.d.ts +29 -0
  96. package/types/fetch/fetchType/appliedReturn.type.d.ts +38 -1
  97. package/types/fetch/fetchType/sliceFetch.type.d.ts +5 -5
  98. package/types/fetch/requestStorage.d.ts +14 -0
  99. package/types/index.d.ts +17 -2
  100. package/types/server/akanOption.d.ts +8 -1
  101. package/types/server/decorators.d.ts +12 -2
  102. package/types/server/di/diLifecycle.d.ts +9 -1
  103. package/types/server/routeTreeBuilder.d.ts +1 -2
  104. package/types/server/routing/appWsData.d.ts +7 -3
  105. package/types/server/staticPath.d.ts +13 -0
  106. package/types/service/adapt.d.ts +1 -1
  107. package/types/service/predefinedAdaptor/database.adaptor.d.ts +15 -407
  108. package/types/service/predefinedAdaptor/sql/PendingStoreEnsures.d.ts +6 -0
  109. package/types/service/predefinedAdaptor/sql/QueryCompiler.d.ts +28 -0
  110. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +127 -0
  111. package/types/service/predefinedAdaptor/sql/UpdateCompiler.d.ts +17 -0
  112. package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +27 -0
  113. package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +27 -0
  114. package/types/service/predefinedAdaptor/sql/driver/bunSqlite.d.ts +16 -0
  115. package/types/service/predefinedAdaptor/sql/driver/libsql.d.ts +17 -0
  116. package/types/service/predefinedAdaptor/sql/driver/postgres.d.ts +17 -0
  117. package/types/service/predefinedAdaptor/sql/types.d.ts +226 -0
  118. package/types/service/predefinedAdaptor/sql/values.d.ts +13 -0
  119. package/types/signal/CrossSiteGuard.d.ts +44 -0
  120. package/types/signal/SignalFailure.d.ts +28 -0
  121. package/types/signal/exception.d.ts +38 -54774
  122. package/types/signal/index.d.ts +2 -0
  123. package/types/signal/intercept.d.ts +3 -3
  124. package/types/signal/internal.d.ts +5 -1
  125. package/types/signal/serializer/fetch.serializer.d.ts +7 -1
  126. package/types/store/SliceRequest.d.ts +16 -0
  127. package/types/store/agentic/StToolBuilder.d.ts +10 -1
  128. package/types/ui/Agent/Menu.d.ts +1 -1
  129. package/types/ui/Field/Choice.d.ts +52 -0
  130. package/types/ui/Field/Collection.d.ts +51 -0
  131. package/types/ui/Field/DateField.d.ts +32 -0
  132. package/types/ui/Field/Label.d.ts +9 -0
  133. package/types/ui/Field/NumberField.d.ts +43 -0
  134. package/types/ui/Field/Relation.d.ts +80 -0
  135. package/types/ui/Field/Text.d.ts +101 -0
  136. package/types/ui/Field/index.d.ts +50 -0
  137. package/types/ui/Field.d.ts +1 -412
  138. package/types/ui/Load/Edit_Client.d.ts +3 -1
  139. package/types/ui/Load/Stream.d.ts +22 -0
  140. package/types/ui/Load/index.d.ts +2 -0
  141. package/types/ui/System/Root.d.ts +4 -0
  142. package/types/ui/index.d.ts +1 -1
  143. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  144. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  145. package/types/webkit/useCamera.d.ts +10 -1
  146. package/types/webkit/usePurchase.d.ts +4 -0
  147. package/ui/Agent/Menu.tsx +2 -2
  148. package/ui/Data/TableList.tsx +1 -1
  149. package/ui/DraggableList.tsx +1 -2
  150. package/ui/Field/Choice.tsx +172 -0
  151. package/ui/Field/Collection.tsx +293 -0
  152. package/ui/Field/DateField.tsx +138 -0
  153. package/ui/Field/Label.tsx +31 -0
  154. package/ui/Field/NumberField.tsx +169 -0
  155. package/ui/Field/Relation.tsx +387 -0
  156. package/ui/Field/Text.tsx +360 -0
  157. package/ui/Field/index.tsx +70 -0
  158. package/ui/Input.tsx +3 -3
  159. package/ui/Load/Edit.tsx +2 -2
  160. package/ui/Load/Edit_Client.tsx +11 -3
  161. package/ui/Load/Pagination.tsx +25 -25
  162. package/ui/Load/Stream.tsx +38 -0
  163. package/ui/Load/Units.tsx +50 -37
  164. package/ui/Load/View.tsx +25 -17
  165. package/ui/Load/index.ts +2 -0
  166. package/ui/Model/EditModal.tsx +7 -7
  167. package/ui/Signal/Message.tsx +7 -7
  168. package/ui/Signal/PubSub.tsx +4 -1
  169. package/ui/System/Client.tsx +0 -2
  170. package/ui/System/Gtag.tsx +1 -1
  171. package/ui/System/Root.tsx +4 -0
  172. package/ui/index.ts +1 -1
  173. package/ui/overlayPosition.ts +1 -2
  174. package/webkit/useCamera.tsx +22 -6
  175. package/webkit/useCodepush.tsx +2 -2
  176. package/webkit/useCsrValues.ts +1 -3
  177. package/webkit/useFetch.ts +4 -3
  178. package/webkit/useFrameRuntime.ts +14 -7
  179. package/webkit/usePurchase.tsx +4 -1
  180. package/fetch/serializer/fetch.serializer.ts +0 -142
  181. package/fetch/serializer/index.ts +0 -1
  182. package/types/common/httpClient.d.ts +0 -20
  183. package/types/fetch/serializer/fetch.serializer.d.ts +0 -14
  184. package/types/fetch/serializer/index.d.ts +0 -1
  185. package/types/fetch/serializer.d.ts +0 -1
  186. package/ui/Field.tsx +0 -1639
@@ -1,13 +1,6 @@
1
- import { DataList, getEnv, PrimitiveRegistry, type PromiseOrObject } from "akanjs/base";
1
+ import { getEnv, PrimitiveRegistry, type PromiseOrObject } from "akanjs/base";
2
2
  import { capitalize, type FetchPolicy, fileUploadContract, Logger, resolveFileUploadCapability } from "akanjs/common";
3
- import {
4
- type BaseInsight,
5
- type BaseObject,
6
- ConstantRegistry,
7
- deserialize,
8
- serialize,
9
- withSharedInstances,
10
- } from "akanjs/constant";
3
+ import { type BaseInsight, type BaseObject, ConstantRegistry, deserialize, serialize } from "akanjs/constant";
11
4
  import type {
12
5
  DatabaseSignal,
13
6
  SerializedArg,
@@ -19,14 +12,11 @@ import type {
19
12
  } from "akanjs/signal";
20
13
  import { agentTurnConstant } from "../agentTurn";
21
14
  import type { ClientSignal, FetchClientType, FetchSignalInput, MergeAllFetchTypes, SliceMeta } from "../fetchType";
22
- import {
23
- getRequestStore,
24
- memoizeRequestQuery,
25
- cookies as requestCookies,
26
- headers as requestHeaders,
27
- } from "../requestStorage";
15
+ import { claimRequestQuery, cookies as requestCookies, headers as requestHeaders } from "../requestStorage";
28
16
  import type { GetSliceMetaObjFromDatabaseSignals } from "../types";
17
+ import { FetchHandle } from "./fetchHandle";
29
18
  import { type ErrorConstructor, HttpClient } from "./httpClient";
19
+ import { SliceInitHandle } from "./sliceInitHandle";
30
20
  import { WsClient } from "./wsClient";
31
21
 
32
22
  type FetchHandler = (...args: unknown[]) => PromiseOrObject<unknown>;
@@ -313,11 +303,12 @@ export class FetchClient {
313
303
  const baseUrl = option?.origin;
314
304
 
315
305
  const requestQuery = () => this.http.get(url, { headers, baseUrl });
316
- const response = baseUrl
317
- ? await requestQuery()
318
- : await memoizeRequestQuery(FetchClient.#makeRequestQueryCacheKey(this.origin, url, headers), requestQuery);
319
306
 
320
- const payload = !baseUrl && getRequestStore() ? FetchClient.#deepCopy(response) : response;
307
+ const claim = baseUrl
308
+ ? { value: requestQuery(), owned: true }
309
+ : claimRequestQuery(FetchClient.#makeRequestQueryCacheKey(this.origin, url, headers), requestQuery);
310
+ const response = await claim.value;
311
+ const payload = claim.owned ? response : FetchClient.#deepCopy(response);
321
312
  return parseReturn(payload, { crystalize: option?.crystalize ?? true });
322
313
  };
323
314
  return queryFn;
@@ -525,19 +516,8 @@ export class FetchClient {
525
516
  const anyCruGuards = signal.cruGuards ?? signal.createGuards ?? signal.updateGuards ?? signal.removeGuards;
526
517
  const updateGuards = signal.updateGuards ?? signal.cruGuards;
527
518
  if (anyCruGuards) {
528
- this.#setHandlerFactory(
529
- names.viewModel,
530
- () =>
531
- (async (id: string, option?: FetchPolicy) => {
532
- const cnst = ConstantRegistry.getDatabase(refName);
533
- const modelFn = this.#requireHandler(names.model, names.viewModel);
534
- const modelObj = await modelFn(id, { ...option, crystalize: false });
535
- const model = new cnst.full(modelObj as object);
536
- return {
537
- [refName]: model,
538
- [`${refName}View`]: { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() },
539
- };
540
- }) as FetchHandler,
519
+ this.#setHandlerFactory(names.viewModel, () =>
520
+ this.#makeModelHandleFn(refName, names.model, names.viewModel, `${refName}View`),
541
521
  );
542
522
  this.#setHandlerFactory(
543
523
  names.getModelView,
@@ -548,19 +528,8 @@ export class FetchClient {
548
528
  return { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() };
549
529
  }) as FetchHandler,
550
530
  );
551
- this.#setHandlerFactory(
552
- names.editModel,
553
- () =>
554
- (async (id: string, option?: FetchPolicy) => {
555
- const cnst = ConstantRegistry.getDatabase(refName);
556
- const modelFn = this.#requireHandler(names.model, names.editModel);
557
- const modelObj = await modelFn(id, { ...option, crystalize: false });
558
- const model = new cnst.full(modelObj as object);
559
- return {
560
- [refName]: model,
561
- [`${refName}Edit`]: { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() },
562
- };
563
- }) as FetchHandler,
531
+ this.#setHandlerFactory(names.editModel, () =>
532
+ this.#makeModelHandleFn(refName, names.model, names.editModel, `${refName}Edit`),
564
533
  );
565
534
  this.#setHandlerFactory(
566
535
  names.getModelEdit,
@@ -606,6 +575,35 @@ export class FetchClient {
606
575
  );
607
576
  }
608
577
 
578
+ /**
579
+ * `view<Model>` and `edit<Model>` differ only in the key their payload lands under, so they share one read.
580
+ * The full model is built lazily for the same reason a slice's list is: a route that only hands the payload to
581
+ * `Load.View` never reads it, and `ViewAt` is stamped once so two reads agree.
582
+ */
583
+ #makeModelHandleFn(refName: string, modelKey: string, callerKey: string, payloadKey: string) {
584
+ return ((id: string, option?: FetchPolicy) => {
585
+ const cnst = ConstantRegistry.getDatabase(refName);
586
+ const modelFn = this.#requireHandler(modelKey, callerKey);
587
+ const modelRequest = modelFn(id, { ...option, crystalize: false }) as Promise<object>;
588
+ let model: object | undefined;
589
+ let payload: object | undefined;
590
+ const modelOf = (modelObj: object) => (model ??= new cnst.full(modelObj));
591
+ const payloadOf = (modelObj: object) =>
592
+ (payload ??= { refName, [`${refName}Obj`]: modelObj, [`${refName}ViewAt`]: new Date() });
593
+ return FetchHandle.of<Record<string, unknown>, Record<string, Promise<unknown>>>(
594
+ [modelRequest],
595
+ async () => {
596
+ const modelObj = await modelRequest;
597
+ return FetchHandle.lazy({}, { [refName]: () => modelOf(modelObj), [payloadKey]: () => payloadOf(modelObj) });
598
+ },
599
+ {
600
+ [refName]: () => modelRequest.then(modelOf),
601
+ [payloadKey]: () => modelRequest.then(payloadOf),
602
+ },
603
+ );
604
+ }) as FetchHandler;
605
+ }
606
+
609
607
  static getEndpointFromSlice(
610
608
  refName: string,
611
609
  suffix: string,
@@ -654,7 +652,7 @@ export class FetchClient {
654
652
 
655
653
  const argLength = slice.args.length;
656
654
  this.slice[sliceName] = { refName, sliceName, argLength };
657
- this.#setHandlerFactory(names.init, () => async (...argData: unknown[]) => {
655
+ this.#setHandlerFactory(names.init, () => (...argData: unknown[]) => {
658
656
  const cnst = ConstantRegistry.getDatabase(refName);
659
657
  const queryArgs = normalizeQueryArgs(
660
658
  Array.from({ length: Math.min(argData.length, argLength) }, (_, idx) => argData[idx]),
@@ -667,56 +665,35 @@ export class FetchClient {
667
665
  const listFn = this.#requireHandler<(...args: unknown[]) => Promise<unknown[]>>(names.list, names.init);
668
666
  const insightFn = this.#requireHandler<(...args: unknown[]) => Promise<unknown>>(names.insight, names.init);
669
667
 
670
- const [modelObjList, modelObjInsight] = (await Promise.all([
671
- listFn(...fetchQueryArgs, skip, limit, sort, { ...option, crystalize: false }),
672
- fetchInsight ? insightFn(...fetchQueryArgs, { ...option, crystalize: false }) : null,
673
- ])) as unknown as [BaseObject[], BaseInsight];
674
- const lastPage = modelObjInsight?.count
675
- ? Math.max(Math.floor((modelObjInsight.count - 1) / (limit || 20)) + 1, 1)
676
- : 1;
677
-
678
- const serverInit = {
668
+ const listRequest = listFn(...fetchQueryArgs, skip, limit, sort, {
669
+ ...option,
670
+ crystalize: false,
671
+ }) as Promise<BaseObject[]>;
672
+ const insightRequest = (
673
+ fetchInsight ? insightFn(...fetchQueryArgs, { ...option, crystalize: false }) : Promise.resolve(null)
674
+ ) as Promise<BaseInsight | null>;
675
+ return new SliceInitHandle({
679
676
  refName,
677
+ capRefName,
678
+ capSuffix,
680
679
  sliceName,
681
680
  argLength,
682
- [`${refName}ObjList`]: modelObjList,
683
- [`${refName}ObjInsight`]: modelObjInsight,
684
- [`pageOf${capRefName}`]: page,
685
- [`lastPageOf${capRefName}`]: lastPage,
686
- [`limitOf${capRefName}`]: limit,
687
- [`queryArgsOf${capRefName}`]: queryArgs,
688
- [`sortOf${capRefName}`]: sort,
689
- [`${refName}InitAt`]: new Date(),
690
- };
691
-
692
- let modelList: DataList<BaseObject> | undefined;
693
- let modelInsight: object | undefined;
694
- return Object.defineProperties({ [`${refName}Init${capSuffix}`]: serverInit } as Record<string, unknown>, {
695
- [`${refName}List${capSuffix}`]: {
696
- enumerable: true,
697
- get: () => {
698
- modelList ??= new DataList(
699
- withSharedInstances(() => modelObjList.map((modelObj) => new cnst.light(modelObj) as BaseObject)),
700
- );
701
- return modelList;
702
- },
703
- },
704
- [`${refName}Insight${capSuffix}`]: {
705
- enumerable: true,
706
- get: () => {
707
- modelInsight ??= new cnst.insight(modelObjInsight);
708
- return modelInsight;
709
- },
710
- },
711
- });
681
+ queryArgs,
682
+ page,
683
+ limit,
684
+ sort,
685
+ listRequest,
686
+ insightRequest,
687
+ light: cnst.light as unknown as new (obj?: unknown) => BaseObject,
688
+ insight: cnst.insight as unknown as new (obj?: unknown) => object,
689
+ }).build();
712
690
  });
713
691
  this.#setHandlerFactory(names.getInit, () => async (...args: unknown[]) => {
714
- const initFn = this.#requireHandler<(...args: unknown[]) => Promise<Record<string, unknown>>>(
692
+ const initFn = this.#requireHandler<(...args: unknown[]) => Record<string, Promise<unknown>>>(
715
693
  names.init,
716
694
  names.getInit,
717
695
  );
718
- const result = await initFn(...args);
719
- return result[`${refName}Init${capSuffix}`];
696
+ return await initFn(...args)[`${refName}Init${capSuffix}`];
720
697
  });
721
698
  }
722
699
  #makeArgSerializer(args: SerializedArg[]) {
@@ -0,0 +1,57 @@
1
+ type FieldFactories<Handle> = { [Key in keyof Handle]: () => Handle[Key] };
2
+
3
+ /**
4
+ * A fetch result that is both awaitable and destructurable.
5
+ *
6
+ * `await` gives the object the helper has always given, so every existing call site reads unchanged. Reading a
7
+ * field off the un-awaited result gives that field's own promise instead, so a route can hand each one to the
8
+ * section that renders it and let the slowest arrive last rather than holding the whole page.
9
+ */
10
+ export class FetchHandle {
11
+ /**
12
+ * @param requests the calls already in flight. Each gets a swallowing handler so a field nobody reads cannot
13
+ * surface as an unhandled rejection; the handle's own `then` is what reports one.
14
+ * @param settle builds the awaited shape — its own fields stay lazy, so awaiting costs nothing extra.
15
+ * @param fields one factory per field of the un-awaited shape, memoized on first read.
16
+ */
17
+ static of<Awaited extends object, Handle extends object>(
18
+ requests: Promise<unknown>[],
19
+ settle: () => Promise<Awaited>,
20
+ fields: FieldFactories<Handle>,
21
+ ): PromiseLike<Awaited> & Handle {
22
+ for (const request of requests) void request.catch(() => undefined);
23
+ let settled: Promise<Awaited> | undefined;
24
+ const resolve = () => {
25
+ if (settled) return settled;
26
+ settled = settle();
27
+ void settled.catch(() => undefined);
28
+ return settled;
29
+ };
30
+ const read = new Map<string, unknown>();
31
+ const factories = fields as { [key: string]: () => unknown };
32
+ const handle = {} as { [key: string]: unknown };
33
+ for (const key of Object.keys(factories)) {
34
+ Object.defineProperty(handle, key, {
35
+ enumerable: true,
36
+ get: () => {
37
+ if (!read.has(key)) read.set(key, factories[key]());
38
+ return read.get(key);
39
+ },
40
+ });
41
+ }
42
+
43
+ return Object.defineProperties(handle, {
44
+
45
+ then: { value: (...args: unknown[]) => Reflect.apply(resolve().then, resolve(), args) },
46
+ catch: { value: (...args: unknown[]) => Reflect.apply(resolve().catch, resolve(), args) },
47
+ finally: { value: (...args: unknown[]) => Reflect.apply(resolve().finally, resolve(), args) },
48
+ }) as PromiseLike<Awaited> & Handle;
49
+ }
50
+
51
+ /** The awaited shape's fields stay lazy for the same reason the handle's do: a caller reads one, not all. */
52
+ static lazy<Target extends object>(target: Target, fields: { [key: string]: () => unknown }): Target {
53
+ for (const [key, factory] of Object.entries(fields))
54
+ Object.defineProperty(target, key, { enumerable: true, get: factory });
55
+ return target;
56
+ }
57
+ }
@@ -25,6 +25,8 @@ export interface ErrorConstructor {
25
25
  interface FetchOptions {
26
26
  headers?: Record<string, string>;
27
27
  baseUrl?: string;
28
+ /** Milliseconds before the request is abandoned; `false` waits as long as the browser will. */
29
+ timeout?: number | false;
28
30
  }
29
31
 
30
32
  const jsonContentType = /^application\/(?:[\w.+-]+\+)?json\b/i;
@@ -39,6 +41,15 @@ const transportErrorKeyMap = {
39
41
  const serverUnreachableKey = "base.error.serverUnreachable";
40
42
  const unexpectedResponseKey = "base.error.unexpectedResponse";
41
43
  const transportDetailLimit = 200;
44
+ /**
45
+ * How long a call waits before giving up.
46
+ *
47
+ * Nothing else bounds it: a gateway answers a dead upstream with a 504, but a solo process has no gateway and a
48
+ * severed connection produces no response at all — the request then sits until the browser's own limit, which is
49
+ * minutes. The dictionary already has the wording for a slow server (`gatewayTimeout`), so this is the client
50
+ * side of an answer that only existed for one deployment shape.
51
+ */
52
+ const DEFAULT_TIMEOUT_MS = 30_000;
42
53
 
43
54
  export class HttpClient {
44
55
  readonly baseUrl: string;
@@ -63,9 +74,11 @@ export class HttpClient {
63
74
  return { Accept: "application/json", ...headers, ...options.headers };
64
75
  }
65
76
  async get<Returns = unknown>(url: string, options: FetchOptions = {}): Promise<Returns> {
66
- return await this.#request<Returns>(this.#resolveUrl(url, options), {
67
- headers: HttpClient.#makeHeaders({ "Content-Type": "application/json" }, options),
68
- });
77
+ return await this.#request<Returns>(
78
+ this.#resolveUrl(url, options),
79
+ { headers: HttpClient.#makeHeaders({ "Content-Type": "application/json" }, options) },
80
+ options,
81
+ );
69
82
  }
70
83
  #makeReqContent(data: FormData | Record<string, unknown>): { body: BodyInit; headers: Record<string, string> } {
71
84
 
@@ -79,11 +92,11 @@ export class HttpClient {
79
92
  options: FetchOptions = {},
80
93
  ): Promise<Returns> {
81
94
  const { body, headers } = this.#makeReqContent(data);
82
- return await this.#request<Returns>(this.#resolveUrl(url, options), {
83
- method,
84
- body,
85
- headers: HttpClient.#makeHeaders(headers, options),
86
- });
95
+ return await this.#request<Returns>(
96
+ this.#resolveUrl(url, options),
97
+ { method, body, headers: HttpClient.#makeHeaders(headers, options) },
98
+ options,
99
+ );
87
100
  }
88
101
  async put<Returns = unknown>(
89
102
  url: string,
@@ -100,17 +113,29 @@ export class HttpClient {
100
113
  return await this.send<Returns>("POST", url, data, options);
101
114
  }
102
115
  async delete<Returns = unknown>(url: string, options: FetchOptions = {}): Promise<Returns> {
103
- return await this.#request<Returns>(this.#resolveUrl(url, options), {
104
- method: "DELETE",
105
- headers: HttpClient.#makeHeaders({ "Content-Type": "application/json" }, options),
106
- });
116
+ return await this.#request<Returns>(
117
+ this.#resolveUrl(url, options),
118
+ { method: "DELETE", headers: HttpClient.#makeHeaders({ "Content-Type": "application/json" }, options) },
119
+ options,
120
+ );
107
121
  }
108
122
 
109
- async #request<Returns>(url: string, init: RequestInit): Promise<Returns> {
110
- const res = await this.#fetch(url, init);
123
+ async #request<Returns>(url: string, init: RequestInit, options: FetchOptions = {}): Promise<Returns> {
124
+ const res = await this.#fetch(url, HttpClient.#withTimeout(init, options));
111
125
  return await this.#readJsonResponse<Returns>(res);
112
126
  }
113
127
 
128
+ /**
129
+ * An upload gets no deadline of its own: a large file on a slow uplink is a long request that is working, and
130
+ * the request body is the only thing this side can tell that from. Everything else takes the default unless
131
+ * the caller named one.
132
+ */
133
+ static #withTimeout(init: RequestInit, options: FetchOptions): RequestInit {
134
+ const timeout = options.timeout ?? (init.body instanceof FormData ? false : DEFAULT_TIMEOUT_MS);
135
+ if (timeout === false || !Number.isFinite(timeout) || timeout <= 0) return init;
136
+ return { ...init, signal: AbortSignal.timeout(timeout) };
137
+ }
138
+
114
139
  /**
115
140
  * `fetch` rejects only when no response arrived at all — a refused connection, a DNS failure, a socket
116
141
  * dropped mid-flight. That is the server being unreachable, not an error this API reported, so it is
@@ -120,6 +145,9 @@ export class HttpClient {
120
145
  try {
121
146
  return await fetch(url, init);
122
147
  } catch (error) {
148
+
149
+ if (error instanceof Error && error.name === "TimeoutError")
150
+ throw this.#restoreError({ error: transportErrorKeyMap[408], data: { status: 408 } }, 408);
123
151
  if (error instanceof Error && error.name === "AbortError") throw error;
124
152
  throw this.#restoreError({ error: serverUnreachableKey, details: String(error) }, 503);
125
153
  }
@@ -0,0 +1,101 @@
1
+ import { DataList } from "akanjs/base";
2
+ import { type BaseInsight, type BaseObject, withSharedInstances } from "akanjs/constant";
3
+
4
+ import { FetchHandle } from "./fetchHandle";
5
+
6
+ export interface SliceInitInput {
7
+ refName: string;
8
+ capRefName: string;
9
+ capSuffix: string;
10
+ sliceName: string;
11
+ argLength: number;
12
+ queryArgs: unknown[];
13
+ page: number;
14
+ limit: number;
15
+ sort: string;
16
+ listRequest: Promise<BaseObject[]>;
17
+ /** `null` when the caller passed `{ insight: false }` — the aggregate query is then never made. */
18
+ insightRequest: Promise<BaseInsight | null>;
19
+ light: new (obj?: unknown) => BaseObject;
20
+ insight: new (obj?: unknown) => object;
21
+ }
22
+
23
+ /**
24
+ * The two calls a slice's `init` makes and the three values callers read off them.
25
+ *
26
+ * The list does not wait for the aggregate: `x<Slice>List` resolves as soon as the rows land, while
27
+ * `x<Slice>Init` — which carries `lastPageOf<Model>` and therefore needs the count — waits for both. Every
28
+ * derived value is memoized, so the awaited shape and the un-awaited handle hand out the same instances.
29
+ */
30
+ export class SliceInitHandle {
31
+ readonly #input: SliceInitInput;
32
+ #serverInit?: object;
33
+ #modelList?: DataList<BaseObject>;
34
+ #modelInsight?: object;
35
+
36
+ constructor(input: SliceInitInput) {
37
+ this.#input = input;
38
+ }
39
+
40
+ build() {
41
+ const { refName, capSuffix, listRequest, insightRequest } = this.#input;
42
+ const keys = {
43
+ init: `${refName}Init${capSuffix}`,
44
+ list: `${refName}List${capSuffix}`,
45
+ insight: `${refName}Insight${capSuffix}`,
46
+ };
47
+ return FetchHandle.of<Record<string, unknown>, Record<string, Promise<unknown>>>(
48
+ [listRequest, insightRequest],
49
+ async () => {
50
+ const [modelObjList, modelObjInsight] = await Promise.all([listRequest, insightRequest]);
51
+ return FetchHandle.lazy({ [keys.init]: this.#serverInitOf(modelObjList, modelObjInsight) }, {
52
+ [keys.list]: () => this.#listOf(modelObjList),
53
+ [keys.insight]: () => this.#insightOf(modelObjInsight),
54
+ } as { [key: string]: () => unknown });
55
+ },
56
+ {
57
+ [keys.init]: () =>
58
+ Promise.all([listRequest, insightRequest]).then(([modelObjList, modelObjInsight]) =>
59
+ this.#serverInitOf(modelObjList, modelObjInsight),
60
+ ),
61
+ [keys.list]: () => listRequest.then((modelObjList) => this.#listOf(modelObjList)),
62
+ [keys.insight]: () => insightRequest.then((modelObjInsight) => this.#insightOf(modelObjInsight)),
63
+ },
64
+ );
65
+ }
66
+
67
+ #serverInitOf(modelObjList: BaseObject[], modelObjInsight: BaseInsight | null) {
68
+ const { refName, capRefName, sliceName, argLength, queryArgs, page, limit, sort } = this.#input;
69
+ const lastPage = modelObjInsight?.count
70
+ ? Math.max(Math.floor((modelObjInsight.count - 1) / (limit || 20)) + 1, 1)
71
+ : 1;
72
+ this.#serverInit ??= {
73
+ refName,
74
+ sliceName,
75
+ argLength,
76
+ [`${refName}ObjList`]: modelObjList,
77
+ [`${refName}ObjInsight`]: modelObjInsight,
78
+ [`pageOf${capRefName}`]: page,
79
+ [`lastPageOf${capRefName}`]: lastPage,
80
+ [`limitOf${capRefName}`]: limit,
81
+ [`queryArgsOf${capRefName}`]: queryArgs,
82
+ [`sortOf${capRefName}`]: sort,
83
+ [`${refName}InitAt`]: new Date(),
84
+ };
85
+ return this.#serverInit;
86
+ }
87
+
88
+ #listOf(modelObjList: BaseObject[]) {
89
+ const { light } = this.#input;
90
+ this.#modelList ??= new DataList(
91
+ withSharedInstances(() => modelObjList.map((modelObj) => new light(modelObj) as BaseObject)),
92
+ );
93
+ return this.#modelList;
94
+ }
95
+
96
+ #insightOf(modelObjInsight: BaseInsight | null) {
97
+ const { insight } = this.#input;
98
+ this.#modelInsight ??= new insight(modelObjInsight);
99
+ return this.#modelInsight;
100
+ }
101
+ }
@@ -35,7 +35,8 @@ type ServerInitShape<
35
35
  > = SliceMeta & {
36
36
  [K in `${RefName}ObjList`]: LightObj[];
37
37
  } & {
38
- [K in `${RefName}ObjInsight`]: InsightObj;
38
+ /** `null` when the caller passed `{ insight: false }`: the aggregate query was never made. */
39
+ [K in `${RefName}ObjInsight`]: InsightObj | null;
39
40
  } & {
40
41
  [K in `pageOf${CapRefName}`]: number;
41
42
  } & {
@@ -100,6 +101,31 @@ export type EditReturn<RefName extends string, Full> = {
100
101
  [K in `${RefName}Edit`]: ServerEdit<RefName, Full>;
101
102
  };
102
103
 
104
+ /**
105
+ * What `fetch.view<Model>` / `fetch.edit<Model>` / `fetch.init<Model><Suffix>` return: awaitable for the shape
106
+ * the helper has always given, and destructurable into one promise per field for a route that wants each
107
+ * section to render as its own data lands instead of holding the page for the slowest.
108
+ */
109
+ export type FetchHandleOf<Awaited, Fields> = PromiseLike<Awaited> & Fields;
110
+
111
+ export type ViewHandle<RefName extends string, Full> = FetchHandleOf<
112
+ ViewReturn<RefName, Full>,
113
+ {
114
+ [K in RefName]: Promise<Full>;
115
+ } & {
116
+ [K in `${RefName}View`]: Promise<ServerView<RefName, Full>>;
117
+ }
118
+ >;
119
+
120
+ export type EditHandle<RefName extends string, Full> = FetchHandleOf<
121
+ EditReturn<RefName, Full>,
122
+ {
123
+ [K in RefName]: Promise<Full>;
124
+ } & {
125
+ [K in `${RefName}Edit`]: Promise<ServerEdit<RefName, Full>>;
126
+ }
127
+ >;
128
+
103
129
  type InitReturnShape<
104
130
  RefName extends string,
105
131
  CapSuffix extends string,
@@ -127,3 +153,40 @@ export type InitReturn<
127
153
  Insight
128
154
  >;
129
155
 
156
+ /**
157
+ * The list resolves without waiting for the aggregate, so `x<Slice>List` lands before `x<Slice>Init` — which
158
+ * carries `lastPageOf<Model>` and therefore needs the count.
159
+ *
160
+ * `x<Slice>List` and `x<Slice>Insight` hold hydrated model instances, which React Flight refuses as props to a
161
+ * client component: consume them in a server component and hand `x<Slice>Init` to a `Zone`.
162
+ */
163
+ type InitHandleShape<
164
+ RefName extends string,
165
+ CapSuffix extends string,
166
+ Init,
167
+ ListItem extends { id: string },
168
+ Insight,
169
+ > = {
170
+ [K in `${RefName}Init${CapSuffix}`]: Promise<Init>;
171
+ } & {
172
+ [K in `${RefName}List${CapSuffix}`]: Promise<DataList<ListItem>>;
173
+ } & { [K in `${RefName}Insight${CapSuffix}`]: Promise<Insight> };
174
+
175
+ export type InitHandle<
176
+ RefName extends string,
177
+ Suffix extends string,
178
+ Light,
179
+ Insight,
180
+ Args,
181
+ Filter extends FilterInstance,
182
+ > = FetchHandleOf<
183
+ InitReturn<RefName, Suffix, Light, Insight, Args, Filter>,
184
+ InitHandleShape<
185
+ RefName,
186
+ Capitalize<Suffix>,
187
+ ServerInit<RefName, Light, Insight, Args, Filter>,
188
+ Light extends { id: string } ? Light : { id: string },
189
+ Insight
190
+ >
191
+ >;
192
+
@@ -15,7 +15,7 @@ import type {
15
15
  SliceCls,
16
16
  SliceInfoArgs,
17
17
  } from "akanjs/signal";
18
- import type { EditReturn, InitReturn, ServerEdit, ServerInit, ServerView, ViewReturn } from "./appliedReturn.type";
18
+ import type { EditHandle, InitHandle, ServerEdit, ServerInit, ServerView, ViewHandle } from "./appliedReturn.type";
19
19
 
20
20
  type _SliceMap<S extends SliceCls> = S[typeof SLICE_META];
21
21
  type _RefName<S extends SliceCls> = SlceCnstRefName<S>;
@@ -47,7 +47,7 @@ type SliceInsightFetch<S extends SliceCls> = {
47
47
  ) => Promise<_Insight<S>>;
48
48
  };
49
49
 
50
- type SliceInit<S extends SliceCls, Suffix extends keyof _SliceMap<S>> = InitReturn<
50
+ type SliceInit<S extends SliceCls, Suffix extends keyof _SliceMap<S>> = InitHandle<
51
51
  _RefName<S>,
52
52
  Suffix & string,
53
53
  _Light<S>,
@@ -67,7 +67,7 @@ type SliceServerInit<S extends SliceCls, Suffix extends keyof _SliceMap<S>> = Se
67
67
  type SliceInitFetch<S extends SliceCls> = {
68
68
  [Suffix in keyof _SliceMap<S> as Suffix extends string ? `init${_Cap<S>}${Capitalize<Suffix>}` : never]: (
69
69
  ...args: [...SliceInfoArgs<_SliceMap<S>[Suffix]>, option?: SliceInitOption<S>]
70
- ) => Promise<SliceInit<S, Suffix>>;
70
+ ) => SliceInit<S, Suffix>;
71
71
  };
72
72
 
73
73
  type SliceGetInitFetch<S extends SliceCls> = {
@@ -130,11 +130,11 @@ type AppliedBaseSliceFetchType<
130
130
  _CapitalizedRefName extends string = Capitalize<RefName>,
131
131
  _PurifiedInput = PurifiedModel<Input>,
132
132
  > = {
133
- [K in `view${_CapitalizedRefName}`]: (id: string, option?: FetchPolicy) => Promise<ViewReturn<RefName, Full>>;
133
+ [K in `view${_CapitalizedRefName}`]: (id: string, option?: FetchPolicy) => ViewHandle<RefName, Full>;
134
134
  } & {
135
135
  [K in `get${_CapitalizedRefName}View`]: (id: string, option?: FetchPolicy) => Promise<ServerView<RefName, Full>>;
136
136
  } & {
137
- [K in `edit${_CapitalizedRefName}`]: (id: string, option?: FetchPolicy) => Promise<EditReturn<RefName, Full>>;
137
+ [K in `edit${_CapitalizedRefName}`]: (id: string, option?: FetchPolicy) => EditHandle<RefName, Full>;
138
138
  } & {
139
139
  [K in `get${_CapitalizedRefName}Edit`]: (id: string, option?: FetchPolicy) => Promise<ServerEdit<RefName, Full>>;
140
140
  } & {
@@ -169,15 +169,29 @@ export function getRequestDynamicUsage(): AkanDynamicUsage | undefined {
169
169
  return getRequestStore()?.dynamicUsage;
170
170
  }
171
171
 
172
- /** Deduplicates a promise-producing query within the active request. */
173
- export function memoizeRequestQuery<T>(key: string, factory: () => Promise<T>): Promise<T> {
172
+ /**
173
+ * Deduplicates a promise-producing query within the active request, and says whether this caller is the one
174
+ * that started it.
175
+ *
176
+ * `owned` is what lets the caller skip copying the response. The copy exists because one response object is
177
+ * handed to every caller in the request and a parsed model can hold references into it, so a second caller must
178
+ * not be given the same graph — but a query asked for once (which is nearly all of them: the key carries the
179
+ * URL and the auth headers) has no second caller to protect, and copying for it is a whole JSON round-trip
180
+ * spent on nothing.
181
+ */
182
+ export function claimRequestQuery<T>(key: string, factory: () => Promise<T>): { value: Promise<T>; owned: boolean } {
174
183
  const store = getRequestStore();
175
- if (!store) return factory();
184
+ if (!store) return { value: factory(), owned: true };
176
185
  const existing = store.queryCache.get(key);
177
- if (existing) return existing as Promise<T>;
186
+ if (existing) return { value: existing as Promise<T>, owned: false };
178
187
  const promise = factory();
179
188
  store.queryCache.set(key, promise);
180
- return promise;
189
+ return { value: promise, owned: true };
190
+ }
191
+
192
+ /** Deduplicates a promise-producing query within the active request. */
193
+ export function memoizeRequestQuery<T>(key: string, factory: () => Promise<T>): Promise<T> {
194
+ return claimRequestQuery(key, factory).value;
181
195
  }
182
196
 
183
197
  /** Returns current request headers as a Map, or an empty Map outside a request. */
package/index.ts CHANGED
@@ -291,6 +291,20 @@ export interface FileConventionScanResult {
291
291
  zone: { databases: string[]; services: string[]; scalars: string[] };
292
292
  }
293
293
 
294
+ /**
295
+ * What a *live* scan exposes on `AppInfo.file` / `LibInfo.file`: a set per module kind, with every file
296
+ * type carrying all four kinds. `FileConventionScanResult` above is the serialized form that crosses a
297
+ * process boundary as JSON, which is why it is arrays and why each file type lists only the kinds it can
298
+ * hold. The two are not interchangeable — every reader of `.file` calls `.has()`.
299
+ */
300
+ export interface FileConventionScanSet {
301
+ all: Set<string>;
302
+ databases: Set<string>;
303
+ services: Set<string>;
304
+ scalars: Set<string>;
305
+ }
306
+ export type FileConventionScanSets = { [key in keyof FileConventionScanResult]: FileConventionScanSet };
307
+
294
308
  export interface ScanResult {
295
309
  name: string;
296
310
  type: "app" | "lib";
@@ -333,7 +347,7 @@ export interface AppInfo {
333
347
  readonly database: Map<string, Set<string>>;
334
348
  readonly service: Map<string, Set<string>>;
335
349
  readonly scalar: Map<string, Set<string>>;
336
- readonly file: FileConventionScanResult;
350
+ readonly file: FileConventionScanSets;
337
351
  getLibs(): string[];
338
352
  getLibInfos(): Map<string, LibInfo>;
339
353
  getDatabaseModules(): string[];
@@ -347,7 +361,7 @@ export interface LibInfo {
347
361
  readonly database: Map<string, Set<string>>;
348
362
  readonly service: Map<string, Set<string>>;
349
363
  readonly scalar: Map<string, Set<string>>;
350
- readonly file: FileConventionScanResult;
364
+ readonly file: FileConventionScanSets;
351
365
  getLibs(): string[];
352
366
  getLibInfos(): Map<string, LibInfo>;
353
367
  getLibInfo(libName: string): LibInfo | undefined;