akanjs 3.0.0-alpha.2 → 3.0.0-alpha.4

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 (76) hide show
  1. package/base/symbols.ts +0 -1
  2. package/client/cn.ts +7 -0
  3. package/client/csrTypes.ts +2 -0
  4. package/client/frameDebug.ts +3 -2
  5. package/constant/cascadePaths.ts +8 -79
  6. package/document/database.ts +1 -2
  7. package/document/databaseRegistry.ts +2 -2
  8. package/document/into.ts +6 -10
  9. package/package.json +1 -1
  10. package/server/SSR_MEMORY_DIAGNOSIS.md +2 -19
  11. package/server/cachePolicy.ts +12 -114
  12. package/server/di/diLifecycle.ts +10 -8
  13. package/server/processMetricsCollector.ts +0 -4
  14. package/server/resolver/database.resolver.ts +3 -23
  15. package/server/resolver/index.ts +0 -1
  16. package/server/resolver/service.resolver.ts +25 -9
  17. package/server/rscWorker.tsx +4 -32
  18. package/server/rscWorkerHost.ts +12 -61
  19. package/server/ssrFromRscRenderer.tsx +2 -2
  20. package/server/webRouter.ts +96 -9
  21. package/service/ipcTypes.ts +0 -25
  22. package/service/predefinedAdaptor/database.adaptor.ts +3 -3
  23. package/service/serve.ts +1 -5
  24. package/service/types.ts +0 -2
  25. package/types/base/symbols.d.ts +0 -1
  26. package/types/client/cn.d.ts +5 -0
  27. package/types/client/csrTypes.d.ts +2 -0
  28. package/types/constant/cascadePaths.d.ts +4 -22
  29. package/types/document/database.d.ts +1 -2
  30. package/types/document/databaseRegistry.d.ts +2 -2
  31. package/types/document/into.d.ts +5 -6
  32. package/types/server/cachePolicy.d.ts +2 -33
  33. package/types/server/resolver/database.resolver.d.ts +2 -6
  34. package/types/server/resolver/index.d.ts +0 -1
  35. package/types/server/resolver/service.resolver.d.ts +3 -3
  36. package/types/server/rscWorkerHost.d.ts +0 -13
  37. package/types/service/ipcTypes.d.ts +0 -24
  38. package/types/service/predefinedAdaptor/database.adaptor.d.ts +2 -2
  39. package/types/service/types.d.ts +1 -2
  40. package/types/ui/Layout/BottomInset.d.ts +2 -1
  41. package/types/ui/Layout/index.d.ts +1 -1
  42. package/types/ui/Signal/style.d.ts +1 -1
  43. package/types/ui/UiOverride/context.d.ts +12 -2
  44. package/types/ui/recipe/badgeRecipe.d.ts +7 -3
  45. package/types/ui/recipe/buttonRecipe.d.ts +7 -3
  46. package/types/ui/recipe/inputRecipe.d.ts +5 -1
  47. package/types/webkit/useCsrValues.d.ts +3 -3
  48. package/types/webkit/useFrameRuntime.d.ts +6 -1
  49. package/ui/Badge.tsx +2 -2
  50. package/ui/Button.tsx +3 -1
  51. package/ui/Constant/Doc.tsx +1 -1
  52. package/ui/Data/ListContainer.tsx +1 -1
  53. package/ui/DatePicker.tsx +4 -3
  54. package/ui/DraggableList.tsx +3 -1
  55. package/ui/Dropdown.tsx +4 -2
  56. package/ui/Field.tsx +17 -9
  57. package/ui/Input.tsx +7 -5
  58. package/ui/Layout/BottomInset.tsx +6 -1
  59. package/ui/Loading/ProgressBar.tsx +8 -1
  60. package/ui/Menu.tsx +7 -8
  61. package/ui/Model/SureToRemove.tsx +2 -1
  62. package/ui/ObjectId.tsx +3 -4
  63. package/ui/Pagination.tsx +3 -4
  64. package/ui/Popconfirm.tsx +5 -7
  65. package/ui/Select.tsx +1 -1
  66. package/ui/System/CSR.tsx +6 -2
  67. package/ui/ToggleSelect.tsx +4 -3
  68. package/ui/UiOverride/context.ts +12 -2
  69. package/ui/recipe/badgeRecipe.ts +22 -3
  70. package/ui/recipe/buttonRecipe.ts +51 -2
  71. package/ui/recipe/factory.ts +2 -2
  72. package/ui/recipe/inputRecipe.ts +18 -4
  73. package/webkit/useCsrValues.ts +121 -4
  74. package/webkit/useFrameRuntime.ts +42 -12
  75. package/server/resolver/CascadeRunner.ts +0 -201
  76. package/types/server/resolver/CascadeRunner.d.ts +0 -13
@@ -13,6 +13,7 @@ import {
13
13
  type NavigationIntent,
14
14
  normalizeDeepLinkHref,
15
15
  type PageState,
16
+ type PageTransition,
16
17
  type PathRoute,
17
18
  type RouteGuide,
18
19
  type RouteOptions,
@@ -23,13 +24,14 @@ import {
23
24
  } from "akanjs/client";
24
25
  import { loadCapacitorApp } from "akanjs/client/capacitor";
25
26
  import { parseAkanI18nEnv, parseBasePaths } from "akanjs/common";
26
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
27
28
  import {
28
29
  createFrameSnapshot,
29
30
  createTransitionPlan,
30
31
  FRAME_Z_INDEX,
31
32
  getFramePlatformProfile,
32
33
  getFrameSlotsForSnapshot,
34
+ hasBottomAnchoredKeyboardSlot,
33
35
  hasKeyboardStickySlot,
34
36
  isPendingFrameReady,
35
37
  PENDING_FRAME_READY_TIMEOUT_MS,
@@ -492,7 +494,6 @@ const useStackTrans = (routeState: RouteState): UseCsrTransition => {
492
494
  gestureIntent.current = "pending";
493
495
  stackBackConfigRef.current = null;
494
496
  unlockPageScroll();
495
- void Device.getDevice().hideKeyboard();
496
497
  }
497
498
  if (ix > initThreshold) {
498
499
  gestureIntent.current = "scroll";
@@ -511,6 +512,7 @@ const useStackTrans = (routeState: RouteState): UseCsrTransition => {
511
512
  if (absX < GESTURE_INTENT_THRESHOLD || absX <= absY * GESTURE_AXIS_LOCK_RATIO) return;
512
513
  gestureIntent.current = "gesture";
513
514
  lockPageScroll();
515
+ void Device.getDevice().hideKeyboard();
514
516
  }
515
517
  if (gestureIntent.current !== "gesture") {
516
518
  cancel();
@@ -668,11 +670,11 @@ const useBottomUpTrans = (routeState: RouteState): UseCsrTransition => {
668
670
 
669
671
  const pageBind = useDrag(
670
672
  ({ first, last, movement: [, my], initial: [, iy], cancel }) => {
671
- if (first) void Device.getDevice().hideKeyboard();
672
673
  if (iy > initThreshold) {
673
674
  cancel();
674
675
  return;
675
676
  }
677
+ if (first) void Device.getDevice().hideKeyboard();
676
678
  if (my < transUnitRange[1]) void transUnit.start(transUnitRange[1], { immediate: true });
677
679
  else if (my > transUnitRange[0]) void transUnit.start(transUnitRange[0], { immediate: true });
678
680
  else if (!last) void transUnit.start(my, { immediate: true });
@@ -864,6 +866,7 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
864
866
  const resolvedPendingLocation = resolveLocationWithFrameState(pendingLocation, resolvedPathRouteMap);
865
867
  const platformProfile = getFramePlatformProfile();
866
868
  const accessoryHeight = resolveKeyboardAccessoryHeight(resolvedLocation.pathRoute.path, frameSlots);
869
+ const shouldAnchorContentBottom = hasBottomAnchoredKeyboardSlot(resolvedLocation.pathRoute.path, frameSlots);
867
870
  const keyboardFrame = useKeyboardFrame({
868
871
  bottomSafeArea: resolvedLocation.pathRoute.pageState.bottomSafeArea,
869
872
  sticky: hasKeyboardStickySlot(resolvedLocation.pathRoute.path, frameSlots),
@@ -881,6 +884,103 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
881
884
  }),
882
885
  [viewport, keyboardFrame, accessoryHeight, resolvedLocation.pathRoute.pageState.bottomSafeArea],
883
886
  );
887
+ const contentBottomAnchorRef = useRef<{
888
+ path: string;
889
+ contentViewportHeight: number;
890
+ bottomDistance: number;
891
+ } | null>(null);
892
+ const contentBottomAnchorRafRef = useRef<number | null>(null);
893
+ useLayoutEffect(() => {
894
+ const element = pageContentRef.current;
895
+ const path = resolvedLocation.pathRoute.path;
896
+ if (!element || !shouldAnchorContentBottom || !keyboardFrame.sticky) {
897
+ contentBottomAnchorRef.current = null;
898
+ if (contentBottomAnchorRafRef.current !== null) {
899
+ cancelAnimationFrame(contentBottomAnchorRafRef.current);
900
+ contentBottomAnchorRafRef.current = null;
901
+ }
902
+ return;
903
+ }
904
+
905
+ const previous = contentBottomAnchorRef.current;
906
+ const currentBottomDistance = Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight);
907
+ if (!previous || previous.path !== path) {
908
+ contentBottomAnchorRef.current = {
909
+ path,
910
+ contentViewportHeight: keyboardLayout.contentViewport.height,
911
+ bottomDistance: currentBottomDistance,
912
+ };
913
+ return;
914
+ }
915
+
916
+ const shouldRestoreBottom = previous.contentViewportHeight !== keyboardLayout.contentViewport.height;
917
+ const bottomDistance = previous.bottomDistance;
918
+ if (shouldRestoreBottom) {
919
+ const nextScrollTop = Math.max(0, element.scrollHeight - element.clientHeight - bottomDistance);
920
+ if (Math.abs(element.scrollTop - nextScrollTop) > 1) {
921
+ debugFrame("keyboard.contentAnchor", {
922
+ path,
923
+ from: element.scrollTop,
924
+ to: nextScrollTop,
925
+ bottomDistance,
926
+ contentViewportHeight: keyboardLayout.contentViewport.height,
927
+ });
928
+ element.scrollTop = nextScrollTop;
929
+ }
930
+ const startedAt = performance.now();
931
+ const duration = (keyboardFrame.animationDuration ?? 0) + 50;
932
+ const keepBottomAnchored = () => {
933
+ const scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - bottomDistance);
934
+ if (Math.abs(element.scrollTop - scrollTop) > 1) element.scrollTop = scrollTop;
935
+ if (performance.now() - startedAt < duration) {
936
+ contentBottomAnchorRafRef.current = requestAnimationFrame(keepBottomAnchored);
937
+ } else {
938
+ contentBottomAnchorRafRef.current = null;
939
+ }
940
+ };
941
+ if (contentBottomAnchorRafRef.current !== null) cancelAnimationFrame(contentBottomAnchorRafRef.current);
942
+ contentBottomAnchorRafRef.current = requestAnimationFrame(keepBottomAnchored);
943
+ }
944
+
945
+ contentBottomAnchorRef.current = {
946
+ path,
947
+ contentViewportHeight: keyboardLayout.contentViewport.height,
948
+ bottomDistance: Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight),
949
+ };
950
+ return () => {
951
+ if (contentBottomAnchorRafRef.current !== null) {
952
+ cancelAnimationFrame(contentBottomAnchorRafRef.current);
953
+ contentBottomAnchorRafRef.current = null;
954
+ }
955
+ };
956
+ }, [
957
+ pageContentRef,
958
+ resolvedLocation.pathRoute.path,
959
+ shouldAnchorContentBottom,
960
+ keyboardFrame.sticky,
961
+ keyboardFrame.animationDuration,
962
+ keyboardLayout.contentViewport.height,
963
+ ]);
964
+ useEffect(() => {
965
+ const element = pageContentRef.current;
966
+ const path = resolvedLocation.pathRoute.path;
967
+ if (!element || !shouldAnchorContentBottom || !keyboardFrame.sticky) return;
968
+ const updateBottomDistance = () => {
969
+ contentBottomAnchorRef.current = {
970
+ path,
971
+ contentViewportHeight: keyboardLayout.contentViewport.height,
972
+ bottomDistance: Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight),
973
+ };
974
+ };
975
+ element.addEventListener("scroll", updateBottomDistance, { passive: true });
976
+ return () => element.removeEventListener("scroll", updateBottomDistance);
977
+ }, [
978
+ pageContentRef,
979
+ resolvedLocation.pathRoute.path,
980
+ shouldAnchorContentBottom,
981
+ keyboardFrame.sticky,
982
+ keyboardLayout.contentViewport.height,
983
+ ]);
884
984
  useFrameRuntimeResync({ updateViewport });
885
985
  const shouldPrepareFrameTransition = useCallback(
886
986
  (nextHref?: string) => {
@@ -1245,6 +1345,7 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1245
1345
  keyboard: keyboardFrame,
1246
1346
  contentViewport: keyboardLayout.contentViewport,
1247
1347
  keyboardAccessory: keyboardLayout.keyboardAccessory,
1348
+ contentAnchor: shouldAnchorContentBottom ? "bottom" : undefined,
1248
1349
  platformProfile,
1249
1350
  zIndex: FRAME_Z_INDEX,
1250
1351
  pageStateByPath: effectivePageStateByPath,
@@ -1262,6 +1363,21 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1262
1363
  bottomUp: useBottomUpTransition,
1263
1364
  scaleOut: useScaleOutTransition,
1264
1365
  };
1366
+
1367
+ const contentResizeStyle = useMemo(() => {
1368
+ if (!shouldAnchorContentBottom || !keyboardFrame.sticky) return null;
1369
+ const duration = keyboardFrame.animationDuration ?? 420;
1370
+ const easing = keyboardFrame.animationEasing ?? "cubic-bezier(0.16, 1, 0.3, 1)";
1371
+ return {
1372
+ transition: `height ${duration}ms ${easing}, padding-bottom ${duration}ms ${easing}`,
1373
+ willChange: "height, padding-bottom",
1374
+ };
1375
+ }, [shouldAnchorContentBottom, keyboardFrame.sticky, keyboardFrame.animationDuration, keyboardFrame.animationEasing]);
1376
+ const csrTransition = useCsrTransitionMap[resolvedLocation.pathRoute.pageState.transition];
1377
+ const page: PageTransition | null =
1378
+ contentResizeStyle && csrTransition.page
1379
+ ? { ...csrTransition.page, contentStyle: { ...csrTransition.page.contentStyle, ...contentResizeStyle } }
1380
+ : csrTransition.page;
1265
1381
  const nativeBackStateRef = useRef({
1266
1382
  path: resolvedLocation.pathRoute.path,
1267
1383
  keyboardHeight: keyboardFrame.height,
@@ -1409,6 +1525,7 @@ export const useCsrValues = (rootRouteGuide: RouteGuide, pathRoutes: PathRoute[]
1409
1525
 
1410
1526
  return {
1411
1527
  ...routeState,
1412
- ...useCsrTransitionMap[resolvedLocation.pathRoute.pageState.transition],
1528
+ ...csrTransition,
1529
+ page,
1413
1530
  } satisfies CsrContextType;
1414
1531
  };
@@ -25,7 +25,10 @@ export type FrameSlotBucket = "active" | "pending";
25
25
  export type FrameSlotMapByBucket = Record<FrameSlotBucket, FrameSlotMap>;
26
26
  export const PENDING_FRAME_READY_TIMEOUT_MS = 80;
27
27
  export const PENDING_FRAME_READY_MAX_TIMEOUT_MS = 120;
28
- export const KEYBOARD_FALLBACK_ANIMATION_DURATION_MS = 285;
28
+ export const KEYBOARD_SHOW_ANIMATION_DURATION_MS = 420;
29
+ export const KEYBOARD_HIDE_ANIMATION_DURATION_MS = 90;
30
+ export const KEYBOARD_SHOW_ANIMATION_EASING = "cubic-bezier(0.16, 1, 0.3, 1)";
31
+ export const KEYBOARD_HIDE_ANIMATION_EASING = "cubic-bezier(0.7, 0, 0.84, 0)";
29
32
 
30
33
  export const FRAME_Z_INDEX = {
31
34
  page: 10,
@@ -207,9 +210,14 @@ export function useFrameViewport() {
207
210
  const updateViewport = useCallback((reason = "viewport.change") => {
208
211
  const visualViewport = window.visualViewport;
209
212
  const platform = getCurrentPlatform();
213
+ const usesAkanKeyboardResize = platform === "ios" || platform === "android";
214
+ const width = Math.round(visualViewport?.width ?? window.innerWidth);
215
+ const currentHeight = Math.round(window.innerHeight);
216
+ const stableHeight =
217
+ usesAkanKeyboardResize && width === viewport.width ? Math.max(viewport.height, currentHeight) : currentHeight;
210
218
  const nextViewport = {
211
- width: Math.round(visualViewport?.width ?? window.innerWidth),
212
- height: Math.round(platform === "ios" ? window.innerHeight : (visualViewport?.height ?? window.innerHeight)),
219
+ width,
220
+ height: usesAkanKeyboardResize ? stableHeight : Math.round(visualViewport?.height ?? window.innerHeight),
213
221
  visualWidth: Math.round(visualViewport?.width ?? window.innerWidth),
214
222
  visualHeight: Math.round(visualViewport?.height ?? window.innerHeight),
215
223
  visualOffsetTop: Math.round(visualViewport?.offsetTop ?? 0),
@@ -226,7 +234,7 @@ export function useFrameViewport() {
226
234
  debugFrame(reason, { from: prev, to: nextViewport, platform });
227
235
  return nextViewport;
228
236
  });
229
- }, []);
237
+ }, [viewport.height]);
230
238
 
231
239
  useEffect(() => {
232
240
  updateViewport("viewport.init");
@@ -259,6 +267,10 @@ export function hasKeyboardStickySlot(path: string, frameSlots: FrameSlotMap) {
259
267
  return getKeyboardAccessorySlots(path, frameSlots).length > 0;
260
268
  }
261
269
 
270
+ export function hasBottomAnchoredKeyboardSlot(path: string, frameSlots: FrameSlotMap) {
271
+ return getKeyboardAccessorySlots(path, frameSlots).some((slot) => slot.contentAnchor === "bottom");
272
+ }
273
+
262
274
  export function resolveKeyboardFrame({
263
275
  keyboardHeight,
264
276
  bottomSafeArea,
@@ -274,22 +286,36 @@ export function resolveKeyboardFrame({
274
286
  sticky: boolean;
275
287
  freeze?: boolean;
276
288
  }): KeyboardFrameState {
289
+ const useVisualViewportHeight = platformProfile === "android" && visualViewportKeyboardHeight > 0;
277
290
  const visualFallbackHeight =
278
291
  keyboardHeight <= 0 && visualViewportKeyboardHeight > 0 ? visualViewportKeyboardHeight : 0;
279
- const effectiveKeyboardHeight = keyboardHeight > 0 ? keyboardHeight : visualFallbackHeight;
280
- const source = keyboardHeight > 0 ? "native" : visualFallbackHeight > 0 ? "visualViewport" : "fallback";
292
+ const effectiveKeyboardHeight = useVisualViewportHeight
293
+ ? visualViewportKeyboardHeight
294
+ : keyboardHeight > 0
295
+ ? keyboardHeight
296
+ : visualFallbackHeight;
297
+ const source = useVisualViewportHeight
298
+ ? "visualViewport"
299
+ : keyboardHeight > 0
300
+ ? "native"
301
+ : visualFallbackHeight > 0
302
+ ? "visualViewport"
303
+ : "fallback";
281
304
  const visualCompensation =
282
- platformProfile === "ios" ? 0 : Math.min(visualViewportKeyboardHeight, effectiveKeyboardHeight);
305
+ platformProfile === "web" || platformProfile === "mobileWeb"
306
+ ? Math.min(visualViewportKeyboardHeight, effectiveKeyboardHeight)
307
+ : 0;
283
308
  const offset = sticky && !freeze ? Math.max(0, effectiveKeyboardHeight - visualCompensation) : 0;
309
+ const isShowing = effectiveKeyboardHeight > 0 && !freeze;
284
310
  return {
285
311
  height: effectiveKeyboardHeight,
286
312
  offset,
287
- visible: effectiveKeyboardHeight > 0 && !freeze,
313
+ visible: isShowing,
288
314
  sticky,
289
315
  frozen: freeze,
290
316
  source,
291
- animationDuration: KEYBOARD_FALLBACK_ANIMATION_DURATION_MS,
292
- animationEasing: "ease-out",
317
+ animationDuration: isShowing ? KEYBOARD_SHOW_ANIMATION_DURATION_MS : KEYBOARD_HIDE_ANIMATION_DURATION_MS,
318
+ animationEasing: isShowing ? KEYBOARD_SHOW_ANIMATION_EASING : KEYBOARD_HIDE_ANIMATION_EASING,
293
319
  };
294
320
  }
295
321
 
@@ -344,14 +370,18 @@ export function useKeyboardFrame({
344
370
  }: {
345
371
  bottomSafeArea: number;
346
372
  sticky: boolean;
347
- viewport: { visualHeight: number; visualOffsetTop: number };
373
+ viewport: { height: number; visualHeight: number; visualOffsetTop: number };
348
374
  platformProfile: FramePlatformProfile;
349
375
  freeze?: boolean;
350
376
  }) {
351
377
  const keyboardHeight = st.use.keyboardHeight();
378
+ const androidViewportKeyboardHeight =
379
+ platformProfile === "android" ? Math.max(0, Math.round(viewport.height - window.innerHeight)) : 0;
352
380
  const visualViewportKeyboardHeight = Math.max(
353
381
  0,
354
- Math.round(window.innerHeight - viewport.visualHeight - viewport.visualOffsetTop),
382
+ androidViewportKeyboardHeight > 0
383
+ ? androidViewportKeyboardHeight
384
+ : Math.round(viewport.height - viewport.visualHeight - viewport.visualOffsetTop),
355
385
  );
356
386
  return useMemo(
357
387
  () =>
@@ -1,201 +0,0 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import { LIBS_REMOVE_HOOK } from "akanjs/base";
3
- import { Logger } from "akanjs/common";
4
- import { type CascadeWithPath, type ConstantModel, ConstantRegistry } from "akanjs/constant";
5
- import { type DocumentSchema, documentQueryHelper } from "akanjs/document";
6
- import type { DatabaseService, ServiceCls } from "akanjs/service";
7
-
8
- /** A relation the removed document owns: the ids it holds name documents to remove. */
9
- interface RefEdge {
10
- readonly key: string;
11
- readonly refName: string;
12
- }
13
-
14
- /** A model that declared itself removable with this one: its rows name the removed document as their owner. */
15
- interface WithEdge {
16
- readonly refName: string;
17
- readonly key: string;
18
- readonly typeKey: string | null;
19
- }
20
-
21
- interface CascadeModule {
22
- readonly constant: ConstantModel;
23
- readonly schema: DocumentSchema;
24
- readonly srvRef: ServiceCls;
25
- }
26
-
27
- interface CascadePlan {
28
- readonly refEdges: RefEdge[];
29
- readonly withEdges: WithEdge[];
30
- }
31
-
32
- /** One cascade in flight. Shared down the chain so a cycle is caught wherever it closes. */
33
- interface CascadeContext {
34
- readonly seen: Set<string>;
35
- readonly depth: number;
36
- }
37
-
38
- /** How deep one removal may cascade before the chain is treated as runaway and abandoned. */
39
- const maxDepth = 16;
40
- /** Ids taken per query while draining children one document at a time. */
41
- const drainSize = 200;
42
-
43
- export class CascadeRunner {
44
- readonly #modules = new Map<string, CascadeModule>();
45
- readonly #plans = new Map<string, CascadePlan>();
46
- readonly #bulk = new Set<string>();
47
- readonly #context = new AsyncLocalStorage<CascadeContext>();
48
- readonly #logger = new Logger("Cascade");
49
- #getService: ((refName: string) => DatabaseService) | null = null;
50
-
51
- register(constant: ConstantModel, schema: DocumentSchema, srvRef: ServiceCls) {
52
- this.#modules.set(constant.refName, { constant, schema, srvRef });
53
- }
54
-
55
- /**
56
- * Called once every service is live. A `_postRemove` and a `listenPost("remove")` registered during boot both
57
- * count against bulk removal, so the strategy cannot be decided before the last service has initialized.
58
- */
59
- seal(getService: (refName: string) => DatabaseService) {
60
- this.#getService = getService;
61
- for (const [refName, mod] of this.#modules) {
62
- this.#plans.set(refName, { refEdges: this.#collectRefEdges(refName, mod), withEdges: [] });
63
- }
64
- for (const [refName, mod] of this.#modules) this.#collectWithEdges(refName, mod);
65
- for (const refName of this.#modules.keys()) {
66
- if (this.#hasRemoveSideEffect(refName)) continue;
67
- this.#bulk.add(refName);
68
- }
69
- this.#report();
70
- }
71
-
72
- async run(refName: string, doc: Record<string, unknown>) {
73
- const plan = this.#plans.get(refName);
74
- if (!plan?.refEdges.length && !plan?.withEdges.length) return;
75
- const parent = this.#context.getStore();
76
- const seen = parent?.seen ?? new Set<string>();
77
- const depth = (parent?.depth ?? 0) + 1;
78
- const id = typeof doc.id === "string" ? doc.id : null;
79
- if (id) seen.add(`${refName}:${id}`);
80
- if (depth > maxDepth) {
81
- this.#logger.error(`Cascade from ${refName} exceeded depth ${maxDepth} and was abandoned`);
82
- return;
83
- }
84
- await this.#context.run({ seen, depth }, async () => {
85
- for (const edge of plan.refEdges) await this.#removeRef(edge, doc, seen);
86
- if (id) for (const edge of plan.withEdges) await this.#removeWith(edge, refName, id, seen);
87
- });
88
- }
89
-
90
- async #removeRef(edge: RefEdge, doc: Record<string, unknown>, seen: Set<string>) {
91
- const value = doc[edge.key];
92
- const ids = (Array.isArray(value) ? value : [value]).filter(
93
- (item): item is string => typeof item === "string" && !seen.has(`${edge.refName}:${item}`),
94
- );
95
- if (!ids.length) return;
96
- const service = this.#service(edge.refName);
97
- if (this.#bulk.has(edge.refName)) {
98
- await service.__removeMany({ id: documentQueryHelper.oneOf(ids) });
99
- return;
100
- }
101
-
102
- for (const id of ids) await service.__remove(id);
103
- }
104
-
105
- async #removeWith(edge: WithEdge, ownerRef: string, ownerId: string, seen: Set<string>) {
106
- const service = this.#service(edge.refName);
107
- const query = edge.typeKey ? { [edge.key]: ownerId, [edge.typeKey]: ownerRef } : { [edge.key]: ownerId };
108
- if (this.#bulk.has(edge.refName)) {
109
- await service.__removeMany(query);
110
- return;
111
- }
112
-
113
- for (;;) {
114
- const ids = await service.__listIds(query, { limit: drainSize });
115
- if (!ids.length) return;
116
- let removed = 0;
117
- for (const id of ids) {
118
- if (seen.has(`${edge.refName}:${id}`)) continue;
119
- await service.__remove(id);
120
- removed += 1;
121
- }
122
- if (!removed) return;
123
- }
124
- }
125
-
126
- #collectRefEdges(refName: string, mod: CascadeModule) {
127
- return [...mod.constant.full.cascade.removeRef].map(([key, modelRef]) => {
128
- const target = ConstantRegistry.getRefName(modelRef);
129
-
130
- if (!this.#modules.has(target)) {
131
- throw new Error(`Cascade field "${refName}.${key}" removes "${target}", which this app does not mount`);
132
- }
133
- return { key, refName: target };
134
- });
135
- }
136
-
137
- #collectWithEdges(childRef: string, mod: CascadeModule) {
138
- for (const [key, path] of mod.constant.full.cascade.removeWith) {
139
- for (const owner of this.#resolveOwners(childRef, key, path)) {
140
- this.#plans.get(owner)?.withEdges.push({ refName: childRef, key, typeKey: path.typeKey });
141
- }
142
- }
143
- }
144
-
145
- #resolveOwners(childRef: string, key: string, path: CascadeWithPath) {
146
- if (path.typeValues.length) {
147
-
148
- const mounted = path.typeValues.filter((owner) => this.#modules.has(owner));
149
- for (const owner of path.typeValues) {
150
- if (mounted.includes(owner)) continue;
151
- this.#logger.warn(`Cascade field "${childRef}.${key}" names owner "${owner}", which this app does not mount`);
152
- }
153
- return mounted;
154
- }
155
- const owner = path.refName ?? ConstantRegistry.getRefName(path.modelRef as never);
156
- if (!this.#modules.has(owner)) {
157
- throw new Error(`Cascade field "${childRef}.${key}" is owned by "${owner}", which this app does not mount`);
158
- }
159
- return [owner];
160
- }
161
-
162
- /** Everything a bulk `removeMany` would skip. All of it absent means the two paths leave the same rows behind. */
163
- #hasRemoveSideEffect(refName: string) {
164
- const mod = this.#modules.get(refName);
165
- if (!mod) return true;
166
- if (mod.schema.preHooks.get("remove")?.length || mod.schema.postHooks.get("remove")?.length) return true;
167
- if ((mod.srvRef as unknown as { [LIBS_REMOVE_HOOK]?: boolean })[LIBS_REMOVE_HOOK]) return true;
168
- const proto = mod.srvRef.prototype as { _preRemove?: unknown; _postRemove?: unknown };
169
- if (typeof proto._preRemove === "function" || typeof proto._postRemove === "function") return true;
170
- const plan = this.#plans.get(refName);
171
- return !!plan?.refEdges.length || !!plan?.withEdges.length;
172
- }
173
-
174
- /** Neither the strategy nor the edge list is visible from the source, and adding a `_postRemove` to a target
175
- * silently flips it from one query back to one per document. A quiet cascade is the one nobody can explain. */
176
- #report() {
177
- const lines: string[] = [];
178
- for (const [refName, plan] of this.#plans) {
179
- for (const edge of plan.refEdges) {
180
- lines.push(`${refName}.${edge.key} removeRef ${edge.refName} (${this.#strategy(edge.refName)})`);
181
- }
182
- for (const edge of plan.withEdges) {
183
- const path = edge.typeKey ? `${edge.key}+${edge.typeKey}` : edge.key;
184
- lines.push(`${refName} removeWith ${edge.refName}.${path} (${this.#strategy(edge.refName)})`);
185
- }
186
- }
187
- if (!lines.length) return;
188
- const bulk = lines.filter((line) => line.endsWith("(bulk)")).length;
189
- this.#logger.info(`${lines.length} cascade edge(s), ${bulk} in one query`);
190
- for (const line of lines) this.#logger.verbose(line);
191
- }
192
-
193
- #strategy(refName: string) {
194
- return this.#bulk.has(refName) ? "bulk" : "per document";
195
- }
196
-
197
- #service(refName: string) {
198
- if (!this.#getService) throw new Error(`Cascade ran before the plan was sealed: ${refName}`);
199
- return this.#getService(refName);
200
- }
201
- }
@@ -1,13 +0,0 @@
1
- import { type ConstantModel } from "akanjs/constant";
2
- import { type DocumentSchema } from "akanjs/document";
3
- import type { DatabaseService, ServiceCls } from "akanjs/service";
4
- export declare class CascadeRunner {
5
- #private;
6
- register(constant: ConstantModel, schema: DocumentSchema, srvRef: ServiceCls): void;
7
- /**
8
- * Called once every service is live. A `_postRemove` and a `listenPost("remove")` registered during boot both
9
- * count against bulk removal, so the strategy cannot be decided before the last service has initialized.
10
- */
11
- seal(getService: (refName: string) => DatabaseService): void;
12
- run(refName: string, doc: Record<string, unknown>): Promise<void>;
13
- }