akanjs 3.0.0-alpha.87 → 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.
Files changed (127) hide show
  1. package/base/baseEnv.ts +44 -1
  2. package/constant/mask.ts +27 -0
  3. package/dictionary/base.dictionary.ts +12 -2
  4. package/document/database.ts +8 -2
  5. package/document/documentQuery.ts +21 -0
  6. package/document/documentSchema.ts +8 -0
  7. package/document/index.ts +1 -0
  8. package/document/into.ts +10 -10
  9. package/document/queryEvaluator.ts +319 -0
  10. package/fetch/client/fetchClient.ts +8 -4
  11. package/fetch/client/wsClient.ts +46 -5
  12. package/index.ts +13 -0
  13. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  14. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  15. package/package.json +1 -1
  16. package/server/akanApp.ts +25 -3
  17. package/server/akanServer.ts +34 -9
  18. package/server/di/diLifecycle.ts +3 -0
  19. package/server/proxy/localeWebProxy.ts +3 -1
  20. package/server/resolver/database.resolver.ts +24 -12
  21. package/server/resolver/service.resolver.ts +2 -2
  22. package/server/resolver/signal.resolver.ts +194 -27
  23. package/server/robots.ts +4 -2
  24. package/server/routing/apiRouter.ts +6 -3
  25. package/server/ssrFromRscRenderer.tsx +18 -3
  26. package/service/index.ts +1 -1
  27. package/service/injectInfo.ts +4 -0
  28. package/service/liveSyncHub.ts +185 -0
  29. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +8 -5
  30. package/service/predefinedAdaptor/sql/types.ts +3 -17
  31. package/service/predefinedAdaptor/storage.adaptor.ts +4 -3
  32. package/service/predefinedAdaptor/websocket.adaptor.ts +31 -0
  33. package/service/serviceModule.ts +2 -2
  34. package/service/types.ts +8 -2
  35. package/signal/serializer/fetch.serializer.ts +2 -1
  36. package/signal/signalContext.ts +52 -1
  37. package/signal/sliceInfo.ts +42 -0
  38. package/signal/types.ts +49 -2
  39. package/store/action.ts +277 -39
  40. package/store/databaseStateNames.ts +1 -0
  41. package/store/draftStore.ts +321 -0
  42. package/store/index.ts +2 -0
  43. package/store/liveInsert.ts +82 -0
  44. package/store/sliceRole.ts +3 -1
  45. package/store/state.ts +23 -0
  46. package/store/stateBuilder.ts +15 -0
  47. package/store/store.ts +7 -0
  48. package/store/storeInstance.ts +104 -3
  49. package/types/base/baseEnv.d.ts +16 -0
  50. package/types/constant/mask.d.ts +13 -0
  51. package/types/dictionary/base.dictionary.d.ts +1 -1
  52. package/types/dictionary/dictionary.d.ts +8 -8
  53. package/types/document/database.d.ts +2 -2
  54. package/types/document/documentQuery.d.ts +4 -0
  55. package/types/document/documentSchema.d.ts +8 -1
  56. package/types/document/index.d.ts +1 -0
  57. package/types/document/into.d.ts +10 -10
  58. package/types/document/queryEvaluator.d.ts +49 -0
  59. package/types/fetch/client/fetchClient.d.ts +6 -0
  60. package/types/fetch/client/wsClient.d.ts +2 -0
  61. package/types/index.d.ts +12 -0
  62. package/types/server/akanApp.d.ts +11 -1
  63. package/types/server/resolver/signal.resolver.d.ts +15 -3
  64. package/types/server/routing/apiRouter.d.ts +4 -2
  65. package/types/service/index.d.ts +1 -1
  66. package/types/service/injectInfo.d.ts +3 -0
  67. package/types/service/liveSyncHub.d.ts +67 -0
  68. package/types/service/predefinedAdaptor/storage.adaptor.d.ts +2 -2
  69. package/types/service/predefinedAdaptor/websocket.adaptor.d.ts +9 -0
  70. package/types/service/serviceModule.d.ts +3 -3
  71. package/types/service/types.d.ts +2 -2
  72. package/types/signal/signalContext.d.ts +22 -1
  73. package/types/signal/sliceInfo.d.ts +31 -0
  74. package/types/signal/types.d.ts +46 -0
  75. package/types/store/action.d.ts +18 -4
  76. package/types/store/databaseStateNames.d.ts +1 -0
  77. package/types/store/draftStore.d.ts +57 -0
  78. package/types/store/index.d.ts +2 -0
  79. package/types/store/liveInsert.d.ts +40 -0
  80. package/types/store/sliceRole.d.ts +1 -1
  81. package/types/store/state.d.ts +24 -0
  82. package/types/store/stateBuilder.d.ts +12 -0
  83. package/types/store/storeInstance.d.ts +2 -0
  84. package/types/ui/Agent/Queued.d.ts +16 -0
  85. package/types/ui/Agent/attachment.d.ts +2 -0
  86. package/types/ui/Agent/useChatAttachments.d.ts +1 -0
  87. package/types/ui/Agent/useChatQueue.d.ts +31 -0
  88. package/types/ui/Agent/useChatVoice.d.ts +4 -3
  89. package/types/ui/Field/Collection.d.ts +1 -2
  90. package/types/ui/Field/NumberField.d.ts +2 -4
  91. package/types/ui/Field/Text.d.ts +5 -10
  92. package/types/ui/Field/index.d.ts +8 -8
  93. package/types/ui/Input.d.ts +0 -6
  94. package/types/ui/Load/Edit_Client.d.ts +4 -1
  95. package/types/ui/Model/DraftBar.d.ts +14 -0
  96. package/types/ui/Model/EditModal.d.ts +7 -1
  97. package/types/ui/Model/EditWrapper.d.ts +4 -1
  98. package/types/ui/Model/New.d.ts +4 -1
  99. package/types/ui/Model/NewWrapper.d.ts +3 -0
  100. package/types/ui/Model/NewWrapper_Client.d.ts +3 -1
  101. package/types/ui/Model/draftScope.d.ts +17 -0
  102. package/types/ui/UiOverride/context.d.ts +2 -0
  103. package/types/ui/index.d.ts +2 -0
  104. package/ui/Agent/Chat.tsx +48 -19
  105. package/ui/Agent/Composer.tsx +17 -4
  106. package/ui/Agent/Queued.tsx +58 -0
  107. package/ui/Agent/attachment.ts +7 -0
  108. package/ui/Agent/useChatAttachments.ts +11 -0
  109. package/ui/Agent/useChatQueue.ts +68 -0
  110. package/ui/Agent/useChatVoice.ts +20 -10
  111. package/ui/Field/Collection.tsx +0 -3
  112. package/ui/Field/NumberField.tsx +0 -7
  113. package/ui/Field/Text.tsx +0 -15
  114. package/ui/Input.tsx +1 -61
  115. package/ui/Load/Edit_Client.tsx +7 -0
  116. package/ui/Load/Units.tsx +10 -1
  117. package/ui/Load/View.tsx +10 -0
  118. package/ui/Model/DraftBar.tsx +84 -0
  119. package/ui/Model/EditModal.tsx +26 -4
  120. package/ui/Model/EditWrapper.tsx +5 -1
  121. package/ui/Model/New.tsx +6 -1
  122. package/ui/Model/NewWrapper.tsx +3 -0
  123. package/ui/Model/NewWrapper_Client.tsx +12 -2
  124. package/ui/Model/draftScope.ts +27 -0
  125. package/ui/System/Client.tsx +5 -1
  126. package/ui/UiOverride/context.ts +2 -0
  127. 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 serverHttpUri = `${serverHttpProtocol}//${serverHost}${serverPort === 443 ? "" : `:${serverPort}`}/api`;
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, and Stop ends a running turn.",
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", "저장"],
@@ -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: (type: SaveEventType, listener: (doc: Doc, type: CRUDEventType) => PromiseOrObject<void>) => () => void;
141
- listenPost: (type: SaveEventType, listener: (doc: Doc, type: CRUDEventType) => PromiseOrObject<void>) => () => void;
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
@@ -8,5 +8,6 @@ export * from "./filterMeta";
8
8
  export * from "./into";
9
9
  export * from "./loaderInfo";
10
10
  export * from "./noDocumentError";
11
+ export * from "./queryEvaluator";
11
12
  export * from "./schema";
12
13
  export * from "./types";
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://")}/ws`;
420
+ return `${origin.replace("http://", "ws://").replace("https://", "wss://")}${getWsPrefix()}`;
417
421
  }
418
422
 
419
423
  static paginationArgs: SerializedArg[] = [