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 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) {
@@ -450,7 +450,13 @@ export class ConstantField<
450
450
  get isMap() {
451
451
  return (this.modelRef as Cls) === Map;
452
452
  }
453
+
454
+ #props: FieldProps | null = null;
453
455
  getProps(): FieldProps {
456
+ this.#props ??= Object.freeze(this.#buildProps());
457
+ return this.#props;
458
+ }
459
+ #buildProps(): FieldProps {
454
460
  return {
455
461
  nullable: this.nullable as unknown as boolean,
456
462
  ref: this.ref,
@@ -2,17 +2,45 @@ import { DEFAULT_VALUE, FIELD_META, type PrimitiveScalar } from "akanjs/base";
2
2
  import type { FieldObject } from ".";
3
3
  import type { DefaultOf } from "./types";
4
4
 
5
+ interface DefaultPlan {
6
+ /** Fields whose default is a value that can be shared: a primitive, `null`, or the field's own literal. */
7
+ shared: Record<string, unknown>;
8
+ /** Fields that have to be produced per call — a thunk, a fresh array, or a nested scalar record. */
9
+ perCall: [key: string, make: () => unknown][];
10
+ }
11
+
12
+ const planCache = new WeakMap<FieldObject, DefaultPlan>();
13
+
14
+ /**
15
+ * The split is what keeps this faithful: `default: () => dayjs()` still means "now" on every call, and an array
16
+ * or nested-scalar default is still a fresh object, so two documents filled from the same model never end up
17
+ * sharing one. Only values that were already shared before this cache existed live in `shared`.
18
+ */
5
19
  export const getDefault = <T>(fieldObj: FieldObject): DefaultOf<T> => {
6
- const result: Record<string, unknown> = {};
20
+ let plan = planCache.get(fieldObj);
21
+ if (!plan) {
22
+ plan = buildPlan(fieldObj);
23
+ planCache.set(fieldObj, plan);
24
+ }
25
+ const result: Record<string, unknown> = { ...plan.shared };
26
+ for (const [key, make] of plan.perCall) result[key] = make();
27
+ return result as DefaultOf<T>;
28
+ };
29
+
30
+ const buildPlan = (fieldObj: FieldObject): DefaultPlan => {
31
+ const shared: Record<string, unknown> = {};
32
+ const perCall: [string, () => unknown][] = [];
7
33
  for (const [key, field] of Object.entries(fieldObj)) {
8
- if (field.fieldType === "hidden" || field.fieldType === "secret") result[key] = null;
34
+ if (field.fieldType === "hidden" || field.fieldType === "secret") shared[key] = null;
9
35
  else if (field.default !== undefined && field.default !== null) {
10
- if (typeof field.default === "function") result[key] = (field.default as () => object)();
11
- else result[key] = field.default as object;
12
- } else if (field.isArray) result[key] = [];
13
- else if (field.nullable) result[key] = null;
14
- else if (field.isClass) result[key] = field.isScalar ? getDefault(field.modelRef[FIELD_META]) : null;
15
- else result[key] = (field.modelRef as unknown as typeof PrimitiveScalar)[DEFAULT_VALUE];
36
+ if (typeof field.default === "function") perCall.push([key, field.default as () => unknown]);
37
+ else shared[key] = field.default as object;
38
+ } else if (field.isArray) perCall.push([key, () => []]);
39
+ else if (field.nullable) shared[key] = null;
40
+ else if (field.isClass) {
41
+ if (field.isScalar) perCall.push([key, () => getDefault(field.modelRef[FIELD_META])]);
42
+ else shared[key] = null;
43
+ } else shared[key] = (field.modelRef as unknown as typeof PrimitiveScalar)[DEFAULT_VALUE];
16
44
  }
17
- return result as DefaultOf<T>;
45
+ return { shared, perCall };
18
46
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.3",
3
+ "version": "3.0.0-alpha.5",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -7,6 +7,7 @@ import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport,
7
7
  import { isTraceEnabled } from "akanjs/signal";
8
8
  import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
9
9
  import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
10
+ import { resolveEncodedSidecar } from "./assetEncoding";
10
11
  import { isPortInUseError } from "./lifecycle/portInUse";
11
12
  import { RotatingLogWriter } from "./logging/rotatingLogWriter";
12
13
  import { ProcessMetricsCollector } from "./processMetricsCollector";
@@ -808,41 +809,17 @@ export class AkanApp {
808
809
  const headers = new Headers({ "Content-Type": options.contentType });
809
810
  if (options.cacheControl) headers.set("Cache-Control", options.cacheControl);
810
811
 
811
- const gzipPath = `${filePath}.gz`;
812
- if (this.#acceptsGzip(req) && this.#isCompressible(options.contentType)) {
813
- const gzipFile = Bun.file(gzipPath);
814
- if (await gzipFile.exists()) {
815
- const gzipBytes = await gzipFile.bytes();
816
- headers.set("Content-Encoding", "gzip");
817
- headers.set("Content-Length", String(gzipBytes.byteLength));
818
- headers.set("Vary", "Accept-Encoding");
819
- return new Response(this.#toArrayBuffer(gzipBytes), { headers });
820
- }
812
+ const sidecar = await resolveEncodedSidecar(req, filePath, options.contentType);
813
+ if (sidecar) {
814
+ headers.set("Content-Encoding", sidecar.encoding);
815
+ headers.set("Content-Length", String(sidecar.bytes.byteLength));
816
+ headers.set("Vary", "Accept-Encoding");
817
+ return new Response(sidecar.bytes, { headers });
821
818
  }
822
819
 
823
820
  return new Response(Bun.file(filePath).stream(), { headers });
824
821
  }
825
822
 
826
- #acceptsGzip(req: Request): boolean {
827
- const acceptEncoding = req.headers.get("accept-encoding") ?? "";
828
- return /\bgzip\b/.test(acceptEncoding);
829
- }
830
-
831
- #isCompressible(contentType: string): boolean {
832
- const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
833
- return (
834
- type.startsWith("text/") ||
835
- type === "application/javascript" ||
836
- type === "application/json" ||
837
- type === "application/manifest+json" ||
838
- type === "image/svg+xml"
839
- );
840
- }
841
-
842
- #toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
843
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
844
- }
845
-
846
823
  #safeResolve(baseDir: string, urlPath: string): string | null {
847
824
  let decoded: string;
848
825
  try {
@@ -48,12 +48,22 @@ export class RouteClientCache {
48
48
  };
49
49
  readonly #buildRoute: RouteBuildFn;
50
50
  readonly #onMerge?: OnMergeFn;
51
+ #revision = 0;
51
52
 
52
53
  constructor({ buildRoute, onMerge }: RouteClientCacheOptions) {
53
54
  this.#buildRoute = buildRoute;
54
55
  this.#onMerge = onMerge;
55
56
  }
56
57
 
58
+ /**
59
+ * Bumped on every mutation of `merged`, including a delta merge that leaves `generation` where it was. It lets a
60
+ * consumer memoize work derived from the manifest — merging the runtime manifest over it, say — without having to
61
+ * copy the manifest to find out whether anything changed. In production nothing after `seed` moves it at all.
62
+ */
63
+ get revision(): number {
64
+ return this.#revision;
65
+ }
66
+
57
67
  #getEmptyDelta(): BuildRouteClientResult {
58
68
  return {
59
69
  manifestDelta: {},
@@ -75,6 +85,7 @@ export class RouteClientCache {
75
85
  Object.assign(this.merged.ssrManifest.moduleMap, manifest.ssrManifest.moduleMap);
76
86
  for (const abs of manifest.knownEntries) this.merged.knownEntries.add(abs);
77
87
  for (const routeId of manifest.routeIds) this.#built.set(routeId, this.#getEmptyDelta());
88
+ this.#revision += 1;
78
89
  }
79
90
 
80
91
  async ensure(routeId: string, seeds: string[]): Promise<MergedManifest> {
@@ -125,6 +136,7 @@ export class RouteClientCache {
125
136
  for (const [url, byName] of Object.entries(delta.ssrManifestDelta.moduleMap))
126
137
  this.merged.ssrManifest.moduleMap[url] = byName;
127
138
  for (const entry of delta.newEntries) this.merged.knownEntries.add(entry);
139
+ this.#revision += 1;
128
140
  this.#built.set(routeId, delta);
129
141
  this.#logger.verbose(
130
142
  `[route-cache] build done routeId=${routeId} generation=${generation} entries=+${delta.newEntries.length} deps=${delta.clientDeps.length} in ${Date.now() - started}ms`,
@@ -176,6 +188,7 @@ export class RouteClientCache {
176
188
  }
177
189
  const nextGeneration = this.merged.generation + 1;
178
190
  this.merged = this.#getEmptyMerged(nextGeneration);
191
+ this.#revision += 1;
179
192
  this.#building.clear();
180
193
  this.#logger.verbose(`[route-cache] cleared generation=${nextGeneration} dropped=${dropped.length}`);
181
194
  return dropped;
@@ -209,6 +222,7 @@ export class RouteClientCache {
209
222
  ),
210
223
  };
211
224
  this.merged = next;
225
+ this.#revision += 1;
212
226
  }
213
227
 
214
228
  static #normalizePath(filePath: string): string {
@@ -0,0 +1,47 @@
1
+ const COMPRESSIBLE_TYPES = new Set([
2
+ "application/javascript",
3
+ "application/json",
4
+ "application/manifest+json",
5
+ "image/svg+xml",
6
+ ]);
7
+
8
+ /**
9
+ * br is tried first: it is ~15% smaller than gzip across the artifact and ~22% on the CSS bundle.
10
+ * The gzip sidecar stays the fallback because browsers only advertise `br` on secure origins, so a
11
+ * plain-http dev server or an intermediary that rewrites Accept-Encoding still gets a compressed body.
12
+ */
13
+ const SIDECAR_ENCODINGS = [
14
+ { encoding: "br", ext: ".br", accept: /(?:^|,)\s*(?:br|\*)(?![\w-])\s*(?:;\s*q=([\d.]+))?/i },
15
+ { encoding: "gzip", ext: ".gz", accept: /(?:^|,)\s*(?:gzip|\*)(?![\w-])\s*(?:;\s*q=([\d.]+))?/i },
16
+ ] as const;
17
+
18
+ export interface EncodedSidecar {
19
+ bytes: ArrayBuffer;
20
+ encoding: string;
21
+ }
22
+
23
+ export const isCompressibleContentType = (contentType: string): boolean => {
24
+ const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
25
+ return type.startsWith("text/") || COMPRESSIBLE_TYPES.has(type);
26
+ };
27
+
28
+ /** Picks the best precompressed sidecar the caller accepts, or null to serve the file as-is. */
29
+ export const resolveEncodedSidecar = async (
30
+ req: Request,
31
+ filePath: string,
32
+ contentType: string,
33
+ ): Promise<EncodedSidecar | null> => {
34
+ if (!isCompressibleContentType(contentType)) return null;
35
+ const acceptEncoding = req.headers.get("accept-encoding") ?? "";
36
+ for (const { encoding, ext, accept } of SIDECAR_ENCODINGS) {
37
+ const match = accept.exec(acceptEncoding);
38
+
39
+ if (!match || (match[1] !== undefined && Number.parseFloat(match[1]) <= 0)) continue;
40
+ const file = Bun.file(`${filePath}${ext}`);
41
+ if (!(await file.exists())) continue;
42
+ const bytes = await file.bytes();
43
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
44
+ return { bytes: buffer, encoding };
45
+ }
46
+ return null;
47
+ };
@@ -473,7 +473,7 @@ export class DiLifecycle {
473
473
  );
474
474
  }
475
475
 
476
- this.#cascade.seal((refName) => this.getService(refName));
476
+ this.#cascade.seal((refName: string) => this.getService(refName));
477
477
  }
478
478
 
479
479
  async #initializeInternal() {
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
- export * from "./routeElementComposer";
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
- return Object.fromEntries(Object.keys(fields).map((field) => [field, true]));
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>) {
@@ -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(this.#routeCache.merged).clientManifest,
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(this.#routeCache.snapshot());
818
+ return this.#mergeRuntimeManifest();
817
819
  }
818
820
 
819
- #mergeRuntimeManifest(manifest: MergedManifest): MergedManifest {
820
- return {
821
- ...manifest,
822
- clientManifest: WebRouter.#mergeClientManifest(this.#artifact.rscRuntimeClientManifest, manifest.clientManifest),
823
- ssrManifest: WebRouter.#mergeSsrManifest(this.#artifact.rscRuntimeSsrManifest, manifest.ssrManifest),
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 gzipPath = `${filePath}.gz`;
1105
- if (WebRouter.#acceptsGzip(req) && WebRouter.#isCompressible(options.contentType)) {
1106
- const gzipFile = Bun.file(gzipPath);
1107
- if (await gzipFile.exists()) {
1108
- const gzipBytes = await gzipFile.bytes();
1109
- headers.set("Content-Encoding", "gzip");
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
- hydrate(data: DocumentRecord, originalData: DocumentRecord = data) {
1592
- const store = this;
1593
- const original = JSON.parse(JSON.stringify(sanitizeJson(originalData) ?? {})) as Record<string, unknown>;
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.database.doc.prototype), hydratedData);
1597
- Object.defineProperties(doc, {
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
- if (isNew) return true;
1618
- if (!field) return JSON.stringify(sanitizeJson(this)) !== JSON.stringify(original);
1619
- return JSON.stringify(sanitizeJson(this[field])) !== JSON.stringify(original[field]);
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 doc;
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(
@@ -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
- context.adaptor.logger.debug(`Before ${context.endpointInfo.type}-${context.key} / ${start}`);
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
- const duration = Date.now() - start;
32
- context.adaptor.logger.debug(`After ${context.endpointInfo.type}-${context.key} / ${duration}ms`);
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;
@@ -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 guard = new GuardCls();
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 () => await (await middleware.use(this.getEnv()))(this, currentNext);
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 () => {
@@ -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>;
@@ -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): any;
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
- hydrate(data: DocumentRecord, originalData?: DocumentRecord): any;
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
- {/* //! daysi UI datetime-local 컴포넌트에 max 값 넣으면 오른쪽 끝 짤리는 버그 있음.*/}
736
+ {/* FIXME: daysi UI datetime-local 컴포넌트에 max 값 넣으면 오른쪽 끝 짤리는 버그 있음.*/}
737
737
  <input
738
738
  type={showTime ? "datetime-local" : "date"}
739
739
  className={cn(