akanjs 3.0.0-alpha.3 → 3.0.0-alpha.5
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/common/Logger.ts +4 -0
- package/constant/fieldInfo.ts +6 -0
- package/constant/getDefault.ts +37 -9
- package/package.json +1 -1
- package/server/akanApp.ts +7 -30
- package/server/artifact/routeClientCache.ts +14 -0
- package/server/assetEncoding.ts +47 -0
- package/server/di/diLifecycle.ts +1 -1
- package/server/index.ts +1 -2
- package/server/resolver/signal.resolver.ts +6 -1
- package/server/webRouter.ts +27 -33
- package/service/predefinedAdaptor/database.adaptor.ts +66 -21
- package/signal/middleware.ts +7 -3
- package/signal/signalContext.ts +40 -4
- package/types/common/Logger.d.ts +2 -0
- package/types/constant/fieldInfo.d.ts +1 -0
- package/types/constant/getDefault.d.ts +5 -0
- package/types/server/artifact/routeClientCache.d.ts +6 -0
- package/types/server/assetEncoding.d.ts +7 -0
- package/types/server/index.d.ts +0 -2
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +12 -2
- package/ui/Field.tsx +1 -1
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/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/package.json
CHANGED
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
|
+
};
|
package/server/di/diLifecycle.ts
CHANGED
package/server/index.ts
CHANGED
|
@@ -9,8 +9,7 @@ export * from "./devtools";
|
|
|
9
9
|
export type { ChangeBatch, ChangeKind } from "./hmr/changeBatch";
|
|
10
10
|
export * from "./processMetricsCollector";
|
|
11
11
|
export * from "./proxy";
|
|
12
|
-
|
|
13
|
-
export * from "./routeTreeBuilder";
|
|
12
|
+
|
|
14
13
|
export * from "./sitemap";
|
|
15
14
|
export type { SsrManifest, SsrManifestEntry } from "./ssrTypes";
|
|
16
15
|
export * from "./types";
|
|
@@ -429,10 +429,15 @@ export class SignalResolver {
|
|
|
429
429
|
return trimmed ? `/${trimmed}` : "";
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
static #selectCache = new WeakMap<Cls, Record<string, true>>();
|
|
432
433
|
static #selectForConstant(constant: Cls): Record<string, true> | undefined {
|
|
434
|
+
const cached = SignalResolver.#selectCache.get(constant);
|
|
435
|
+
if (cached) return cached;
|
|
433
436
|
const fields = (constant as { [FIELD_META]?: Record<string, unknown> })[FIELD_META];
|
|
434
437
|
if (!fields) return undefined;
|
|
435
|
-
|
|
438
|
+
const select = Object.fromEntries(Object.keys(fields).map((field) => [field, true] as const));
|
|
439
|
+
SignalResolver.#selectCache.set(constant, select);
|
|
440
|
+
return select;
|
|
436
441
|
}
|
|
437
442
|
|
|
438
443
|
static #canUsePrimitiveQueryFastPath(endpointInfo: EndpointInfo, middleware: Map<string, MiddlewareCls>) {
|
package/server/webRouter.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
RouteSeedIndexStore,
|
|
21
21
|
RoutesManifestStore,
|
|
22
22
|
} from "./artifact";
|
|
23
|
+
import { resolveEncodedSidecar } from "./assetEncoding";
|
|
23
24
|
import {
|
|
24
25
|
getClientFacingOrigin,
|
|
25
26
|
hasRouteCacheInvalidationScope,
|
|
@@ -297,6 +298,7 @@ export class WebRouter {
|
|
|
297
298
|
#htmlCacheHits = 0;
|
|
298
299
|
#htmlCacheMisses = 0;
|
|
299
300
|
#htmlCacheBypass = 0;
|
|
301
|
+
#runtimeManifest: { revision: number; manifest: MergedManifest } | null = null;
|
|
300
302
|
renderState: RenderState;
|
|
301
303
|
#seedIndex: RouteSeedIndex;
|
|
302
304
|
constructor({ artifact, cssBytesByUrl, rsc, seedIndex, upgradeHmrWs }: WebRouterOptions) {
|
|
@@ -340,7 +342,7 @@ export class WebRouter {
|
|
|
340
342
|
if (prebuilt) {
|
|
341
343
|
this.#routeCache.seed(prebuilt);
|
|
342
344
|
await this.#rsc.reload({
|
|
343
|
-
clientManifest: this.#mergeRuntimeManifest(
|
|
345
|
+
clientManifest: this.#mergeRuntimeManifest().clientManifest,
|
|
344
346
|
cssAssets: this.renderState.cssAssets,
|
|
345
347
|
buildId: this.renderState.buildId,
|
|
346
348
|
});
|
|
@@ -813,15 +815,27 @@ export class WebRouter {
|
|
|
813
815
|
this.#logger.verbose(
|
|
814
816
|
`[route-cache] ensure pathname=${url.pathname} routeId=${matched?.entry.routeId ?? "(none)"} in ${Date.now() - started}ms`,
|
|
815
817
|
);
|
|
816
|
-
return this.#mergeRuntimeManifest(
|
|
818
|
+
return this.#mergeRuntimeManifest();
|
|
817
819
|
}
|
|
818
820
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
821
|
+
/**
|
|
822
|
+
* Memoized on the route cache's revision. Both the snapshot and the runtime merge copy the whole client manifest
|
|
823
|
+
* and SSR module map, which is a few hundred KB of structure for a small app — and this ran on every request. In
|
|
824
|
+
* production the revision never moves after `seed`, so the manifest is built once; in dev a rebuild bumps it and
|
|
825
|
+
* the next request pays for one fresh copy. The cached object is still a copy, so an in-flight request keeps
|
|
826
|
+
* consuming a stable manifest across an invalidate exactly as the per-request snapshot made it.
|
|
827
|
+
*/
|
|
828
|
+
#mergeRuntimeManifest(): MergedManifest {
|
|
829
|
+
const revision = this.#routeCache.revision;
|
|
830
|
+
if (this.#runtimeManifest?.revision === revision) return this.#runtimeManifest.manifest;
|
|
831
|
+
const snapshot = this.#routeCache.snapshot();
|
|
832
|
+
const manifest: MergedManifest = {
|
|
833
|
+
...snapshot,
|
|
834
|
+
clientManifest: WebRouter.#mergeClientManifest(this.#artifact.rscRuntimeClientManifest, snapshot.clientManifest),
|
|
835
|
+
ssrManifest: WebRouter.#mergeSsrManifest(this.#artifact.rscRuntimeSsrManifest, snapshot.ssrManifest),
|
|
824
836
|
};
|
|
837
|
+
this.#runtimeManifest = { revision, manifest };
|
|
838
|
+
return manifest;
|
|
825
839
|
}
|
|
826
840
|
#renderSystemNotFoundFallbackResponse(req: Request, url: URL): Promise<Response> {
|
|
827
841
|
return createSystemPageResponse({
|
|
@@ -1101,16 +1115,12 @@ export class WebRouter {
|
|
|
1101
1115
|
headers.set("Last-Modified", new Date(lastModifiedMs).toUTCString());
|
|
1102
1116
|
if (WebRouter.#isNotModified(req, etag, lastModifiedMs)) return new Response(null, { status: 304, headers });
|
|
1103
1117
|
|
|
1104
|
-
const
|
|
1105
|
-
if (
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
headers.set("Content-Length", String(gzipBytes.byteLength));
|
|
1111
|
-
headers.set("Vary", "Accept-Encoding");
|
|
1112
|
-
return new Response(WebRouter.#toArrayBuffer(gzipBytes), { headers });
|
|
1113
|
-
}
|
|
1118
|
+
const sidecar = await resolveEncodedSidecar(req, filePath, options.contentType);
|
|
1119
|
+
if (sidecar) {
|
|
1120
|
+
headers.set("Content-Encoding", sidecar.encoding);
|
|
1121
|
+
headers.set("Content-Length", String(sidecar.bytes.byteLength));
|
|
1122
|
+
headers.set("Vary", "Accept-Encoding");
|
|
1123
|
+
return new Response(sidecar.bytes, { headers });
|
|
1114
1124
|
}
|
|
1115
1125
|
|
|
1116
1126
|
return new Response(file.stream(), { headers });
|
|
@@ -1197,22 +1207,6 @@ export class WebRouter {
|
|
|
1197
1207
|
return Number.isFinite(sinceMs) && sinceMs >= lastModifiedMs;
|
|
1198
1208
|
}
|
|
1199
1209
|
|
|
1200
|
-
static #acceptsGzip(req: Request): boolean {
|
|
1201
|
-
const acceptEncoding = req.headers.get("accept-encoding") ?? "";
|
|
1202
|
-
return /\bgzip\b/.test(acceptEncoding);
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
static #isCompressible(contentType: string): boolean {
|
|
1206
|
-
const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
1207
|
-
return (
|
|
1208
|
-
type.startsWith("text/") ||
|
|
1209
|
-
type === "application/javascript" ||
|
|
1210
|
-
type === "application/json" ||
|
|
1211
|
-
type === "application/manifest+json" ||
|
|
1212
|
-
type === "image/svg+xml"
|
|
1213
|
-
);
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
1210
|
static #safeResolve(baseDir: string, urlPath: string): string | null {
|
|
1217
1211
|
let decoded: string;
|
|
1218
1212
|
try {
|
|
@@ -119,7 +119,7 @@ export interface DocumentStore {
|
|
|
119
119
|
exists(query?: DocumentQuery): Promise<string | null>;
|
|
120
120
|
count(query?: DocumentQuery): Promise<number>;
|
|
121
121
|
insight(query?: DocumentQuery): Promise<any>;
|
|
122
|
-
hydrate(data: DocumentRecord, originalData?: DocumentRecord): any;
|
|
122
|
+
hydrate(data: DocumentRecord, originalData?: DocumentRecord, options?: { track?: boolean }): any;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export interface SqlResultRows<Row = Record<string, unknown>> {
|
|
@@ -960,6 +960,17 @@ class UpdateCompiler {
|
|
|
960
960
|
}
|
|
961
961
|
}
|
|
962
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Per-document modification state, attached only when a document is hydrated for writing.
|
|
965
|
+
* Non-enumerable so `{ ...doc }` in `toRow` and `Object.entries` in `sanitizeJson` never see it.
|
|
966
|
+
*/
|
|
967
|
+
const MODIFICATION_STATE = Symbol("akan.document.modificationState");
|
|
968
|
+
|
|
969
|
+
interface ModificationState {
|
|
970
|
+
isNew: boolean;
|
|
971
|
+
original: Record<string, unknown>;
|
|
972
|
+
}
|
|
973
|
+
|
|
963
974
|
export class SqlDocumentStore {
|
|
964
975
|
readonly schema: DocumentSchema;
|
|
965
976
|
readonly table: string;
|
|
@@ -967,6 +978,7 @@ export class SqlDocumentStore {
|
|
|
967
978
|
readonly updateCompiler: UpdateCompiler;
|
|
968
979
|
#insertStmt: AkanSqlStatement | null = null;
|
|
969
980
|
#readStmtCache = new Map<string, AkanSqlStatement>();
|
|
981
|
+
#docPrototype: object | null = null;
|
|
970
982
|
|
|
971
983
|
constructor(
|
|
972
984
|
private readonly owner: DocumentDatabaseOwner,
|
|
@@ -1133,14 +1145,14 @@ export class SqlDocumentStore {
|
|
|
1133
1145
|
const rows = await this.prepareReadStmt(
|
|
1134
1146
|
`SELECT ${this.projectionSql(projection)} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
1135
1147
|
).all<ProjectedSqliteDocumentRow>(...args);
|
|
1136
|
-
return rows.map((row) => this.hydrate(this.fromProjectedRow(row, projection)));
|
|
1148
|
+
return rows.map((row) => this.hydrate(this.fromProjectedRow(row, projection), undefined, { track: false }));
|
|
1137
1149
|
}
|
|
1138
1150
|
|
|
1139
1151
|
const star = joins.length ? `${quoteIdent(this.table)}.*` : "*";
|
|
1140
1152
|
const rows = await this.prepareReadStmt(
|
|
1141
1153
|
`SELECT ${star} FROM ${quoteIdent(this.table)}${join} WHERE ${where} ${order}${limit}${offset}`,
|
|
1142
1154
|
).all<SqliteDocumentRow>(...args);
|
|
1143
|
-
return rows.map((row) => this.hydrate(this.fromRow(row)));
|
|
1155
|
+
return rows.map((row) => this.hydrate(this.fromRow(row), undefined, { track: false }));
|
|
1144
1156
|
}
|
|
1145
1157
|
|
|
1146
1158
|
async findIds(
|
|
@@ -1588,49 +1600,82 @@ export class SqlDocumentStore {
|
|
|
1588
1600
|
return result;
|
|
1589
1601
|
}
|
|
1590
1602
|
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1603
|
+
/**
|
|
1604
|
+
* `track` buys `isModified()` and costs a deep clone of the row, so it is decided per call site rather than
|
|
1605
|
+
* paid everywhere. Only the read paths (`find`, and the projected read behind it) opt out — that clone was 42%
|
|
1606
|
+
* of a list query, and a listed document is never the one a save hook runs on. Everything else keeps it, so an
|
|
1607
|
+
* external caller of `hydrate` sees no change.
|
|
1608
|
+
*/
|
|
1609
|
+
hydrate(data: DocumentRecord, originalData: DocumentRecord = data, { track = true }: { track?: boolean } = {}) {
|
|
1594
1610
|
const isNew = !originalData.id;
|
|
1595
1611
|
const hydratedData = isNew ? this.prepareDocument(data) : data;
|
|
1596
|
-
const doc = Object.assign(Object.create(this
|
|
1597
|
-
|
|
1612
|
+
const doc = Object.assign(Object.create(this.#documentPrototype()), hydratedData);
|
|
1613
|
+
if (!track) return doc;
|
|
1614
|
+
Object.defineProperty(doc, MODIFICATION_STATE, {
|
|
1615
|
+
value: {
|
|
1616
|
+
isNew,
|
|
1617
|
+
|
|
1618
|
+
original: JSON.parse(JSON.stringify(sanitizeJson(originalData) ?? {})) as Record<string, unknown>,
|
|
1619
|
+
} satisfies ModificationState,
|
|
1620
|
+
});
|
|
1621
|
+
return doc;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* One prototype per store instead of six closures per document. It extends the model's own document prototype,
|
|
1626
|
+
* so declared chain methods and `instanceof` are unaffected, and every method here is non-enumerable exactly as
|
|
1627
|
+
* the previous per-document `defineProperties` made them.
|
|
1628
|
+
*/
|
|
1629
|
+
#documentPrototype() {
|
|
1630
|
+
if (this.#docPrototype) return this.#docPrototype;
|
|
1631
|
+
const store = this;
|
|
1632
|
+
this.#docPrototype = Object.create(this.database.doc.prototype, {
|
|
1598
1633
|
set: {
|
|
1599
|
-
value(patch: DocumentRecord) {
|
|
1634
|
+
value(this: DocumentRecord, patch: DocumentRecord) {
|
|
1600
1635
|
Object.assign(this, patch);
|
|
1601
1636
|
return this;
|
|
1602
1637
|
},
|
|
1603
1638
|
},
|
|
1604
1639
|
save: {
|
|
1605
|
-
async value() {
|
|
1606
|
-
return this.id ? store.update(this.id, this) : store.create(this);
|
|
1640
|
+
async value(this: DocumentRecord) {
|
|
1641
|
+
return this.id ? store.update(this.id as string, this) : store.create(this);
|
|
1607
1642
|
},
|
|
1608
1643
|
},
|
|
1609
1644
|
refresh: {
|
|
1610
|
-
async value() {
|
|
1611
|
-
Object.assign(this, await store.pickById(this.id));
|
|
1645
|
+
async value(this: DocumentRecord) {
|
|
1646
|
+
Object.assign(this, await store.pickById(this.id as string));
|
|
1612
1647
|
return this;
|
|
1613
1648
|
},
|
|
1614
1649
|
},
|
|
1615
1650
|
isModified: {
|
|
1616
|
-
value(field?: string) {
|
|
1617
|
-
|
|
1618
|
-
if (!
|
|
1619
|
-
|
|
1651
|
+
value(this: DocumentRecord & { [MODIFICATION_STATE]?: ModificationState }, field?: string) {
|
|
1652
|
+
const state = this[MODIFICATION_STATE];
|
|
1653
|
+
if (!state) throw new Error(SqlDocumentStore.#untrackedModificationMessage(store.table));
|
|
1654
|
+
if (state.isNew) return true;
|
|
1655
|
+
if (!field) return JSON.stringify(sanitizeJson(this)) !== JSON.stringify(state.original);
|
|
1656
|
+
return JSON.stringify(sanitizeJson(this[field])) !== JSON.stringify(state.original[field]);
|
|
1620
1657
|
},
|
|
1621
1658
|
},
|
|
1622
1659
|
toJSON: {
|
|
1623
|
-
value() {
|
|
1660
|
+
value(this: DocumentRecord) {
|
|
1624
1661
|
return sanitizeJson(this);
|
|
1625
1662
|
},
|
|
1626
1663
|
},
|
|
1627
1664
|
toObject: {
|
|
1628
|
-
value() {
|
|
1665
|
+
value(this: DocumentRecord) {
|
|
1629
1666
|
return sanitizeJson(this);
|
|
1630
1667
|
},
|
|
1631
1668
|
},
|
|
1632
|
-
});
|
|
1633
|
-
return
|
|
1669
|
+
}) as object;
|
|
1670
|
+
return this.#docPrototype;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
static #untrackedModificationMessage(table: string) {
|
|
1674
|
+
return (
|
|
1675
|
+
`isModified() is unavailable on this ${table} document: it was loaded through a read query, which does not ` +
|
|
1676
|
+
`snapshot the row. Call it inside a save hook, or re-load the document through the write path (\`save()\`, ` +
|
|
1677
|
+
`\`update()\`) before comparing.`
|
|
1678
|
+
);
|
|
1634
1679
|
}
|
|
1635
1680
|
|
|
1636
1681
|
private async runHooks(
|
package/signal/middleware.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BaseEnv, Cls, PromiseOrObject } from "akanjs/base";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
2
3
|
import { type CacheAdaptor, CacheAdaptorRole } from "akanjs/service";
|
|
3
4
|
import dayjs from "dayjs";
|
|
4
5
|
import type { SignalContext } from "./signalContext";
|
|
@@ -25,11 +26,14 @@ export class Logging extends middleware("logging") {
|
|
|
25
26
|
override async use() {
|
|
26
27
|
return async (context: SignalContext, next: () => Promise<unknown>) => {
|
|
27
28
|
const start = Date.now();
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
const debug = Logger.shouldLog("debug");
|
|
31
|
+
if (debug) context.adaptor.logger.debug(`Before ${context.endpointInfo.type}-${context.key} / ${start}`);
|
|
29
32
|
try {
|
|
30
33
|
const result = await next();
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
if (debug) {
|
|
35
|
+
context.adaptor.logger.debug(`After ${context.endpointInfo.type}-${context.key} / ${Date.now() - start}ms`);
|
|
36
|
+
}
|
|
33
37
|
return result;
|
|
34
38
|
} catch (error) {
|
|
35
39
|
const duration = Date.now() - start;
|
package/signal/signalContext.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { Adaptor, AdaptorCls, DatabaseService, InjectRegistry, LiveRegistry
|
|
|
18
18
|
import type { Internal, InternalCls, InternalInfo, MiddlewareCls } from ".";
|
|
19
19
|
import type { EndpointInfo } from "./endpointInfo";
|
|
20
20
|
import { Exception } from "./exception";
|
|
21
|
+
import type { Guard, GuardCls } from "./guard";
|
|
21
22
|
import { isTraceEnabled, runWithTrace, SignalTrace, traceSpan } from "./trace";
|
|
22
23
|
|
|
23
24
|
export type SignalTransportType = "http" | "websocket";
|
|
@@ -28,6 +29,7 @@ interface WebSocketRequest {
|
|
|
28
29
|
eventType: WebSocketEventType;
|
|
29
30
|
}
|
|
30
31
|
type RuntimeRecord = Record<string, unknown>;
|
|
32
|
+
type MiddlewareHandler = (context: SignalContext, next: () => Promise<unknown>) => PromiseOrObject<unknown>;
|
|
31
33
|
|
|
32
34
|
interface ExceptionLike {
|
|
33
35
|
statusCode: number;
|
|
@@ -114,13 +116,25 @@ export class SignalContext<
|
|
|
114
116
|
}
|
|
115
117
|
return this;
|
|
116
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Guards read everything from the context they are handed and are already required to be side-effect free and
|
|
121
|
+
* safe to re-run — `SignalResolver.revalidateWsRooms` re-runs them outside of any request — so one instance per
|
|
122
|
+
* class serves every call instead of one per guard per request.
|
|
123
|
+
*/
|
|
124
|
+
static #guards = new WeakMap<GuardCls, Guard>();
|
|
125
|
+
static #getGuard(GuardCls: GuardCls): Guard {
|
|
126
|
+
const cached = SignalContext.#guards.get(GuardCls);
|
|
127
|
+
if (cached) return cached;
|
|
128
|
+
const guard = new GuardCls();
|
|
129
|
+
SignalContext.#guards.set(GuardCls, guard);
|
|
130
|
+
return guard;
|
|
131
|
+
}
|
|
117
132
|
async #checkGuards() {
|
|
118
133
|
const guards = this.endpointInfo.signalOption.guards ?? [];
|
|
119
134
|
if (guards.length === 0) return;
|
|
120
135
|
await Promise.all(
|
|
121
136
|
guards.map(async (GuardCls) => {
|
|
122
|
-
const
|
|
123
|
-
const canPass = await guard.canPass(this);
|
|
137
|
+
const canPass = await SignalContext.#getGuard(GuardCls).canPass(this);
|
|
124
138
|
if (!canPass) throw new Exception.Forbidden(`Access denied by guard: ${GuardCls.name}`);
|
|
125
139
|
}),
|
|
126
140
|
);
|
|
@@ -151,12 +165,34 @@ export class SignalContext<
|
|
|
151
165
|
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
152
166
|
const MiddlewareCls = middlewares[i];
|
|
153
167
|
if (!MiddlewareCls) continue;
|
|
154
|
-
const middleware = new MiddlewareCls();
|
|
155
168
|
const currentNext = next;
|
|
156
|
-
next = async () =>
|
|
169
|
+
next = async () =>
|
|
170
|
+
await (await SignalContext.#getMiddlewareHandler(MiddlewareCls, this.getEnv()))(this, currentNext);
|
|
157
171
|
}
|
|
158
172
|
return next;
|
|
159
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* `use(env)` takes no context, so the instance and the handler it returns are a function of `(class, env)` and
|
|
176
|
+
* hold for the life of the process. Building both per request cost an instance, a handler and a closure on every
|
|
177
|
+
* call for every registered middleware — and `Logging` is registered by default.
|
|
178
|
+
*/
|
|
179
|
+
static #middlewareHandlers = new WeakMap<MiddlewareCls, WeakMap<object, Promise<MiddlewareHandler>>>();
|
|
180
|
+
static #getMiddlewareHandler(MiddlewareCls: MiddlewareCls, env: BaseEnv): Promise<MiddlewareHandler> {
|
|
181
|
+
const byEnv =
|
|
182
|
+
SignalContext.#middlewareHandlers.get(MiddlewareCls) ?? new WeakMap<object, Promise<MiddlewareHandler>>();
|
|
183
|
+
SignalContext.#middlewareHandlers.set(MiddlewareCls, byEnv);
|
|
184
|
+
const cached = byEnv.get(env);
|
|
185
|
+
if (cached) return cached;
|
|
186
|
+
|
|
187
|
+
const handler = Promise.resolve(new MiddlewareCls().use(env) as PromiseOrObject<MiddlewareHandler>).catch(
|
|
188
|
+
(error: unknown) => {
|
|
189
|
+
byEnv.delete(env);
|
|
190
|
+
throw error;
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
byEnv.set(env, handler);
|
|
194
|
+
return handler;
|
|
195
|
+
}
|
|
160
196
|
async exec() {
|
|
161
197
|
if (!this.trace) return await this.#exec();
|
|
162
198
|
return await runWithTrace(this.trace, async () => {
|
package/types/common/Logger.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare class Logger {
|
|
|
17
17
|
static removeSink(sink: LoggerSink): void;
|
|
18
18
|
static setFileLevel(level: LogLevel): void;
|
|
19
19
|
static isVerbose(): boolean;
|
|
20
|
+
/** For hot-path callers that would otherwise build a message the level is about to discard. */
|
|
21
|
+
static shouldLog(logLevel: LogLevel): boolean;
|
|
20
22
|
name?: string;
|
|
21
23
|
constructor(name?: string);
|
|
22
24
|
trace(msg: string, context?: string, name?: string): void;
|
|
@@ -114,6 +114,7 @@ export type FieldInfoObjectToFieldObject<Obj extends FieldInfoObject> = {
|
|
|
114
114
|
export declare class ConstantField<FieldType extends ConstantFieldKind = ConstantFieldKind, Value extends ConstantFieldTypeInput | null = any, FieldValue = any, MapValue = any, Nullable extends boolean = boolean, IsRelation extends boolean = boolean, IsEnum extends boolean = boolean, IsPrimitive extends boolean = boolean, IsScalar extends boolean = boolean, IsHidden extends boolean = boolean, IsSecret extends boolean = boolean, IsMap extends boolean = boolean, Metadata = {
|
|
115
115
|
[key: string]: any;
|
|
116
116
|
}> implements ConstantFieldBuildProps<FieldType, FieldValue, MapValue, Metadata> {
|
|
117
|
+
#private;
|
|
117
118
|
static getBaseModelField(): FieldObject;
|
|
118
119
|
static getBaseInsightField(): FieldObject;
|
|
119
120
|
readonly nullable: Nullable;
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
import type { FieldObject } from ".";
|
|
2
2
|
import type { DefaultOf } from "./types.d.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The split is what keeps this faithful: `default: () => dayjs()` still means "now" on every call, and an array
|
|
5
|
+
* or nested-scalar default is still a fresh object, so two documents filled from the same model never end up
|
|
6
|
+
* sharing one. Only values that were already shared before this cache existed live in `shared`.
|
|
7
|
+
*/
|
|
3
8
|
export declare const getDefault: <T>(fieldObj: FieldObject) => DefaultOf<T>;
|
|
@@ -35,6 +35,12 @@ export declare class RouteClientCache {
|
|
|
35
35
|
#private;
|
|
36
36
|
merged: MergedManifest;
|
|
37
37
|
constructor({ buildRoute, onMerge }: RouteClientCacheOptions);
|
|
38
|
+
/**
|
|
39
|
+
* Bumped on every mutation of `merged`, including a delta merge that leaves `generation` where it was. It lets a
|
|
40
|
+
* consumer memoize work derived from the manifest — merging the runtime manifest over it, say — without having to
|
|
41
|
+
* copy the manifest to find out whether anything changed. In production nothing after `seed` moves it at all.
|
|
42
|
+
*/
|
|
43
|
+
get revision(): number;
|
|
38
44
|
seed(manifest: RoutesManifest): void;
|
|
39
45
|
ensure(routeId: string, seeds: string[]): Promise<MergedManifest>;
|
|
40
46
|
snapshot(): MergedManifest;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface EncodedSidecar {
|
|
2
|
+
bytes: ArrayBuffer;
|
|
3
|
+
encoding: string;
|
|
4
|
+
}
|
|
5
|
+
export declare const isCompressibleContentType: (contentType: string) => boolean;
|
|
6
|
+
/** Picks the best precompressed sidecar the caller accepts, or null to serve the file as-is. */
|
|
7
|
+
export declare const resolveEncodedSidecar: (req: Request, filePath: string, contentType: string) => Promise<EncodedSidecar | null>;
|
package/types/server/index.d.ts
CHANGED
|
@@ -9,8 +9,6 @@ export * from "./devtools.d.ts";
|
|
|
9
9
|
export type { ChangeBatch, ChangeKind } from "./hmr/changeBatch.d.ts";
|
|
10
10
|
export * from "./processMetricsCollector.d.ts";
|
|
11
11
|
export * from "./proxy.d.ts";
|
|
12
|
-
export * from "./routeElementComposer.d.ts";
|
|
13
|
-
export * from "./routeTreeBuilder.d.ts";
|
|
14
12
|
export * from "./sitemap.d.ts";
|
|
15
13
|
export type { SsrManifest, SsrManifestEntry } from "./ssrTypes.d.ts";
|
|
16
14
|
export * from "./types.d.ts";
|
|
@@ -97,7 +97,9 @@ export interface DocumentStore {
|
|
|
97
97
|
exists(query?: DocumentQuery): Promise<string | null>;
|
|
98
98
|
count(query?: DocumentQuery): Promise<number>;
|
|
99
99
|
insight(query?: DocumentQuery): Promise<any>;
|
|
100
|
-
hydrate(data: DocumentRecord, originalData?: DocumentRecord
|
|
100
|
+
hydrate(data: DocumentRecord, originalData?: DocumentRecord, options?: {
|
|
101
|
+
track?: boolean;
|
|
102
|
+
}): any;
|
|
101
103
|
}
|
|
102
104
|
export interface SqlResultRows<Row = Record<string, unknown>> {
|
|
103
105
|
rows: Row[];
|
|
@@ -384,7 +386,15 @@ export declare class SqlDocumentStore {
|
|
|
384
386
|
private decodeNestedValue;
|
|
385
387
|
private normalizeWriteValue;
|
|
386
388
|
private fillScalarDefaults;
|
|
387
|
-
|
|
389
|
+
/**
|
|
390
|
+
* `track` buys `isModified()` and costs a deep clone of the row, so it is decided per call site rather than
|
|
391
|
+
* paid everywhere. Only the read paths (`find`, and the projected read behind it) opt out — that clone was 42%
|
|
392
|
+
* of a list query, and a listed document is never the one a save hook runs on. Everything else keeps it, so an
|
|
393
|
+
* external caller of `hydrate` sees no change.
|
|
394
|
+
*/
|
|
395
|
+
hydrate(data: DocumentRecord, originalData?: DocumentRecord, { track }?: {
|
|
396
|
+
track?: boolean;
|
|
397
|
+
}): any;
|
|
388
398
|
private runHooks;
|
|
389
399
|
private insertStmt;
|
|
390
400
|
private prepareReadStmt;
|
package/ui/Field.tsx
CHANGED
|
@@ -733,7 +733,7 @@ const Date = <Nullable extends boolean>({
|
|
|
733
733
|
return (
|
|
734
734
|
<div className={cn("flex flex-col", className)}>
|
|
735
735
|
{label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
|
|
736
|
-
{/*
|
|
736
|
+
{/* FIXME: daysi UI datetime-local 컴포넌트에 max 값 넣으면 오른쪽 끝 짤리는 버그 있음.*/}
|
|
737
737
|
<input
|
|
738
738
|
type={showTime ? "datetime-local" : "date"}
|
|
739
739
|
className={cn(
|