akanjs 2.4.1 → 2.4.2-rc.1
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/client/csrTypes.ts +7 -0
- package/client/frameConfig.ts +6 -1
- package/client/rscNavigation.ts +9 -0
- package/common/index.ts +5 -0
- package/common/websocketAuth.ts +24 -0
- package/constant/fieldInfo.ts +4 -3
- package/constant/index.ts +2 -0
- package/constant/textFieldPathSet.ts +8 -0
- package/constant/textFieldPaths.ts +59 -0
- package/constant/types.ts +0 -4
- package/constant/via.ts +13 -28
- package/dictionary/dictInfo.ts +4 -0
- package/document/documentQuery.ts +17 -1
- package/document/documentSchema.ts +1 -15
- package/document/filterMeta.ts +4 -2
- package/fetch/client/fetchClient.ts +1 -0
- package/fetch/client/wsClient.ts +28 -1
- package/index.ts +6 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-wal +0 -0
- package/package.json +1 -1
- package/server/akanServer.ts +5 -4
- package/server/devtools/types.ts +2 -2
- package/server/resolver/database.resolver.ts +5 -6
- package/server/resolver/signal.resolver.ts +23 -0
- package/server/routing/apiRouter.ts +11 -3
- package/server/routing/appWsData.ts +50 -0
- package/server/rscClient.tsx +9 -0
- package/service/predefinedAdaptor/database.adaptor.ts +212 -88
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/searchIndex.ts +517 -0
- package/service/predefinedAdaptor/sqlDescriptor.ts +25 -0
- package/signal/signalContext.ts +37 -12
- package/signal/types.ts +2 -1
- package/types/client/csrTypes.d.ts +7 -0
- package/types/client/rscNavigation.d.ts +8 -0
- package/types/common/index.d.ts +1 -0
- package/types/common/websocketAuth.d.ts +19 -0
- package/types/constant/fieldInfo.d.ts +4 -3
- package/types/constant/index.d.ts +2 -0
- package/types/constant/textFieldPathSet.d.ts +8 -0
- package/types/constant/textFieldPaths.d.ts +10 -0
- package/types/constant/types.d.ts +0 -3
- package/types/constant/via.d.ts +4 -24
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +13 -1
- package/types/document/documentSchema.d.ts +0 -3
- package/types/document/filterMeta.d.ts +2 -1
- package/types/fetch/client/wsClient.d.ts +6 -0
- package/types/index.d.ts +6 -0
- package/types/server/devtools/types.d.ts +2 -2
- package/types/server/resolver/signal.resolver.d.ts +6 -0
- package/types/server/routing/apiRouter.d.ts +3 -4
- package/types/server/routing/appWsData.d.ts +24 -0
- package/types/server/rscClient.d.ts +1 -0
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +46 -13
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/searchIndex.d.ts +73 -0
- package/types/service/predefinedAdaptor/sqlDescriptor.d.ts +5 -0
- package/types/signal/signalContext.d.ts +7 -0
- package/types/signal/types.d.ts +5 -1
- package/types/ui/Constant/schemaDoc.d.ts +2 -2
- package/ui/Constant/schemaDoc.ts +8 -2
- package/ui/Model/EditModal.tsx +24 -6
package/client/csrTypes.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface PageConfig {
|
|
|
50
50
|
rscPatchHeadSafe?: boolean;
|
|
51
51
|
topSafeAreaColor?: string;
|
|
52
52
|
bottomSafeAreaColor?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
|
|
55
|
+
* reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
|
|
56
|
+
* directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
|
|
57
|
+
* the source without evaluating the module.
|
|
58
|
+
*/
|
|
59
|
+
devOnly?: boolean;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface CsrState {
|
package/client/frameConfig.ts
CHANGED
|
@@ -24,6 +24,8 @@ const pageConfigKeys = new Set<keyof PageConfig>([
|
|
|
24
24
|
"topSafeAreaColor",
|
|
25
25
|
"bottomSafeAreaColor",
|
|
26
26
|
]);
|
|
27
|
+
|
|
28
|
+
const buildPageConfigKeys = new Set<keyof PageConfig>(["devOnly"]);
|
|
27
29
|
const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
|
|
28
30
|
const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
|
|
29
31
|
const DEFAULT_BOOLEAN_INSET = 48;
|
|
@@ -38,10 +40,13 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
|
|
|
38
40
|
if (!isRecord(config)) throw new Error(`[route-convention] pageConfig in ${routeKey} must be an object.`);
|
|
39
41
|
const pageConfig = config as PageConfig;
|
|
40
42
|
for (const key of Object.keys(pageConfig)) {
|
|
41
|
-
if (!pageConfigKeys.has(key as keyof PageConfig)) {
|
|
43
|
+
if (!pageConfigKeys.has(key as keyof PageConfig) && !buildPageConfigKeys.has(key as keyof PageConfig)) {
|
|
42
44
|
throw new Error(`[route-convention] unsupported pageConfig option "${key}" in ${routeKey}`);
|
|
43
45
|
}
|
|
44
46
|
}
|
|
47
|
+
if (pageConfig.devOnly !== undefined && typeof pageConfig.devOnly !== "boolean") {
|
|
48
|
+
throw new Error(`[route-convention] pageConfig.devOnly in ${routeKey} must be a boolean.`);
|
|
49
|
+
}
|
|
45
50
|
if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
|
|
46
51
|
throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
|
|
47
52
|
}
|
package/client/rscNavigation.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
declare global {
|
|
2
2
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
3
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
3
4
|
var __AKAN_RSC_NAVIGATE__:
|
|
4
5
|
| ((href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => Promise<void>)
|
|
5
6
|
| undefined;
|
|
@@ -9,11 +10,19 @@ export const clearRscNavigationCache = () => {
|
|
|
9
10
|
globalThis.__AKAN_RSC_CLEAR_CACHE__?.();
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* True when the page tree currently on screen was replayed from the RSC navigation cache instead of
|
|
15
|
+
* fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
|
|
16
|
+
* that must show current values should refetch.
|
|
17
|
+
*/
|
|
18
|
+
export const isRscNavigationFromCache = () => globalThis.__AKAN_RSC_IS_FROM_CACHE__?.() ?? false;
|
|
19
|
+
|
|
12
20
|
export const navigateRsc = (href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => {
|
|
13
21
|
return globalThis.__AKAN_RSC_NAVIGATE__?.(href, options);
|
|
14
22
|
};
|
|
15
23
|
|
|
16
24
|
export const useRscNavigation = () => ({
|
|
17
25
|
clearCache: clearRscNavigationCache,
|
|
26
|
+
isFromCache: isRscNavigationFromCache,
|
|
18
27
|
navigate: navigateRsc,
|
|
19
28
|
});
|
package/common/index.ts
CHANGED
|
@@ -54,3 +54,8 @@ export { sleep } from "./sleep";
|
|
|
54
54
|
export { splitVersion } from "./splitVersion";
|
|
55
55
|
export { getBasePathFromPathname, parseBasePaths } from "./subRoute";
|
|
56
56
|
export type * from "./types";
|
|
57
|
+
export {
|
|
58
|
+
type WebsocketAuthAckData,
|
|
59
|
+
type WebsocketAuthRequest,
|
|
60
|
+
websocketAuthContract,
|
|
61
|
+
} from "./websocketAuth";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface WebsocketAuthRequest {
|
|
2
|
+
key: string;
|
|
3
|
+
data: [string | null];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface WebsocketAuthAckData {
|
|
7
|
+
type: "auth";
|
|
8
|
+
revokedRooms: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Framework-owned websocket auth contract shared by the client and the server dispatcher.
|
|
13
|
+
* The credential frame carries the raw bearer token; verifying it stays in userland middleware,
|
|
14
|
+
* so the server only swaps the credential snapshot held on the socket.
|
|
15
|
+
*/
|
|
16
|
+
export const websocketAuthContract = {
|
|
17
|
+
key: "__auth",
|
|
18
|
+
makeRequest: (jwt: string | null): WebsocketAuthRequest => ({ key: "__auth", data: [jwt] }),
|
|
19
|
+
makeAck: (revokedRooms: string[]): WebsocketAuthAckData => ({ type: "auth", revokedRooms }),
|
|
20
|
+
readJwt: (data: unknown): string | null => {
|
|
21
|
+
const jwt = Array.isArray(data) ? data[0] : null;
|
|
22
|
+
return typeof jwt === "string" && jwt.length > 0 ? jwt : null;
|
|
23
|
+
},
|
|
24
|
+
} as const;
|
package/constant/fieldInfo.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type UnCls,
|
|
18
18
|
} from "akanjs/base";
|
|
19
19
|
import { ConstantRegistry } from "./constantRegistry";
|
|
20
|
+
import type { TextFieldRole } from "./textFieldPaths";
|
|
20
21
|
import type { BaseObject } from "./types";
|
|
21
22
|
import type { ConstantModelRef } from "./via";
|
|
22
23
|
|
|
@@ -102,7 +103,7 @@ export interface ConstantFieldProps<
|
|
|
102
103
|
example?: FieldValue;
|
|
103
104
|
of?: MapValue;
|
|
104
105
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
105
|
-
text?:
|
|
106
|
+
text?: TextFieldRole;
|
|
106
107
|
meta?: Metadata;
|
|
107
108
|
}
|
|
108
109
|
export const fieldPresets = ["email", "password", "url"] as const;
|
|
@@ -192,7 +193,7 @@ interface ConstantFieldBuildProps<
|
|
|
192
193
|
example?: FieldValue;
|
|
193
194
|
of?: MapValue;
|
|
194
195
|
validate?: (value: FieldValue, model: any) => boolean;
|
|
195
|
-
text?:
|
|
196
|
+
text?: TextFieldRole;
|
|
196
197
|
modelRef: ConstantModelRef;
|
|
197
198
|
arrDepth: number;
|
|
198
199
|
optArrDepth: number;
|
|
@@ -315,7 +316,7 @@ export class ConstantField<
|
|
|
315
316
|
readonly example?: FieldValue;
|
|
316
317
|
readonly of?: MapValue;
|
|
317
318
|
readonly validate?: (value: FieldValue, model: any) => boolean;
|
|
318
|
-
readonly text?:
|
|
319
|
+
readonly text?: TextFieldRole;
|
|
319
320
|
readonly modelRef: ConstantModelRef;
|
|
320
321
|
readonly arrDepth: number;
|
|
321
322
|
readonly optArrDepth: number;
|
package/constant/index.ts
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The `search_doc` columns a model feeds, each holding the document paths that write it. */
|
|
2
|
+
export class TextFieldPathSet {
|
|
3
|
+
readonly title = new Set<string>();
|
|
4
|
+
readonly desc = new Set<string>();
|
|
5
|
+
readonly tag = new Set<string>();
|
|
6
|
+
readonly thumb = new Set<string>();
|
|
7
|
+
readonly filter = new Set<string>();
|
|
8
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Cls, PrimitiveRegistry, type PrimitiveScalar } from "akanjs/base";
|
|
2
|
+
import type { ConstantField, FieldObject } from "./fieldInfo";
|
|
3
|
+
import { TextFieldPathSet } from "./textFieldPathSet";
|
|
4
|
+
|
|
5
|
+
/** Column a `text` field feeds in the `search_doc` mirror. */
|
|
6
|
+
export const textFieldRoles = ["title", "desc", "tag", "thumb", "filter"] as const;
|
|
7
|
+
export type TextFieldRole = (typeof textFieldRoles)[number];
|
|
8
|
+
|
|
9
|
+
const refRoles = new Set<TextFieldRole>(["thumb", "filter"]);
|
|
10
|
+
|
|
11
|
+
export class TextFieldPaths extends TextFieldPathSet {
|
|
12
|
+
readonly children = new TextFieldPathSet();
|
|
13
|
+
|
|
14
|
+
collect(fieldMap: FieldObject) {
|
|
15
|
+
for (const [key, field] of Object.entries(fieldMap)) {
|
|
16
|
+
if (field.text) {
|
|
17
|
+
this.#assertIndexable(key, field.text, field);
|
|
18
|
+
this[field.text].add(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (field.isClass && field.isScalar) this.#mergeChild(key, field);
|
|
22
|
+
}
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#mergeChild(key: string, parent: ConstantField) {
|
|
27
|
+
const child = parent.modelRef.text as TextFieldPaths;
|
|
28
|
+
for (const role of textFieldRoles) {
|
|
29
|
+
for (const path of [...child[role], ...child.children[role]]) {
|
|
30
|
+
this.#assertReachable(`${key}.${path}`, parent);
|
|
31
|
+
this.children[role].add(`${key}.${path}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#assertReachable(path: string, parent: ConstantField) {
|
|
37
|
+
if (parent.fieldType === "secret") throw new Error(`Text field "${path}" is under a secret field`);
|
|
38
|
+
if (parent.fieldType === "hidden") throw new Error(`Text field "${path}" is under a hidden field`);
|
|
39
|
+
if (parent.fieldType === "resolve") throw new Error(`Text field "${path}" is under a resolved field`);
|
|
40
|
+
if (parent.arrDepth > 1) throw new Error(`Text field "${path}" is under a nested array and cannot be indexed`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#assertIndexable(key: string, role: TextFieldRole, field: ConstantField) {
|
|
44
|
+
|
|
45
|
+
if (field.fieldType === "secret") throw new Error(`Text field "${key}" is secret and must not be indexed`);
|
|
46
|
+
if (field.fieldType === "hidden") throw new Error(`Text field "${key}" is hidden and must not be indexed`);
|
|
47
|
+
if (field.fieldType === "resolve") throw new Error(`Text field "${key}" is resolved and is absent from _doc`);
|
|
48
|
+
if (field.isMap) throw new Error(`Text field "${key}" is a Map and cannot be indexed`);
|
|
49
|
+
if (field.arrDepth > 1) throw new Error(`Text field "${key}" is a nested array and cannot be indexed`);
|
|
50
|
+
const modelRef = field.modelRef as unknown as Cls;
|
|
51
|
+
const refName = PrimitiveRegistry.has(modelRef)
|
|
52
|
+
? PrimitiveRegistry.getName(modelRef as unknown as typeof PrimitiveScalar)
|
|
53
|
+
: null;
|
|
54
|
+
if (refName === "String") return;
|
|
55
|
+
if (refRoles.has(role) && (refName === "ID" || (field.isClass && !field.isScalar))) return;
|
|
56
|
+
const accepted = refRoles.has(role) ? "String, ID, or a model reference" : "String";
|
|
57
|
+
throw new Error(`Text field "${key}" declares text: "${role}", which accepts ${accepted}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/constant/types.ts
CHANGED
|
@@ -98,10 +98,6 @@ export interface ProtoPatch {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export const DEFAULT_PAGE_SIZE = 20;
|
|
101
|
-
export interface TextDoc {
|
|
102
|
-
[key: string]: string | TextDoc;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
101
|
export type NonFunctionalKeys<T> = {
|
|
106
102
|
[K in keyof T]: T[K] extends (...args: never[]) => unknown ? never : K;
|
|
107
103
|
}[keyof T];
|
package/constant/via.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolve,
|
|
27
27
|
} from "./fieldInfo";
|
|
28
28
|
import { makePurify, type PurifiedModel, type PurifyFunc } from "./purify";
|
|
29
|
+
import { TextFieldPaths } from "./textFieldPaths";
|
|
29
30
|
import type { BaseInsight, BaseObject, ConstantType, DefaultOf, DefaultOfSchema, NonFunctionalKeys } from "./types";
|
|
30
31
|
|
|
31
32
|
type BaseFields = "id" | "createdAt" | "updatedAt" | "removedAt";
|
|
@@ -191,8 +192,7 @@ const getBaseConstantClass = (field: FieldObject, modelType: ConstantType = "sca
|
|
|
191
192
|
class BaseConstant {
|
|
192
193
|
static readonly [FIELD_META]: FieldObject = field;
|
|
193
194
|
static modelType: ConstantType = modelType;
|
|
194
|
-
static text:
|
|
195
|
-
{ search: new Set(), filter: new Set(), children: { search: new Set(), filter: new Set() } };
|
|
195
|
+
static text: TextFieldPaths = new TextFieldPaths();
|
|
196
196
|
static children: Set<ConstantModelRef> = new Set();
|
|
197
197
|
static relations: Set<ConstantModelRef> = new Set();
|
|
198
198
|
static enums: Set<EnumInstance> = new Set();
|
|
@@ -268,7 +268,7 @@ export interface ConstantStatics<
|
|
|
268
268
|
children: Set<ConstantModelRef>;
|
|
269
269
|
relations: Set<ConstantModelRef>;
|
|
270
270
|
enums: Set<EnumInstance>;
|
|
271
|
-
text:
|
|
271
|
+
text: TextFieldPaths;
|
|
272
272
|
_OptionalKey: OptionalKey;
|
|
273
273
|
_RelationKey: RelationKey;
|
|
274
274
|
_PrimitiveKey: PrimitiveKey;
|
|
@@ -295,7 +295,7 @@ export interface DatabaseConstantStatics<Schema = any, FieldObj extends FieldObj
|
|
|
295
295
|
children: Set<ConstantModelRef>;
|
|
296
296
|
relations: Set<ConstantModelRef>;
|
|
297
297
|
enums: Set<EnumInstance>;
|
|
298
|
-
text:
|
|
298
|
+
text: TextFieldPaths;
|
|
299
299
|
_DatabaseSchema: {
|
|
300
300
|
[K in keyof Schema]: K extends keyof FieldObj
|
|
301
301
|
? FieldObj[K]["fieldType"] extends "hidden"
|
|
@@ -317,7 +317,7 @@ export type ConstantModelRef<
|
|
|
317
317
|
children: Set<ConstantModelRef>;
|
|
318
318
|
relations: Set<ConstantModelRef>;
|
|
319
319
|
enums: Set<EnumInstance>;
|
|
320
|
-
text:
|
|
320
|
+
text: TextFieldPaths;
|
|
321
321
|
}
|
|
322
322
|
>;
|
|
323
323
|
|
|
@@ -430,31 +430,16 @@ const applyConstantStatics = <Model>(model: ConstantCls<Model>, fieldMap: FieldO
|
|
|
430
430
|
purify: makePurify(model),
|
|
431
431
|
getDefault: () => ({ ...defaultValue }),
|
|
432
432
|
});
|
|
433
|
-
Object.entries(fieldMap).forEach(([
|
|
433
|
+
Object.entries(fieldMap).forEach(([, field]) => {
|
|
434
434
|
if (field.enum) model.enums.add(field.enum);
|
|
435
|
-
if (field.
|
|
436
|
-
|
|
437
|
-
else
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
for (const childEnum of field.modelRef.enums) model.enums.add(childEnum);
|
|
442
|
-
for (const relation of field.modelRef.relations) model.relations.add(relation);
|
|
443
|
-
for (const relationEnum of field.modelRef.enums) model.enums.add(relationEnum);
|
|
444
|
-
field.modelRef.text.search.forEach((subKey) => {
|
|
445
|
-
model.text.children.search.add(`${key}.${subKey}`);
|
|
446
|
-
});
|
|
447
|
-
field.modelRef.text.filter.forEach((subKey) => {
|
|
448
|
-
model.text.children.filter.add(`${key}.${subKey}`);
|
|
449
|
-
});
|
|
450
|
-
field.modelRef.text.children.search.forEach((subKey) => {
|
|
451
|
-
model.text.children.search.add(`${key}.${subKey}`);
|
|
452
|
-
});
|
|
453
|
-
field.modelRef.text.children.filter.forEach((subKey) => {
|
|
454
|
-
model.text.children.filter.add(`${key}.${subKey}`);
|
|
455
|
-
});
|
|
456
|
-
}
|
|
435
|
+
if (!field.isClass) return;
|
|
436
|
+
if (field.isScalar) model.children.add(field.modelRef);
|
|
437
|
+
else model.relations.add(field.modelRef);
|
|
438
|
+
for (const child of field.modelRef.children) model.children.add(child);
|
|
439
|
+
for (const childEnum of field.modelRef.enums) model.enums.add(childEnum);
|
|
440
|
+
for (const relation of field.modelRef.relations) model.relations.add(relation);
|
|
457
441
|
});
|
|
442
|
+
model.text.collect(fieldMap);
|
|
458
443
|
return model as unknown as ConstantCls<Model>;
|
|
459
444
|
};
|
|
460
445
|
|
package/dictionary/dictInfo.ts
CHANGED
|
@@ -169,6 +169,10 @@ export class ModelDictInfo<
|
|
|
169
169
|
} = {
|
|
170
170
|
latest: FieldTranslation.translate(["Latest", "최신순"]).desc(["Latest", "최신순"]),
|
|
171
171
|
oldest: FieldTranslation.translate(["Oldest", "오래된순"]).desc(["Oldest", "오래된순"]),
|
|
172
|
+
relevance: FieldTranslation.translate(["Relevance", "관련도순"]).desc([
|
|
173
|
+
"Best text-search match first",
|
|
174
|
+
"검색어와 가장 관련있는 순",
|
|
175
|
+
]),
|
|
172
176
|
};
|
|
173
177
|
static getBaseSignalDictionary<T extends string>(refName: T): BaseModelCrudGetSignalTranslation<T, [string, string]> {
|
|
174
178
|
const capRefName = capitalize(refName);
|
|
@@ -23,12 +23,22 @@ export const isDocumentId = (value: unknown): value is DocumentId =>
|
|
|
23
23
|
export type DocumentPrimitive = string | number | boolean | null | Dayjs | Date;
|
|
24
24
|
export type DocumentPath<T = any> = Extract<keyof T, string> | (string & {});
|
|
25
25
|
|
|
26
|
+
export const searchColumns = ["title", "desc", "tag", "filter"] as const;
|
|
27
|
+
export type SearchColumn = (typeof searchColumns)[number];
|
|
28
|
+
|
|
29
|
+
export interface DocumentSearchOptions {
|
|
30
|
+
columns?: SearchColumn[];
|
|
31
|
+
prefix?: boolean;
|
|
32
|
+
weights?: number[];
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
export type DocumentQueryNode =
|
|
27
36
|
| { kind: "all"; queries: DocumentQuery[] }
|
|
28
37
|
| { kind: "any"; queries: DocumentQuery[] }
|
|
29
38
|
| { kind: "not"; query: DocumentQuery }
|
|
30
39
|
| { kind: "op"; op: DocumentQueryOperator; value?: unknown }
|
|
31
|
-
| { kind: "raw"; sql: string; params: unknown[] }
|
|
40
|
+
| { kind: "raw"; sql: string; params: unknown[] }
|
|
41
|
+
| ({ kind: "search"; text: string } & DocumentSearchOptions);
|
|
32
42
|
|
|
33
43
|
export type DocumentQueryOperator =
|
|
34
44
|
| "eq"
|
|
@@ -152,6 +162,12 @@ export const createDocumentQueryHelper = () => ({
|
|
|
152
162
|
has: (value: unknown) => op("has", value),
|
|
153
163
|
contains: (value: unknown) => op("contains", value),
|
|
154
164
|
raw: (sql: string, params: unknown[] = []): DocumentQueryNode => ({ kind: "raw", sql, params }),
|
|
165
|
+
|
|
166
|
+
search: (text: string, options: DocumentSearchOptions = {}): DocumentQueryNode => ({
|
|
167
|
+
kind: "search",
|
|
168
|
+
text,
|
|
169
|
+
...options,
|
|
170
|
+
}),
|
|
155
171
|
when: (condition: unknown, query: DocumentQuery): DocumentQuery => (condition ? query : {}),
|
|
156
172
|
});
|
|
157
173
|
|
|
@@ -7,15 +7,14 @@ export type DocumentHookName = `before${Capitalize<SaveEventType>}` | `after${Ca
|
|
|
7
7
|
|
|
8
8
|
export interface DocumentIndexDescriptor {
|
|
9
9
|
name?: string;
|
|
10
|
+
|
|
10
11
|
fields: Record<string, 1 | -1 | "text" | boolean>;
|
|
11
12
|
unique?: boolean;
|
|
12
|
-
text?: boolean;
|
|
13
13
|
where?: DocumentQuery;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface DocumentIndexBuilder<Schema> {
|
|
17
17
|
path(path: string, order?: 1 | -1): DocumentIndexBuilder<Schema>;
|
|
18
|
-
text(path: string): DocumentIndexBuilder<Schema>;
|
|
19
18
|
unique(): DocumentIndexBuilder<Schema>;
|
|
20
19
|
where(where: DocumentQuery | ((q: DocumentQueryHelper) => DocumentQuery)): DocumentIndexBuilder<Schema>;
|
|
21
20
|
done(): Schema;
|
|
@@ -58,14 +57,6 @@ export class DocumentSchema<Doc = unknown> {
|
|
|
58
57
|
return this;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
text(...fields: string[]) {
|
|
62
|
-
this.indexes.push({
|
|
63
|
-
text: true,
|
|
64
|
-
fields: Object.fromEntries(fields.map((field) => [field, "text" as const])),
|
|
65
|
-
});
|
|
66
|
-
return this;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
60
|
createIndex(name: string): DocumentIndexBuilder<this> {
|
|
70
61
|
const schema = this;
|
|
71
62
|
const descriptor: DocumentIndexDescriptor = { name, fields: {} };
|
|
@@ -74,11 +65,6 @@ export class DocumentSchema<Doc = unknown> {
|
|
|
74
65
|
descriptor.fields[path] = order;
|
|
75
66
|
return api;
|
|
76
67
|
},
|
|
77
|
-
text(path: string) {
|
|
78
|
-
descriptor.fields[path] = "text";
|
|
79
|
-
descriptor.text = true;
|
|
80
|
-
return api;
|
|
81
|
-
},
|
|
82
68
|
unique() {
|
|
83
69
|
descriptor.unique = true;
|
|
84
70
|
return api;
|
package/document/filterMeta.ts
CHANGED
|
@@ -98,7 +98,7 @@ export const fillMissingFilterArgs = (filterInfo: FilterInfo, args: unknown[]) =
|
|
|
98
98
|
return [...args, ...Array(filterInfo.args.length - args.length).fill(undefined)];
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
-
export type BaseFilterSortKey = "latest" | "oldest";
|
|
101
|
+
export type BaseFilterSortKey = "latest" | "oldest" | "relevance";
|
|
102
102
|
export type BaseFilterQueryKey = "any";
|
|
103
103
|
export type BaseFilterKey = BaseFilterSortKey | BaseFilterQueryKey;
|
|
104
104
|
|
|
@@ -130,6 +130,8 @@ interface BaseQuery<Model> {
|
|
|
130
130
|
interface BaseSort {
|
|
131
131
|
latest: { createdAt: -1 };
|
|
132
132
|
oldest: { createdAt: 1 };
|
|
133
|
+
|
|
134
|
+
relevance: Record<string, never>;
|
|
133
135
|
}
|
|
134
136
|
type LibFilterQuery<LibFilters extends FilterCls[]> = MergeAllDoubleKeyOfObjects<
|
|
135
137
|
LibFilters,
|
|
@@ -189,7 +191,7 @@ export const from = <
|
|
|
189
191
|
any: filter().query((_q) => ({ removedAt: { empty: true } })),
|
|
190
192
|
...querySort.query,
|
|
191
193
|
},
|
|
192
|
-
sort: Object.assign({ latest: { createdAt: -1 }, oldest: { createdAt: 1 } }, querySort.sort),
|
|
194
|
+
sort: Object.assign({ latest: { createdAt: -1 }, oldest: { createdAt: 1 }, relevance: {} }, querySort.sort),
|
|
193
195
|
},
|
|
194
196
|
...libFilterRefs.map((libFilterRef) => getFilterMeta(libFilterRef)),
|
|
195
197
|
);
|
|
@@ -197,6 +197,7 @@ export class FetchClient {
|
|
|
197
197
|
}
|
|
198
198
|
setJwt(jwt: string | null) {
|
|
199
199
|
this.jwt = jwt;
|
|
200
|
+
this.ws.setJwt(jwt);
|
|
200
201
|
}
|
|
201
202
|
#makeAuthHeaders(option?: FetchPolicy): Record<string, string> {
|
|
202
203
|
if (option?.token) return { Authorization: `Bearer ${option.token}` };
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Logger } from "akanjs/common";
|
|
1
|
+
import { Logger, websocketAuthContract } from "akanjs/common";
|
|
2
2
|
import type {
|
|
3
|
+
WebsocketAuthAck,
|
|
3
4
|
WebsocketMessageData,
|
|
4
5
|
WebsocketPublishData,
|
|
5
6
|
WebsocketReqData,
|
|
@@ -39,6 +40,7 @@ export class WsClient {
|
|
|
39
40
|
#roomSubscribeMap = new Map<string, SubscribeOption>();
|
|
40
41
|
#listenerMap = new Map<string, Set<Listener>>();
|
|
41
42
|
#destroyed = false;
|
|
43
|
+
#jwt: string | null = null;
|
|
42
44
|
connected = false;
|
|
43
45
|
|
|
44
46
|
constructor(
|
|
@@ -52,6 +54,21 @@ export class WsClient {
|
|
|
52
54
|
this.ErrorCls = ErrorCls;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The handshake only carries a same-origin cookie, so clients that hold the token in memory
|
|
59
|
+
* (native, cross-origin) authenticate with this frame instead. Signing out sends `null`, which
|
|
60
|
+
* drops the handshake cookie server-side and revokes the rooms it had authorized.
|
|
61
|
+
*/
|
|
62
|
+
setJwt(jwt: string | null) {
|
|
63
|
+
if (this.#jwt === jwt) return;
|
|
64
|
+
this.#jwt = jwt;
|
|
65
|
+
if (this.#ws?.readyState === WebSocket.OPEN) this.#sendAuth();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#sendAuth() {
|
|
69
|
+
this.#ws?.send(JSON.stringify(websocketAuthContract.makeRequest(this.#jwt)));
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
connect() {
|
|
56
73
|
if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) return;
|
|
57
74
|
this.logger.debug(`Connecting to ${this.url}`);
|
|
@@ -68,6 +85,8 @@ export class WsClient {
|
|
|
68
85
|
this.#reconnectAttempts = 0;
|
|
69
86
|
this.connected = true;
|
|
70
87
|
this.logger.debug(`WebSocket connected`);
|
|
88
|
+
|
|
89
|
+
if (this.#jwt) this.#sendAuth();
|
|
71
90
|
this.#roomSubscribeMap.forEach((option) => {
|
|
72
91
|
const data: WebsocketReqData = { key: option.key, data: option.data, subscribe: true };
|
|
73
92
|
this.#ws?.send(JSON.stringify(data));
|
|
@@ -97,6 +116,14 @@ export class WsClient {
|
|
|
97
116
|
this.#handlePubsub(publishData.roomId, publishData.data);
|
|
98
117
|
break;
|
|
99
118
|
}
|
|
119
|
+
case "auth": {
|
|
120
|
+
const ack = parsed as WebsocketAuthAck;
|
|
121
|
+
for (const roomId of ack.revokedRooms) {
|
|
122
|
+
this.#roomSubscribeMap.delete(roomId);
|
|
123
|
+
this.logger.warn(`Websocket room ${roomId} is no longer authorized`);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
100
127
|
default:
|
|
101
128
|
this.logger.warn(`Unknown WebSocket message type: ${type} ${JSON.stringify(parsed)}`);
|
|
102
129
|
break;
|
package/index.ts
CHANGED
|
@@ -163,6 +163,12 @@ export interface AppConfigResult {
|
|
|
163
163
|
docker: DockerConfig;
|
|
164
164
|
defaultDatabaseMode: DatabaseMode;
|
|
165
165
|
routes?: AkanRouteConfig[];
|
|
166
|
+
/**
|
|
167
|
+
* Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
|
|
168
|
+
* dependency that ships a `page` folder, an array takes exactly the libs listed, `false` (the default)
|
|
169
|
+
* syncs nothing and removes what a previous sync created.
|
|
170
|
+
*/
|
|
171
|
+
syncPageLibs?: string[] | boolean;
|
|
166
172
|
externalLibs: string[];
|
|
167
173
|
barrelImports: string[];
|
|
168
174
|
optimizeImports: string[];
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanServer.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
|
23
23
|
import { WebProxyRunner } from "./proxy";
|
|
24
24
|
import { SignalResolver } from "./resolver";
|
|
25
25
|
import { ApiRouter } from "./routing/apiRouter";
|
|
26
|
+
import type { AppWsData } from "./routing/appWsData";
|
|
26
27
|
import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "./types";
|
|
27
28
|
import type { WebRouter } from "./webRouter";
|
|
28
29
|
|
|
@@ -65,8 +66,8 @@ export interface AkanServerConsoleInfo {
|
|
|
65
66
|
export class AkanServer {
|
|
66
67
|
status: "stopped" | "initializing" | "initialized" | "starting" | "running" | "stopping" = "stopped";
|
|
67
68
|
|
|
68
|
-
#server: Bun.Server<
|
|
69
|
-
#wsServer: Bun.Server<
|
|
69
|
+
#server: Bun.Server<AppWsData | HmrWsData> | null = null;
|
|
70
|
+
#wsServer: Bun.Server<AppWsData | HmrWsData> | null = null;
|
|
70
71
|
#prepared: AkanAppPrepared | null = null;
|
|
71
72
|
readonly logger: Logger;
|
|
72
73
|
readonly name: string;
|
|
@@ -241,7 +242,7 @@ export class AkanServer {
|
|
|
241
242
|
}),
|
|
242
243
|
|
|
243
244
|
data: {},
|
|
244
|
-
} as Bun.WebSocketHandler<
|
|
245
|
+
} as Bun.WebSocketHandler<AppWsData | HmrWsData>;
|
|
245
246
|
|
|
246
247
|
this.#server = Bun.serve({
|
|
247
248
|
idleTimeout: 0,
|
|
@@ -270,7 +271,7 @@ export class AkanServer {
|
|
|
270
271
|
builtinRoutes,
|
|
271
272
|
routeOptions,
|
|
272
273
|
renderEnvRoutes,
|
|
273
|
-
upgradeAppWs: (req: Request, data:
|
|
274
|
+
upgradeAppWs: (req: Request, data: AppWsData) => this.#wsServer?.upgrade(req, { data }) ?? false,
|
|
274
275
|
webProxyRunner,
|
|
275
276
|
}),
|
|
276
277
|
websocket: websocketHandlers,
|
package/server/devtools/types.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* GET /_akan/deps -> DevtoolsEnvelope<"deps", DepsData>
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { ConstantType } from "akanjs/constant";
|
|
11
|
+
import type { ConstantType, TextFieldRole } from "akanjs/constant";
|
|
12
12
|
import type { RootDictionary } from "akanjs/dictionary";
|
|
13
13
|
import type { ArgType, SerializedArg, SerializedReturns } from "akanjs/signal";
|
|
14
14
|
|
|
@@ -73,7 +73,7 @@ export interface ConstantFieldNode {
|
|
|
73
73
|
maxlength?: number;
|
|
74
74
|
/** `ConstantFieldProps["type"]`: "email" | "password" | "url". */
|
|
75
75
|
preset?: string;
|
|
76
|
-
text?:
|
|
76
|
+
text?: TextFieldRole;
|
|
77
77
|
accumulate?: unknown;
|
|
78
78
|
example?: unknown;
|
|
79
79
|
meta?: Record<string, unknown>;
|
|
@@ -49,11 +49,12 @@ const timedQuery = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
|
49
49
|
export class DatabaseResolver {
|
|
50
50
|
static resolveDatabase(constant: ConstantModel, database: DatabaseModel): AdaptorCls<DatabaseInstance> {
|
|
51
51
|
const [modelName, className]: [string, string] = [database.refName, capitalize(database.refName)];
|
|
52
|
+
|
|
53
|
+
const resolveSort = (sortKey?: string | null) =>
|
|
54
|
+
sortKey ? (getFilterSortByKey(database.filter, sortKey) as { [key: string]: 1 | -1 }) : null;
|
|
52
55
|
const getListQuery = (query?: QueryOf<any>, queryOption?: ListQueryOption) => {
|
|
53
56
|
const find = query ?? {};
|
|
54
|
-
const sort =
|
|
55
|
-
[key: string]: 1 | -1;
|
|
56
|
-
};
|
|
57
|
+
const sort = resolveSort(queryOption?.sort);
|
|
57
58
|
const skip = Number(queryOption?.skip ?? 0);
|
|
58
59
|
const limit = queryOption?.limit === null ? DEFAULT_PAGE_SIZE : Number(queryOption?.limit ?? 0);
|
|
59
60
|
const select = queryOption?.select;
|
|
@@ -62,9 +63,7 @@ export class DatabaseResolver {
|
|
|
62
63
|
};
|
|
63
64
|
const getFindQuery = (query?: QueryOf<any>, queryOption?: FindQueryOption) => {
|
|
64
65
|
const find = query ?? {};
|
|
65
|
-
const sort =
|
|
66
|
-
[key: string]: 1 | -1;
|
|
67
|
-
};
|
|
66
|
+
const sort = resolveSort(queryOption?.sort);
|
|
68
67
|
const skip = Number(queryOption?.skip ?? 0);
|
|
69
68
|
const select = queryOption?.select;
|
|
70
69
|
const sample = queryOption?.sample ?? false;
|
|
@@ -452,6 +452,29 @@ export class SignalResolver {
|
|
|
452
452
|
return Boolean(req.headers.get("authorization") || req.headers.get("cookie")?.includes("jwt="));
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Re-checks the guards of every room this socket is subscribed to and drops the ones that no
|
|
457
|
+
* longer pass. Called when the socket's credential changes: a pubsub room is authorized once at
|
|
458
|
+
* subscribe time, so without this a signed-out socket would keep receiving its old rooms.
|
|
459
|
+
*/
|
|
460
|
+
static async revalidateWsRooms(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<string[]> {
|
|
461
|
+
const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
|
|
462
|
+
if (!roomCtxMap?.size) return [];
|
|
463
|
+
const websocket = SignalResolver.#getWebsocket(registry);
|
|
464
|
+
const revokedRooms: string[] = [];
|
|
465
|
+
for (const [roomId, roomCtx] of [...roomCtxMap]) {
|
|
466
|
+
if (await roomCtx.authorize()) continue;
|
|
467
|
+
ws.unsubscribe(roomId);
|
|
468
|
+
await Promise.all([...roomCtx.getWebSocketContext().onUnsubscribe.values()].map((handler) => handler()));
|
|
469
|
+
roomCtxMap.delete(roomId);
|
|
470
|
+
websocket.leaveRoom(ws, roomId);
|
|
471
|
+
revokedRooms.push(roomId);
|
|
472
|
+
SignalResolver.logger.verbose(`WebSocket lost access to room ${roomId}; unsubscribed`);
|
|
473
|
+
}
|
|
474
|
+
if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
|
|
475
|
+
return revokedRooms;
|
|
476
|
+
}
|
|
477
|
+
|
|
455
478
|
static async handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
|
|
456
479
|
await SignalResolver.#getWebsocket(registry).registerSocket(ws);
|
|
457
480
|
}
|