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.
Files changed (66) hide show
  1. package/client/csrTypes.ts +7 -0
  2. package/client/frameConfig.ts +6 -1
  3. package/client/rscNavigation.ts +9 -0
  4. package/common/index.ts +5 -0
  5. package/common/websocketAuth.ts +24 -0
  6. package/constant/fieldInfo.ts +4 -3
  7. package/constant/index.ts +2 -0
  8. package/constant/textFieldPathSet.ts +8 -0
  9. package/constant/textFieldPaths.ts +59 -0
  10. package/constant/types.ts +0 -4
  11. package/constant/via.ts +13 -28
  12. package/dictionary/dictInfo.ts +4 -0
  13. package/document/documentQuery.ts +17 -1
  14. package/document/documentSchema.ts +1 -15
  15. package/document/filterMeta.ts +4 -2
  16. package/fetch/client/fetchClient.ts +1 -0
  17. package/fetch/client/wsClient.ts +28 -1
  18. package/index.ts +6 -0
  19. package/local/apps/serverLifecycle/serverLifecycle-local.db +0 -0
  20. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  21. package/local/apps/serverLifecycle/serverLifecycle-local.db-wal +0 -0
  22. package/package.json +1 -1
  23. package/server/akanServer.ts +5 -4
  24. package/server/devtools/types.ts +2 -2
  25. package/server/resolver/database.resolver.ts +5 -6
  26. package/server/resolver/signal.resolver.ts +23 -0
  27. package/server/routing/apiRouter.ts +11 -3
  28. package/server/routing/appWsData.ts +50 -0
  29. package/server/rscClient.tsx +9 -0
  30. package/service/predefinedAdaptor/database.adaptor.ts +212 -88
  31. package/service/predefinedAdaptor/index.ts +1 -0
  32. package/service/predefinedAdaptor/searchIndex.ts +517 -0
  33. package/service/predefinedAdaptor/sqlDescriptor.ts +25 -0
  34. package/signal/signalContext.ts +37 -12
  35. package/signal/types.ts +2 -1
  36. package/types/client/csrTypes.d.ts +7 -0
  37. package/types/client/rscNavigation.d.ts +8 -0
  38. package/types/common/index.d.ts +1 -0
  39. package/types/common/websocketAuth.d.ts +19 -0
  40. package/types/constant/fieldInfo.d.ts +4 -3
  41. package/types/constant/index.d.ts +2 -0
  42. package/types/constant/textFieldPathSet.d.ts +8 -0
  43. package/types/constant/textFieldPaths.d.ts +10 -0
  44. package/types/constant/types.d.ts +0 -3
  45. package/types/constant/via.d.ts +4 -24
  46. package/types/dictionary/base.dictionary.d.ts +1 -1
  47. package/types/dictionary/dictionary.d.ts +8 -8
  48. package/types/document/documentQuery.d.ts +13 -1
  49. package/types/document/documentSchema.d.ts +0 -3
  50. package/types/document/filterMeta.d.ts +2 -1
  51. package/types/fetch/client/wsClient.d.ts +6 -0
  52. package/types/index.d.ts +6 -0
  53. package/types/server/devtools/types.d.ts +2 -2
  54. package/types/server/resolver/signal.resolver.d.ts +6 -0
  55. package/types/server/routing/apiRouter.d.ts +3 -4
  56. package/types/server/routing/appWsData.d.ts +24 -0
  57. package/types/server/rscClient.d.ts +1 -0
  58. package/types/service/predefinedAdaptor/database.adaptor.d.ts +46 -13
  59. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  60. package/types/service/predefinedAdaptor/searchIndex.d.ts +73 -0
  61. package/types/service/predefinedAdaptor/sqlDescriptor.d.ts +5 -0
  62. package/types/signal/signalContext.d.ts +7 -0
  63. package/types/signal/types.d.ts +5 -1
  64. package/types/ui/Constant/schemaDoc.d.ts +2 -2
  65. package/ui/Constant/schemaDoc.ts +8 -2
  66. package/ui/Model/EditModal.tsx +24 -6
@@ -0,0 +1,73 @@
1
+ import { type ConstantModel, textFieldRoles } from "akanjs/constant";
2
+ import { type DatabaseModel, type SearchColumn } from "akanjs/document";
3
+ import type { AkanSqlClient } from "./database.adaptor";
4
+ export declare const DOC_TABLE = "search_doc";
5
+ export declare const FTS_TABLE = "search_fts";
6
+ export declare const DEFAULT_TOKENIZER = "unicode61 remove_diacritics 2";
7
+ export declare const OPTIMIZE_CRON_KEY = "searchIndexOptimize";
8
+ export declare const OPTIMIZE_CRON = "17 4 * * *";
9
+ export declare const RETRY_INTERVAL_KEY = "searchIndexRetry";
10
+ export declare const RETRY_INTERVAL_MS = 60000;
11
+ export declare const DEFAULT_SEARCH_WEIGHTS: number[];
12
+ export interface SearchIndexOwner {
13
+ getConnection(): AkanSqlClient;
14
+ getMeta(key: string): Promise<string | undefined> | string | undefined;
15
+ setMeta(key: string, value: string): Promise<void>;
16
+ }
17
+ export interface SearchIndexOptions {
18
+ enabled: boolean;
19
+ tokenizer: string;
20
+ }
21
+ type SearchColumns = Record<(typeof textFieldRoles)[number], string>;
22
+ /**
23
+ * Reads `AKAN_SEARCH_ENABLED`. Unset means enabled; an unrecognised value fails the boot rather than
24
+ * silently falling back, because a typo like `ture` would otherwise look identical to the default.
25
+ */
26
+ export declare const parseSearchEnabled: (value: string | undefined) => boolean;
27
+ /**
28
+ * Turns raw user input into an fts5 MATCH expression.
29
+ *
30
+ * Raw text cannot be passed through: `-`, `:`, `*`, `"` and a trailing `AND` are all fts5 syntax and each
31
+ * raises `SQLiteError` instead of returning no rows. Quoting every term makes the whole input literal.
32
+ */
33
+ export declare const toMatchExpression: (text: string, { prefix, columns }?: {
34
+ prefix?: boolean;
35
+ columns?: readonly SearchColumn[];
36
+ }) => string | null;
37
+ /**
38
+ * Owns the `search_doc` mirror and its fts5 index.
39
+ *
40
+ * The mirror is maintained by SQL triggers rather than document hooks because `updateOneByQuery` and friends
41
+ * deliberately fire no hooks — most searchable-field mutations go through exactly that path, so an app-level
42
+ * hook would miss them silently.
43
+ */
44
+ export declare class SearchIndex {
45
+ #private;
46
+ constructor(owner: SearchIndexOwner, { enabled, tokenizer }: SearchIndexOptions);
47
+ get enabled(): boolean;
48
+ ensureSchema(): Promise<void>;
49
+ /** Returns whether this ref's mirror is now current. `false` means another process is rebuilding it. */
50
+ ensureRef(constant: ConstantModel, database: DatabaseModel): Promise<boolean>;
51
+ /** Re-runs the refs another process was holding. Returns how many are still outstanding. */
52
+ retryPending(): Promise<number>;
53
+ /**
54
+ * Rebuilds one ref's mirror rows in id-ordered chunks so a large table does not block the boot. `onChunk` runs
55
+ * between chunks and reports whether this process still holds the claim: a backfill that outlives the lock TTL
56
+ * would otherwise keep writing rows underneath the `DELETE` of the process that took over. Returns whether the
57
+ * whole table was covered.
58
+ */
59
+ reconcileRef(ref: string, columns: SearchColumns, onChunk?: () => Promise<boolean>): Promise<boolean>;
60
+ /** Merges accumulated fts5 segments. Returns whether this process was the one that did the work. */
61
+ optimize(): Promise<boolean>;
62
+ /**
63
+ * Drops a ref's triggers so a bulk import skips the per-row mirror write. Pair with `resume`.
64
+ *
65
+ * The hash is cleared here rather than in `resume` alone: a process that dies mid-import never reaches `resume`,
66
+ * and a boot that finds the hash intact recreates the triggers and skips the backfill, leaving everything
67
+ * written in between missing from the mirror for good.
68
+ */
69
+ suspend(database: DatabaseModel): Promise<void>;
70
+ /** Returns whether the mirror is current again; `false` means another process holds the rebuild. */
71
+ resume(constant: ConstantModel, database: DatabaseModel): Promise<boolean>;
72
+ }
73
+ export {};
@@ -0,0 +1,5 @@
1
+ export declare const quoteIdent: (identifier: string) => string;
2
+ export declare const jsonPath: (path: string) => string;
3
+ export declare const stableJson: (value: unknown) => string;
4
+ /** Stable hash of a schema descriptor, stored in `_akan_meta` so a changed definition is detected without migrations. */
5
+ export declare const descriptorHash: (value: unknown) => Promise<string>;
@@ -32,6 +32,12 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
32
32
  getAdaptor<T extends Adaptor>(adaptorCls: AdaptorCls<T>): T;
33
33
  getService<T>(refName: string): T;
34
34
  init(): Promise<this>;
35
+ /**
36
+ * Re-checks this context's guards outside of a request, for a websocket room that is already
37
+ * subscribed. Only global middlewares run: they carry the account resolution this depends on,
38
+ * while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
39
+ */
40
+ authorize(): Promise<boolean>;
35
41
  exec(): Promise<Response | undefined>;
36
42
  static try(endpoint: Adaptor, endpointInfo: EndpointInfo, key: string, fn: () => Promise<Response | undefined>): Promise<Response | undefined>;
37
43
  static resolveReturn(value: unknown, { signalContext, returnRef, arrDepth, registry, live, }: {
@@ -47,6 +53,7 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
47
53
  }): Promise<unknown>;
48
54
  getHttpContext<Appended = unknown>(): HttpExecutionContext<Appended>;
49
55
  getWebSocketContext<Appended = unknown>(): WebSocketExecutionContext<Appended>;
56
+ get<T = unknown>(key: string): T | null;
50
57
  getRoomId(key: string): string;
51
58
  getEnv(): Env;
52
59
  getArg<T = unknown>(argName: string): T | undefined;
@@ -147,5 +147,9 @@ export type WebsocketPublishData = {
147
147
  roomId: string;
148
148
  data: object | object[];
149
149
  };
150
- export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData;
150
+ export type WebsocketAuthAck = {
151
+ type: "auth";
152
+ revokedRooms: string[];
153
+ };
154
+ export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
151
155
  export {};
@@ -1,5 +1,5 @@
1
1
  import { type EnumInstance } from "akanjs/base";
2
- import { type ConstantCls, type ConstantField } from "akanjs/constant";
2
+ import { type ConstantCls, type ConstantField, type TextFieldRole } from "akanjs/constant";
3
3
  export declare const databaseModelVariants: readonly ["input", "object", "full", "light", "insight"];
4
4
  export type DatabaseModelVariant = (typeof databaseModelVariants)[number];
5
5
  export interface ConstantSchemaOptions {
@@ -75,7 +75,7 @@ export interface FieldSchema {
75
75
  defaultLabel?: string;
76
76
  exampleLabel?: string;
77
77
  constraints: string[];
78
- text?: "search" | "filter";
78
+ text?: TextFieldRole;
79
79
  meta: Record<string, unknown>;
80
80
  raw: ConstantField;
81
81
  }
@@ -7,7 +7,13 @@ import {
7
7
  type PrimitiveScalar,
8
8
  } from "akanjs/base";
9
9
  import { capitalize } from "akanjs/common";
10
- import { type ConstantCls, type ConstantField, type ConstantModel, ConstantRegistry } from "akanjs/constant";
10
+ import {
11
+ type ConstantCls,
12
+ type ConstantField,
13
+ type ConstantModel,
14
+ ConstantRegistry,
15
+ type TextFieldRole,
16
+ } from "akanjs/constant";
11
17
 
12
18
  export const databaseModelVariants = ["input", "object", "full", "light", "insight"] as const;
13
19
  export type DatabaseModelVariant = (typeof databaseModelVariants)[number];
@@ -93,7 +99,7 @@ export interface FieldSchema {
93
99
  defaultLabel?: string;
94
100
  exampleLabel?: string;
95
101
  constraints: string[];
96
- text?: "search" | "filter";
102
+ text?: TextFieldRole;
97
103
  meta: Record<string, unknown>;
98
104
  raw: ConstantField;
99
105
  }
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { clsx, router, usePage } from "akanjs/client";
2
+ import { clsx, isRscNavigationFromCache, router, usePage } from "akanjs/client";
3
3
  import { capitalize, deepObjectify, lowerlize } from "akanjs/common";
4
4
  import { ConstantRegistry, immerify } from "akanjs/constant";
5
5
  import type { ClientEdit, ServerEdit, SliceMeta } from "akanjs/fetch";
@@ -11,6 +11,8 @@ import { AiOutlinePlus, AiOutlineSave } from "react-icons/ai";
11
11
  import { Button } from "../Button";
12
12
  import { Modal } from "../Modal";
13
13
 
14
+ const EDIT_PAYLOAD_MAX_AGE_MS = 60_000;
15
+
14
16
  interface EditModelProps<Full> {
15
17
  /** Rendering mode for the edit shell. */
16
18
  type?: "modal" | "form" | "empty";
@@ -148,6 +150,7 @@ export default function EditModal<Full extends { id: string }>({
148
150
  setModelModal: `set${ModelName}Modal`,
149
151
  modelLoading: `${modelName}Loading`,
150
152
  modelViewAt: `${modelName}ViewAt`,
153
+ editModel: `edit${ModelName}`,
151
154
  newModel: `new${ModelName}`,
152
155
  crystalizeModel: `crystalize${ModelName}`,
153
156
  modelObj: `${modelName}Obj`,
@@ -159,10 +162,19 @@ export default function EditModal<Full extends { id: string }>({
159
162
  (state: unknown) => (state as { [key: string]: { id: string | null } })[names.modelForm].id,
160
163
  );
161
164
  const modelFormLoading = storeUse[names.modelFormLoading]() as string | boolean;
165
+ const modalId = id ?? ((modelEdit as any)?.[names.modelObj] as Full | undefined)?.id ?? undefined;
162
166
  const isModalOpen =
163
167
  modelModal === (modal ?? "edit") &&
164
- (modelFormLoading === false || modelFormLoading === id) &&
165
- ((!modelFormId && !id) || id === modelFormId);
168
+ (modelFormLoading === false || modelFormLoading === modalId) &&
169
+ ((!modelFormId && !modalId) || modalId === modelFormId);
170
+ const isEditPayloadStale = useCallback((viewAt?: Date | null) => {
171
+ if (isRscNavigationFromCache()) return true;
172
+ return (
173
+ viewAt instanceof Date &&
174
+ !Number.isNaN(viewAt.getTime()) &&
175
+ Date.now() - viewAt.getTime() > EDIT_PAYLOAD_MAX_AGE_MS
176
+ );
177
+ }, []);
166
178
  useEffect(() => {
167
179
  if (!modelEdit) return;
168
180
  const refName = (modelEdit as ServerEdit<string, Full>).refName;
@@ -170,15 +182,21 @@ export default function EditModal<Full extends { id: string }>({
170
182
  const cnst = ConstantRegistry.getDatabase(modelName);
171
183
  const modelRef = cnst.full;
172
184
  if (editType === "edit") {
173
- const crystal = new modelRef().set((modelEdit as any)[names.modelObj] as Full) as unknown as Full;
185
+ const modelObj = (modelEdit as any)[names.modelObj] as Full;
186
+ const viewAt = (modelEdit as any)[names.modelViewAt] as Date;
187
+ const crystal = new modelRef().set(modelObj) as unknown as Full;
174
188
  st.set({
175
189
  [names.model]: crystal,
176
190
  [names.modelLoading]: false,
177
191
  [names.modelForm]: immerify(modelRef, crystal),
178
192
  [names.modelFormLoading]: false,
179
193
  [names.modelModal]: modal ?? "edit",
180
- [names.modelViewAt]: (modelEdit as any)[names.modelViewAt] as Date,
194
+ [names.modelViewAt]: viewAt,
181
195
  });
196
+ if (isEditPayloadStale(viewAt))
197
+ void storeDo[names.editModel](modelObj.id, { modal }).catch(() => {
198
+ st.set({ [names.modelFormLoading]: false });
199
+ });
182
200
  } else {
183
201
 
184
202
  const crystal = new modelRef().set(modelEdit as Full) as unknown as Full;
@@ -186,7 +204,7 @@ export default function EditModal<Full extends { id: string }>({
186
204
  }
187
205
  return () => {
188
206
  };
189
- }, [modelEdit]);
207
+ }, [modelEdit, isEditPayloadStale]);
190
208
 
191
209
  const handleCancel = useCallback(() => {
192
210
  const modelForm = (st.get() as any)[names.modelForm] as Full;