akanjs 3.0.0-alpha.1 → 3.0.0-alpha.10

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 (126) hide show
  1. package/base/symbols.ts +1 -0
  2. package/client/clientRuntime.ts +8 -0
  3. package/client/cn.ts +7 -0
  4. package/client/csrTypes.ts +2 -0
  5. package/client/frameDebug.ts +3 -2
  6. package/common/Logger.ts +4 -0
  7. package/common/deepObjectify.ts +21 -7
  8. package/common/index.ts +5 -0
  9. package/common/pathSet.ts +17 -5
  10. package/common/routeConvention.ts +30 -0
  11. package/constant/cascadePaths.ts +79 -8
  12. package/constant/crystalize.ts +3 -1
  13. package/constant/fieldInfo.ts +6 -0
  14. package/constant/getDefault.ts +37 -9
  15. package/document/database.ts +23 -1
  16. package/document/databaseRegistry.ts +2 -2
  17. package/document/filterMeta.ts +7 -0
  18. package/document/into.ts +17 -7
  19. package/fetch/client/fetchClient.ts +26 -2
  20. package/fetch/client/wsClient.ts +17 -2
  21. package/package.json +2 -2
  22. package/server/akanApp.ts +7 -30
  23. package/server/artifact/routeClientCache.ts +14 -0
  24. package/server/assetEncoding.ts +47 -0
  25. package/server/di/diLifecycle.ts +8 -10
  26. package/server/index.ts +1 -2
  27. package/server/resolver/CascadeRunner.ts +201 -0
  28. package/server/resolver/database.resolver.ts +60 -4
  29. package/server/resolver/index.ts +1 -0
  30. package/server/resolver/service.resolver.ts +38 -25
  31. package/server/resolver/signal.resolver.ts +6 -1
  32. package/server/routeTreeBuilder.ts +2 -44
  33. package/server/webRouter.ts +123 -33
  34. package/service/predefinedAdaptor/database.adaptor.ts +77 -24
  35. package/service/serve.ts +5 -1
  36. package/service/serviceModule.ts +17 -0
  37. package/service/types.ts +6 -0
  38. package/signal/middleware.ts +7 -3
  39. package/signal/signalContext.ts +40 -4
  40. package/store/action.ts +1 -1
  41. package/store/storeInstance.ts +3 -1
  42. package/store/types.ts +7 -0
  43. package/store/withSelector.ts +3 -5
  44. package/types/base/symbols.d.ts +1 -0
  45. package/types/client/cn.d.ts +5 -0
  46. package/types/client/csrTypes.d.ts +2 -0
  47. package/types/common/Logger.d.ts +2 -0
  48. package/types/common/deepObjectify.d.ts +4 -2
  49. package/types/common/index.d.ts +1 -1
  50. package/types/common/routeConvention.d.ts +8 -0
  51. package/types/constant/cascadePaths.d.ts +22 -4
  52. package/types/constant/fieldInfo.d.ts +1 -0
  53. package/types/constant/getDefault.d.ts +5 -0
  54. package/types/document/database.d.ts +22 -1
  55. package/types/document/databaseRegistry.d.ts +2 -2
  56. package/types/document/filterMeta.d.ts +1 -0
  57. package/types/document/into.d.ts +12 -6
  58. package/types/fetch/client/fetchClient.d.ts +1 -0
  59. package/types/server/artifact/routeClientCache.d.ts +6 -0
  60. package/types/server/assetEncoding.d.ts +7 -0
  61. package/types/server/index.d.ts +0 -2
  62. package/types/server/resolver/CascadeRunner.d.ts +13 -0
  63. package/types/server/resolver/database.resolver.d.ts +6 -2
  64. package/types/server/resolver/index.d.ts +1 -0
  65. package/types/server/resolver/service.resolver.d.ts +3 -3
  66. package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -4
  67. package/types/service/types.d.ts +5 -1
  68. package/types/store/baseSt.d.ts +1 -1
  69. package/types/store/types.d.ts +4 -0
  70. package/types/store/withSelector.d.ts +3 -5
  71. package/types/ui/CsrImage.d.ts +1 -1
  72. package/types/ui/Dropdown.d.ts +2 -0
  73. package/types/ui/Image.d.ts +3 -3
  74. package/types/ui/Layout/BottomInset.d.ts +2 -1
  75. package/types/ui/Layout/index.d.ts +1 -1
  76. package/types/ui/Signal/style.d.ts +1 -1
  77. package/types/ui/UiOverride/context.d.ts +12 -2
  78. package/types/ui/index.d.ts +2 -1
  79. package/types/ui/overlayLayer.d.ts +24 -0
  80. package/types/ui/recipe/badgeRecipe.d.ts +7 -3
  81. package/types/ui/recipe/buttonRecipe.d.ts +7 -3
  82. package/types/ui/recipe/inputRecipe.d.ts +5 -1
  83. package/types/webkit/index.d.ts +1 -0
  84. package/types/webkit/lazy.d.ts +12 -0
  85. package/types/webkit/useCsrValues.d.ts +3 -3
  86. package/types/webkit/useEscapeKey.d.ts +5 -0
  87. package/types/webkit/useFrameRuntime.d.ts +6 -1
  88. package/ui/Badge.tsx +2 -2
  89. package/ui/BottomSheet.tsx +5 -0
  90. package/ui/Button.tsx +3 -1
  91. package/ui/Constant/Doc.tsx +1 -1
  92. package/ui/CsrImage.tsx +1 -1
  93. package/ui/Data/ListContainer.tsx +1 -1
  94. package/ui/DatePicker.tsx +4 -3
  95. package/ui/Dialog/Modal.tsx +12 -15
  96. package/ui/DraggableList.tsx +3 -1
  97. package/ui/Dropdown.tsx +33 -11
  98. package/ui/Field.tsx +15 -9
  99. package/ui/Image.tsx +3 -3
  100. package/ui/Input.tsx +7 -5
  101. package/ui/Layout/BottomInset.tsx +6 -1
  102. package/ui/Loading/ProgressBar.tsx +8 -1
  103. package/ui/Menu.tsx +7 -8
  104. package/ui/Model/EditModal.tsx +37 -2
  105. package/ui/Model/SureToRemove.tsx +2 -1
  106. package/ui/Model/index_.tsx +42 -15
  107. package/ui/ObjectId.tsx +3 -4
  108. package/ui/Pagination.tsx +3 -4
  109. package/ui/Popconfirm.tsx +8 -7
  110. package/ui/Select.tsx +4 -3
  111. package/ui/System/CSR.tsx +6 -2
  112. package/ui/ToggleSelect.tsx +4 -3
  113. package/ui/Tooltip.tsx +2 -1
  114. package/ui/UiOverride/context.ts +12 -2
  115. package/ui/index.ts +8 -1
  116. package/ui/overlayLayer.ts +39 -0
  117. package/ui/recipe/badgeRecipe.ts +22 -3
  118. package/ui/recipe/buttonRecipe.ts +51 -2
  119. package/ui/recipe/factory.ts +2 -2
  120. package/ui/recipe/inputRecipe.ts +18 -4
  121. package/webkit/bootCsr.tsx +2 -33
  122. package/webkit/index.ts +1 -0
  123. package/webkit/lazy.tsx +22 -2
  124. package/webkit/useCsrValues.ts +121 -4
  125. package/webkit/useEscapeKey.tsx +42 -0
  126. package/webkit/useFrameRuntime.ts +65 -32
package/base/symbols.ts CHANGED
@@ -3,6 +3,7 @@ export const SLICE_META = Symbol.for("akan.slice");
3
3
  export const FILTER_META = Symbol.for("akan.filter");
4
4
  export const LOADER_META = Symbol.for("akan.loader");
5
5
  export const INJECT_META = Symbol.for("akan.inject");
6
+ export const LIBS_REMOVE_HOOK = Symbol.for("akan.service.libsRemoveHook");
6
7
  export const ENDPOINT_META = Symbol.for("akan.endpoint");
7
8
  export const ENDPOINT_DICT_SHAPE: unique symbol = Symbol.for("akan.endpoint.dictShape") as never;
8
9
  export const SLICE_DICT_SHAPE: unique symbol = Symbol.for("akan.slice.dictShape") as never;
@@ -75,6 +75,13 @@ globalWithRuntime[CLIENT_RUNTIME_KEY] = state;
75
75
  const missingRuntimeError = () =>
76
76
  new Error("Akan client runtime is not registered. Import the generated app client first.");
77
77
 
78
+ const applyRuntimeErrorConstructor = (runtime: ClientRuntime) => {
79
+ const instance = (runtime.fetch as RuntimeFetch | undefined)?.instance as
80
+ | { setErrorConstructor?: (Err: unknown) => void }
81
+ | undefined;
82
+ if (typeof instance?.setErrorConstructor === "function") instance.setErrorConstructor(runtime.Err);
83
+ };
84
+
78
85
  export const registerClientRuntime = <Runtime>(
79
86
  runtime: Runtime,
80
87
  { scope = "app" }: { scope?: RuntimeScope } = {},
@@ -82,6 +89,7 @@ export const registerClientRuntime = <Runtime>(
82
89
  if (state.scope === "app" && scope === "lib") return runtime;
83
90
  state.runtime = runtime as ClientRuntime;
84
91
  state.scope = scope;
92
+ applyRuntimeErrorConstructor(state.runtime);
85
93
  return runtime;
86
94
  };
87
95
 
package/client/cn.ts CHANGED
@@ -38,10 +38,17 @@ export const colorTokens = [
38
38
  "ring",
39
39
  ];
40
40
 
41
+ /**
42
+ * Akan's semantic radius tokens (`--radius-box` / `--radius-field` in ui/styles.css). Without them
43
+ * `cn("rounded-field", "rounded-full")` keeps both classes and stylesheet order decides the winner.
44
+ */
45
+ export const radiusTokens = ["box", "field"];
46
+
41
47
  const twMerge = extendTailwindMerge({
42
48
  extend: {
43
49
  theme: {
44
50
  color: colorTokens,
51
+ radius: radiusTokens,
45
52
  },
46
53
  },
47
54
  });
@@ -455,6 +455,7 @@ export interface FrameLayoutState {
455
455
  keyboard: KeyboardFrameState;
456
456
  contentViewport: FrameContentViewportState;
457
457
  keyboardAccessory: KeyboardAccessoryFrameState;
458
+ contentAnchor?: "bottom";
458
459
  platformProfile: FramePlatformProfile;
459
460
  zIndex: FrameLayerZIndex;
460
461
  pageStateByPath: Map<string, PageState>;
@@ -463,6 +464,7 @@ export interface FrameSlotRegistration {
463
464
  scope?: FrameSlotScope;
464
465
  type: FrameSlotType;
465
466
  role?: FrameSlotRole;
467
+ contentAnchor?: "bottom";
466
468
  height?: number;
467
469
  estimatedHeight?: number;
468
470
  source?: "navbar" | "topInset" | "bottomInset" | "bottomTab" | (string & {});
@@ -20,9 +20,10 @@ const isFrameDebugEnabled = () => {
20
20
  export function debugFrame(event: string, payload: DebugPayload = {}) {
21
21
  if (!isFrameDebugEnabled()) return;
22
22
  debugSeq += 1;
23
- console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, {
23
+ const details = {
24
24
  href: window.location.href,
25
25
  now: Math.round(performance.now()),
26
26
  ...payload,
27
- });
27
+ };
28
+ console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, details, JSON.stringify(details));
28
29
  }
package/common/Logger.ts CHANGED
@@ -60,6 +60,10 @@ export class Logger {
60
60
  static isVerbose() {
61
61
  return Logger.#levelIdx <= 1;
62
62
  }
63
+ /** For hot-path callers that would otherwise build a message the level is about to discard. */
64
+ static shouldLog(logLevel: LogLevel) {
65
+ return Logger.#shouldLog(logLevel);
66
+ }
63
67
 
64
68
  name?: string;
65
69
  constructor(name?: string) {
@@ -1,9 +1,16 @@
1
1
  import { isDayjs } from "./isDayjs";
2
2
 
3
- export const deepObjectify = <T = unknown>(
4
- obj: T | null | undefined,
5
- option: { serializable?: boolean; convertDate?: "string" | "number" } = {},
6
- ): T => {
3
+ interface DeepObjectifyOption {
4
+ serializable?: boolean;
5
+ convertDate?: "string" | "number";
6
+ }
7
+
8
+ const objectifyChild = (value: unknown, option: DeepObjectifyOption): unknown => {
9
+ const modelValue = value as { __ModelType__?: string } | null | undefined;
10
+ return modelValue?.__ModelType__ && !option.serializable ? value : deepObjectify(value, option);
11
+ };
12
+
13
+ export const deepObjectify = <T = unknown>(obj: T | null | undefined, option: DeepObjectifyOption = {}): T => {
7
14
  if (isDayjs(obj) || obj?.constructor === Date) {
8
15
  if (!option.serializable && !option.convertDate) return obj as T;
9
16
  if (option.convertDate === "string") return obj.toISOString() as T;
@@ -12,13 +19,20 @@ export const deepObjectify = <T = unknown>(
12
19
  else return (isDayjs(obj) ? obj.toDate() : obj) as T;
13
20
  } else if (Array.isArray(obj)) {
14
21
  return obj.map((o: unknown) => deepObjectify(o, option)) as T;
22
+ } else if (obj instanceof Map) {
23
+
24
+ const entries = [...obj.entries()].map(
25
+ ([key, value]: [string, unknown]) => [key, objectifyChild(value, option)] as const,
26
+ );
27
+ return (option.serializable ? Object.fromEntries(entries) : new Map(entries)) as T;
28
+ } else if (obj instanceof Set) {
29
+ const values = [...obj.values()].map((value: unknown) => objectifyChild(value, option));
30
+ return (option.serializable ? values : new Set(values)) as T;
15
31
  } else if (obj && typeof obj === "object") {
16
32
  const val: Record<string, unknown> = {};
17
33
  const objRecord = obj as Record<string, unknown>;
18
34
  Object.keys(obj).forEach((key) => {
19
- const fieldValue = objRecord[key] as { __ModelType__: string } | null | undefined;
20
- if (fieldValue?.__ModelType__ && !option.serializable) val[key] = fieldValue;
21
- else if (typeof objRecord[key] !== "function") val[key] = deepObjectify(fieldValue, option);
35
+ if (typeof objRecord[key] !== "function") val[key] = objectifyChild(objRecord[key], option);
22
36
  });
23
37
  return val as T;
24
38
  } else {
package/common/index.ts CHANGED
@@ -36,12 +36,17 @@ export { randomPicks } from "./randomPicks";
36
36
  export {
37
37
  assertUniqueRoutePatterns,
38
38
  compareRouteSpecificity,
39
+ getRouteExports,
39
40
  isRouteSourceFile,
40
41
  isSpecialRouteLeaf,
42
+ LAYOUT_ROUTE_EXPORTS,
41
43
  matchRoutePattern,
42
44
  normalizeRoutePattern,
45
+ PAGE_ROUTE_EXPORTS,
43
46
  type ParsedRouteModuleKey,
44
47
  parseRouteModuleKey,
48
+ RESERVED_ROUTE_CONFIG_EXPORTS,
49
+ ROOT_LAYOUT_ROUTE_EXPORTS,
45
50
  type RouteModuleKind,
46
51
  routeSegmentToPatternPart,
47
52
  routeSegmentToTreePath,
package/common/pathSet.ts CHANGED
@@ -1,16 +1,28 @@
1
1
  type MutableIndexable = Record<string | number, unknown>;
2
2
  type PathSegment = string | number;
3
+ type Container = MutableIndexable | Map<PathSegment, unknown>;
3
4
 
4
5
  const toPathSegments = (path: string | readonly PathSegment[]) =>
5
6
  Array.isArray(path) ? [...path] : path.toString().match(/[^.[\]]+/g) || [];
6
7
 
8
+ const readChild = (container: Container, key: PathSegment) =>
9
+ container instanceof Map ? container.get(key) : container[key];
10
+
11
+ const writeChild = (container: Container, key: PathSegment, value: unknown) => {
12
+ if (container instanceof Map) container.set(key, value);
13
+ else container[key] = value;
14
+ };
15
+
7
16
  export const pathSet = <T>(obj: T, path: string | readonly PathSegment[], value: unknown): T => {
8
17
  if (Object(obj) !== obj) return obj;
9
18
  const pathSegments = toPathSegments(path);
10
- pathSegments.slice(0, -1).reduce<MutableIndexable>((a, c, i) => {
11
- if (Object(a[c]) === a[c]) return a[c] as MutableIndexable;
12
- a[c] = Math.abs(Number(pathSegments[i + 1])) >> 0 === +pathSegments[i + 1] ? [] : {};
13
- return a[c] as MutableIndexable;
14
- }, obj as MutableIndexable)[pathSegments[pathSegments.length - 1]] = value;
19
+ const parent = pathSegments.slice(0, -1).reduce<Container>((a, c, i) => {
20
+ const child = readChild(a, c);
21
+ if (Object(child) === child) return child as Container;
22
+ const created = Math.abs(Number(pathSegments[i + 1])) >> 0 === +pathSegments[i + 1] ? [] : {};
23
+ writeChild(a, c, created);
24
+ return created as unknown as Container;
25
+ }, obj as Container);
26
+ writeChild(parent, pathSegments[pathSegments.length - 1], value);
15
27
  return obj;
16
28
  };
@@ -9,6 +9,36 @@ const DIRECTORY_SCOPED_LEAVES = new Set(["_layout", "_index", "_overrides"]);
9
9
 
10
10
  export type RouteModuleKind = "page" | "layout" | "overrides";
11
11
 
12
+ export const PAGE_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
13
+ "default",
14
+ "pageConfig",
15
+ "head",
16
+ "metadata",
17
+ "generateHead",
18
+ "generateMetadata",
19
+ "Loading",
20
+ ]);
21
+ export const LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([...PAGE_ROUTE_EXPORTS, "NotFound", "Error"]);
22
+ export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
23
+ ...LAYOUT_ROUTE_EXPORTS,
24
+ "fonts",
25
+ "manifest",
26
+ "theme",
27
+ "reconnect",
28
+ "wsConnect",
29
+ "layoutStyle",
30
+ "gaTrackingId",
31
+ ]);
32
+ /** Root-layout exports that are plain config rather than components, so a PascalCase check cannot allow them. */
33
+ export const RESERVED_ROUTE_CONFIG_EXPORTS: ReadonlySet<string> = new Set(
34
+ [...ROOT_LAYOUT_ROUTE_EXPORTS].filter((name) => name !== "default" && !/^[A-Z]/.test(name)),
35
+ );
36
+
37
+ export function getRouteExports(kind: "page" | "layout", { rootLayout = false } = {}): ReadonlySet<string> {
38
+ if (kind === "page") return PAGE_ROUTE_EXPORTS;
39
+ return rootLayout ? ROOT_LAYOUT_ROUTE_EXPORTS : LAYOUT_ROUTE_EXPORTS;
40
+ }
41
+
12
42
  export interface ParsedRouteModuleKey {
13
43
  key: string;
14
44
  kind: RouteModuleKind;
@@ -1,32 +1,103 @@
1
+ import { type Cls, PrimitiveRegistry, type PrimitiveScalar } from "akanjs/base";
1
2
  import type { ConstantField, FieldObject } from "./fieldInfo";
2
3
  import type { ConstantModelRef } from "./via";
3
4
 
4
- /** What happens to the documents a relation field points at when the owner is removed. */
5
- export const cascadeActions = ["remove"] as const;
5
+ /**
6
+ * Which end of a relation goes away with the other. `removeRef` removes what the field points at when this
7
+ * document is removed; `removeWith` removes this document when what the field points at is removed. The two
8
+ * read identically on a relation field, so the value has to name the direction — a mistake here is a data loss.
9
+ */
10
+ export const cascadeActions = ["removeRef", "removeWith"] as const;
6
11
  export type CascadeAction = (typeof cascadeActions)[number];
7
12
 
13
+ /** How a `removeWith` field names the owner whose removal takes this document with it. */
14
+ export interface CascadeWithPath {
15
+ readonly key: string;
16
+ /** Set when the field is a relation. Resolved to a refName later: the owner may not be registered yet. */
17
+ readonly modelRef: ConstantModelRef | null;
18
+ /** Set when the field declares `ref`, which names the owner at declaration. */
19
+ readonly refName: string | null;
20
+ /** Set when the field declares `refPath`: the sibling field holding the owner's refName. */
21
+ readonly typeKey: string | null;
22
+ /** The refNames `typeKey` may hold. Empty unless the field is polymorphic. */
23
+ readonly typeValues: readonly string[];
24
+ }
25
+
26
+ const idNames = new Set(["ID", "String"]);
27
+
8
28
  export class CascadePaths {
9
- /** Field key → the model its ids point at. Resolved to a refName later: the target may not be registered yet. */
10
- readonly remove = new Map<string, ConstantModelRef>();
29
+ /** Field key → the model its ids point at, removed when this document is. */
30
+ readonly removeRef = new Map<string, ConstantModelRef>();
31
+ /** Field key → the owner whose removal removes this document. */
32
+ readonly removeWith = new Map<string, CascadeWithPath>();
11
33
 
12
34
  collect(fieldMap: FieldObject) {
13
35
  for (const [key, field] of Object.entries(fieldMap)) {
14
36
  if (!field.cascade) continue;
15
- this.#assertCascadable(key, field.cascade, field);
16
- this.remove.set(key, field.modelRef);
37
+ this.#assertKnownAction(key, field.cascade);
38
+ if (field.cascade === "removeRef") this.removeRef.set(key, this.#readOwnedRelation(key, field));
39
+ else this.removeWith.set(key, this.#readOwnerPath(key, field, fieldMap));
17
40
  }
18
41
  return this;
19
42
  }
20
43
 
21
- #assertCascadable(key: string, action: CascadeAction, field: ConstantField) {
22
-
44
+ #assertKnownAction(key: string, action: CascadeAction) {
45
+
23
46
  if (!cascadeActions.includes(action)) {
24
47
  throw new Error(`Cascade field "${key}" declares cascade: "${action}", which is not one of ${cascadeActions}`);
25
48
  }
49
+ }
50
+
51
+ #readOwnedRelation(key: string, field: ConstantField) {
26
52
 
27
53
  if (!field.isClass || field.isScalar) {
28
54
  throw new Error(`Cascade field "${key}" is not a model reference and has no document to remove`);
29
55
  }
30
56
  if (field.arrDepth > 1) throw new Error(`Cascade field "${key}" is a nested array and cannot cascade`);
57
+ return field.modelRef;
58
+ }
59
+
60
+ #readOwnerPath(key: string, field: ConstantField, fieldMap: FieldObject): CascadeWithPath {
61
+
62
+ if (field.arrDepth > 0) throw new Error(`Cascade field "${key}" is an array and names more than one owner`);
63
+ if (field.isMap) throw new Error(`Cascade field "${key}" is a Map and names no owner`);
64
+ if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap);
65
+ if (field.ref) {
66
+ this.#assertHoldsId(key, field);
67
+ return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [] };
68
+ }
69
+ if (field.isClass && !field.isScalar) {
70
+ return { key, modelRef: field.modelRef, refName: null, typeKey: null, typeValues: [] };
71
+ }
72
+ throw new Error(
73
+ `Cascade field "${key}" declares cascade: "removeWith" but names no owner; make it a model reference, ` +
74
+ `or add ref: "<model>" / refPath: "<typeField>"`,
75
+ );
76
+ }
77
+
78
+ #readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject): CascadeWithPath {
79
+ if (field.ref) throw new Error(`Cascade field "${key}" declares both ref and refPath; keep one`);
80
+ this.#assertHoldsId(key, field);
81
+ const typeKey = field.refPath as string;
82
+ const typeField = fieldMap[typeKey];
83
+ if (!typeField) throw new Error(`Cascade field "${key}" declares refPath: "${typeKey}", which is not a field`);
84
+
85
+ if (!typeField.enum) {
86
+ throw new Error(
87
+ `Cascade field "${key}" declares refPath: "${typeKey}", which must be an enumOf(...) naming the owner ` +
88
+ `refNames it may hold`,
89
+ );
90
+ }
91
+ const typeValues = typeField.enum.values.map((value) => String(value));
92
+ return { key, modelRef: null, refName: null, typeKey, typeValues };
93
+ }
94
+
95
+ #assertHoldsId(key: string, field: ConstantField) {
96
+ const modelRef = field.modelRef as unknown as Cls;
97
+ const refName = PrimitiveRegistry.has(modelRef)
98
+ ? PrimitiveRegistry.getName(modelRef as unknown as typeof PrimitiveScalar)
99
+ : null;
100
+ if (refName && idNames.has(refName)) return;
101
+ throw new Error(`Cascade field "${key}" declares ref or refPath and must hold an ID`);
31
102
  }
32
103
  }
@@ -30,8 +30,10 @@ export const crystalize = (field: FieldProps, value: unknown): unknown => {
30
30
  isArray: false,
31
31
  arrDepth: 0,
32
32
  };
33
+
34
+ const entries = value instanceof Map ? [...value.entries()] : Object.entries(value as Record<string, unknown>);
33
35
  return new Map(
34
- Object.entries(value as Record<string, unknown>).map(([key, val]) => [
36
+ entries.map(([key, val]: [string, unknown]) => [
35
37
  key,
36
38
  field.of
37
39
  ? applyFnToArrayObjects(val, (v: never) => crystalize(mapValueField, v))
@@ -450,7 +450,13 @@ export class ConstantField<
450
450
  get isMap() {
451
451
  return (this.modelRef as Cls) === Map;
452
452
  }
453
+
454
+ #props: FieldProps | null = null;
453
455
  getProps(): FieldProps {
456
+ this.#props ??= Object.freeze(this.#buildProps());
457
+ return this.#props;
458
+ }
459
+ #buildProps(): FieldProps {
454
460
  return {
455
461
  nullable: this.nullable as unknown as boolean,
456
462
  ref: this.ref,
@@ -2,17 +2,45 @@ import { DEFAULT_VALUE, FIELD_META, type PrimitiveScalar } from "akanjs/base";
2
2
  import type { FieldObject } from ".";
3
3
  import type { DefaultOf } from "./types";
4
4
 
5
+ interface DefaultPlan {
6
+ /** Fields whose default is a value that can be shared: a primitive, `null`, or the field's own literal. */
7
+ shared: Record<string, unknown>;
8
+ /** Fields that have to be produced per call — a thunk, a fresh array, or a nested scalar record. */
9
+ perCall: [key: string, make: () => unknown][];
10
+ }
11
+
12
+ const planCache = new WeakMap<FieldObject, DefaultPlan>();
13
+
14
+ /**
15
+ * The split is what keeps this faithful: `default: () => dayjs()` still means "now" on every call, and an array
16
+ * or nested-scalar default is still a fresh object, so two documents filled from the same model never end up
17
+ * sharing one. Only values that were already shared before this cache existed live in `shared`.
18
+ */
5
19
  export const getDefault = <T>(fieldObj: FieldObject): DefaultOf<T> => {
6
- const result: Record<string, unknown> = {};
20
+ let plan = planCache.get(fieldObj);
21
+ if (!plan) {
22
+ plan = buildPlan(fieldObj);
23
+ planCache.set(fieldObj, plan);
24
+ }
25
+ const result: Record<string, unknown> = { ...plan.shared };
26
+ for (const [key, make] of plan.perCall) result[key] = make();
27
+ return result as DefaultOf<T>;
28
+ };
29
+
30
+ const buildPlan = (fieldObj: FieldObject): DefaultPlan => {
31
+ const shared: Record<string, unknown> = {};
32
+ const perCall: [string, () => unknown][] = [];
7
33
  for (const [key, field] of Object.entries(fieldObj)) {
8
- if (field.fieldType === "hidden" || field.fieldType === "secret") result[key] = null;
34
+ if (field.fieldType === "hidden" || field.fieldType === "secret") shared[key] = null;
9
35
  else if (field.default !== undefined && field.default !== null) {
10
- if (typeof field.default === "function") result[key] = (field.default as () => object)();
11
- else result[key] = field.default as object;
12
- } else if (field.isArray) result[key] = [];
13
- else if (field.nullable) result[key] = null;
14
- else if (field.isClass) result[key] = field.isScalar ? getDefault(field.modelRef[FIELD_META]) : null;
15
- else result[key] = (field.modelRef as unknown as typeof PrimitiveScalar)[DEFAULT_VALUE];
36
+ if (typeof field.default === "function") perCall.push([key, field.default as () => unknown]);
37
+ else shared[key] = field.default as object;
38
+ } else if (field.isArray) perCall.push([key, () => []]);
39
+ else if (field.nullable) shared[key] = null;
40
+ else if (field.isClass) {
41
+ if (field.isScalar) perCall.push([key, () => getDefault(field.modelRef[FIELD_META])]);
42
+ else shared[key] = null;
43
+ } else shared[key] = (field.modelRef as unknown as typeof PrimitiveScalar)[DEFAULT_VALUE];
16
44
  }
17
- return result as DefaultOf<T>;
45
+ return { shared, perCall };
18
46
  };
@@ -3,8 +3,9 @@ import { Logger } from "akanjs/common";
3
3
  import type { DocumentModel, QueryOf } from "akanjs/constant";
4
4
  import type { CacheAdaptor, CacheSetOptions } from "akanjs/service";
5
5
  import type { DataLoader } from "./dataLoader";
6
+ import type { DocumentUpdateInput } from "./documentQuery";
6
7
  import type { ExtractQuery, ExtractSort, FilterInstance } from "./filterMeta";
7
- import type { CRUDEventType, Mdl, SaveEventType } from "./into";
8
+ import type { CRUDEventType, Mdl, SaveEventType, UpdateResult } from "./into";
8
9
  import type { DataInputOf, FindQueryOption, ListQueryOption } from "./types";
9
10
 
10
11
  export class CacheDatabase<T = unknown> {
@@ -25,6 +26,15 @@ export class CacheDatabase<T = unknown> {
25
26
  await this.cache.delete(this.refName, `${topic}:${key}`);
26
27
  }
27
28
  }
29
+ /**
30
+ * What `update<Filter>` returns. The patch cannot be a trailing parameter — a filter's own args may be optional,
31
+ * and no tuple type puts a required element after those — and leading it reads backwards. So it lands here, on a
32
+ * terminal `.set()` that mirrors the `UPDATE … SET …` it compiles to.
33
+ */
34
+ export interface UpdateChain<Doc = any> {
35
+ set(update: DocumentUpdateInput<Doc>): Promise<UpdateResult>;
36
+ }
37
+
28
38
  type QueryMethodOfKey<
29
39
  CapitalizedK extends string,
30
40
  Doc,
@@ -53,6 +63,14 @@ type QueryMethodOfKey<
53
63
  [K in `insight${CapitalizedK}`]: (...args: _Args) => Promise<Insight>;
54
64
  } & {
55
65
  [K in `query${CapitalizedK}`]: (...args: _Args) => _QueryOfDoc;
66
+ } & {
67
+ [K in `remove${CapitalizedK}`]: (...args: _Args) => Promise<UpdateResult>;
68
+ } & {
69
+ [K in `removeOne${CapitalizedK}`]: (...args: _Args) => Promise<UpdateResult>;
70
+ } & {
71
+ [K in `update${CapitalizedK}`]: (...args: _Args) => UpdateChain<Doc>;
72
+ } & {
73
+ [K in `updateOne${CapitalizedK}`]: (...args: _Args) => UpdateChain<Doc>;
56
74
  };
57
75
  type QueryMethodMap<Query, Doc, Insight, _FindQueryOption, _ListQueryOption, _QueryOfDoc> = {
58
76
  [K in keyof Query]: K extends string
@@ -105,6 +123,10 @@ type DatabaseModelWithQuerySort<
105
123
  __create: (data: _DataInput) => Promise<Doc>;
106
124
  __update: (id: string, data: Partial<Doc>) => Promise<Doc>;
107
125
  __remove: (id: string) => Promise<Doc>;
126
+ __removeMany: (query: _QueryOfDoc) => Promise<UpdateResult>;
127
+ __removeOne: (query: _QueryOfDoc) => Promise<UpdateResult>;
128
+ __updateMany: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
129
+ __updateOne: (query: _QueryOfDoc, update: DocumentUpdateInput<Doc>) => Promise<UpdateResult>;
108
130
  __list(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
109
131
  __listIds(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<string[]>;
110
132
  __find(query: _QueryOfDoc, queryOption?: _FindQueryOption): Promise<Doc | null>;
@@ -19,7 +19,7 @@ export interface DatabaseModel<
19
19
  model: ModelCls<Model>;
20
20
  filter: FilterCls<Filter>;
21
21
  obj: ConstantCls<Obj>;
22
- insight: ConstantCls<Insight>;
22
+ insight: DatabaseCls<Insight>;
23
23
  _Input: Input;
24
24
  _Doc: Doc;
25
25
  _Model: Model;
@@ -92,7 +92,7 @@ export class DatabaseRegistry {
92
92
  doc: DatabaseCls<Doc>,
93
93
  model: ModelCls<Model>,
94
94
  obj: ConstantCls<Obj>,
95
- insight: ConstantCls<Insight>,
95
+ insight: DatabaseCls<Insight>,
96
96
  filter: FilterCls<Filter>,
97
97
  ): DatabaseModel<T, Input, Doc, Model, Obj, Insight, Filter, _Query, _Sort> {
98
98
  const dbInfo = {
@@ -98,6 +98,13 @@ export const fillMissingFilterArgs = (filterInfo: FilterInfo, args: unknown[]) =
98
98
  return [...args, ...Array(filterInfo.args.length - args.length).fill(undefined)];
99
99
  };
100
100
 
101
+ export const assertFilterFitsCrud = (refName: string, queryKey: string, className: string) => {
102
+ if (queryKey.toLowerCase() !== refName.toLowerCase()) return;
103
+ throw new Error(
104
+ `Filter "${queryKey}" on "${refName}" generates remove${className}/update${className}, which are the generated CRUD methods; rename the filter`,
105
+ );
106
+ };
107
+
101
108
  export type BaseFilterSortKey = "latest" | "oldest" | "relevance";
102
109
  export type BaseFilterQueryKey = "any";
103
110
  export type BaseFilterKey = BaseFilterSortKey | BaseFilterQueryKey;
package/document/into.ts CHANGED
@@ -77,22 +77,29 @@ export type Mdl<
77
77
  find(query: _RawQuery, projection?: _Projection): FindManyChain<Doc>;
78
78
  findOne(query: _RawQuery, projection?: _Projection): FindOneChain<Doc>;
79
79
  findById(id: string | undefined, projection?: _Projection): Promise<Doc | null>;
80
- countDocuments(query: _RawQuery): Promise<number>;
80
+ count(query: _RawQuery): Promise<number>;
81
81
  exists(query: _RawQuery): Promise<string | null>;
82
+
82
83
  updateOne(
83
84
  query: _RawQuery,
84
85
  update: DocumentUpdateInput<_RawDoc>,
85
86
  options?: DocumentUpdateOptions,
86
87
  ): Promise<UpdateResult>;
87
88
  updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>): Promise<UpdateResult>;
88
- deleteMany(query: _RawQuery): Promise<UpdateResult>;
89
+ removeOne(query: _RawQuery): Promise<UpdateResult>;
90
+ removeMany(query: _RawQuery): Promise<UpdateResult>;
91
+ updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
92
+ removeById(id: string): Promise<UpdateResult>;
89
93
  bulkWrite(operations: BulkWriteOperation<Raw, _RawDoc, _RawQuery>[]): Promise<UpdateResult>;
94
+ /** @deprecated Renamed to `count`. */
95
+ countDocuments(query: _RawQuery): Promise<number>;
90
96
  };
91
97
 
92
- interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw> {
98
+ interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw, Insight> {
93
99
  refName: T;
94
100
  _CapitalizedRefName: _CapitalizedRefName;
95
101
  _Full: Raw;
102
+ _Insight: Insight;
96
103
  }
97
104
  type NoInferType<T> = [T][T extends unknown ? 0 : never];
98
105
  type IntoModelActions<
@@ -100,6 +107,8 @@ type IntoModelActions<
100
107
  _CapitalizedRefName extends string,
101
108
  Doc,
102
109
  Raw,
110
+
111
+ Insight,
103
112
  _Query,
104
113
  _Sort,
105
114
  _QueryOfDoc = QueryOf<Doc>,
@@ -121,13 +130,14 @@ type IntoModelActions<
121
130
  [K in `update${_CapitalizedRefName}`]: (id: string, data: Partial<Doc>) => Promise<Doc>;
122
131
  } & {
123
132
  [K in `remove${_CapitalizedRefName}`]: (id: string) => Promise<Doc>;
124
- } & QueryMethodPart<_Query, _Sort, Raw, Doc, unknown, unknown, unknown, _QueryOfDoc>;
133
+ } & QueryMethodPart<_Query, _Sort, Raw, Doc, DocumentModel<Insight>, unknown, unknown, _QueryOfDoc>;
125
134
 
126
135
  export const into = <
127
136
  Doc,
128
137
  FilterRef extends FilterCls,
129
138
  T extends string,
130
139
  Raw,
140
+ Insight,
131
141
  AddDbModels extends ModelCls[],
132
142
  _CapitalizedRefName extends string,
133
143
  _QueryOfDoc = QueryOf<Doc>,
@@ -137,11 +147,11 @@ export const into = <
137
147
  >(
138
148
  docRef: Cls<Doc>,
139
149
  filterRef: FilterRef,
140
- cnst: IntoConstantModel<T, _CapitalizedRefName, Raw>,
150
+ cnst: IntoConstantModel<T, _CapitalizedRefName, Raw, Insight>,
141
151
  loaderBuilder: _LoaderBuilder,
142
152
  ...addMdls: [...AddDbModels]
143
153
  ): ModelCls<
144
- IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
154
+ IntoModelActions<T, _CapitalizedRefName, Doc, Raw, Insight, _Query, _Sort, _QueryOfDoc>,
145
155
  ReturnType<_LoaderBuilder>
146
156
  > => {
147
157
  const loaderInfoMap = loaderBuilder(makeLoaderBuilder<Doc>());
@@ -162,7 +172,7 @@ export const into = <
162
172
  });
163
173
  });
164
174
  return DefaultModel as unknown as ModelCls<
165
- IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
175
+ IntoModelActions<T, _CapitalizedRefName, Doc, Raw, Insight, _Query, _Sort, _QueryOfDoc>,
166
176
  ReturnType<_LoaderBuilder>
167
177
  >;
168
178
  };
@@ -37,6 +37,16 @@ export type FetchProxy<
37
37
  FetchClient &
38
38
  FetchType & { slice: SliceMetaObj; instance: FetchClient; _FetchType: FetchType; _SliceMetaObj: SliceMetaObj };
39
39
 
40
+ interface SharedClientState {
41
+ proxy: FetchProxy | null;
42
+ origin: string | null;
43
+ }
44
+
45
+ const SHARED_CLIENT_KEY = Symbol.for("akanjs.fetch.sharedClient");
46
+ const globalWithSharedClient = globalThis as typeof globalThis & { [SHARED_CLIENT_KEY]?: SharedClientState };
47
+ const sharedClientState: SharedClientState = globalWithSharedClient[SHARED_CLIENT_KEY] ?? { proxy: null, origin: null };
48
+ globalWithSharedClient[SHARED_CLIENT_KEY] = sharedClientState;
49
+
40
50
  type ClientSignalMap<SigType extends { fetch: any }> = {
41
51
  [K in keyof SigType as SigType[K] extends DatabaseSignal<any, any, any, any>
42
52
  ? K
@@ -80,6 +90,19 @@ export class FetchClient {
80
90
  FetchClient.#sharedSerializedSignal = {};
81
91
  FetchClient.#sharedRegistryVersion++;
82
92
  }
93
+ static resetSharedClient() {
94
+ sharedClientState.proxy = null;
95
+ sharedClientState.origin = null;
96
+ }
97
+ static #resolveSharedClientProxy(origin: string, Err?: ErrorConstructor) {
98
+ if (typeof window === "undefined") return null;
99
+
100
+ if (sharedClientState.proxy) return sharedClientState.origin === origin ? sharedClientState.proxy : null;
101
+ const proxy = FetchClient.#makeProxy<unknown, Record<string, SliceMeta>>(new FetchClient(origin, {}, {}, Err));
102
+ sharedClientState.proxy = proxy;
103
+ sharedClientState.origin = origin;
104
+ return proxy;
105
+ }
83
106
  static #mergeSerializedSignalInto(
84
107
  serializedSignal: { [key: string]: SerializedSignal },
85
108
  refName: string,
@@ -700,10 +723,11 @@ export class FetchClient {
700
723
  sig: ClientSignalMap<SigType>;
701
724
  fetch: SigType["fetch"];
702
725
  } {
703
- if (base) base.instance.applySignal(serializedSignal);
726
+ const shared = base ?? FetchClient.#resolveSharedClientProxy(origin, Err);
727
+ if (shared) shared.instance.applySignal(serializedSignal);
704
728
  if (base && Err) base.instance.setErrorConstructor(Err);
705
729
  const proxy =
706
- base ??
730
+ shared ??
707
731
  FetchClient.#makeProxy<unknown, Record<string, SliceMeta>>(new FetchClient(origin, {}, serializedSignal, Err));
708
732
  if (connect) proxy.instance.connect();
709
733
  const sig = {} as any;