akanjs 2.0.6 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/cli/application/application.command.ts +4 -1
  4. package/cli/application/application.runner.ts +5 -7
  5. package/cli/build.ts +1 -0
  6. package/cli/index.js +114 -74
  7. package/constant/serialize.ts +1 -1
  8. package/devkit/capacitor.base.config.ts +18 -4
  9. package/devkit/capacitorApp.ts +118 -64
  10. package/devkit/mobile/mobileTarget.ts +2 -1
  11. package/devkit/scanInfo.ts +1 -0
  12. package/package.json +1 -1
  13. package/server/akanApp.ts +53 -12
  14. package/server/processMetricsCollector.ts +79 -1
  15. package/server/resolver/database.resolver.ts +82 -31
  16. package/server/resolver/signal.resolver.ts +67 -28
  17. package/service/ipcTypes.ts +5 -0
  18. package/service/predefinedAdaptor/database.adaptor.ts +95 -27
  19. package/service/predefinedAdaptor/solidSqlite.ts +7 -7
  20. package/service/predefinedAdaptor/storage.adaptor.ts +35 -9
  21. package/signal/index.ts +1 -0
  22. package/signal/middleware.ts +5 -1
  23. package/signal/signalContext.ts +85 -31
  24. package/signal/trace.ts +279 -0
  25. package/types/devkit/capacitorApp.d.ts +14 -5
  26. package/types/server/processMetricsCollector.d.ts +2 -0
  27. package/types/service/ipcTypes.d.ts +5 -0
  28. package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -32
  29. package/types/service/predefinedAdaptor/solidSqlite.d.ts +3 -3
  30. package/types/service/predefinedAdaptor/storage.adaptor.d.ts +8 -2
  31. package/types/signal/index.d.ts +1 -0
  32. package/types/signal/signalContext.d.ts +4 -1
  33. package/types/signal/trace.d.ts +97 -0
  34. package/types/ui/Signal/style.d.ts +15 -0
  35. package/ui/Signal/Arg.tsx +22 -15
  36. package/ui/Signal/Doc.tsx +28 -21
  37. package/ui/Signal/Listener.tsx +15 -39
  38. package/ui/Signal/Message.tsx +32 -50
  39. package/ui/Signal/Object.tsx +16 -13
  40. package/ui/Signal/PubSub.tsx +29 -47
  41. package/ui/Signal/Response.tsx +7 -17
  42. package/ui/Signal/RestApi.tsx +41 -57
  43. package/ui/Signal/WebSocket.tsx +1 -1
  44. package/ui/Signal/style.ts +36 -0
  45. package/webkit/useCsrValues.ts +147 -37
@@ -27,6 +27,22 @@ import {
27
27
  DatabaseAdaptorRole,
28
28
  type DocumentStore,
29
29
  } from "akanjs/service";
30
+ import { getCurrentTrace, traceDataLoaderBatch } from "akanjs/signal";
31
+
32
+ /**
33
+ * Times a store query and records it against the active request trace (no-op when
34
+ * tracing is disabled). Used to surface "queries per request" and DB latency share.
35
+ */
36
+ const timedQuery = async <T>(fn: () => Promise<T>): Promise<T> => {
37
+ const trace = getCurrentTrace();
38
+ if (!trace) return await fn();
39
+ const start = performance.now();
40
+ try {
41
+ return await fn();
42
+ } finally {
43
+ trace.countDbQuery(performance.now() - start);
44
+ }
45
+ };
30
46
 
31
47
  export class DatabaseResolver {
32
48
  static resolveDatabase(constant: ConstantModel, database: DatabaseModel): AdaptorCls<DatabaseInstance> {
@@ -55,6 +71,18 @@ export class DatabaseResolver {
55
71
  const schema = new DocumentSchema();
56
72
  database.model._onSchema(schema as any);
57
73
  database.model._libsOnSchema(schema as any);
74
+ const filterMeta = getFilterMeta(database.filter);
75
+ const indexedSortFieldKeys = new Set<string>();
76
+ for (const sort of Object.values(filterMeta.sort)) {
77
+ if (!sort || typeof sort !== "object") continue;
78
+ const sortFields = Object.entries(sort as Record<string, 1 | -1>);
79
+ if (!sortFields.length) continue;
80
+ const fields = Object.fromEntries([["removedAt", 1] as const, ...sortFields]);
81
+ const key = Object.keys(fields).join(",");
82
+ if (indexedSortFieldKeys.has(key)) continue;
83
+ indexedSortFieldKeys.add(key);
84
+ schema.index(fields);
85
+ }
58
86
 
59
87
  class DatabaseModelInstance extends adapt(`${modelName}Model`, ({ plug }) => ({
60
88
  __database: plug(DatabaseAdaptorRole, (database) => database),
@@ -71,7 +99,12 @@ export class DatabaseResolver {
71
99
  await this.__store.ensure();
72
100
  this.__model = this.#createModelFacade() as unknown as Mdl<any, any>;
73
101
  this.__loader = new DataLoader<string, any>(
74
- async (ids) => await Promise.all(ids.map((id) => this.__store.findOne({ id }))),
102
+ async (ids) => {
103
+ traceDataLoaderBatch(ids.length);
104
+ const docs = await timedQuery(() => this.__store.find({ id: documentQueryHelper.oneOf([...ids]) }));
105
+ const byId = new Map(docs.map((doc) => [String(doc.id), doc]));
106
+ return ids.map((id) => byId.get(String(id)) ?? null);
107
+ },
75
108
  { name: `${modelName}Loader`, cache: false },
76
109
  );
77
110
  Object.assign(this, {
@@ -81,18 +114,35 @@ export class DatabaseResolver {
81
114
  Object.entries(getLoaderInfos(database.model)).forEach(([key, loaderInfo]) => {
82
115
  Object.assign(this, {
83
116
  [key]: new DataLoader<any, any>(async (keys) => {
84
- return await Promise.all(
85
- keys.map(async (key) => {
86
- const query =
87
- loaderInfo.type === "query"
88
- ? key
89
- : {
90
- [loaderInfo.field as string]:
91
- loaderInfo.type === "arrayField" ? documentQueryHelper.has(key) : key,
92
- };
93
- return await this.__store.findOne(documentQueryHelper.all(loaderInfo.defaultQuery, query));
94
- }),
117
+ traceDataLoaderBatch(keys.length);
118
+ if (loaderInfo.type === "query") {
119
+ const fields = loaderInfo.field as string[];
120
+ const query = { kind: "any", queries: keys } as QueryOf<unknown>;
121
+ const docs = await timedQuery(() =>
122
+ this.__store.find(documentQueryHelper.all(loaderInfo.defaultQuery, query)),
123
+ );
124
+ const byKey = new Map(docs.map((doc) => [fields.map((field) => String(doc[field])).join(""), doc]));
125
+ return keys.map(
126
+ (queryKey) => byKey.get(fields.map((field) => String(queryKey[field])).join("")) ?? null,
127
+ );
128
+ }
129
+ const field = loaderInfo.field as string;
130
+ const query = {
131
+ [field]: documentQueryHelper.oneOf([...keys]),
132
+ };
133
+ const docs = await timedQuery(() =>
134
+ this.__store.find(documentQueryHelper.all(loaderInfo.defaultQuery, query)),
95
135
  );
136
+ if (loaderInfo.type === "arrayField") {
137
+ const byKey = new Map<string, unknown>();
138
+ for (const doc of docs) {
139
+ const values = Array.isArray(doc[field]) ? doc[field] : [];
140
+ for (const value of values) if (!byKey.has(String(value))) byKey.set(String(value), doc);
141
+ }
142
+ return keys.map((key) => byKey.get(String(key)) ?? null);
143
+ }
144
+ const byKey = new Map(docs.map((doc) => [String(doc[field]), doc]));
145
+ return keys.map((key) => byKey.get(String(key)) ?? null);
96
146
  }),
97
147
  });
98
148
  });
@@ -105,7 +155,7 @@ export class DatabaseResolver {
105
155
  }
106
156
  const createFindManyChain = (
107
157
  query: QueryOf<any>,
108
- options: { sort?: any; skip?: number; limit?: number } = {},
158
+ options: { sort?: any; skip?: number; limit?: number; select?: any } = {},
109
159
  ) => {
110
160
  const chain: any = {
111
161
  sort(sort: any) {
@@ -117,8 +167,8 @@ export class DatabaseResolver {
117
167
  limit(limit: number) {
118
168
  return createFindManyChain(query, { ...options, limit });
119
169
  },
120
- select() {
121
- return createFindManyChain(query, options);
170
+ select(select?: any) {
171
+ return createFindManyChain(query, { ...options, select });
122
172
  },
123
173
 
124
174
  then(resolve: (value: any[]) => void, reject: (reason: unknown) => void) {
@@ -130,7 +180,7 @@ export class DatabaseResolver {
130
180
  };
131
181
  return chain;
132
182
  };
133
- const createFindOneChain = (query: QueryOf<any>, options: { sort?: any; skip?: number } = {}) => {
183
+ const createFindOneChain = (query: QueryOf<any>, options: { sort?: any; skip?: number; select?: any } = {}) => {
134
184
  const chain: any = {
135
185
  sort(sort: any) {
136
186
  return createFindOneChain(query, { ...options, sort });
@@ -138,8 +188,8 @@ export class DatabaseResolver {
138
188
  skip(skip: number) {
139
189
  return createFindOneChain(query, { ...options, skip });
140
190
  },
141
- select() {
142
- return createFindOneChain(query, options);
191
+ select(select?: any) {
192
+ return createFindOneChain(query, { ...options, select });
143
193
  },
144
194
 
145
195
  then(resolve: (value: any | null) => void, reject: (reason: unknown) => void) {
@@ -153,10 +203,13 @@ export class DatabaseResolver {
153
203
  };
154
204
  return Object.assign(Model, {
155
205
  refName: modelName,
156
- pickOne: (query: QueryOf<any>, _projection?: any) => store.pickOne(query),
157
- pickById: (id: string | undefined, _projection?: any) => {
206
+ pickOne: (query: QueryOf<any>, projection?: any) => store.pickOne(query, { select: projection }),
207
+ pickById: (id: string | undefined, projection?: any) => {
158
208
  if (!id) throw new Error("No Document ID");
159
- return store.pickById(id);
209
+ return store.findOne({ id }, { select: projection }).then((doc) => {
210
+ if (!doc) throw new Error(`No Document (${modelName}): ${id}`);
211
+ return doc;
212
+ });
160
213
  },
161
214
  exists: async (query: QueryOf<any>) => await store.exists(query),
162
215
  sample: (query: QueryOf<any>, size = 1) => store.find(query, { sample: size, limit: size }),
@@ -184,20 +237,20 @@ export class DatabaseResolver {
184
237
  }
185
238
 
186
239
  async __list(query?: QueryOf<any>, queryOption?: ListQueryOption): Promise<any[]> {
187
- const { find, sort, skip, limit, sample } = getListQuery(query, queryOption);
188
- return await this.__store.find(find, { sort, skip, limit, sample });
240
+ const { find, sort, skip, limit, sample, select } = getListQuery(query, queryOption);
241
+ return await timedQuery(() => this.__store.find(find, { sort, skip, limit, sample, select }));
189
242
  }
190
243
  async __listIds(query?: QueryOf<any>, queryOption?: ListQueryOption): Promise<string[]> {
191
244
  const { find, sort, skip, limit, sample } = getListQuery(query, queryOption);
192
- return await this.__store.findIds(find, { sort, skip, limit, sample });
245
+ return await timedQuery(() => this.__store.findIds(find, { sort, skip, limit, sample }));
193
246
  }
194
247
  async __find(query?: QueryOf<any>, queryOption?: FindQueryOption): Promise<any | null> {
195
248
  const { find, sort, skip, sample } = getFindQuery(query, queryOption);
196
- return await this.__store.findOne(find, { sort, skip, sample });
249
+ return await timedQuery(() => this.__store.findOne(find, { sort, skip, sample }));
197
250
  }
198
251
  async __findId(query?: QueryOf<any>, queryOption?: FindQueryOption): Promise<string | null> {
199
252
  const { find, sort, skip, sample } = getFindQuery(query, queryOption);
200
- return await this.__store.findId(find, { sort, skip, sample });
253
+ return await timedQuery(() => this.__store.findId(find, { sort, skip, sample }));
201
254
  }
202
255
  async __pick(query?: QueryOf<any>, queryOption?: FindQueryOption): Promise<any> {
203
256
  const { find, sort, skip, sample } = getFindQuery(query, queryOption);
@@ -213,7 +266,7 @@ export class DatabaseResolver {
213
266
  return await this.__store.exists(query);
214
267
  }
215
268
  async __count(query?: QueryOf<any>): Promise<number> {
216
- return await this.__store.count(query);
269
+ return await timedQuery(() => this.__store.count(query));
217
270
  }
218
271
  async __insight(query?: QueryOf<any>): Promise<any> {
219
272
  return await this.__store.insight(query);
@@ -254,13 +307,13 @@ export class DatabaseResolver {
254
307
  return await this.__store.clone(data);
255
308
  }
256
309
  async __create(data: DataInputOf<any, any>) {
257
- return await this.__store.create(data);
310
+ return await timedQuery(() => this.__store.create(data));
258
311
  }
259
312
  async [`create${className}`](data: DataInputOf<any, any>) {
260
313
  return this.__create(data);
261
314
  }
262
315
  async __update(id: string, data: DataInputOf<any, any>) {
263
- return await this.__store.update(id, data);
316
+ return await timedQuery(() => this.__store.update(id, data));
264
317
  }
265
318
  async [`update${className}`](id: string, data: DataInputOf<any, any>) {
266
319
  return this.__update(id, data);
@@ -289,8 +342,6 @@ export class DatabaseResolver {
289
342
  const queryOption = hasQueryOption ? lastArg : {};
290
343
  return { query, queryOption };
291
344
  };
292
- const filterMeta = getFilterMeta(database.filter);
293
-
294
345
  Object.entries(filterMeta.query).forEach(([queryKey, filterInfo]) => {
295
346
  const queryFn = filterInfo.queryFn;
296
347
  if (!queryFn) throw new Error(`No query function for key: ${queryKey}`);
@@ -1,4 +1,15 @@
1
- import { type BaseEnv, type Cls, ENDPOINT_META, getEnv, ID, INTERNAL_META, Int, SLICE_META } from "akanjs/base";
1
+ import {
2
+ type BaseEnv,
3
+ type Cls,
4
+ ENDPOINT_META,
5
+ FIELD_META,
6
+ getEnv,
7
+ ID,
8
+ INTERNAL_META,
9
+ Int,
10
+ PrimitiveRegistry,
11
+ SLICE_META,
12
+ } from "akanjs/base";
2
13
  import { capitalize, Logger } from "akanjs/common";
3
14
  import { serialize } from "akanjs/constant";
4
15
  import { documentQueryHelper } from "akanjs/document";
@@ -169,7 +180,12 @@ export class SignalResolver {
169
180
  const sort = requestArgs[argLength + 2] ?? "latest";
170
181
  const internalArgs = requestArgs.slice(argLength + 3);
171
182
  const query = await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]);
172
- return (await this[serviceName].__list(query, { skip, limit, sort })) as any;
183
+ return (await this[serviceName].__list(query, {
184
+ skip,
185
+ limit,
186
+ sort,
187
+ select: SignalResolver.#selectForConstant(sliceInfo.light),
188
+ })) as any;
173
189
  });
174
190
 
175
191
  const insightKey = `${refName}Insight${capitalizedKey}`;
@@ -249,37 +265,37 @@ export class SignalResolver {
249
265
  if (endpointInfo.signalOption.globalPrefix !== undefined) {
250
266
  routeOptions[path] = { globalPrefix: endpointInfo.signalOption.globalPrefix };
251
267
  }
268
+ const normalHttpHandler = async (req: Bun.BunRequest) =>
269
+ await SignalContext.try(endpoint, endpointInfo, key, async () => {
270
+ const context = await new SignalContext(key, req, {
271
+ endpointInfo,
272
+ adaptor: endpoint,
273
+ registry,
274
+ env,
275
+ live,
276
+ middleware,
277
+ }).init();
278
+ return await context.exec();
279
+ });
252
280
  switch (endpointInfo.type) {
253
281
  case "query":
254
- routes[path] = {
255
- GET: async (req) =>
256
- await SignalContext.try(endpoint, endpointInfo, key, async () => {
257
- const context = await new SignalContext(key, req, {
258
- endpointInfo,
259
- adaptor: endpoint,
260
- registry,
261
- env,
262
- live,
263
- middleware,
264
- }).init();
265
- return await context.exec();
266
- }),
267
- };
282
+ routes[path] = SignalResolver.#canUsePrimitiveQueryFastPath(endpointInfo, middleware)
283
+ ? {
284
+ GET: async (req) => {
285
+ if (SignalResolver.#hasAuthCredential(req)) return await normalHttpHandler(req);
286
+ return await SignalContext.try(endpoint, endpointInfo, key, async () => {
287
+ const result = await endpointInfo.execFn?.call(endpoint);
288
+ return result instanceof Response ? result : Response.json(result);
289
+ });
290
+ },
291
+ }
292
+ : {
293
+ GET: normalHttpHandler,
294
+ };
268
295
  break;
269
296
  case "mutation":
270
297
  routes[path] = {
271
- POST: async (req) =>
272
- await SignalContext.try(endpoint, endpointInfo, key, async () => {
273
- const context = await new SignalContext(key, req, {
274
- endpointInfo,
275
- adaptor: endpoint,
276
- registry,
277
- env,
278
- live,
279
- middleware,
280
- }).init();
281
- return await context.exec();
282
- }),
298
+ POST: normalHttpHandler,
283
299
  };
284
300
  break;
285
301
  case "pubsub":
@@ -349,6 +365,29 @@ export class SignalResolver {
349
365
  return trimmed ? `/${trimmed}` : "";
350
366
  }
351
367
 
368
+ static #selectForConstant(constant: Cls): Record<string, true> | undefined {
369
+ const fields = (constant as { [FIELD_META]?: Record<string, unknown> })[FIELD_META];
370
+ if (!fields) return undefined;
371
+ return Object.fromEntries(Object.keys(fields).map((field) => [field, true]));
372
+ }
373
+
374
+ static #canUsePrimitiveQueryFastPath(endpointInfo: EndpointInfo, middleware: Map<string, MiddlewareCls>) {
375
+ return (
376
+ process.env.AKAN_TRACE !== "1" &&
377
+ endpointInfo.args.length === 0 &&
378
+ endpointInfo.internalArgs.length === 0 &&
379
+ (endpointInfo.signalOption.guards?.length ?? 0) === 0 &&
380
+ (endpointInfo.signalOption.middlewares?.length ?? 0) === 0 &&
381
+ [...middleware.values()].every((MiddlewareCls) => MiddlewareCls.refName === "AccountMiddleware") &&
382
+ endpointInfo.returns.arrDepth === 0 &&
383
+ PrimitiveRegistry.has(endpointInfo.returns.returnRef as Cls)
384
+ );
385
+ }
386
+
387
+ static #hasAuthCredential(req: Request) {
388
+ return Boolean(req.headers.get("authorization") || req.headers.get("cookie")?.includes("jwt="));
389
+ }
390
+
352
391
  static async handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
353
392
  await SignalResolver.#getWebsocket(registry).registerSocket(ws);
354
393
  }
@@ -94,6 +94,11 @@ export interface AkanMetricsReport {
94
94
  httpHtmlCacheHits?: number;
95
95
  httpHtmlCacheMisses?: number;
96
96
  httpHtmlCacheBypass?: number;
97
+ eventLoopLagMeanMs?: number;
98
+ eventLoopLagP99Ms?: number;
99
+ eventLoopLagMaxMs?: number;
100
+ gcDurationMs?: number;
101
+ trace?: unknown;
97
102
  }
98
103
 
99
104
  export type AkanIpcMessage =
@@ -74,23 +74,17 @@ export interface DocumentStore {
74
74
  bulkWrite(
75
75
  operations: { updateOne: { filter: DocumentQuery; update: DocumentUpdate; upsert?: boolean } }[],
76
76
  ): Promise<{ acknowledged: boolean; matchedCount: number; modifiedCount: number; upsertedId: string | null }>;
77
- find(
78
- query?: DocumentQuery,
79
- options?: { sort?: SortOption; skip?: number | null; limit?: number | null; sample?: number },
80
- ): Promise<any[]>;
77
+ find(query?: DocumentQuery, options?: FindManyOptions): Promise<any[]>;
81
78
  findIds(
82
79
  query?: DocumentQuery,
83
80
  options?: { sort?: SortOption; skip?: number | null; limit?: number | null; sample?: number },
84
81
  ): Promise<string[]>;
85
- findOne(
86
- query?: DocumentQuery,
87
- options?: { sort?: SortOption; skip?: number | null; sample?: boolean },
88
- ): Promise<any | null>;
82
+ findOne(query?: DocumentQuery, options?: FindOneOptions): Promise<any | null>;
89
83
  findId(
90
84
  query?: DocumentQuery,
91
85
  options?: { sort?: SortOption; skip?: number | null; sample?: boolean },
92
86
  ): Promise<string | null>;
93
- pickOne(query?: DocumentQuery, options?: { sort?: SortOption; skip?: number | null; sample?: boolean }): Promise<any>;
87
+ pickOne(query?: DocumentQuery, options?: FindOneOptions): Promise<any>;
94
88
  pickById(id: string): Promise<any>;
95
89
  exists(query?: DocumentQuery): Promise<string | null>;
96
90
  count(query?: DocumentQuery): Promise<number>;
@@ -137,6 +131,15 @@ type DocumentRecord = Record<string, unknown>;
137
131
  type MutableDocumentRecord = Record<string, unknown>;
138
132
  type FieldMap = Record<string, { getProps: () => Record<string, unknown>; [key: string]: unknown }>;
139
133
  type SortOption = Record<string, 1 | -1> | null | undefined;
134
+ type ProjectionOption = Partial<Record<string, boolean>> | null | undefined;
135
+ type FindManyOptions = {
136
+ sort?: SortOption;
137
+ skip?: number | null;
138
+ limit?: number | null;
139
+ sample?: number;
140
+ select?: ProjectionOption;
141
+ };
142
+ type FindOneOptions = { sort?: SortOption; skip?: number | null; sample?: boolean; select?: ProjectionOption };
140
143
  type QueryOperatorName = Exclude<
141
144
  DocumentQueryNode,
142
145
  { kind: "all" } | { kind: "any" } | { kind: "not" } | { kind: "raw" }
@@ -148,6 +151,7 @@ interface SqliteDocumentRow {
148
151
  removedAt?: number | string | null;
149
152
  _doc: string;
150
153
  }
154
+ type ProjectedSqliteDocumentRow = Omit<SqliteDocumentRow, "_doc"> & Record<string, unknown>;
151
155
 
152
156
  interface DocumentDatabaseOwner {
153
157
  getConnection(): AkanSqlClient;
@@ -510,6 +514,7 @@ export class SqliteDocumentStore {
510
514
  readonly table: string;
511
515
  readonly compiler: QueryCompiler;
512
516
  #insertStmt: AkanSqlStatement | null = null;
517
+ #readStmtCache = new Map<string, AkanSqlStatement>();
513
518
 
514
519
  constructor(
515
520
  private readonly owner: DocumentDatabaseOwner,
@@ -637,20 +642,23 @@ export class SqliteDocumentStore {
637
642
  return { acknowledged: true, matchedCount, modifiedCount, upsertedId };
638
643
  }
639
644
 
640
- async find(
641
- query?: DocumentQuery,
642
- options: { sort?: SortOption; skip?: number | null; limit?: number | null; sample?: number } = {},
643
- ) {
645
+ async find(query?: DocumentQuery, options: FindManyOptions = {}) {
644
646
  const { where, params } = this.safeQuery(query);
645
647
  const limitValue = Number(options.limit ?? 0);
646
648
  const skipValue = Number(options.skip ?? 0);
647
649
  const limit = limitValue ? ` LIMIT ${limitValue}` : "";
648
650
  const offset = skipValue ? ` OFFSET ${skipValue}` : "";
649
651
  const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.compiler.orderBy(options.sort ?? undefined)}`;
650
- const rows = await this.owner
651
- .getConnection()
652
- .prepare(`SELECT * FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`)
653
- .all<SqliteDocumentRow>(...params);
652
+ const projection = this.normalizeProjection(options.select);
653
+ if (projection) {
654
+ const rows = await this.prepareReadStmt(
655
+ `SELECT ${this.projectionSql(projection)} FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
656
+ ).all<ProjectedSqliteDocumentRow>(...params);
657
+ return rows.map((row) => this.fromProjectedRow(row, projection));
658
+ }
659
+ const rows = await this.prepareReadStmt(
660
+ `SELECT * FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
661
+ ).all<SqliteDocumentRow>(...params);
654
662
  return rows.map((row) => this.hydrate(this.fromRow(row)));
655
663
  }
656
664
 
@@ -664,14 +672,13 @@ export class SqliteDocumentStore {
664
672
  const limit = limitValue ? ` LIMIT ${limitValue}` : "";
665
673
  const offset = skipValue ? ` OFFSET ${skipValue}` : "";
666
674
  const order = options.sample ? "ORDER BY random()" : `ORDER BY ${this.compiler.orderBy(options.sort ?? undefined)}`;
667
- const rows = await this.owner
668
- .getConnection()
669
- .prepare(`SELECT "id" FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`)
670
- .all<{ id: string }>(...params);
675
+ const rows = await this.prepareReadStmt(
676
+ `SELECT "id" FROM ${quoteIdent(this.table)} WHERE ${where} ${order}${limit}${offset}`,
677
+ ).all<{ id: string }>(...params);
671
678
  return rows.map((row) => row.id);
672
679
  }
673
680
 
674
- async findOne(query?: DocumentQuery, options: { sort?: SortOption; skip?: number | null; sample?: boolean } = {}) {
681
+ async findOne(query?: DocumentQuery, options: FindOneOptions = {}) {
675
682
  return (await this.find(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
676
683
  }
677
684
 
@@ -679,7 +686,7 @@ export class SqliteDocumentStore {
679
686
  return (await this.findIds(query, { ...options, limit: 1, sample: options.sample ? 1 : undefined })).at(0) ?? null;
680
687
  }
681
688
 
682
- async pickOne(query?: DocumentQuery, options: { sort?: SortOption; skip?: number | null; sample?: boolean } = {}) {
689
+ async pickOne(query?: DocumentQuery, options: FindOneOptions = {}) {
683
690
  const doc = await this.findOne(query, options);
684
691
  if (!doc) throw new Error(`No Document (${this.table}): ${JSON.stringify(query)}`);
685
692
  return doc;
@@ -697,10 +704,9 @@ export class SqliteDocumentStore {
697
704
 
698
705
  async count(query?: DocumentQuery) {
699
706
  const { where, params } = this.safeQuery(query);
700
- const row = await this.owner
701
- .getConnection()
702
- .prepare(`SELECT count(*) as count FROM ${quoteIdent(this.table)} WHERE ${where}`)
703
- .get<{ count: number }>(...params);
707
+ const row = await this.prepareReadStmt(
708
+ `SELECT count(*) as count FROM ${quoteIdent(this.table)} WHERE ${where}`,
709
+ ).get<{ count: number }>(...params);
704
710
  return row?.count ?? 0;
705
711
  }
706
712
 
@@ -901,6 +907,55 @@ export class SqliteDocumentStore {
901
907
  };
902
908
  }
903
909
 
910
+ private normalizeProjection(select: ProjectionOption): string[] | null {
911
+ if (!select) return null;
912
+ const fields = Object.entries(select)
913
+ .filter(([, included]) => included)
914
+ .map(([field]) => field);
915
+ if (!fields.length) return null;
916
+ return [...new Set(fields.filter((field) => field !== "_doc"))];
917
+ }
918
+
919
+ private projectionSql(fields: string[]) {
920
+ const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
921
+ const baseColumns = [...BASE_COLUMNS].map((field) => quoteIdent(field));
922
+ const jsonColumns = jsonFields.map(
923
+ (field, idx) => `${this.compiler.fieldExpr(field)} AS ${quoteIdent(this.projectionAlias(idx))}`,
924
+ );
925
+ return [...baseColumns, ...jsonColumns].join(", ");
926
+ }
927
+
928
+ private projectionAlias(idx: number) {
929
+ return `__akan_projection_${idx}`;
930
+ }
931
+
932
+ private fromProjectedRow(row: ProjectedSqliteDocumentRow, fields: string[]) {
933
+ const doc: DocumentRecord = {
934
+ id: row.id,
935
+ createdAt: Number(row.createdAt),
936
+ updatedAt: Number(row.updatedAt),
937
+ removedAt: row.removedAt ? Number(row.removedAt) : undefined,
938
+ };
939
+ const jsonFields = fields.filter((field) => !BASE_COLUMNS.has(field));
940
+ for (const [idx, field] of jsonFields.entries()) {
941
+ const value = this.parseProjectedValue(row[this.projectionAlias(idx)]);
942
+ const props = (this.database.doc[FIELD_META] as unknown as FieldMap)[field]?.getProps?.();
943
+ doc[field] = props ? this.decodeFieldValue(value, props) : value;
944
+ }
945
+ return doc;
946
+ }
947
+
948
+ private parseProjectedValue(value: unknown) {
949
+ if (typeof value !== "string") return value;
950
+ const trimmed = value.trim();
951
+ if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return value;
952
+ try {
953
+ return JSON.parse(trimmed);
954
+ } catch {
955
+ return value;
956
+ }
957
+ }
958
+
904
959
  private decodeDocumentPayload(payload: Record<string, unknown>) {
905
960
  const fields = this.database.doc[FIELD_META] as unknown as FieldMap;
906
961
  return Object.fromEntries(
@@ -1011,6 +1066,19 @@ export class SqliteDocumentStore {
1011
1066
  return this.#insertStmt;
1012
1067
  }
1013
1068
 
1069
+ private prepareReadStmt(sql: string) {
1070
+ const cached = this.#readStmtCache.get(sql);
1071
+ if (cached) return cached;
1072
+
1073
+ if (this.#readStmtCache.size >= 128) {
1074
+ const oldest = this.#readStmtCache.keys().next().value;
1075
+ if (oldest) this.#readStmtCache.delete(oldest);
1076
+ }
1077
+ const stmt = this.owner.getConnection().prepare(sql);
1078
+ this.#readStmtCache.set(sql, stmt);
1079
+ return stmt;
1080
+ }
1081
+
1014
1082
  private assertValidRefName(refName: string) {
1015
1083
  if (!REF_NAME_RE.test(refName) || RESERVED_RE.test(refName))
1016
1084
  throw new Error(`Invalid database identifier: ${refName}`);
@@ -19,7 +19,7 @@ export interface SolidEnv extends BaseEnv {
19
19
  solid?: SolidConfig;
20
20
  }
21
21
 
22
- export type SolidValueType = "string" | "number" | "buffer";
22
+ export type SolidValueType = "string" | "number" | "buffer" | "json";
23
23
 
24
24
  const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
25
25
 
@@ -62,19 +62,19 @@ export const openSolidDatabase = async (config: Required<SolidConfig>) => {
62
62
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
63
63
  };
64
64
 
65
- export const encodeSolidValue = (value: string | number | Buffer): { type: SolidValueType; value: string | Buffer } => {
65
+ export const encodeSolidValue = (value: unknown): { type: SolidValueType; value: string | Buffer } => {
66
66
  if (Buffer.isBuffer(value)) return { type: "buffer", value };
67
67
  if (typeof value === "number") return { type: "number", value: String(value) };
68
- return { type: "string", value };
68
+ if (typeof value === "string") return { type: "string", value };
69
+
70
+ return { type: "json", value: JSON.stringify(value ?? null) };
69
71
  };
70
72
 
71
- export const decodeSolidValue = <T extends string | number | Buffer>(
72
- type: SolidValueType,
73
- value: string | Buffer | null,
74
- ): T | undefined => {
73
+ export const decodeSolidValue = <T>(type: SolidValueType, value: string | Buffer | null): T | undefined => {
75
74
  if (value === null) return undefined;
76
75
  if (type === "buffer") return Buffer.isBuffer(value) ? (value as T) : (Buffer.from(value) as T);
77
76
  if (type === "number") return Number(value) as T;
77
+ if (type === "json") return JSON.parse(typeof value === "string" ? value : value.toString()) as T;
78
78
  return String(value) as T;
79
79
  };
80
80