akanjs 3.0.0-alpha.88 → 3.0.0-alpha.89
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/base/baseEnv.ts +44 -1
- package/constant/mask.ts +27 -0
- package/dictionary/base.dictionary.ts +12 -2
- package/document/database.ts +8 -2
- package/document/documentQuery.ts +21 -0
- package/document/documentSchema.ts +8 -0
- package/document/index.ts +1 -0
- package/document/into.ts +10 -10
- package/document/queryEvaluator.ts +319 -0
- package/fetch/client/fetchClient.ts +8 -4
- package/fetch/client/wsClient.ts +46 -5
- package/index.ts +13 -0
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +25 -3
- package/server/akanServer.ts +34 -9
- package/server/di/diLifecycle.ts +3 -0
- package/server/proxy/localeWebProxy.ts +3 -1
- package/server/resolver/database.resolver.ts +24 -12
- package/server/resolver/service.resolver.ts +2 -2
- package/server/resolver/signal.resolver.ts +194 -27
- package/server/robots.ts +4 -2
- package/server/routing/apiRouter.ts +6 -3
- package/server/ssrFromRscRenderer.tsx +18 -3
- package/service/index.ts +1 -1
- package/service/injectInfo.ts +4 -0
- package/service/liveSyncHub.ts +185 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +8 -5
- package/service/predefinedAdaptor/sql/types.ts +3 -17
- package/service/predefinedAdaptor/storage.adaptor.ts +4 -3
- package/service/predefinedAdaptor/websocket.adaptor.ts +31 -0
- package/service/serviceModule.ts +2 -2
- package/service/types.ts +8 -2
- package/signal/serializer/fetch.serializer.ts +2 -1
- package/signal/signalContext.ts +52 -1
- package/signal/sliceInfo.ts +42 -0
- package/signal/types.ts +49 -2
- package/store/action.ts +267 -33
- package/store/databaseStateNames.ts +1 -0
- package/store/draftStore.ts +321 -0
- package/store/index.ts +2 -0
- package/store/liveInsert.ts +82 -0
- package/store/sliceRole.ts +3 -1
- package/store/state.ts +23 -0
- package/store/stateBuilder.ts +15 -0
- package/store/store.ts +7 -0
- package/store/storeInstance.ts +104 -3
- package/types/base/baseEnv.d.ts +16 -0
- package/types/constant/mask.d.ts +13 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/database.d.ts +2 -2
- package/types/document/documentQuery.d.ts +4 -0
- package/types/document/documentSchema.d.ts +8 -1
- package/types/document/index.d.ts +1 -0
- package/types/document/into.d.ts +10 -10
- package/types/document/queryEvaluator.d.ts +49 -0
- package/types/fetch/client/fetchClient.d.ts +6 -0
- package/types/fetch/client/wsClient.d.ts +2 -0
- package/types/index.d.ts +12 -0
- package/types/server/akanApp.d.ts +11 -1
- package/types/server/resolver/signal.resolver.d.ts +15 -3
- package/types/server/routing/apiRouter.d.ts +4 -2
- package/types/service/index.d.ts +1 -1
- package/types/service/injectInfo.d.ts +3 -0
- package/types/service/liveSyncHub.d.ts +67 -0
- package/types/service/predefinedAdaptor/storage.adaptor.d.ts +2 -2
- package/types/service/predefinedAdaptor/websocket.adaptor.d.ts +9 -0
- package/types/service/serviceModule.d.ts +3 -3
- package/types/service/types.d.ts +2 -2
- package/types/signal/signalContext.d.ts +22 -1
- package/types/signal/sliceInfo.d.ts +31 -0
- package/types/signal/types.d.ts +46 -0
- package/types/store/action.d.ts +18 -4
- package/types/store/databaseStateNames.d.ts +1 -0
- package/types/store/draftStore.d.ts +57 -0
- package/types/store/index.d.ts +2 -0
- package/types/store/liveInsert.d.ts +40 -0
- package/types/store/sliceRole.d.ts +1 -1
- package/types/store/state.d.ts +24 -0
- package/types/store/stateBuilder.d.ts +12 -0
- package/types/store/storeInstance.d.ts +2 -0
- package/types/ui/Agent/Queued.d.ts +16 -0
- package/types/ui/Agent/attachment.d.ts +2 -0
- package/types/ui/Agent/useChatAttachments.d.ts +1 -0
- package/types/ui/Agent/useChatQueue.d.ts +31 -0
- package/types/ui/Agent/useChatVoice.d.ts +4 -3
- package/types/ui/Field/Collection.d.ts +1 -2
- package/types/ui/Field/NumberField.d.ts +2 -4
- package/types/ui/Field/Text.d.ts +5 -10
- package/types/ui/Field/index.d.ts +8 -8
- package/types/ui/Input.d.ts +0 -6
- package/types/ui/Load/Edit_Client.d.ts +4 -1
- package/types/ui/Model/DraftBar.d.ts +14 -0
- package/types/ui/Model/EditModal.d.ts +7 -1
- package/types/ui/Model/EditWrapper.d.ts +4 -1
- package/types/ui/Model/New.d.ts +4 -1
- package/types/ui/Model/NewWrapper.d.ts +3 -0
- package/types/ui/Model/NewWrapper_Client.d.ts +3 -1
- package/types/ui/Model/draftScope.d.ts +17 -0
- package/types/ui/UiOverride/context.d.ts +2 -0
- package/types/ui/index.d.ts +2 -0
- package/ui/Agent/Chat.tsx +48 -19
- package/ui/Agent/Composer.tsx +17 -4
- package/ui/Agent/Queued.tsx +58 -0
- package/ui/Agent/attachment.ts +7 -0
- package/ui/Agent/useChatAttachments.ts +11 -0
- package/ui/Agent/useChatQueue.ts +68 -0
- package/ui/Agent/useChatVoice.ts +20 -10
- package/ui/Field/Collection.tsx +0 -3
- package/ui/Field/NumberField.tsx +0 -7
- package/ui/Field/Text.tsx +0 -15
- package/ui/Input.tsx +1 -61
- package/ui/Load/Edit_Client.tsx +7 -0
- package/ui/Load/Units.tsx +9 -0
- package/ui/Model/DraftBar.tsx +84 -0
- package/ui/Model/EditModal.tsx +26 -4
- package/ui/Model/EditWrapper.tsx +5 -1
- package/ui/Model/New.tsx +6 -1
- package/ui/Model/NewWrapper.tsx +3 -0
- package/ui/Model/NewWrapper_Client.tsx +12 -2
- package/ui/Model/draftScope.ts +27 -0
- package/ui/System/Client.tsx +5 -1
- package/ui/UiOverride/context.ts +2 -0
- package/ui/index.ts +2 -0
package/base/baseEnv.ts
CHANGED
|
@@ -59,6 +59,8 @@ export type ClientEnv = BaseEnv & {
|
|
|
59
59
|
side: "server" | "client";
|
|
60
60
|
renderMode: "ssr" | "csr";
|
|
61
61
|
websocket: boolean;
|
|
62
|
+
apiPrefix: string;
|
|
63
|
+
wsPrefix: string;
|
|
62
64
|
clientHost: string;
|
|
63
65
|
clientPort: number;
|
|
64
66
|
clientHttpProtocol: "http:" | "https:";
|
|
@@ -73,6 +75,43 @@ export type ClientEnv = BaseEnv & {
|
|
|
73
75
|
|
|
74
76
|
let cachedEnv: ClientEnv | undefined;
|
|
75
77
|
|
|
78
|
+
type RoutePrefixOverride = { api?: string; ws?: string };
|
|
79
|
+
|
|
80
|
+
const globalWithPrefix = globalThis as typeof globalThis & { __AKAN_PREFIX__?: RoutePrefixOverride };
|
|
81
|
+
|
|
82
|
+
/** Leading slash, no trailing slash. A blank value — or a bare `/`, which would swallow every page route — is no prefix at all and falls through to the next source. */
|
|
83
|
+
export const normalizeRoutePrefix = (value: string | undefined | null): string | undefined => {
|
|
84
|
+
const trimmed = value?.trim();
|
|
85
|
+
if (!trimmed) return undefined;
|
|
86
|
+
const normalized = `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
|
|
87
|
+
return normalized === "/" ? undefined : normalized;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Three sources, narrowest first. The global is what a server-rendered page's bootstrap script writes, so the
|
|
92
|
+
* browser follows the process that rendered it. `AKAN_API_PREFIX` is deliberately outside the `AKAN_PUBLIC_*`
|
|
93
|
+
* namespace: only that namespace is inlined into client bundles at build time, so a name inside it could never
|
|
94
|
+
* act as a runtime override. `AKAN_PUBLIC_API_PREFIX` is the build-time default, and the only one a prebuilt CSR
|
|
95
|
+
* or mobile bundle can read. Written out rather than looked up by key — a computed `process.env[...]` is opaque
|
|
96
|
+
* to the bundler's define pass, and the public value would stop being inlined.
|
|
97
|
+
*/
|
|
98
|
+
export const getApiPrefix = (): string =>
|
|
99
|
+
normalizeRoutePrefix(globalWithPrefix.__AKAN_PREFIX__?.api) ??
|
|
100
|
+
normalizeRoutePrefix(process.env.AKAN_API_PREFIX) ??
|
|
101
|
+
normalizeRoutePrefix(process.env.AKAN_PUBLIC_API_PREFIX) ??
|
|
102
|
+
"/api";
|
|
103
|
+
|
|
104
|
+
export const getWsPrefix = (): string =>
|
|
105
|
+
normalizeRoutePrefix(globalWithPrefix.__AKAN_PREFIX__?.ws) ??
|
|
106
|
+
normalizeRoutePrefix(process.env.AKAN_WS_PREFIX) ??
|
|
107
|
+
normalizeRoutePrefix(process.env.AKAN_PUBLIC_WS_PREFIX) ??
|
|
108
|
+
"/ws";
|
|
109
|
+
|
|
110
|
+
/** `AkanApp` rewrites `process.env` for the replica it runs in-process, after this module may already have cached. */
|
|
111
|
+
export const resetEnvCache = () => {
|
|
112
|
+
cachedEnv = undefined;
|
|
113
|
+
};
|
|
114
|
+
|
|
76
115
|
const missingPublicEnv = (key: string) =>
|
|
77
116
|
`getEnv() cannot run at build time: akan build does not inject ${key}. Call it from a runtime function instead of at module scope (e.g. env(() => getEnv()) in adapt(), a method body, or a default thunk).`;
|
|
78
117
|
|
|
@@ -141,7 +180,9 @@ export const getEnv = (): ClientEnv => {
|
|
|
141
180
|
: side === "client"
|
|
142
181
|
? (window.location.protocol as "http:" | "https:")
|
|
143
182
|
: ("http:" as const));
|
|
144
|
-
const
|
|
183
|
+
const apiPrefix = getApiPrefix();
|
|
184
|
+
const wsPrefix = getWsPrefix();
|
|
185
|
+
const serverHttpUri = `${serverHttpProtocol}//${serverHost}${serverPort === 443 ? "" : `:${serverPort}`}${apiPrefix}`;
|
|
145
186
|
const serverWsProtocol = serverHttpProtocol === "http:" ? "ws:" : "wss:";
|
|
146
187
|
const serverWsUri = `${serverWsProtocol}//${serverHost}${serverPort === 443 ? "" : `:${serverPort}`}`;
|
|
147
188
|
|
|
@@ -150,6 +191,8 @@ export const getEnv = (): ClientEnv => {
|
|
|
150
191
|
side,
|
|
151
192
|
renderMode,
|
|
152
193
|
websocket: true,
|
|
194
|
+
apiPrefix,
|
|
195
|
+
wsPrefix,
|
|
153
196
|
clientHost,
|
|
154
197
|
clientPort,
|
|
155
198
|
clientHttpProtocol,
|
package/constant/mask.ts
CHANGED
|
@@ -65,3 +65,30 @@ export const mask = (model: MaskModel, value: unknown): unknown => {
|
|
|
65
65
|
}
|
|
66
66
|
return masked;
|
|
67
67
|
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Drops what a model marks `hidden` or `secret`, and nothing else.
|
|
71
|
+
*
|
|
72
|
+
* The sibling of `mask()` for the one caller that is not an AI read: a saved form draft. Two differences matter.
|
|
73
|
+
* It keeps `visual` — a rendered body is exactly the field a user spent twenty minutes on, and dropping it from a
|
|
74
|
+
* draft loses the work the draft exists to protect. And it is subtractive rather than reconstructive: `mask()`
|
|
75
|
+
* builds its result from the field metadata, so a key the metadata does not name — `id`, which is what decides
|
|
76
|
+
* whether a form creates or updates — would not survive the round trip.
|
|
77
|
+
*
|
|
78
|
+
* `for...in` rather than `Object.keys`, because a model instance keeps its Date fields as enumerable prototype
|
|
79
|
+
* accessors.
|
|
80
|
+
*/
|
|
81
|
+
export const stripSecrets = (model: MaskModel, value: unknown): unknown => {
|
|
82
|
+
if (value === null || value === undefined || typeof value !== "object") return value;
|
|
83
|
+
if (Array.isArray(value)) return value.map((item: unknown) => stripSecrets(model, item));
|
|
84
|
+
const fields = maskFieldsOf(model);
|
|
85
|
+
if (!fields) return value;
|
|
86
|
+
const source = value as Record<string, unknown>;
|
|
87
|
+
const stripped: Record<string, unknown> = {};
|
|
88
|
+
for (const key in source) {
|
|
89
|
+
const field = fields[key];
|
|
90
|
+
if (field?.fieldType === "hidden" || field?.fieldType === "secret") continue;
|
|
91
|
+
stripped[key] = field?.isClass && field.modelRef ? stripSecrets(field.modelRef, source[key]) : source[key];
|
|
92
|
+
}
|
|
93
|
+
return stripped;
|
|
94
|
+
};
|
|
@@ -92,6 +92,11 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
92
92
|
"{name}을(를) 더하면 메시지가 너무 커서 보낼 수 없습니다.",
|
|
93
93
|
],
|
|
94
94
|
agentAnswerNeeded: ["Type an answer to the question above.", "위 질문에 대한 답을 입력하세요."],
|
|
95
|
+
agentQueue: ["Queue", "예약"],
|
|
96
|
+
agentQueuePlaceholder: ["Message to send after this turn...", "이 턴이 끝난 뒤 보낼 메시지..."],
|
|
97
|
+
agentQueued: ["Sends when this turn ends", "진행 중인 턴이 끝나면 전송됩니다"],
|
|
98
|
+
agentQueueEdit: ["Edit the queued message", "예약된 메시지 수정"],
|
|
99
|
+
agentQueueCancel: ["Cancel the queued message", "예약된 메시지 취소"],
|
|
95
100
|
agentToolDone: ["Done", "완료"],
|
|
96
101
|
agentToolFailed: ["Failed", "실패"],
|
|
97
102
|
agentToolRunning: ["Running", "실행 중"],
|
|
@@ -111,8 +116,8 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
111
116
|
"이 화면에 대해 묻거나 할 일을 지시하세요. 커맨드:",
|
|
112
117
|
],
|
|
113
118
|
agentHelpNote: [
|
|
114
|
-
"The agent asks before anything it should not decide alone
|
|
115
|
-
"에이전트는 혼자 결정하면 안 되는 일을 하기 전에
|
|
119
|
+
"The agent asks before anything it should not decide alone. Stop ends a running turn, and a message sent during one waits for the next.",
|
|
120
|
+
"에이전트는 혼자 결정하면 안 되는 일을 하기 전에 물어봅니다. 중지는 진행 중인 턴을 끝내고, 턴 중에 보낸 메시지는 다음 턴에 전송됩니다.",
|
|
116
121
|
],
|
|
117
122
|
agentNothingToRetry: ["There is no message to send again.", "다시 보낼 메시지가 없습니다."],
|
|
118
123
|
agentNothingToCompact: ["There is nothing to summarize yet.", "아직 요약할 대화가 없습니다."],
|
|
@@ -167,6 +172,11 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
|
|
|
167
172
|
exportCsv: ["Export CSV", "CSV 내보내기"],
|
|
168
173
|
exportJson: ["Export JSON", "JSON 내보내기"],
|
|
169
174
|
actions: ["Actions", "작업"],
|
|
175
|
+
draftConflict: ["You have unsaved changes from", "저장하지 않고 작성 중이던 내용이 있습니다 —"],
|
|
176
|
+
draftApplied: ["Continuing what you were writing", "이어서 작성 중입니다 —"],
|
|
177
|
+
draftRestore: ["Continue", "이어서 작성"],
|
|
178
|
+
draftDiscard: ["Discard", "삭제"],
|
|
179
|
+
draftStartOver: ["Start over", "새로 작성"],
|
|
170
180
|
new: ["New", "신규"],
|
|
171
181
|
edit: ["Edit", "수정"],
|
|
172
182
|
save: ["Save", "저장"],
|
package/document/database.ts
CHANGED
|
@@ -137,8 +137,14 @@ type DatabaseModelWithQuerySort<
|
|
|
137
137
|
__count(query: _QueryOfDoc): Promise<number>;
|
|
138
138
|
__insight(query: _QueryOfDoc): Promise<Insight>;
|
|
139
139
|
clone(data: _DataInput & { id: string }): Promise<Doc>;
|
|
140
|
-
listenPre: (
|
|
141
|
-
|
|
140
|
+
listenPre: (
|
|
141
|
+
type: SaveEventType,
|
|
142
|
+
listener: (doc: Doc, type: CRUDEventType, previous?: Doc) => PromiseOrObject<void>,
|
|
143
|
+
) => () => void;
|
|
144
|
+
listenPost: (
|
|
145
|
+
type: SaveEventType,
|
|
146
|
+
listener: (doc: Doc, type: CRUDEventType, previous?: Doc) => PromiseOrObject<void>,
|
|
147
|
+
) => () => void;
|
|
142
148
|
} & {
|
|
143
149
|
[key in _CapitalizedRefName]: Mdl<Doc, Obj, _DocumentObj>;
|
|
144
150
|
} & {
|
|
@@ -40,6 +40,9 @@ export type DocumentQueryNode =
|
|
|
40
40
|
| { kind: "raw"; sql: string; params: unknown[] }
|
|
41
41
|
| ({ kind: "search"; text: string } & DocumentSearchOptions);
|
|
42
42
|
|
|
43
|
+
/** The four real SQL columns. Everything else lives inside `_doc`, which is what makes the two compile differently. */
|
|
44
|
+
export const baseDocumentColumns = new Set(["id", "createdAt", "updatedAt", "removedAt"]);
|
|
45
|
+
|
|
43
46
|
export type DocumentQueryOperator =
|
|
44
47
|
| "eq"
|
|
45
48
|
| "ne"
|
|
@@ -56,6 +59,24 @@ export type DocumentQueryOperator =
|
|
|
56
59
|
| "has"
|
|
57
60
|
| "contains";
|
|
58
61
|
|
|
62
|
+
/** The operator-object shorthand's keys: `{ status: { oneOf: [...] } }` reaches the same operators as `q.oneOf`. */
|
|
63
|
+
export const queryOperatorKeys = new Set<string>([
|
|
64
|
+
"eq",
|
|
65
|
+
"ne",
|
|
66
|
+
"oneOf",
|
|
67
|
+
"notOneOf",
|
|
68
|
+
"gt",
|
|
69
|
+
"gte",
|
|
70
|
+
"lt",
|
|
71
|
+
"lte",
|
|
72
|
+
"between",
|
|
73
|
+
"exists",
|
|
74
|
+
"missing",
|
|
75
|
+
"empty",
|
|
76
|
+
"has",
|
|
77
|
+
"contains",
|
|
78
|
+
]);
|
|
79
|
+
|
|
59
80
|
export type DocumentQueryValue =
|
|
60
81
|
| DocumentPrimitive
|
|
61
82
|
| DocumentPrimitive[]
|
|
@@ -20,10 +20,18 @@ export interface DocumentIndexBuilder<Schema> {
|
|
|
20
20
|
done(): Schema;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `previous` is the document as it was before this write, and is absent on a create.
|
|
25
|
+
*
|
|
26
|
+
* A hook that has to answer "did this leave the set it was in" cannot do it from the new value alone — a soft
|
|
27
|
+
* delete and a field edit that moves a row out of a filter both look like an ordinary document afterwards. It is a
|
|
28
|
+
* trailing optional parameter, so a listener that ignores it is unaffected.
|
|
29
|
+
*/
|
|
23
30
|
export type DocumentSaveHook<Doc = unknown> = (
|
|
24
31
|
this: Doc,
|
|
25
32
|
next?: () => void,
|
|
26
33
|
type?: CRUDEventType,
|
|
34
|
+
previous?: Doc,
|
|
27
35
|
) => PromiseOrObject<void>;
|
|
28
36
|
|
|
29
37
|
export class DocumentSchema<Doc = unknown> {
|
package/document/index.ts
CHANGED
package/document/into.ts
CHANGED
|
@@ -24,21 +24,21 @@ interface DefaultMdlStats<
|
|
|
24
24
|
pickById: (docId: string | undefined, projection?: _Projection) => Promise<TDocument>;
|
|
25
25
|
sample: (query: _FilterQuery, size?: number) => Promise<TDocument[]>;
|
|
26
26
|
sampleOne: (query: _FilterQuery) => Promise<TDocument | null>;
|
|
27
|
-
preSaveListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
28
|
-
postSaveListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
29
|
-
preCreateListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
30
|
-
postCreateListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
31
|
-
preUpdateListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
32
|
-
postUpdateListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
33
|
-
preRemoveListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
34
|
-
postRemoveListenerSet: Set<(doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>>;
|
|
27
|
+
preSaveListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
28
|
+
postSaveListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
29
|
+
preCreateListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
30
|
+
postCreateListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
31
|
+
preUpdateListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
32
|
+
postUpdateListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
33
|
+
preRemoveListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
34
|
+
postRemoveListenerSet: Set<(doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>>;
|
|
35
35
|
listenPre: (
|
|
36
36
|
eventType: SaveEventType,
|
|
37
|
-
listener: (doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>,
|
|
37
|
+
listener: (doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>,
|
|
38
38
|
) => () => void;
|
|
39
39
|
listenPost: (
|
|
40
40
|
eventType: SaveEventType,
|
|
41
|
-
listener: (doc: TDocument, type: CRUDEventType) => PromiseOrObject<void>,
|
|
41
|
+
listener: (doc: TDocument, type: CRUDEventType, previous?: TDocument) => PromiseOrObject<void>,
|
|
42
42
|
) => () => void;
|
|
43
43
|
}
|
|
44
44
|
export interface UpdateResult {
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import {
|
|
2
|
+
baseDocumentColumns,
|
|
3
|
+
type DocumentQuery,
|
|
4
|
+
type DocumentQueryNode,
|
|
5
|
+
type DocumentQueryOperator,
|
|
6
|
+
encodeDocumentValue,
|
|
7
|
+
queryOperatorKeys,
|
|
8
|
+
sanitizeJson,
|
|
9
|
+
} from "./documentQuery";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A document as the database stores it: base columns as written, everything else inside one JSON payload.
|
|
13
|
+
*
|
|
14
|
+
* Membership is evaluated against this rather than against a hydrated model because that is what the SQL the
|
|
15
|
+
* evaluator has to agree with actually reads. A hydrated instance has already had defaults filled, dates turned
|
|
16
|
+
* back into `Dayjs`, and absent optional keys materialized as `undefined` — every one of which changes an answer.
|
|
17
|
+
*/
|
|
18
|
+
export interface DocumentRowView {
|
|
19
|
+
id: unknown;
|
|
20
|
+
createdAt: unknown;
|
|
21
|
+
updatedAt: unknown;
|
|
22
|
+
removedAt: unknown;
|
|
23
|
+
doc: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Only `isArray` is read; the shape matches the field metadata `QueryCompiler` holds. */
|
|
27
|
+
export type QueryFieldMap = Record<string, { getProps?: () => Record<string, unknown> } & Record<string, unknown>>;
|
|
28
|
+
|
|
29
|
+
interface PathValue {
|
|
30
|
+
/** `json_type(...) IS NOT NULL` — the key is in the stored JSON, whatever it holds. */
|
|
31
|
+
present: boolean;
|
|
32
|
+
/** What `json_extract(...)` yields: a SQL scalar, or JSON text for an object or array. */
|
|
33
|
+
value: unknown;
|
|
34
|
+
/** The value before `json_extract` flattening. `json_each` iterates this one. */
|
|
35
|
+
raw: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Answers "does this document match this query" in memory, reproducing what the SQL compiler would have asked the
|
|
40
|
+
* database. Live sync routes a change to the rooms whose query it belongs to, and asking the database once per room
|
|
41
|
+
* is not a thing that can be afforded on a write path.
|
|
42
|
+
*
|
|
43
|
+
* Every rule here mirrors a specific SQLite behaviour rather than a reasonable JavaScript reading of the same
|
|
44
|
+
* operator, because the only property that matters is agreeing with `QueryCompiler` — a disagreement shows up as a
|
|
45
|
+
* list that is quietly wrong, never as an error. `queryEvaluator.parity.test.ts` asks both and compares.
|
|
46
|
+
*/
|
|
47
|
+
export class DocumentQueryEvaluator {
|
|
48
|
+
readonly #fields: QueryFieldMap;
|
|
49
|
+
|
|
50
|
+
constructor(fields: QueryFieldMap = {}) {
|
|
51
|
+
this.#fields = fields;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Why this query cannot be evaluated in memory, or null when it can.
|
|
56
|
+
*
|
|
57
|
+
* Three things are genuinely out of reach. `raw` carries SQL only the database can run, and `search` compiles to
|
|
58
|
+
* an fts5 JOIN whose bm25 ranking has no in-memory equivalent. `exists`/`missing` on a `_doc` field ask whether
|
|
59
|
+
* the key is in the stored JSON at all, and a document read back has already had every declared nullable field
|
|
60
|
+
* materialized as `null` (`SqlDocumentStore.decodeDocumentPayload`) — so a value in hand cannot tell an absent
|
|
61
|
+
* key from a stored null, and answering anyway would be wrong for exactly the rows the operator exists to find.
|
|
62
|
+
* `empty` covers both cases and is evaluable, which is the operator the guide already directs callers to.
|
|
63
|
+
*
|
|
64
|
+
* Callers decide what to do about it — this reports, it does not throw, because refusing a boot is a policy the
|
|
65
|
+
* signal layer owns.
|
|
66
|
+
*/
|
|
67
|
+
static unevaluableReason(query: DocumentQuery | undefined): string | null {
|
|
68
|
+
if (!query || typeof query !== "object") return null;
|
|
69
|
+
if (DocumentQueryEvaluator.#isNode(query)) {
|
|
70
|
+
if (query.kind === "raw") return "q.raw() carries SQL that only the database can run";
|
|
71
|
+
if (query.kind === "search") return "q.search() compiles to a full-text JOIN with no in-memory equivalent";
|
|
72
|
+
if (query.kind === "all" || query.kind === "any")
|
|
73
|
+
return query.queries.reduce<string | null>(
|
|
74
|
+
(found, sub) => found ?? DocumentQueryEvaluator.unevaluableReason(sub),
|
|
75
|
+
null,
|
|
76
|
+
);
|
|
77
|
+
if (query.kind === "not") return DocumentQueryEvaluator.unevaluableReason(query.query);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return Object.entries(query).reduce<string | null>(
|
|
81
|
+
(found, [path, value]) => found ?? DocumentQueryEvaluator.#unevaluableField(path, value),
|
|
82
|
+
null,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static #unevaluableField(path: string, value: unknown): string | null {
|
|
87
|
+
if (!value || typeof value !== "object") return null;
|
|
88
|
+
if (baseDocumentColumns.has(path)) return null;
|
|
89
|
+
const keyAbsence = `${path}: exists/missing asks whether the key is in the stored JSON, which a document in hand cannot answer — use q.empty()`;
|
|
90
|
+
if (DocumentQueryEvaluator.#isNode(value)) {
|
|
91
|
+
if (value.kind === "op") return value.op === "exists" || value.op === "missing" ? keyAbsence : null;
|
|
92
|
+
return DocumentQueryEvaluator.unevaluableReason(value as DocumentQuery);
|
|
93
|
+
}
|
|
94
|
+
const operators = value as Record<string, unknown>;
|
|
95
|
+
|
|
96
|
+
if ("exists" in operators || "missing" in operators || ("empty" in operators && !operators.empty))
|
|
97
|
+
return keyAbsence;
|
|
98
|
+
return DocumentQueryEvaluator.unevaluableReason(value as DocumentQuery);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Splits a saved document into the row shape above, mirroring `SqlDocumentStore.toRow`. */
|
|
102
|
+
static rowViewOf(doc: Record<string, unknown>): DocumentRowView {
|
|
103
|
+
const payload = { ...doc };
|
|
104
|
+
delete payload.id;
|
|
105
|
+
delete payload.createdAt;
|
|
106
|
+
delete payload.updatedAt;
|
|
107
|
+
delete payload.removedAt;
|
|
108
|
+
return {
|
|
109
|
+
id: doc.id ?? null,
|
|
110
|
+
createdAt: doc.createdAt === undefined ? null : Number(encodeDocumentValue(doc.createdAt)),
|
|
111
|
+
updatedAt: doc.updatedAt === undefined ? null : Number(encodeDocumentValue(doc.updatedAt)),
|
|
112
|
+
removedAt: doc.removedAt ? Number(encodeDocumentValue(doc.removedAt)) : null,
|
|
113
|
+
doc: (sanitizeJson(payload) ?? {}) as Record<string, unknown>,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
evaluate(query: DocumentQuery | undefined, row: DocumentRowView): boolean {
|
|
118
|
+
if (!query || typeof query !== "object") return true;
|
|
119
|
+
if (DocumentQueryEvaluator.#isNode(query)) return this.#node(query, row);
|
|
120
|
+
const entries = Object.entries(query);
|
|
121
|
+
if (!entries.length) return true;
|
|
122
|
+
return entries.every(([path, value]) => this.#field(path, value, row));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#node(node: DocumentQueryNode, row: DocumentRowView): boolean {
|
|
126
|
+
switch (node.kind) {
|
|
127
|
+
|
|
128
|
+
case "all":
|
|
129
|
+
return !node.queries.length || node.queries.every((sub) => this.evaluate(sub, row));
|
|
130
|
+
case "any":
|
|
131
|
+
return !node.queries.length || node.queries.some((sub) => this.evaluate(sub, row));
|
|
132
|
+
case "not":
|
|
133
|
+
return !this.evaluate(node.query, row);
|
|
134
|
+
case "raw":
|
|
135
|
+
case "search":
|
|
136
|
+
throw new Error(`Query cannot be evaluated in memory: ${DocumentQueryEvaluator.unevaluableReason(node)}`);
|
|
137
|
+
case "op":
|
|
138
|
+
throw new Error("Operator nodes must be attached to a document path");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#field(path: string, value: unknown, row: DocumentRowView): boolean {
|
|
143
|
+
if (DocumentQueryEvaluator.#isNode(value)) {
|
|
144
|
+
if (value.kind !== "op") return this.evaluate({ [path]: value } as DocumentQuery, row);
|
|
145
|
+
return this.#operator(path, value.op, value.value, row);
|
|
146
|
+
}
|
|
147
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
148
|
+
const operators = value as Record<string, unknown>;
|
|
149
|
+
const keys = Object.keys(operators);
|
|
150
|
+
if (keys.some((key) => queryOperatorKeys.has(key))) {
|
|
151
|
+
return keys
|
|
152
|
+
.filter((key) => queryOperatorKeys.has(key))
|
|
153
|
+
.every((key) => {
|
|
154
|
+
|
|
155
|
+
if (key === "exists") return this.#operator(path, operators.exists ? "exists" : "missing", undefined, row);
|
|
156
|
+
if (key === "missing")
|
|
157
|
+
return this.#operator(path, operators.missing ? "missing" : "exists", undefined, row);
|
|
158
|
+
if (key === "empty") return this.#operator(path, operators.empty ? "empty" : "exists", undefined, row);
|
|
159
|
+
return this.#operator(path, key as DocumentQueryOperator, operators[key], row);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (this.#isArrayField(path) && !Array.isArray(value)) return this.#operator(path, "has", value, row);
|
|
165
|
+
return this.#operator(path, "eq", value, row);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#operator(path: string, op: DocumentQueryOperator, operand: unknown, row: DocumentRowView): boolean {
|
|
169
|
+
const isBase = baseDocumentColumns.has(path);
|
|
170
|
+
const read = this.#read(path, row);
|
|
171
|
+
switch (op) {
|
|
172
|
+
case "eq":
|
|
173
|
+
return operand === null ? read.value === null : DocumentQueryEvaluator.#equals(read.value, operand);
|
|
174
|
+
case "ne":
|
|
175
|
+
|
|
176
|
+
if (operand === null) return read.value !== null;
|
|
177
|
+
return read.value !== null && !DocumentQueryEvaluator.#equals(read.value, operand);
|
|
178
|
+
case "gt":
|
|
179
|
+
case "gte":
|
|
180
|
+
case "lt":
|
|
181
|
+
case "lte": {
|
|
182
|
+
const order = DocumentQueryEvaluator.#compare(read.value, DocumentQueryEvaluator.#encode(operand));
|
|
183
|
+
if (order === null) return false;
|
|
184
|
+
return op === "gt" ? order > 0 : op === "gte" ? order >= 0 : op === "lt" ? order < 0 : order <= 0;
|
|
185
|
+
}
|
|
186
|
+
case "between": {
|
|
187
|
+
const [from, to] = (operand as [unknown, unknown]) ?? [];
|
|
188
|
+
const low = DocumentQueryEvaluator.#compare(read.value, DocumentQueryEvaluator.#encode(from));
|
|
189
|
+
const high = DocumentQueryEvaluator.#compare(read.value, DocumentQueryEvaluator.#encode(to));
|
|
190
|
+
return low !== null && high !== null && low >= 0 && high <= 0;
|
|
191
|
+
}
|
|
192
|
+
case "oneOf": {
|
|
193
|
+
const values = (operand as unknown[]) ?? [];
|
|
194
|
+
if (!values.length) return false;
|
|
195
|
+
if (this.#isArrayField(path)) return values.some((item) => this.#has(read, item));
|
|
196
|
+
return read.value !== null && values.some((item) => DocumentQueryEvaluator.#equals(read.value, item));
|
|
197
|
+
}
|
|
198
|
+
case "notOneOf": {
|
|
199
|
+
const values = (operand as unknown[]) ?? [];
|
|
200
|
+
if (!values.length) return true;
|
|
201
|
+
if (this.#isArrayField(path)) return !values.some((item) => this.#has(read, item));
|
|
202
|
+
return read.value !== null && !values.some((item) => DocumentQueryEvaluator.#equals(read.value, item));
|
|
203
|
+
}
|
|
204
|
+
case "exists":
|
|
205
|
+
case "missing": {
|
|
206
|
+
|
|
207
|
+
if (!isBase)
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Query cannot be evaluated in memory: ${DocumentQueryEvaluator.#unevaluableField(path, { kind: "op", op })}`,
|
|
210
|
+
);
|
|
211
|
+
return op === "exists" ? read.value !== null : read.value === null;
|
|
212
|
+
}
|
|
213
|
+
case "empty":
|
|
214
|
+
|
|
215
|
+
return isBase ? read.value === null : !read.present || read.value === null;
|
|
216
|
+
case "has":
|
|
217
|
+
return this.#has(read, operand);
|
|
218
|
+
case "contains":
|
|
219
|
+
|
|
220
|
+
return read.value !== null && String(read.value).toLowerCase().includes(String(operand).toLowerCase());
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `EXISTS (SELECT 1 FROM json_each(<extract>) WHERE value = ?)` — an object iterates its values, a scalar itself. */
|
|
225
|
+
#has(read: PathValue, operand: unknown): boolean {
|
|
226
|
+
const { raw } = read;
|
|
227
|
+
if (raw === null || raw === undefined) return false;
|
|
228
|
+
const items = Array.isArray(raw)
|
|
229
|
+
? raw
|
|
230
|
+
: typeof raw === "object"
|
|
231
|
+
? Object.values(raw as Record<string, unknown>)
|
|
232
|
+
: [raw];
|
|
233
|
+
return items.some((item) => DocumentQueryEvaluator.#equals(DocumentQueryEvaluator.#extract(item), operand));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#read(path: string, row: DocumentRowView): PathValue {
|
|
237
|
+
if (baseDocumentColumns.has(path)) {
|
|
238
|
+
const value = (row as unknown as Record<string, unknown>)[path] ?? null;
|
|
239
|
+
return { present: value !== null, value, raw: value };
|
|
240
|
+
}
|
|
241
|
+
let current: unknown = row.doc;
|
|
242
|
+
for (const segment of path.split(".")) {
|
|
243
|
+
|
|
244
|
+
if (current === null || current === undefined || typeof current !== "object" || Array.isArray(current))
|
|
245
|
+
return { present: false, value: null, raw: undefined };
|
|
246
|
+
if (!(segment in (current as Record<string, unknown>))) return { present: false, value: null, raw: undefined };
|
|
247
|
+
current = (current as Record<string, unknown>)[segment];
|
|
248
|
+
}
|
|
249
|
+
if (current === undefined) return { present: false, value: null, raw: undefined };
|
|
250
|
+
return { present: true, value: DocumentQueryEvaluator.#extract(current), raw: current };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#isArrayField(path: string): boolean {
|
|
254
|
+
const field = this.#fields[path.split(".")[0]];
|
|
255
|
+
const props = field?.getProps?.() ?? field;
|
|
256
|
+
return !!props?.isArray;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** What `json_extract` yields for a stored value: booleans collapse to 1/0, containers come back as JSON text. */
|
|
260
|
+
static #extract(value: unknown): unknown {
|
|
261
|
+
if (value === undefined || value === null) return null;
|
|
262
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
263
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
264
|
+
return value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A query operand as the compiler binds it — dates to epoch ms, containers to JSON text. */
|
|
268
|
+
static #encode(operand: unknown): unknown {
|
|
269
|
+
const encoded = encodeDocumentValue(operand);
|
|
270
|
+
if (typeof encoded === "boolean") return encoded ? 1 : 0;
|
|
271
|
+
if (encoded === undefined) return null;
|
|
272
|
+
return encoded;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
static #equals(value: unknown, operand: unknown): boolean {
|
|
276
|
+
return DocumentQueryEvaluator.#compare(value, DocumentQueryEvaluator.#encode(operand)) === 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* SQLite's ordering: NULL sorts below every number, which sorts below every text. Comparing across those classes
|
|
281
|
+
* is never equal, so a query on a field holding the wrong type matches nothing rather than coercing its way to a
|
|
282
|
+
* match the way JavaScript's `<` would.
|
|
283
|
+
*/
|
|
284
|
+
static #compare(left: unknown, right: unknown): number | null {
|
|
285
|
+
const leftClass = DocumentQueryEvaluator.#classOf(left);
|
|
286
|
+
const rightClass = DocumentQueryEvaluator.#classOf(right);
|
|
287
|
+
if (leftClass === 0 || rightClass === 0) return null;
|
|
288
|
+
if (leftClass !== rightClass) return leftClass < rightClass ? -1 : 1;
|
|
289
|
+
if (leftClass === 1) return Number(left) === Number(right) ? 0 : Number(left) < Number(right) ? -1 : 1;
|
|
290
|
+
return DocumentQueryEvaluator.#compareText(String(left), String(right));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
static #classOf(value: unknown): 0 | 1 | 2 {
|
|
294
|
+
if (value === null || value === undefined) return 0;
|
|
295
|
+
if (typeof value === "number" || typeof value === "bigint") return 1;
|
|
296
|
+
if (typeof value === "boolean") return 1;
|
|
297
|
+
return 2;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* By code point, which is exactly UTF-8 byte order and therefore SQLite's BINARY collation. JavaScript's own `<`
|
|
302
|
+
* compares UTF-16 code units, which puts supplementary-plane characters below U+E000 instead of above it.
|
|
303
|
+
*/
|
|
304
|
+
static #compareText(left: string, right: string): number {
|
|
305
|
+
const leftPoints = [...left];
|
|
306
|
+
const rightPoints = [...right];
|
|
307
|
+
const shared = Math.min(leftPoints.length, rightPoints.length);
|
|
308
|
+
for (let idx = 0; idx < shared; idx += 1) {
|
|
309
|
+
const a = leftPoints[idx].codePointAt(0) ?? 0;
|
|
310
|
+
const b = rightPoints[idx].codePointAt(0) ?? 0;
|
|
311
|
+
if (a !== b) return a < b ? -1 : 1;
|
|
312
|
+
}
|
|
313
|
+
return leftPoints.length === rightPoints.length ? 0 : leftPoints.length < rightPoints.length ? -1 : 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
static #isNode(value: unknown): value is DocumentQueryNode {
|
|
317
|
+
return !!value && typeof value === "object" && "kind" in value;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getEnv, PrimitiveRegistry, type PromiseOrObject } from "akanjs/base";
|
|
1
|
+
import { getEnv, getWsPrefix, PrimitiveRegistry, type PromiseOrObject } from "akanjs/base";
|
|
2
2
|
import {
|
|
3
3
|
capitalize,
|
|
4
4
|
type FetchPolicy,
|
|
@@ -90,6 +90,8 @@ export class FetchClient {
|
|
|
90
90
|
readonly handler: Record<string, FetchHandler>;
|
|
91
91
|
readonly slice: Record<string, SliceMeta> = {};
|
|
92
92
|
readonly sortKeyMap = new Map<string, string[]>();
|
|
93
|
+
/** refName → sort key → the field map that key orders by. Read by live insertion, which has to place a row. */
|
|
94
|
+
readonly sortValueMap = new Map<string, { [key: string]: { [path: string]: 1 | -1 } }>();
|
|
93
95
|
readonly filterQueryMap = new Map<string, { [queryKey: string]: SerializedArg[] }>();
|
|
94
96
|
readonly #originWs = new Map<string, WsClient>();
|
|
95
97
|
readonly #handlerStore: Record<string, FetchHandler> = {};
|
|
@@ -154,6 +156,7 @@ export class FetchClient {
|
|
|
154
156
|
? {
|
|
155
157
|
filter: { ...current.filter?.filter, ...signal.filter?.filter },
|
|
156
158
|
sortKeys: [...new Set([...(current.filter?.sortKeys ?? []), ...(signal.filter?.sortKeys ?? [])])],
|
|
159
|
+
sorts: { ...current.filter?.sorts, ...signal.filter?.sorts },
|
|
157
160
|
}
|
|
158
161
|
: undefined,
|
|
159
162
|
getGuards: signal.getGuards ?? current.getGuards,
|
|
@@ -190,7 +193,7 @@ export class FetchClient {
|
|
|
190
193
|
if (signal.filter) {
|
|
191
194
|
|
|
192
195
|
const filter = this.serializedSignal[refName]?.filter ?? signal.filter;
|
|
193
|
-
this.#registerFilterSortKey(refName, filter.sortKeys);
|
|
196
|
+
this.#registerFilterSortKey(refName, filter.sortKeys, filter.sorts);
|
|
194
197
|
this.#registerFilterQuery(refName, filter.filter);
|
|
195
198
|
}
|
|
196
199
|
}
|
|
@@ -286,8 +289,9 @@ export class FetchClient {
|
|
|
286
289
|
}
|
|
287
290
|
return this.jwt ? { Authorization: `Bearer ${this.jwt}` } : {};
|
|
288
291
|
}
|
|
289
|
-
#registerFilterSortKey(refName: string, sortKeys: string[]) {
|
|
292
|
+
#registerFilterSortKey(refName: string, sortKeys: string[], sorts?: { [key: string]: { [path: string]: 1 | -1 } }) {
|
|
290
293
|
this.sortKeyMap.set(refName, sortKeys);
|
|
294
|
+
if (sorts) this.sortValueMap.set(refName, sorts);
|
|
291
295
|
}
|
|
292
296
|
#registerFilterQuery(refName: string, filter: { [queryKey: string]: SerializedArg[] }) {
|
|
293
297
|
this.filterQueryMap.set(refName, filter);
|
|
@@ -413,7 +417,7 @@ export class FetchClient {
|
|
|
413
417
|
}
|
|
414
418
|
}
|
|
415
419
|
static #makeWsUri(origin: string) {
|
|
416
|
-
return `${origin.replace("http://", "ws://").replace("https://", "wss://")}
|
|
420
|
+
return `${origin.replace("http://", "ws://").replace("https://", "wss://")}${getWsPrefix()}`;
|
|
417
421
|
}
|
|
418
422
|
|
|
419
423
|
static paginationArgs: SerializedArg[] = [
|