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.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/cli/application/application.command.ts +4 -1
- package/cli/application/application.runner.ts +5 -7
- package/cli/build.ts +1 -0
- package/cli/index.js +114 -74
- package/constant/serialize.ts +1 -1
- package/devkit/capacitor.base.config.ts +18 -4
- package/devkit/capacitorApp.ts +118 -64
- package/devkit/mobile/mobileTarget.ts +2 -1
- package/devkit/scanInfo.ts +1 -0
- package/package.json +1 -1
- package/server/akanApp.ts +53 -12
- package/server/processMetricsCollector.ts +79 -1
- package/server/resolver/database.resolver.ts +82 -31
- package/server/resolver/signal.resolver.ts +67 -28
- package/service/ipcTypes.ts +5 -0
- package/service/predefinedAdaptor/database.adaptor.ts +95 -27
- package/service/predefinedAdaptor/solidSqlite.ts +7 -7
- package/service/predefinedAdaptor/storage.adaptor.ts +35 -9
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +5 -1
- package/signal/signalContext.ts +85 -31
- package/signal/trace.ts +279 -0
- package/types/devkit/capacitorApp.d.ts +14 -5
- package/types/server/processMetricsCollector.d.ts +2 -0
- package/types/service/ipcTypes.d.ts +5 -0
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -32
- package/types/service/predefinedAdaptor/solidSqlite.d.ts +3 -3
- package/types/service/predefinedAdaptor/storage.adaptor.d.ts +8 -2
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +4 -1
- package/types/signal/trace.d.ts +97 -0
- package/types/ui/Signal/style.d.ts +15 -0
- package/ui/Signal/Arg.tsx +22 -15
- package/ui/Signal/Doc.tsx +28 -21
- package/ui/Signal/Listener.tsx +15 -39
- package/ui/Signal/Message.tsx +32 -50
- package/ui/Signal/Object.tsx +16 -13
- package/ui/Signal/PubSub.tsx +29 -47
- package/ui/Signal/Response.tsx +7 -17
- package/ui/Signal/RestApi.tsx +41 -57
- package/ui/Signal/WebSocket.tsx +1 -1
- package/ui/Signal/style.ts +36 -0
- 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) =>
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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>,
|
|
157
|
-
pickById: (id: string | undefined,
|
|
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.
|
|
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 {
|
|
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, {
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
endpointInfo,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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:
|
|
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
|
}
|
package/service/ipcTypes.ts
CHANGED
|
@@ -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?:
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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.
|
|
668
|
-
.
|
|
669
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
701
|
-
|
|
702
|
-
|
|
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:
|
|
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
|
|
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
|
|