akanjs 3.0.0-alpha.4 → 3.0.0-alpha.6

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 +1 -0
  2. package/common/Logger.ts +4 -0
  3. package/constant/cascadePaths.ts +79 -8
  4. package/constant/fieldInfo.ts +6 -0
  5. package/constant/getDefault.ts +37 -9
  6. package/document/database.ts +23 -1
  7. package/document/databaseRegistry.ts +2 -2
  8. package/document/filterMeta.ts +7 -0
  9. package/document/into.ts +15 -7
  10. package/package.json +1 -1
  11. package/server/SSR_MEMORY_DIAGNOSIS.md +19 -2
  12. package/server/akanApp.ts +7 -30
  13. package/server/artifact/routeClientCache.ts +14 -0
  14. package/server/assetEncoding.ts +47 -0
  15. package/server/cachePolicy.ts +114 -12
  16. package/server/di/diLifecycle.ts +8 -10
  17. package/server/index.ts +1 -2
  18. package/server/processMetricsCollector.ts +4 -0
  19. package/server/resolver/CascadeRunner.ts +201 -0
  20. package/server/resolver/database.resolver.ts +57 -4
  21. package/server/resolver/index.ts +1 -0
  22. package/server/resolver/service.resolver.ts +38 -25
  23. package/server/resolver/signal.resolver.ts +6 -1
  24. package/server/rscWorker.tsx +32 -4
  25. package/server/rscWorkerHost.ts +61 -12
  26. package/server/ssrFromRscRenderer.tsx +2 -2
  27. package/server/webRouter.ts +36 -33
  28. package/service/ipcTypes.ts +25 -0
  29. package/service/predefinedAdaptor/database.adaptor.ts +77 -24
  30. package/service/serve.ts +5 -1
  31. package/service/serviceModule.ts +17 -0
  32. package/service/types.ts +6 -0
  33. package/signal/middleware.ts +7 -3
  34. package/signal/signalContext.ts +40 -4
  35. package/types/base/symbols.d.ts +1 -0
  36. package/types/common/Logger.d.ts +2 -0
  37. package/types/constant/cascadePaths.d.ts +22 -4
  38. package/types/constant/fieldInfo.d.ts +1 -0
  39. package/types/constant/getDefault.d.ts +5 -0
  40. package/types/document/database.d.ts +22 -1
  41. package/types/document/databaseRegistry.d.ts +2 -2
  42. package/types/document/filterMeta.d.ts +1 -0
  43. package/types/document/into.d.ts +10 -6
  44. package/types/server/artifact/routeClientCache.d.ts +6 -0
  45. package/types/server/assetEncoding.d.ts +7 -0
  46. package/types/server/cachePolicy.d.ts +33 -2
  47. package/types/server/index.d.ts +0 -2
  48. package/types/server/resolver/CascadeRunner.d.ts +13 -0
  49. package/types/server/resolver/database.resolver.d.ts +6 -2
  50. package/types/server/resolver/index.d.ts +1 -0
  51. package/types/server/resolver/service.resolver.d.ts +3 -3
  52. package/types/server/rscWorkerHost.d.ts +13 -0
  53. package/types/service/ipcTypes.d.ts +24 -0
  54. package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -4
  55. package/types/service/types.d.ts +5 -1
  56. package/types/ui/Dropdown.d.ts +2 -0
  57. package/types/ui/index.d.ts +2 -1
  58. package/types/ui/overlayLayer.d.ts +24 -0
  59. package/types/webkit/index.d.ts +1 -0
  60. package/types/webkit/lazy.d.ts +12 -0
  61. package/types/webkit/useEscapeKey.d.ts +5 -0
  62. package/ui/BottomSheet.tsx +5 -0
  63. package/ui/Dialog/Modal.tsx +12 -15
  64. package/ui/Dropdown.tsx +29 -9
  65. package/ui/Field.tsx +1 -3
  66. package/ui/Model/EditModal.tsx +37 -2
  67. package/ui/Model/index_.tsx +42 -15
  68. package/ui/Popconfirm.tsx +3 -0
  69. package/ui/Select.tsx +3 -2
  70. package/ui/Tooltip.tsx +2 -1
  71. package/ui/index.ts +8 -1
  72. package/ui/overlayLayer.ts +39 -0
  73. package/webkit/index.ts +1 -0
  74. package/webkit/lazy.tsx +22 -2
  75. package/webkit/useEscapeKey.tsx +42 -0
  76. package/webkit/useFrameRuntime.ts +31 -28
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;
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,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
  }
@@ -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,27 @@ 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>;
89
91
  bulkWrite(operations: BulkWriteOperation<Raw, _RawDoc, _RawQuery>[]): Promise<UpdateResult>;
92
+ /** @deprecated Renamed to `count`. */
93
+ countDocuments(query: _RawQuery): Promise<number>;
90
94
  };
91
95
 
92
- interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw> {
96
+ interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw, Insight> {
93
97
  refName: T;
94
98
  _CapitalizedRefName: _CapitalizedRefName;
95
99
  _Full: Raw;
100
+ _Insight: Insight;
96
101
  }
97
102
  type NoInferType<T> = [T][T extends unknown ? 0 : never];
98
103
  type IntoModelActions<
@@ -100,6 +105,8 @@ type IntoModelActions<
100
105
  _CapitalizedRefName extends string,
101
106
  Doc,
102
107
  Raw,
108
+
109
+ Insight,
103
110
  _Query,
104
111
  _Sort,
105
112
  _QueryOfDoc = QueryOf<Doc>,
@@ -121,13 +128,14 @@ type IntoModelActions<
121
128
  [K in `update${_CapitalizedRefName}`]: (id: string, data: Partial<Doc>) => Promise<Doc>;
122
129
  } & {
123
130
  [K in `remove${_CapitalizedRefName}`]: (id: string) => Promise<Doc>;
124
- } & QueryMethodPart<_Query, _Sort, Raw, Doc, unknown, unknown, unknown, _QueryOfDoc>;
131
+ } & QueryMethodPart<_Query, _Sort, Raw, Doc, DocumentModel<Insight>, unknown, unknown, _QueryOfDoc>;
125
132
 
126
133
  export const into = <
127
134
  Doc,
128
135
  FilterRef extends FilterCls,
129
136
  T extends string,
130
137
  Raw,
138
+ Insight,
131
139
  AddDbModels extends ModelCls[],
132
140
  _CapitalizedRefName extends string,
133
141
  _QueryOfDoc = QueryOf<Doc>,
@@ -137,11 +145,11 @@ export const into = <
137
145
  >(
138
146
  docRef: Cls<Doc>,
139
147
  filterRef: FilterRef,
140
- cnst: IntoConstantModel<T, _CapitalizedRefName, Raw>,
148
+ cnst: IntoConstantModel<T, _CapitalizedRefName, Raw, Insight>,
141
149
  loaderBuilder: _LoaderBuilder,
142
150
  ...addMdls: [...AddDbModels]
143
151
  ): ModelCls<
144
- IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
152
+ IntoModelActions<T, _CapitalizedRefName, Doc, Raw, Insight, _Query, _Sort, _QueryOfDoc>,
145
153
  ReturnType<_LoaderBuilder>
146
154
  > => {
147
155
  const loaderInfoMap = loaderBuilder(makeLoaderBuilder<Doc>());
@@ -162,7 +170,7 @@ export const into = <
162
170
  });
163
171
  });
164
172
  return DefaultModel as unknown as ModelCls<
165
- IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
173
+ IntoModelActions<T, _CapitalizedRefName, Doc, Raw, Insight, _Query, _Sort, _QueryOfDoc>,
166
174
  ReturnType<_LoaderBuilder>
167
175
  >;
168
176
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.4",
3
+ "version": "3.0.0-alpha.6",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,14 +23,31 @@ Read snapshots from:
23
23
  curl http://localhost:8080/_akan/app/metrics
24
24
  ```
25
25
 
26
+ A replica and its RSC worker are separate processes and are reported separately. **`rssBytes` is the
27
+ replica's own; the worker's is `rscWorkerRssBytes`.** Sum them for what the pod pays.
28
+
26
29
  Key fields:
27
30
 
28
- - `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`: distinguish RSS-only native retention from JS heap retention.
31
+ - `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`: the replica. Distinguish RSS-only native retention from JS
32
+ heap retention.
33
+ - `rscWorkerRssBytes`, `rscWorkerHeapUsedBytes`, `rscWorkerJscHeapSizeBytes`, `rscWorkerJscExtraMemorySizeBytes`:
34
+ the same for the worker. `jscExtra` is off-heap, mostly typed-array backing stores.
29
35
  - `rscRenderCount`, `rscInFlightRenderCount`: detect request lifecycle leaks.
30
36
  - `rscLoadedRouteModuleCount`, `rscRouteModuleCacheHits`, `rscRouteModuleCacheMisses`: detect route module warm-up.
31
- - `ssrChunkRegistrySize`, `ssrChunkLoadCount`: detect full-document SSR client chunk retention.
37
+ - `ssrChunkRegistrySize`, `ssrChunkLoadCount`: full-document SSR client chunk loading. **`…RegistrySize` counts
38
+ keys, not bytes** — evicting does not unload the module, so it can never fall on its own.
39
+ - `httpHtmlCacheEntries` / `httpHtmlCacheBytes`, `rscResultCacheEntries` / `rscResultCacheBytes`,
40
+ `rscPatchResultCacheEntries` / `rscPatchResultCacheBytes`: what each cache actually holds. Entry count alone
41
+ says nothing when entries span three orders of magnitude.
32
42
  - `httpFullSsrCount`, `httpRscNavigationCount`, `httpStaticAssetCount`, `httpImageCount`: separate request kinds.
33
43
 
44
+ Two things measured on `apps/akan` that shape how to read all of the above:
45
+
46
+ - **Growth converges; it is not a leak.** Ten passes over the same routes plateau by roughly the sixth, with a
47
+ flat JS heap throughout. Three passes is not enough to tell a plateau from a ratchet.
48
+ - **Freeing JS objects does not lower RSS.** Emptying both result caches returns their bytes to the heap and
49
+ leaves RSS unchanged. Only not allocating, or restarting the process, reduces what the pod pays.
50
+
34
51
  ## Scenarios
35
52
 
36
53
  ### Same Route Repeated
package/server/akanApp.ts CHANGED
@@ -7,6 +7,7 @@ import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport,
7
7
  import { isTraceEnabled } from "akanjs/signal";
8
8
  import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
9
9
  import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
10
+ import { resolveEncodedSidecar } from "./assetEncoding";
10
11
  import { isPortInUseError } from "./lifecycle/portInUse";
11
12
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
12
13
  import { ProcessMetricsCollector } from "./processMetricsCollector";
@@ -808,41 +809,17 @@ export class AkanApp {
808
809
  const headers = new Headers({ "Content-Type": options.contentType });
809
810
  if (options.cacheControl) headers.set("Cache-Control", options.cacheControl);
810
811
 
811
- const gzipPath = `${filePath}.gz`;
812
- if (this.#acceptsGzip(req) && this.#isCompressible(options.contentType)) {
813
- const gzipFile = Bun.file(gzipPath);
814
- if (await gzipFile.exists()) {
815
- const gzipBytes = await gzipFile.bytes();
816
- headers.set("Content-Encoding", "gzip");
817
- headers.set("Content-Length", String(gzipBytes.byteLength));
818
- headers.set("Vary", "Accept-Encoding");
819
- return new Response(this.#toArrayBuffer(gzipBytes), { headers });
820
- }
812
+ const sidecar = await resolveEncodedSidecar(req, filePath, options.contentType);
813
+ if (sidecar) {
814
+ headers.set("Content-Encoding", sidecar.encoding);
815
+ headers.set("Content-Length", String(sidecar.bytes.byteLength));
816
+ headers.set("Vary", "Accept-Encoding");
817
+ return new Response(sidecar.bytes, { headers });
821
818
  }
822
819
 
823
820
  return new Response(Bun.file(filePath).stream(), { headers });
824
821
  }
825
822
 
826
- #acceptsGzip(req: Request): boolean {
827
- const acceptEncoding = req.headers.get("accept-encoding") ?? "";
828
- return /\bgzip\b/.test(acceptEncoding);
829
- }
830
-
831
- #isCompressible(contentType: string): boolean {
832
- const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
833
- return (
834
- type.startsWith("text/") ||
835
- type === "application/javascript" ||
836
- type === "application/json" ||
837
- type === "application/manifest+json" ||
838
- type === "image/svg+xml"
839
- );
840
- }
841
-
842
- #toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
843
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
844
- }
845
-
846
823
  #safeResolve(baseDir: string, urlPath: string): string | null {
847
824
  let decoded: string;
848
825
  try {
@@ -48,12 +48,22 @@ export class RouteClientCache {
48
48
  };
49
49
  readonly #buildRoute: RouteBuildFn;
50
50
  readonly #onMerge?: OnMergeFn;
51
+ #revision = 0;
51
52
 
52
53
  constructor({ buildRoute, onMerge }: RouteClientCacheOptions) {
53
54
  this.#buildRoute = buildRoute;
54
55
  this.#onMerge = onMerge;
55
56
  }
56
57
 
58
+ /**
59
+ * Bumped on every mutation of `merged`, including a delta merge that leaves `generation` where it was. It lets a
60
+ * consumer memoize work derived from the manifest — merging the runtime manifest over it, say — without having to
61
+ * copy the manifest to find out whether anything changed. In production nothing after `seed` moves it at all.
62
+ */
63
+ get revision(): number {
64
+ return this.#revision;
65
+ }
66
+
57
67
  #getEmptyDelta(): BuildRouteClientResult {
58
68
  return {
59
69
  manifestDelta: {},
@@ -75,6 +85,7 @@ export class RouteClientCache {
75
85
  Object.assign(this.merged.ssrManifest.moduleMap, manifest.ssrManifest.moduleMap);
76
86
  for (const abs of manifest.knownEntries) this.merged.knownEntries.add(abs);
77
87
  for (const routeId of manifest.routeIds) this.#built.set(routeId, this.#getEmptyDelta());
88
+ this.#revision += 1;
78
89
  }
79
90
 
80
91
  async ensure(routeId: string, seeds: string[]): Promise<MergedManifest> {
@@ -125,6 +136,7 @@ export class RouteClientCache {
125
136
  for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap))
126
137
  this.merged.ssrManifest.moduleMap[url] = byName;
127
138
  for (const entry of delta.newEntries) this.merged.knownEntries.add(entry);
139
+ this.#revision += 1;
128
140
  this.#built.set(routeId, delta);
129
141
  this.#logger.verbose(
130
142
  `[route-cache] build done routeId=${routeId} generation=${generation} entries=+${delta.newEntries.length} deps=${delta.clientDeps.length} in ${Date.now() - started}ms`,
@@ -176,6 +188,7 @@ export class RouteClientCache {
176
188
  }
177
189
  const nextGeneration = this.merged.generation + 1;
178
190
  this.merged = this.#getEmptyMerged(nextGeneration);
191
+ this.#revision += 1;
179
192
  this.#building.clear();
180
193
  this.#logger.verbose(`[route-cache] cleared generation=${nextGeneration} dropped=${dropped.length}`);
181
194
  return dropped;
@@ -209,6 +222,7 @@ export class RouteClientCache {
209
222
  ),
210
223
  };
211
224
  this.merged = next;
225
+ this.#revision += 1;
212
226
  }
213
227
 
214
228
  static #normalizePath(filePath: string): string {
@@ -0,0 +1,47 @@
1
+ const COMPRESSIBLE_TYPES = new Set([
2
+ "application/javascript",
3
+ "application/json",
4
+ "application/manifest+json",
5
+ "image/svg+xml",
6
+ ]);
7
+
8
+ /**
9
+ * br is tried first: it is ~15% smaller than gzip across the artifact and ~22% on the CSS bundle.
10
+ * The gzip sidecar stays the fallback because browsers only advertise `br` on secure origins, so a
11
+ * plain-http dev server or an intermediary that rewrites Accept-Encoding still gets a compressed body.
12
+ */
13
+ const SIDECAR_ENCODINGS = [
14
+ { encoding: "br", ext: ".br", accept: /(?:^|,)\s*(?:br|\*)(?![\w-])\s*(?:;\s*q=([\d.]+))?/i },
15
+ { encoding: "gzip", ext: ".gz", accept: /(?:^|,)\s*(?:gzip|\*)(?![\w-])\s*(?:;\s*q=([\d.]+))?/i },
16
+ ] as const;
17
+
18
+ export interface EncodedSidecar {
19
+ bytes: ArrayBuffer;
20
+ encoding: string;
21
+ }
22
+
23
+ export const isCompressibleContentType = (contentType: string): boolean => {
24
+ const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
25
+ return type.startsWith("text/") || COMPRESSIBLE_TYPES.has(type);
26
+ };
27
+
28
+ /** Picks the best precompressed sidecar the caller accepts, or null to serve the file as-is. */
29
+ export const resolveEncodedSidecar = async (
30
+ req: Request,
31
+ filePath: string,
32
+ contentType: string,
33
+ ): Promise<EncodedSidecar | null> => {
34
+ if (!isCompressibleContentType(contentType)) return null;
35
+ const acceptEncoding = req.headers.get("accept-encoding") ?? "";
36
+ for (const { encoding, ext, accept } of SIDECAR_ENCODINGS) {
37
+ const match = accept.exec(acceptEncoding);
38
+
39
+ if (!match || (match[1] !== undefined && Number.parseFloat(match[1]) <= 0)) continue;
40
+ const file = Bun.file(`${filePath}${ext}`);
41
+ if (!(await file.exists())) continue;
42
+ const bytes = await file.bytes();
43
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
44
+ return { bytes: buffer, encoding };
45
+ }
46
+ return null;
47
+ };