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.
- package/base/symbols.ts +1 -0
- package/common/Logger.ts +4 -0
- package/constant/cascadePaths.ts +79 -8
- package/constant/fieldInfo.ts +6 -0
- package/constant/getDefault.ts +37 -9
- package/document/database.ts +23 -1
- package/document/databaseRegistry.ts +2 -2
- package/document/filterMeta.ts +7 -0
- package/document/into.ts +15 -7
- package/package.json +1 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +19 -2
- package/server/akanApp.ts +7 -30
- package/server/artifact/routeClientCache.ts +14 -0
- package/server/assetEncoding.ts +47 -0
- package/server/cachePolicy.ts +114 -12
- package/server/di/diLifecycle.ts +8 -10
- package/server/index.ts +1 -2
- package/server/processMetricsCollector.ts +4 -0
- package/server/resolver/CascadeRunner.ts +201 -0
- package/server/resolver/database.resolver.ts +57 -4
- package/server/resolver/index.ts +1 -0
- package/server/resolver/service.resolver.ts +38 -25
- package/server/resolver/signal.resolver.ts +6 -1
- package/server/rscWorker.tsx +32 -4
- package/server/rscWorkerHost.ts +61 -12
- package/server/ssrFromRscRenderer.tsx +2 -2
- package/server/webRouter.ts +36 -33
- package/service/ipcTypes.ts +25 -0
- package/service/predefinedAdaptor/database.adaptor.ts +77 -24
- package/service/serve.ts +5 -1
- package/service/serviceModule.ts +17 -0
- package/service/types.ts +6 -0
- package/signal/middleware.ts +7 -3
- package/signal/signalContext.ts +40 -4
- package/types/base/symbols.d.ts +1 -0
- package/types/common/Logger.d.ts +2 -0
- package/types/constant/cascadePaths.d.ts +22 -4
- package/types/constant/fieldInfo.d.ts +1 -0
- package/types/constant/getDefault.d.ts +5 -0
- package/types/document/database.d.ts +22 -1
- package/types/document/databaseRegistry.d.ts +2 -2
- package/types/document/filterMeta.d.ts +1 -0
- package/types/document/into.d.ts +10 -6
- package/types/server/artifact/routeClientCache.d.ts +6 -0
- package/types/server/assetEncoding.d.ts +7 -0
- package/types/server/cachePolicy.d.ts +33 -2
- package/types/server/index.d.ts +0 -2
- package/types/server/resolver/CascadeRunner.d.ts +13 -0
- package/types/server/resolver/database.resolver.d.ts +6 -2
- package/types/server/resolver/index.d.ts +1 -0
- package/types/server/resolver/service.resolver.d.ts +3 -3
- package/types/server/rscWorkerHost.d.ts +13 -0
- package/types/service/ipcTypes.d.ts +24 -0
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -4
- package/types/service/types.d.ts +5 -1
- package/types/ui/Dropdown.d.ts +2 -0
- package/types/ui/index.d.ts +2 -1
- package/types/ui/overlayLayer.d.ts +24 -0
- package/types/webkit/index.d.ts +1 -0
- package/types/webkit/lazy.d.ts +12 -0
- package/types/webkit/useEscapeKey.d.ts +5 -0
- package/ui/BottomSheet.tsx +5 -0
- package/ui/Dialog/Modal.tsx +12 -15
- package/ui/Dropdown.tsx +29 -9
- package/ui/Field.tsx +1 -3
- package/ui/Model/EditModal.tsx +37 -2
- package/ui/Model/index_.tsx +42 -15
- package/ui/Popconfirm.tsx +3 -0
- package/ui/Select.tsx +3 -2
- package/ui/Tooltip.tsx +2 -1
- package/ui/index.ts +8 -1
- package/ui/overlayLayer.ts +39 -0
- package/webkit/index.ts +1 -0
- package/webkit/lazy.tsx +22 -2
- package/webkit/useEscapeKey.tsx +42 -0
- 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) {
|
package/constant/cascadePaths.ts
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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
|
|
10
|
-
readonly
|
|
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.#
|
|
16
|
-
this.
|
|
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
|
-
#
|
|
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
|
}
|
package/constant/fieldInfo.ts
CHANGED
|
@@ -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,
|
package/constant/getDefault.ts
CHANGED
|
@@ -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
|
-
|
|
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")
|
|
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")
|
|
11
|
-
else
|
|
12
|
-
} else if (field.isArray)
|
|
13
|
-
else if (field.nullable)
|
|
14
|
-
else if (field.isClass)
|
|
15
|
-
|
|
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
|
|
45
|
+
return { shared, perCall };
|
|
18
46
|
};
|
package/document/database.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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 = {
|
package/document/filterMeta.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -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`:
|
|
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`:
|
|
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
|
|
812
|
-
if (
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
+
};
|