akanjs 3.0.0-alpha.3 → 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.
- package/base/symbols.ts +0 -1
- package/client/cn.ts +7 -0
- package/client/csrTypes.ts +2 -0
- package/client/frameDebug.ts +3 -2
- package/constant/cascadePaths.ts +8 -79
- package/document/database.ts +1 -23
- package/document/databaseRegistry.ts +2 -2
- package/document/filterMeta.ts +0 -7
- package/document/into.ts +7 -15
- package/package.json +1 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +2 -19
- package/server/cachePolicy.ts +12 -114
- package/server/di/diLifecycle.ts +10 -8
- package/server/processMetricsCollector.ts +0 -4
- package/server/resolver/database.resolver.ts +4 -57
- package/server/resolver/index.ts +0 -1
- package/server/resolver/service.resolver.ts +25 -38
- package/server/rscWorker.tsx +4 -32
- package/server/rscWorkerHost.ts +12 -61
- package/server/ssrFromRscRenderer.tsx +2 -2
- package/server/webRouter.ts +96 -9
- package/service/ipcTypes.ts +0 -25
- package/service/predefinedAdaptor/database.adaptor.ts +3 -11
- package/service/serve.ts +1 -5
- package/service/serviceModule.ts +0 -17
- package/service/types.ts +0 -6
- package/types/base/symbols.d.ts +0 -1
- package/types/client/cn.d.ts +5 -0
- package/types/client/csrTypes.d.ts +2 -0
- package/types/constant/cascadePaths.d.ts +4 -22
- package/types/document/database.d.ts +1 -22
- package/types/document/databaseRegistry.d.ts +2 -2
- package/types/document/filterMeta.d.ts +0 -1
- package/types/document/into.d.ts +6 -10
- package/types/server/cachePolicy.d.ts +2 -33
- package/types/server/resolver/database.resolver.d.ts +2 -6
- package/types/server/resolver/index.d.ts +0 -1
- package/types/server/resolver/service.resolver.d.ts +3 -3
- package/types/server/rscWorkerHost.d.ts +0 -13
- package/types/service/ipcTypes.d.ts +0 -24
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +2 -14
- package/types/service/types.d.ts +1 -5
- package/types/ui/Layout/BottomInset.d.ts +2 -1
- package/types/ui/Layout/index.d.ts +1 -1
- package/types/ui/Signal/style.d.ts +1 -1
- package/types/ui/UiOverride/context.d.ts +12 -2
- package/types/ui/recipe/badgeRecipe.d.ts +7 -3
- package/types/ui/recipe/buttonRecipe.d.ts +7 -3
- package/types/ui/recipe/inputRecipe.d.ts +5 -1
- package/types/webkit/useCsrValues.d.ts +3 -3
- package/types/webkit/useFrameRuntime.d.ts +6 -1
- package/ui/Badge.tsx +2 -2
- package/ui/Button.tsx +3 -1
- package/ui/Constant/Doc.tsx +1 -1
- package/ui/Data/ListContainer.tsx +1 -1
- package/ui/DatePicker.tsx +4 -3
- package/ui/DraggableList.tsx +3 -1
- package/ui/Dropdown.tsx +4 -2
- package/ui/Field.tsx +17 -9
- package/ui/Input.tsx +7 -5
- package/ui/Layout/BottomInset.tsx +6 -1
- package/ui/Loading/ProgressBar.tsx +8 -1
- package/ui/Menu.tsx +7 -8
- package/ui/Model/SureToRemove.tsx +2 -1
- package/ui/ObjectId.tsx +3 -4
- package/ui/Pagination.tsx +3 -4
- package/ui/Popconfirm.tsx +5 -7
- package/ui/Select.tsx +1 -1
- package/ui/System/CSR.tsx +6 -2
- package/ui/ToggleSelect.tsx +4 -3
- package/ui/UiOverride/context.ts +12 -2
- package/ui/recipe/badgeRecipe.ts +22 -3
- package/ui/recipe/buttonRecipe.ts +51 -2
- package/ui/recipe/factory.ts +2 -2
- package/ui/recipe/inputRecipe.ts +18 -4
- package/webkit/useCsrValues.ts +121 -4
- package/webkit/useFrameRuntime.ts +42 -12
- package/server/resolver/CascadeRunner.ts +0 -201
- package/types/server/resolver/CascadeRunner.d.ts +0 -13
package/base/symbols.ts
CHANGED
|
@@ -3,7 +3,6 @@ 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");
|
|
7
6
|
export const ENDPOINT_META = Symbol.for("akan.endpoint");
|
|
8
7
|
export const ENDPOINT_DICT_SHAPE: unique symbol = Symbol.for("akan.endpoint.dictShape") as never;
|
|
9
8
|
export const SLICE_DICT_SHAPE: unique symbol = Symbol.for("akan.slice.dictShape") as never;
|
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
|
});
|
package/client/csrTypes.ts
CHANGED
|
@@ -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 & {});
|
package/client/frameDebug.ts
CHANGED
|
@@ -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
|
-
|
|
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/constant/cascadePaths.ts
CHANGED
|
@@ -1,103 +1,32 @@
|
|
|
1
|
-
import { type Cls, PrimitiveRegistry, type PrimitiveScalar } from "akanjs/base";
|
|
2
1
|
import type { ConstantField, FieldObject } from "./fieldInfo";
|
|
3
2
|
import type { ConstantModelRef } from "./via";
|
|
4
3
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
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;
|
|
4
|
+
/** What happens to the documents a relation field points at when the owner is removed. */
|
|
5
|
+
export const cascadeActions = ["remove"] as const;
|
|
11
6
|
export type CascadeAction = (typeof cascadeActions)[number];
|
|
12
7
|
|
|
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
|
-
|
|
28
8
|
export class CascadePaths {
|
|
29
|
-
/** Field key → the model its ids point at
|
|
30
|
-
readonly
|
|
31
|
-
/** Field key → the owner whose removal removes this document. */
|
|
32
|
-
readonly removeWith = new Map<string, CascadeWithPath>();
|
|
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>();
|
|
33
11
|
|
|
34
12
|
collect(fieldMap: FieldObject) {
|
|
35
13
|
for (const [key, field] of Object.entries(fieldMap)) {
|
|
36
14
|
if (!field.cascade) continue;
|
|
37
|
-
this.#
|
|
38
|
-
|
|
39
|
-
else this.removeWith.set(key, this.#readOwnerPath(key, field, fieldMap));
|
|
15
|
+
this.#assertCascadable(key, field.cascade, field);
|
|
16
|
+
this.remove.set(key, field.modelRef);
|
|
40
17
|
}
|
|
41
18
|
return this;
|
|
42
19
|
}
|
|
43
20
|
|
|
44
|
-
#
|
|
45
|
-
|
|
21
|
+
#assertCascadable(key: string, action: CascadeAction, field: ConstantField) {
|
|
22
|
+
|
|
46
23
|
if (!cascadeActions.includes(action)) {
|
|
47
24
|
throw new Error(`Cascade field "${key}" declares cascade: "${action}", which is not one of ${cascadeActions}`);
|
|
48
25
|
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
#readOwnedRelation(key: string, field: ConstantField) {
|
|
52
26
|
|
|
53
27
|
if (!field.isClass || field.isScalar) {
|
|
54
28
|
throw new Error(`Cascade field "${key}" is not a model reference and has no document to remove`);
|
|
55
29
|
}
|
|
56
30
|
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`);
|
|
102
31
|
}
|
|
103
32
|
}
|
package/document/database.ts
CHANGED
|
@@ -3,9 +3,8 @@ 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";
|
|
7
6
|
import type { ExtractQuery, ExtractSort, FilterInstance } from "./filterMeta";
|
|
8
|
-
import type { CRUDEventType, Mdl, SaveEventType
|
|
7
|
+
import type { CRUDEventType, Mdl, SaveEventType } from "./into";
|
|
9
8
|
import type { DataInputOf, FindQueryOption, ListQueryOption } from "./types";
|
|
10
9
|
|
|
11
10
|
export class CacheDatabase<T = unknown> {
|
|
@@ -26,15 +25,6 @@ export class CacheDatabase<T = unknown> {
|
|
|
26
25
|
await this.cache.delete(this.refName, `${topic}:${key}`);
|
|
27
26
|
}
|
|
28
27
|
}
|
|
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
|
-
|
|
38
28
|
type QueryMethodOfKey<
|
|
39
29
|
CapitalizedK extends string,
|
|
40
30
|
Doc,
|
|
@@ -63,14 +53,6 @@ type QueryMethodOfKey<
|
|
|
63
53
|
[K in `insight${CapitalizedK}`]: (...args: _Args) => Promise<Insight>;
|
|
64
54
|
} & {
|
|
65
55
|
[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>;
|
|
74
56
|
};
|
|
75
57
|
type QueryMethodMap<Query, Doc, Insight, _FindQueryOption, _ListQueryOption, _QueryOfDoc> = {
|
|
76
58
|
[K in keyof Query]: K extends string
|
|
@@ -123,10 +105,6 @@ type DatabaseModelWithQuerySort<
|
|
|
123
105
|
__create: (data: _DataInput) => Promise<Doc>;
|
|
124
106
|
__update: (id: string, data: Partial<Doc>) => Promise<Doc>;
|
|
125
107
|
__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>;
|
|
130
108
|
__list(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<Doc[]>;
|
|
131
109
|
__listIds(query: _QueryOfDoc, queryOption?: _ListQueryOption): Promise<string[]>;
|
|
132
110
|
__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:
|
|
22
|
+
insight: ConstantCls<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:
|
|
95
|
+
insight: ConstantCls<Insight>,
|
|
96
96
|
filter: FilterCls<Filter>,
|
|
97
97
|
): DatabaseModel<T, Input, Doc, Model, Obj, Insight, Filter, _Query, _Sort> {
|
|
98
98
|
const dbInfo = {
|
package/document/filterMeta.ts
CHANGED
|
@@ -98,13 +98,6 @@ 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
|
-
|
|
108
101
|
export type BaseFilterSortKey = "latest" | "oldest" | "relevance";
|
|
109
102
|
export type BaseFilterQueryKey = "any";
|
|
110
103
|
export type BaseFilterKey = BaseFilterSortKey | BaseFilterQueryKey;
|
package/document/into.ts
CHANGED
|
@@ -77,27 +77,22 @@ 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
|
-
|
|
80
|
+
countDocuments(query: _RawQuery): Promise<number>;
|
|
81
81
|
exists(query: _RawQuery): Promise<string | null>;
|
|
82
|
-
|
|
83
82
|
updateOne(
|
|
84
83
|
query: _RawQuery,
|
|
85
84
|
update: DocumentUpdateInput<_RawDoc>,
|
|
86
85
|
options?: DocumentUpdateOptions,
|
|
87
86
|
): Promise<UpdateResult>;
|
|
88
87
|
updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>): Promise<UpdateResult>;
|
|
89
|
-
|
|
90
|
-
removeMany(query: _RawQuery): Promise<UpdateResult>;
|
|
88
|
+
deleteMany(query: _RawQuery): Promise<UpdateResult>;
|
|
91
89
|
bulkWrite(operations: BulkWriteOperation<Raw, _RawDoc, _RawQuery>[]): Promise<UpdateResult>;
|
|
92
|
-
/** @deprecated Renamed to `count`. */
|
|
93
|
-
countDocuments(query: _RawQuery): Promise<number>;
|
|
94
90
|
};
|
|
95
91
|
|
|
96
|
-
interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw
|
|
92
|
+
interface IntoConstantModel<T extends string, _CapitalizedRefName extends string, Raw> {
|
|
97
93
|
refName: T;
|
|
98
94
|
_CapitalizedRefName: _CapitalizedRefName;
|
|
99
95
|
_Full: Raw;
|
|
100
|
-
_Insight: Insight;
|
|
101
96
|
}
|
|
102
97
|
type NoInferType<T> = [T][T extends unknown ? 0 : never];
|
|
103
98
|
type IntoModelActions<
|
|
@@ -105,8 +100,6 @@ type IntoModelActions<
|
|
|
105
100
|
_CapitalizedRefName extends string,
|
|
106
101
|
Doc,
|
|
107
102
|
Raw,
|
|
108
|
-
|
|
109
|
-
Insight,
|
|
110
103
|
_Query,
|
|
111
104
|
_Sort,
|
|
112
105
|
_QueryOfDoc = QueryOf<Doc>,
|
|
@@ -128,14 +121,13 @@ type IntoModelActions<
|
|
|
128
121
|
[K in `update${_CapitalizedRefName}`]: (id: string, data: Partial<Doc>) => Promise<Doc>;
|
|
129
122
|
} & {
|
|
130
123
|
[K in `remove${_CapitalizedRefName}`]: (id: string) => Promise<Doc>;
|
|
131
|
-
} & QueryMethodPart<_Query, _Sort, Raw, Doc,
|
|
124
|
+
} & QueryMethodPart<_Query, _Sort, Raw, Doc, unknown, unknown, unknown, _QueryOfDoc>;
|
|
132
125
|
|
|
133
126
|
export const into = <
|
|
134
127
|
Doc,
|
|
135
128
|
FilterRef extends FilterCls,
|
|
136
129
|
T extends string,
|
|
137
130
|
Raw,
|
|
138
|
-
Insight,
|
|
139
131
|
AddDbModels extends ModelCls[],
|
|
140
132
|
_CapitalizedRefName extends string,
|
|
141
133
|
_QueryOfDoc = QueryOf<Doc>,
|
|
@@ -145,11 +137,11 @@ export const into = <
|
|
|
145
137
|
>(
|
|
146
138
|
docRef: Cls<Doc>,
|
|
147
139
|
filterRef: FilterRef,
|
|
148
|
-
cnst: IntoConstantModel<T, _CapitalizedRefName, Raw
|
|
140
|
+
cnst: IntoConstantModel<T, _CapitalizedRefName, Raw>,
|
|
149
141
|
loaderBuilder: _LoaderBuilder,
|
|
150
142
|
...addMdls: [...AddDbModels]
|
|
151
143
|
): ModelCls<
|
|
152
|
-
IntoModelActions<T, _CapitalizedRefName, Doc, Raw,
|
|
144
|
+
IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
|
|
153
145
|
ReturnType<_LoaderBuilder>
|
|
154
146
|
> => {
|
|
155
147
|
const loaderInfoMap = loaderBuilder(makeLoaderBuilder<Doc>());
|
|
@@ -170,7 +162,7 @@ export const into = <
|
|
|
170
162
|
});
|
|
171
163
|
});
|
|
172
164
|
return DefaultModel as unknown as ModelCls<
|
|
173
|
-
IntoModelActions<T, _CapitalizedRefName, Doc, Raw,
|
|
165
|
+
IntoModelActions<T, _CapitalizedRefName, Doc, Raw, _Query, _Sort, _QueryOfDoc>,
|
|
174
166
|
ReturnType<_LoaderBuilder>
|
|
175
167
|
>;
|
|
176
168
|
};
|
package/package.json
CHANGED
|
@@ -23,31 +23,14 @@ 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
|
-
|
|
29
26
|
Key fields:
|
|
30
27
|
|
|
31
|
-
- `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`:
|
|
32
|
-
heap retention.
|
|
33
|
-
- `rscWorkerRssBytes`, `rscWorkerHeapUsedBytes`, `rscWorkerJscHeapSizeBytes`, `rscWorkerJscExtraMemorySizeBytes`:
|
|
34
|
-
the same for the worker. `jscExtra` is off-heap, mostly typed-array backing stores.
|
|
28
|
+
- `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`: distinguish RSS-only native retention from JS heap retention.
|
|
35
29
|
- `rscRenderCount`, `rscInFlightRenderCount`: detect request lifecycle leaks.
|
|
36
30
|
- `rscLoadedRouteModuleCount`, `rscRouteModuleCacheHits`, `rscRouteModuleCacheMisses`: detect route module warm-up.
|
|
37
|
-
- `ssrChunkRegistrySize`, `ssrChunkLoadCount`: full-document SSR client chunk
|
|
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.
|
|
31
|
+
- `ssrChunkRegistrySize`, `ssrChunkLoadCount`: detect full-document SSR client chunk retention.
|
|
42
32
|
- `httpFullSsrCount`, `httpRscNavigationCount`, `httpStaticAssetCount`, `httpImageCount`: separate request kinds.
|
|
43
33
|
|
|
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
|
-
|
|
51
34
|
## Scenarios
|
|
52
35
|
|
|
53
36
|
### Same Route Repeated
|
package/server/cachePolicy.ts
CHANGED
|
@@ -234,62 +234,20 @@ export function shouldInvalidateRouteCacheEntry(
|
|
|
234
234
|
return false;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
export interface LruTtlCacheOptions<T> {
|
|
238
|
-
/**
|
|
239
|
-
* Measures one entry's payload. Entry count alone says nothing about a cache whose entries span
|
|
240
|
-
* three orders of magnitude, and a byte ceiling needs a running total to enforce. The default
|
|
241
|
-
* reports 0 rather than guessing, so `byteSize` stays honest about not knowing.
|
|
242
|
-
*/
|
|
243
|
-
sizeOf?: (value: T) => number;
|
|
244
|
-
/** Total payload ceiling. 0 leaves the cache bounded only by `maxEntries`. */
|
|
245
|
-
maxBytes?: number;
|
|
246
|
-
/** An entry over this is not stored at all, rather than evicting everything else to fit it. */
|
|
247
|
-
maxEntryBytes?: number;
|
|
248
|
-
/**
|
|
249
|
-
* Cadence of the idle sweep. Without one a filled cache never shrinks: an entry is dropped only
|
|
250
|
-
* when its own key is fetched after expiry or when a write evicts it, so a pod that stops
|
|
251
|
-
* serving holds its peak forever — measured at 100 entries / 21.4 MiB still resident 310s after
|
|
252
|
-
* the last request, with a 30s TTL. 0 disables it.
|
|
253
|
-
*/
|
|
254
|
-
sweepIntervalMs?: number;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
237
|
export class LruTtlCache<T> {
|
|
258
|
-
readonly #entries = new Map<string, { value: T; expiresAt: number
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
readonly #sizeOf: (value: T) => number;
|
|
262
|
-
readonly #maxBytes: number;
|
|
263
|
-
readonly #maxEntryBytes: number;
|
|
264
|
-
|
|
265
|
-
constructor(
|
|
266
|
-
readonly maxEntries = 100,
|
|
267
|
-
options: LruTtlCacheOptions<T> = {},
|
|
268
|
-
) {
|
|
269
|
-
this.#sizeOf = options.sizeOf ?? (() => 0);
|
|
270
|
-
this.#maxBytes = options.maxBytes ?? 0;
|
|
271
|
-
this.#maxEntryBytes = options.maxEntryBytes ?? 0;
|
|
272
|
-
const sweepIntervalMs = options.sweepIntervalMs ?? 0;
|
|
273
|
-
if (sweepIntervalMs > 0) {
|
|
274
|
-
this.#sweepTimer = setInterval(() => this.sweepExpired(), sweepIntervalMs);
|
|
275
|
-
|
|
276
|
-
(this.#sweepTimer as { unref?: () => void }).unref?.();
|
|
277
|
-
}
|
|
278
|
-
}
|
|
238
|
+
readonly #entries = new Map<string, { value: T; expiresAt: number }>();
|
|
239
|
+
|
|
240
|
+
constructor(readonly maxEntries = 100) {}
|
|
279
241
|
|
|
280
242
|
get size(): number {
|
|
281
243
|
return this.#entries.size;
|
|
282
244
|
}
|
|
283
245
|
|
|
284
|
-
get byteSize(): number {
|
|
285
|
-
return this.#byteLength;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
246
|
get(key: string): T | null {
|
|
289
247
|
const entry = this.#entries.get(key);
|
|
290
248
|
if (!entry) return null;
|
|
291
249
|
if (entry.expiresAt <= Date.now()) {
|
|
292
|
-
this.#
|
|
250
|
+
this.#entries.delete(key);
|
|
293
251
|
return null;
|
|
294
252
|
}
|
|
295
253
|
this.#entries.delete(key);
|
|
@@ -297,56 +255,26 @@ export class LruTtlCache<T> {
|
|
|
297
255
|
return entry.value;
|
|
298
256
|
}
|
|
299
257
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this.#remove(key);
|
|
303
|
-
const byteLength = LruTtlCache.#measure(this.#sizeOf, value);
|
|
304
|
-
if (this.#maxEntryBytes > 0 && byteLength > this.#maxEntryBytes) return false;
|
|
305
|
-
this.sweepExpired();
|
|
258
|
+
set(key: string, value: T, ttlSeconds: number): void {
|
|
259
|
+
this.#entries.delete(key);
|
|
306
260
|
const maxEntries = this.maxEntries > 0 ? this.maxEntries : 100;
|
|
307
261
|
while (this.#entries.size >= maxEntries) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (!this.#removeOldest()) break;
|
|
262
|
+
const oldest = this.#entries.keys().next().value;
|
|
263
|
+
if (!oldest) break;
|
|
264
|
+
this.#entries.delete(oldest);
|
|
312
265
|
}
|
|
313
|
-
this.#entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000
|
|
314
|
-
this.#byteLength += byteLength;
|
|
315
|
-
return true;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Drops every expired entry. Deliberately a full scan rather than a walk from the oldest that
|
|
320
|
-
* stops at the first live entry: insertion order is *LRU* order because `get` reinserts, and TTLs
|
|
321
|
-
* differ per entry, so expiry is not monotonic in map order and an early break would leave
|
|
322
|
-
* expired entries behind. The map is bounded by `maxEntries`, so the scan is cheap.
|
|
323
|
-
*/
|
|
324
|
-
sweepExpired(now = Date.now()): number {
|
|
325
|
-
let removed = 0;
|
|
326
|
-
for (const [key, entry] of this.#entries) {
|
|
327
|
-
if (entry.expiresAt > now) continue;
|
|
328
|
-
this.#remove(key);
|
|
329
|
-
removed += 1;
|
|
330
|
-
}
|
|
331
|
-
return removed;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** Stops the idle sweep. The cache stays usable; only the timer goes away. */
|
|
335
|
-
dispose(): void {
|
|
336
|
-
if (!this.#sweepTimer) return;
|
|
337
|
-
clearInterval(this.#sweepTimer);
|
|
338
|
-
this.#sweepTimer = null;
|
|
266
|
+
this.#entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
339
267
|
}
|
|
340
268
|
|
|
341
269
|
delete(key: string): boolean {
|
|
342
|
-
return this.#
|
|
270
|
+
return this.#entries.delete(key);
|
|
343
271
|
}
|
|
344
272
|
|
|
345
273
|
invalidate(predicate: (key: string, value: T) => boolean): number {
|
|
346
274
|
let count = 0;
|
|
347
275
|
for (const [key, entry] of this.#entries) {
|
|
348
276
|
if (!predicate(key, entry.value)) continue;
|
|
349
|
-
this.#
|
|
277
|
+
this.#entries.delete(key);
|
|
350
278
|
count += 1;
|
|
351
279
|
}
|
|
352
280
|
return count;
|
|
@@ -354,35 +282,5 @@ export class LruTtlCache<T> {
|
|
|
354
282
|
|
|
355
283
|
clear(): void {
|
|
356
284
|
this.#entries.clear();
|
|
357
|
-
this.#byteLength = 0;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
static parseByteCeiling(value: string | undefined | null, fallback = 0): number {
|
|
361
|
-
const parsed = Number.parseInt(value ?? "", 10);
|
|
362
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
#remove(key: string): boolean {
|
|
366
|
-
const entry = this.#entries.get(key);
|
|
367
|
-
if (!entry) return false;
|
|
368
|
-
this.#entries.delete(key);
|
|
369
|
-
this.#byteLength -= entry.byteLength;
|
|
370
|
-
return true;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
#removeOldest(): boolean {
|
|
374
|
-
const oldest = this.#entries.keys().next().value;
|
|
375
|
-
if (!oldest) return false;
|
|
376
|
-
return this.#remove(oldest);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** A measurement must never fail a cache write, and a bad measurement must never skew the total. */
|
|
380
|
-
static #measure<T>(sizeOf: (value: T) => number, value: T): number {
|
|
381
|
-
try {
|
|
382
|
-
const measured = sizeOf(value);
|
|
383
|
-
return Number.isFinite(measured) && measured > 0 ? measured : 0;
|
|
384
|
-
} catch {
|
|
385
|
-
return 0;
|
|
386
|
-
}
|
|
387
285
|
}
|
|
388
286
|
}
|
package/server/di/diLifecycle.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { SignalRegistry } from "../../signal/signalRegistry";
|
|
|
21
21
|
import type { AkanLib, DatabaseModule, ScalarModule, ServiceModule } from "../akanLib";
|
|
22
22
|
import { createDefaultAkanOption } from "../akanOption";
|
|
23
23
|
import type { WebProxyRegistration } from "../proxy";
|
|
24
|
-
import {
|
|
24
|
+
import { DatabaseResolver, ServiceResolver, SignalResolver } from "../resolver";
|
|
25
25
|
import type { SignalRoutes, WebsocketRoutes } from "../types";
|
|
26
26
|
import { getPredefinedAdaptor, predefinedAdaptorRole } from "./predefinedAdaptor";
|
|
27
27
|
import { collectAdaptors, resolveAdaptorHierarchy } from "./resolveAdaptorHierarchy";
|
|
@@ -63,7 +63,6 @@ export class DiLifecycle {
|
|
|
63
63
|
readonly disabledModules = new Map<string, string>();
|
|
64
64
|
readonly #predefinedAdaptor;
|
|
65
65
|
readonly #predefinedAdaptorRole = predefinedAdaptorRole;
|
|
66
|
-
readonly #cascade = new CascadeRunner();
|
|
67
66
|
|
|
68
67
|
/** Read-only view of the resolved module maps, for tooling that needs to describe the container. */
|
|
69
68
|
get modules(): {
|
|
@@ -123,9 +122,8 @@ export class DiLifecycle {
|
|
|
123
122
|
this.#service.set(refName, module as ServiceModule);
|
|
124
123
|
});
|
|
125
124
|
this.#database.forEach((mod) => {
|
|
126
|
-
const
|
|
127
|
-
this.#adaptor.set(
|
|
128
|
-
this.#cascade.register(mod.constant, schema, mod.service.srv);
|
|
125
|
+
const databaseAdaptor = DatabaseResolver.resolveDatabase(mod.constant, mod.database);
|
|
126
|
+
this.#adaptor.set(databaseAdaptor.refName, databaseAdaptor);
|
|
129
127
|
});
|
|
130
128
|
const services = [
|
|
131
129
|
...[...this.#service.values()].map((mod) => mod.service.srv),
|
|
@@ -459,7 +457,13 @@ export class DiLifecycle {
|
|
|
459
457
|
if (serviceCls.type === "database") {
|
|
460
458
|
const databaseModule = this.#database.get(serviceCls.refName);
|
|
461
459
|
if (!databaseModule) throw new Error(`Database "${serviceCls.refName}" is not registered`);
|
|
462
|
-
ServiceResolver.resolveDatabaseService(
|
|
460
|
+
ServiceResolver.resolveDatabaseService(
|
|
461
|
+
databaseModule.constant,
|
|
462
|
+
databaseModule.database,
|
|
463
|
+
serviceCls,
|
|
464
|
+
|
|
465
|
+
(refName) => this.getService(refName),
|
|
466
|
+
);
|
|
463
467
|
}
|
|
464
468
|
const service = new serviceCls();
|
|
465
469
|
await InjectInfo.resolveInjection(service, serviceCls, this.registry, this.#env);
|
|
@@ -472,8 +476,6 @@ export class DiLifecycle {
|
|
|
472
476
|
})),
|
|
473
477
|
);
|
|
474
478
|
}
|
|
475
|
-
|
|
476
|
-
this.#cascade.seal((refName) => this.getService(refName));
|
|
477
479
|
}
|
|
478
480
|
|
|
479
481
|
async #initializeInternal() {
|
|
@@ -138,10 +138,6 @@ export class ProcessMetricsCollector {
|
|
|
138
138
|
...(metrics.jscHeapSizeBytes !== undefined
|
|
139
139
|
? [`jscHeap=${ProcessMetricsCollector.formatBytes(metrics.jscHeapSizeBytes)}`]
|
|
140
140
|
: []),
|
|
141
|
-
|
|
142
|
-
...(metrics.rscWorkerRssBytes !== undefined
|
|
143
|
-
? [`rscWorkerRss=${ProcessMetricsCollector.formatBytes(metrics.rscWorkerRssBytes)}`]
|
|
144
|
-
: []),
|
|
145
141
|
...(metrics.eventLoopLagMeanMs !== undefined
|
|
146
142
|
? [`elLag=${metrics.eventLoopLagMeanMs}/${metrics.eventLoopLagP99Ms ?? 0}/${metrics.eventLoopLagMaxMs ?? 0}ms`]
|
|
147
143
|
: []),
|