akanjs 3.0.0-alpha.11 → 3.0.0-alpha.12

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 (69) hide show
  1. package/base/symbols.ts +4 -0
  2. package/constant/index.ts +1 -0
  3. package/constant/mask.ts +60 -0
  4. package/dictionary/dictInfo.ts +12 -1
  5. package/fetch/client/fetchClient.ts +9 -0
  6. package/package.json +1 -1
  7. package/server/akanApp.ts +3 -1
  8. package/service/predefinedAdaptor/index.ts +1 -0
  9. package/service/predefinedAdaptor/insightQuery.ts +183 -0
  10. package/signal/mcp/Msg.ts +5 -33
  11. package/store/action.ts +9 -20
  12. package/store/actionTag.ts +28 -0
  13. package/store/agent/AgentBridge.ts +281 -0
  14. package/store/agent/StoreCatalogue.ts +296 -0
  15. package/store/agent/index.ts +3 -0
  16. package/store/agent/types.ts +50 -0
  17. package/store/databaseStateNames.ts +31 -0
  18. package/store/formSetterNames.ts +21 -0
  19. package/store/index.ts +7 -0
  20. package/store/rootStore.ts +2 -1
  21. package/store/sliceRole.ts +36 -0
  22. package/store/state.ts +2 -12
  23. package/store/store.ts +5 -0
  24. package/store/storeInstance.ts +54 -16
  25. package/store/storeRegistry.ts +10 -0
  26. package/types/base/symbols.d.ts +4 -0
  27. package/types/constant/index.d.ts +1 -0
  28. package/types/constant/mask.d.ts +34 -0
  29. package/types/dictionary/dictInfo.d.ts +9 -2
  30. package/types/fetch/client/fetchClient.d.ts +9 -0
  31. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  32. package/types/service/predefinedAdaptor/insightQuery.d.ts +50 -0
  33. package/types/signal/mcp/Msg.d.ts +3 -4
  34. package/types/store/actionTag.d.ts +17 -0
  35. package/types/store/agent/AgentBridge.d.ts +70 -0
  36. package/types/store/agent/StoreCatalogue.d.ts +21 -0
  37. package/types/store/agent/index.d.ts +3 -0
  38. package/types/store/agent/types.d.ts +49 -0
  39. package/types/store/agent.d.ts +1 -0
  40. package/types/store/databaseStateNames.d.ts +25 -0
  41. package/types/store/formSetterNames.d.ts +16 -0
  42. package/types/store/index.d.ts +6 -0
  43. package/types/store/rootStore.d.ts +4 -1
  44. package/types/store/sliceRole.d.ts +25 -0
  45. package/types/store/store.d.ts +4 -1
  46. package/types/store/storeInstance.d.ts +16 -0
  47. package/types/store/storeRegistry.d.ts +3 -0
  48. package/types/ui/Agent/Dock.d.ts +16 -0
  49. package/types/ui/Agent/Section.d.ts +10 -0
  50. package/types/ui/Agent/StateKey.d.ts +15 -0
  51. package/types/ui/Agent/Tool.d.ts +15 -0
  52. package/types/ui/Agent/Transcript.d.ts +13 -0
  53. package/types/ui/Agent/index.d.ts +11 -0
  54. package/types/ui/Agent.d.ts +1 -0
  55. package/types/ui/agentAttrs.d.ts +14 -0
  56. package/types/ui/index.d.ts +2 -0
  57. package/ui/Agent/Dock.tsx +61 -0
  58. package/ui/Agent/Section.tsx +24 -0
  59. package/ui/Agent/StateKey.tsx +42 -0
  60. package/ui/Agent/Tool.tsx +66 -0
  61. package/ui/Agent/Transcript.tsx +33 -0
  62. package/ui/Agent/index.ts +7 -0
  63. package/ui/Button.tsx +2 -0
  64. package/ui/Field.tsx +15 -7
  65. package/ui/Input.tsx +7 -0
  66. package/ui/Select.tsx +2 -1
  67. package/ui/Switch.tsx +2 -0
  68. package/ui/agentAttrs.ts +19 -0
  69. package/ui/index.ts +2 -0
package/base/symbols.ts CHANGED
@@ -13,6 +13,10 @@ export const STATE_META = Symbol.for("akan.state");
13
13
  export const STATE_INIT_META = Symbol.for("akan.state.init");
14
14
  export const STATE_DERIVED_META = Symbol.for("akan.state.derived");
15
15
  export const ACTION_META = Symbol.for("akan.action");
16
+ /** Which module declared each action, which is the dictionary node its words are written in. */
17
+ export const ACTION_OWNER_META = Symbol.for("akan.action.owner");
18
+ /** What a dispatcher does, carried on the function so a component handed one can annotate the DOM with it. */
19
+ export const ACTION_TAG = Symbol.for("akan.action.tag");
16
20
  export const SERVER_VALUE = Symbol.for("akan.value.server");
17
21
  export const CLIENT_VALUE = Symbol.for("akan.value.client");
18
22
  export const DEFAULT_VALUE = Symbol.for("akan.value.default");
package/constant/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./deserialize";
5
5
  export * from "./fieldInfo";
6
6
  export * from "./getDefault";
7
7
  export * from "./immerify";
8
+ export * from "./mask";
8
9
  export * from "./purify";
9
10
  export * from "./serialize";
10
11
  export * from "./textFieldPathSet";
@@ -0,0 +1,60 @@
1
+ import { FIELD_META } from "akanjs/base";
2
+
3
+ /**
4
+ * A model as masking reads it — the constructor, for the field metadata it carries at runtime.
5
+ *
6
+ * Structural rather than `ConstantModelRef` so that anything holding the class can name it, and read through
7
+ * `FIELD_META` the way `resolveReturn` reads it.
8
+ */
9
+ export interface MaskModel {
10
+ name: string;
11
+ }
12
+
13
+ /** The part of a field's metadata masking turns on. Mirrors what `resolveReturn` branches over. */
14
+ interface MaskField {
15
+ fieldType?: string;
16
+ isClass?: boolean;
17
+ modelRef?: MaskModel;
18
+ }
19
+
20
+ export const maskFieldsOf = (model: MaskModel): Record<string, MaskField> | null => {
21
+ const fields = (model as unknown as { [key: symbol]: unknown })[FIELD_META];
22
+ return fields && typeof fields === "object" ? (fields as Record<string, MaskField>) : null;
23
+ };
24
+
25
+ /** The `hidden` and `secret` field names of `model` that `value` still carries populated. */
26
+ export const leakingFieldsOf = (model: MaskModel, value: Record<string, unknown>): string[] => {
27
+ const fields = maskFieldsOf(model);
28
+ if (!fields) return [];
29
+ return Object.entries(fields)
30
+ .filter(([key, field]) => (field.fieldType === "hidden" || field.fieldType === "secret") && key in value)
31
+ .map(([key]) => key);
32
+ };
33
+
34
+ /**
35
+ * Strips what a model marks `hidden` or `secret`, by the model the caller names rather than by the one the value
36
+ * happens to still carry.
37
+ *
38
+ * That distinction is the whole point. A check that reads the class off the value can only mask what arrives as an
39
+ * instance, so a `{ ...doc }` spread, a `toJSON()`, an `immerify()`, or a round-trip through `JSON.stringify` reaches
40
+ * its destination with the metadata already gone and nothing can be done about it. A named model is metadata the
41
+ * value cannot lose, so a hydrated document and a plain object copied out of one mask identically.
42
+ *
43
+ * This is the field half of `resolveReturn` and deliberately not the whole of it. That one also loads every relation
44
+ * it walks past, which is right for a query's return value and wrong here, where the value is already in hand.
45
+ *
46
+ * Returns `unknown` rather than the argument's type, because what comes back is missing fields that type promises.
47
+ */
48
+ export const mask = (model: MaskModel, value: unknown): unknown => {
49
+ if (value === null || value === undefined || typeof value !== "object") return value;
50
+ if (Array.isArray(value)) return value.map((item: unknown) => mask(model, item));
51
+ const fields = maskFieldsOf(model);
52
+ if (!fields) return value;
53
+ const source = value as Record<string, unknown>;
54
+ const masked: Record<string, unknown> = {};
55
+ for (const [key, field] of Object.entries(fields)) {
56
+ if (field.fieldType === "hidden" || field.fieldType === "secret" || !(key in source)) continue;
57
+ masked[key] = field.isClass && field.modelRef ? mask(field.modelRef, source[key]) : source[key];
58
+ }
59
+ return masked;
60
+ };
@@ -880,7 +880,15 @@ export class ModelDictInfo<
880
880
  }
881
881
  }
882
882
 
883
- type AnyModelDictInfo = ModelDictInfo<any, any, any, any, any, any, any, any, any, any, any>;
883
+ /**
884
+ * Every parameter of `ModelDictInfo` is listed here positionally, so a parameter added to the class has to be
885
+ * added to all three lists below in the same slot. Omitting one does not fail to compile — inference silently
886
+ * shifts, so the last parameter falls off the end and becomes its default `never`: adding `StoreKey` before
887
+ * `ErrorKey` once cost an extending app the whole of the lib's `EtcKey` (`.translate()`) union, which reads at
888
+ * the call site as `l("<model>.<key>")` no longer existing.
889
+ */
890
+
891
+ type AnyModelDictInfo = ModelDictInfo<any, any, any, any, any, any, any, any, any, any, any, any>;
884
892
 
885
893
  type MergeTwoModelDicts<ModelDict1, ModelDict2> =
886
894
  ModelDict1 extends ModelDictInfo<
@@ -893,6 +901,7 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
893
901
  infer BaseSignalKey1,
894
902
  infer SliceKey1,
895
903
  infer EndpointKey1,
904
+ infer StoreKey1,
896
905
  infer ErrorKey1,
897
906
  infer EtcKey1
898
907
  >
@@ -906,6 +915,7 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
906
915
  infer BaseSignalKey2,
907
916
  infer SliceKey2,
908
917
  infer EndpointKey2,
918
+ infer StoreKey2,
909
919
  infer ErrorKey2,
910
920
  infer EtcKey2
911
921
  >
@@ -919,6 +929,7 @@ type MergeTwoModelDicts<ModelDict1, ModelDict2> =
919
929
  BaseSignalKey1 | BaseSignalKey2,
920
930
  SliceKey1 | SliceKey2,
921
931
  EndpointKey1 | EndpointKey2,
932
+ StoreKey1 | StoreKey2,
922
933
  ErrorKey1 | ErrorKey2,
923
934
  EtcKey1 | EtcKey2
924
935
  >
@@ -86,6 +86,15 @@ export class FetchClient {
86
86
  this.handler = this.#makeHandlerProxy();
87
87
  this.applySignal(serializedSignal);
88
88
  }
89
+ /**
90
+ * Every signal any client in this process has applied, which is the whole callable surface of the app.
91
+ *
92
+ * A copy, because this is the registry each client merges its own signals into and a reader that mutated it
93
+ * would change what the next client applies. Read by the agent catalogue, which needs the argument schemas.
94
+ */
95
+ static get sharedSerializedSignal(): { [key: string]: SerializedSignal } {
96
+ return { ...FetchClient.#sharedSerializedSignal };
97
+ }
89
98
  static resetSharedRegistry() {
90
99
  FetchClient.#sharedSerializedSignal = {};
91
100
  FetchClient.#sharedRegistryVersion++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.11",
3
+ "version": "3.0.0-alpha.12",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -82,6 +82,8 @@ export class AkanApp {
82
82
  /** Hosted by `akan start`: crash loops should yield to the dev host, which restarts on file edits. */
83
83
  readonly #devHosted = process.env.AKAN_COMMAND_TYPE === "start";
84
84
  readonly #healthTimeoutMs = AkanApp.#parseHealthTimeoutMs();
85
+ /** Child stderr is bundler/runtime noise the gateway cannot act on; it still reaches the rotating log file. */
86
+ readonly #printChildStderr = process.env.AKAN_CHILD_STDERR === "1";
85
87
  readonly #serverPath: string;
86
88
  readonly #artifactDir: string;
87
89
  readonly #replica: AkanReplicaConfig;
@@ -1249,7 +1251,7 @@ export class AkanApp {
1249
1251
 
1250
1252
  #writeChildOutputLineRaw(idx: number, role: AkanChildRole, type: "stdout" | "stderr", line: string) {
1251
1253
  const prefixedLine = `[child:${idx} ${role}] [${type}] ${line}`;
1252
- process[type].write(prefixedLine);
1254
+ if (type === "stdout" || this.#printChildStderr) process[type].write(prefixedLine);
1253
1255
  this.#logWriter?.write(`${idx}-${role}`, AkanApp.#stripAnsi(prefixedLine));
1254
1256
  }
1255
1257
 
@@ -1,6 +1,7 @@
1
1
  export * from "./cache.adaptor";
2
2
  export * from "./compress.adaptor";
3
3
  export * from "./database.adaptor";
4
+ export * from "./insightQuery";
4
5
  export * from "./logging.adaptor";
5
6
  export * from "./queue.adaptor";
6
7
  export * from "./role.adaptor";
@@ -0,0 +1,183 @@
1
+ import type { AkanSqlClient } from "./database.adaptor";
2
+
3
+ export interface InsightQueryOptions {
4
+ /** Rows to return at most. Clamped to `InsightQuery.maxRows`, which no caller can raise. */
5
+ limit?: number;
6
+ /** How long to wait for the driver, in ms. See the note on `#raced` for which dialects this can actually stop. */
7
+ timeoutMs?: number;
8
+ }
9
+
10
+ export interface InsightQueryResult {
11
+ columns: string[];
12
+ rows: Record<string, unknown>[];
13
+ /** The ceiling cut the answer short, so the caller knows not to read it as complete. */
14
+ truncated: boolean;
15
+ }
16
+
17
+ /**
18
+ * One read-only SQL statement, for an agent or an operator asking a question the domain endpoints cannot express.
19
+ *
20
+ * This is the layer-bypassing read, and every safeguard the framework has is bypassed with it — guards, soft delete,
21
+ * cascade, `_postRemove`, and the `hidden`/`secret` masking every other response path performs. So it is read-only
22
+ * by construction rather than by convention, and it is deliberately *not* wired to an endpoint here: the framework
23
+ * owns no guard strong enough to sit in front of it. An app that wants it writes the endpoint with its own
24
+ * `SuperAdmin`, the same way guards ship with the library that owns the model.
25
+ *
26
+ * Three things enforce read-only, and only the third is ours:
27
+ *
28
+ * 1. The statement is wrapped as a derived table — `SELECT * FROM (<sql>) AS "akanInsight" LIMIT ?`. Nothing but a
29
+ * query is legal in that position, in either dialect, so a write is a syntax error from the engine rather than a
30
+ * pattern this code had to recognise. A second statement smuggled behind `;` is a syntax error for the same
31
+ * reason, and the row ceiling rides along on the same wrapper.
32
+ * 2. A rejection before execution, so the caller reads why rather than a syntax error. It runs on the statement with
33
+ * comments and string literals removed, because that is what makes `-- ` and `'…'` unable to hide anything.
34
+ * 3. **`_doc` never crosses the boundary.** Every non-base field lives in that one JSON column, which is where the
35
+ * plan's "re-apply schema-based masking" runs into the fact that an arbitrary SELECT has no model to mask by. So
36
+ * the enforceable rule is the column itself: unnameable in the statement, dropped from the rows, and any cell
37
+ * that still arrives holding a JSON object or array is refused. An insight is made of scalars; a value that is
38
+ * not one is either a document or indistinguishable from it.
39
+ *
40
+ * What that costs is real and worth saying: this answers "how many, since when, grouped how" over base columns and
41
+ * the search mirror, and it cannot read a domain field. Field-level reads go through the domain tools, which mask.
42
+ */
43
+ export class InsightQuery {
44
+ /** Not an option. A caller asking for more gets this, because the point is that no caller sets the ceiling. */
45
+ static readonly maxRows = 1000;
46
+ static readonly #allowedFirstKeywords = new Set(["select", "with"]);
47
+ /**
48
+ * Words that cannot appear anywhere in a read, checked so read-only does not rest on a dialect's own rule.
49
+ *
50
+ * `WITH` has to be allowed as a first keyword — a CTE is how a real question gets asked — and Postgres lets a CTE
51
+ * modify data. That it is illegal *inside* the derived table this wraps the statement in is true and is what would
52
+ * stop it, but it is one sentence of another project's documentation away from not being true. This does not
53
+ * depend on it. Word-boundary matched on the comment- and literal-stripped statement, so `deleted_at` is fine and
54
+ * a column that is genuinely named `update` is refused — the wrong answer in the safe direction.
55
+ */
56
+ static readonly #forbidden =
57
+ /\b(insert|update|delete|drop|alter|create|truncate|replace|grant|revoke|attach|detach|vacuum|reindex|pragma)\b/i;
58
+ /** The column every document's non-base fields live in. See the class note. */
59
+ static readonly #documentColumn = "_doc";
60
+
61
+ readonly #client: AkanSqlClient;
62
+
63
+ constructor(client: AkanSqlClient) {
64
+ this.#client = client;
65
+ }
66
+
67
+ async run(sql: string, { limit = InsightQuery.maxRows, timeoutMs = 10_000 }: InsightQueryOptions = {}) {
68
+ const statement = InsightQuery.#assertReadable(sql);
69
+ const rows = Math.max(1, Math.min(limit, InsightQuery.maxRows));
70
+
71
+ const wrapped = `SELECT * FROM (${statement}) AS "akanInsight" LIMIT ${rows + 1}`;
72
+ const found = await InsightQuery.#raced(this.#client.prepare(wrapped).all(), timeoutMs);
73
+ const truncated = found.length > rows;
74
+ const kept = truncated ? found.slice(0, rows) : found;
75
+ return {
76
+ columns: InsightQuery.#columnsOf(kept),
77
+ rows: kept.map((row) => InsightQuery.#readable(row)),
78
+ truncated,
79
+ } satisfies InsightQueryResult;
80
+ }
81
+
82
+ /**
83
+ * Stops waiting; does not stop the query.
84
+ *
85
+ * With libsql or Postgres the driver call is genuinely asynchronous, so the caller is freed and the connection
86
+ * finishes on its own. With `bun:sqlite` it is synchronous and holds the event loop, so this timer cannot fire
87
+ * until the query is already done — the ceiling is what limits that case, not the clock. Do not read the timeout
88
+ * as protection against an expensive statement.
89
+ */
90
+ static async #raced<T>(work: Promise<T[]>, timeoutMs: number): Promise<T[]> {
91
+ let timer: ReturnType<typeof setTimeout> | undefined;
92
+ const expiry = new Promise<never>((_, reject) => {
93
+ timer = setTimeout(() => reject(new Error(`Insight query exceeded ${timeoutMs}ms.`)), timeoutMs);
94
+ });
95
+ try {
96
+ return await Promise.race([work, expiry]);
97
+ } finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+
102
+ /** Returns the statement with a trailing `;` removed, or throws naming what is wrong with it. */
103
+ static #assertReadable(sql: string) {
104
+ const statement = sql.trim().replace(/;\s*$/, "");
105
+ if (!statement) throw new Error("An insight query needs a statement.");
106
+ const bare = InsightQuery.#stripLiterals(statement);
107
+ if (bare.includes(";")) throw new Error("An insight query is one statement. Remove the `;`.");
108
+ const first = /[a-z]+/.exec(bare.toLowerCase())?.[0];
109
+ if (!first || !InsightQuery.#allowedFirstKeywords.has(first))
110
+ throw new Error(
111
+ `An insight query reads: it starts with SELECT or WITH, not ${first ? first.toUpperCase() : "that"}.`,
112
+ );
113
+ const forbidden = InsightQuery.#forbidden.exec(bare)?.[0];
114
+ if (forbidden) throw new Error(`An insight query reads: ${forbidden.toUpperCase()} has no place in one.`);
115
+ if (new RegExp(`\\b${InsightQuery.#documentColumn}\\b`).test(bare))
116
+ throw new Error(
117
+ `An insight query cannot read \`${InsightQuery.#documentColumn}\`: every field a model marks hidden or secret is inside it, and an arbitrary statement names no model to mask it by. Read base columns, or use the model's own endpoint.`,
118
+ );
119
+ return statement;
120
+ }
121
+
122
+ /**
123
+ * Blanks out comments and string literals so the checks above read only what the engine would treat as syntax.
124
+ *
125
+ * Replaced with spaces rather than deleted, so nothing that was two tokens becomes one — `a/**\/b` must not read
126
+ * as the identifier `ab`. Dollar-quoting is not handled: it is Postgres function-body syntax, and a statement
127
+ * that begins with SELECT or WITH has nowhere legal to put one.
128
+ *
129
+ * A double-quoted span is **unquoted, not blanked**. It is an identifier in both dialects, not a literal, so
130
+ * blanking it was what let `SELECT "_doc"` through the column check while `SELECT _doc` was refused. Keeping the
131
+ * contents means SQLite's fallback — a double-quoted string, where no such column exists — is read as an
132
+ * identifier too, which errs toward refusing a statement rather than toward reading the column.
133
+ */
134
+ static #stripLiterals(sql: string) {
135
+ return sql
136
+ .replace(/\/\*[\s\S]*?(\*\/|$)/g, (match) => " ".repeat(match.length))
137
+ .replace(/--[^\n]*/g, (match) => " ".repeat(match.length))
138
+ .replace(/'(?:''|[^'])*'/g, (match) => " ".repeat(match.length))
139
+ .replace(/"(?:""|[^"])*"/g, (match) => ` ${match.slice(1, -1)} `);
140
+ }
141
+
142
+ static #columnsOf(rows: Record<string, unknown>[]) {
143
+ const columns = new Set<string>();
144
+ for (const row of rows) for (const key of Object.keys(row)) columns.add(key);
145
+ columns.delete(InsightQuery.#documentColumn);
146
+ return [...columns];
147
+ }
148
+
149
+ /**
150
+ * Drops the document column and refuses anything else shaped like one.
151
+ *
152
+ * The column has to be dropped here as well as rejected in the statement, because `SELECT * FROM "user"` returns
153
+ * it without ever naming it. The JSON check is for the ways a dialect can hand back a whole row under another
154
+ * name — `row_to_json(u)` — which no list of forbidden function names would keep up with.
155
+ */
156
+ static #readable(row: Record<string, unknown>) {
157
+ const readable: Record<string, unknown> = {};
158
+ for (const [key, value] of Object.entries(row)) {
159
+ if (key === InsightQuery.#documentColumn) continue;
160
+ if (InsightQuery.#isDocumentShaped(value))
161
+ throw new Error(
162
+ `Column "${key}" of the insight query holds an object, which may be a document with its hidden or secret fields intact. Select the values you need instead.`,
163
+ );
164
+ readable[key] = value;
165
+ }
166
+ return readable;
167
+ }
168
+
169
+ static #isDocumentShaped(value: unknown): boolean {
170
+ if (value === null || value === undefined) return false;
171
+ if (value instanceof Date) return false;
172
+ if (typeof value === "object") return true;
173
+ if (typeof value !== "string") return false;
174
+ const trimmed = value.trim();
175
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
176
+ try {
177
+ return typeof JSON.parse(trimmed) === "object";
178
+ } catch {
179
+
180
+ return false;
181
+ }
182
+ }
183
+ }
package/signal/mcp/Msg.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FIELD_META } from "akanjs/base";
1
+ import { leakingFieldsOf, type MaskModel, mask } from "akanjs/constant";
2
2
  import type { JsonSchema } from "../schema";
3
3
 
4
4
  export type PromptRole = "user" | "assistant";
@@ -68,18 +68,9 @@ export interface PromptFileSource {
68
68
 
69
69
  /**
70
70
  * A model class, named by the caller so an attachment can be masked by what it *is* rather than by what it still
71
- * carries at runtime. Structural, and read through `FIELD_META` the way `resolveReturn` reads it.
71
+ * carries at runtime. The same model any other audience masks by — see `mask` in `akanjs/constant`.
72
72
  */
73
- export interface PromptModel {
74
- name: string;
75
- }
76
-
77
- /** The part of a field's metadata masking turns on. Mirrors what `resolveReturn` branches over. */
78
- interface PromptField {
79
- fieldType?: string;
80
- isClass?: boolean;
81
- modelRef?: PromptModel;
82
- }
73
+ export type PromptModel = MaskModel;
83
74
 
84
75
  /**
85
76
  * The string fields each block type must carry, and the set of legal types at once.
@@ -205,17 +196,7 @@ export class Msg {
205
196
  * promises. The value's only destination is a JSON payload, so nothing downstream wanted the type anyway.
206
197
  */
207
198
  static mask(model: PromptModel, value: unknown): unknown {
208
- if (value === null || value === undefined || typeof value !== "object") return value;
209
- if (Array.isArray(value)) return value.map((item: unknown) => Msg.mask(model, item));
210
- const fields = Msg.#fieldsOf(model);
211
- if (!fields) return value;
212
- const source = value as Record<string, unknown>;
213
- const masked: Record<string, unknown> = {};
214
- for (const [key, field] of Object.entries(fields)) {
215
- if (field.fieldType === "hidden" || field.fieldType === "secret" || !(key in source)) continue;
216
- masked[key] = field.isClass && field.modelRef ? Msg.mask(field.modelRef, source[key]) : source[key];
217
- }
218
- return masked;
199
+ return mask(model, value);
219
200
  }
220
201
 
221
202
  /**
@@ -316,22 +297,13 @@ export class Msg {
316
297
 
317
298
  static #assertSample(uri: string, sample: Record<string, unknown>) {
318
299
  const model = sample.constructor as PromptModel | undefined;
319
- const fields = model ? Msg.#fieldsOf(model) : null;
320
- if (!fields) return;
321
- const leaking = Object.entries(fields)
322
- .filter(([key, field]) => (field.fieldType === "hidden" || field.fieldType === "secret") && key in sample)
323
- .map(([key]) => key);
300
+ const leaking = model ? leakingFieldsOf(model, sample) : [];
324
301
  if (!leaking.length) return;
325
302
  throw new Error(
326
303
  `Msg.resource("${uri}") embeds ${model?.name} with its hidden/secret fields populated: ${leaking.join(", ")}. Name the model so they are stripped — Msg.resource(uri, value, { model: cnst.${model?.name} }), or Msg.mask(cnst.${model?.name}, value) for one piece of an assembled payload.`,
327
304
  );
328
305
  }
329
306
 
330
- static #fieldsOf(model: PromptModel): Record<string, PromptField> | null {
331
- const fields = (model as unknown as { [key: symbol]: unknown })[FIELD_META];
332
- return fields && typeof fields === "object" ? (fields as Record<string, PromptField>) : null;
333
- }
334
-
335
307
  /**
336
308
  * An empty annotation object is dropped rather than emitted: a block carrying `annotations: {}` reads to a
337
309
  * client as a deliberate "no audience, no priority", which is not the same as saying nothing.
package/store/action.ts CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  type FetchPolicy,
6
6
  isQueryEqual,
7
7
  Logger,
8
- lowerlize,
9
8
  pathSet,
10
9
  resolveFileUploadCapability,
11
10
  } from "akanjs/common";
@@ -33,18 +32,12 @@ import type {
33
32
  SlceDbSort,
34
33
  SliceCls,
35
34
  } from "akanjs/signal";
35
+ import { tagAction } from "./actionTag";
36
+ import { formSetterNames } from "./formSetterNames";
37
+ import type { SliceActionKey } from "./sliceRole";
36
38
  import type { SliceStateKey } from "./state";
37
39
  import type { SetGet, StoreSliceArgs, StoreSliceMap, StoreSliceSuffixCap } from "./types";
38
40
 
39
- type SliceActionKey =
40
- | "initModel"
41
- | "refreshModel"
42
- | "selectModel"
43
- | "setPageOfModel"
44
- | "addPageOfModel"
45
- | "setLimitOfModel"
46
- | "setQueryArgsOfModel"
47
- | "setSortOfModel";
48
41
  type _SliceMap<S extends SliceCls> = StoreSliceMap<S>;
49
42
  type _ActionRefName<S extends SliceCls> = SlceCnstRefName<S>;
50
43
  type _ActionCap<S extends SliceCls> = SlceCnstCapitalizedRefName<S>;
@@ -311,16 +304,7 @@ export const makeFormSetter = (refName: string, fetch: FetchProxy<any>) => {
311
304
  },
312
305
  };
313
306
  const fieldSetAction = Object.entries(modelRef[FIELD_META]).reduce((acc, [key, field]) => {
314
- const [fieldKeyName, classKeyName] = [lowerlize(key), capitalize(key)];
315
- const namesOfField = {
316
- field: fieldKeyName,
317
- Field: classKeyName,
318
- setFieldOnModel: `set${classKeyName}On${className}`,
319
- addFieldOnModel: `add${classKeyName}On${className}`,
320
- subFieldOnModel: `sub${classKeyName}On${className}`,
321
- addOrSubFieldOnModel: `addOrSub${classKeyName}On${className}`,
322
- uploadFieldOnModel: `upload${classKeyName}On${className}`,
323
- };
307
+ const namesOfField = formSetterNames(className, key);
324
308
  const singleFieldSetAction = {
325
309
  [namesOfField.setFieldOnModel]: function (this: SetGet, value: any | null) {
326
310
  this.set((state: { [key: string]: any }) => {
@@ -427,6 +411,11 @@ export const makeFormSetter = (refName: string, fetch: FetchProxy<any>) => {
427
411
  }
428
412
  : {}),
429
413
  };
414
+
415
+ tagAction(singleFieldSetAction[namesOfField.setFieldOnModel] as (...args: never[]) => unknown, {
416
+ action: namesOfField.setFieldOnModel,
417
+ state: `${names.modelForm}.${namesOfField.field}`,
418
+ });
430
419
  return Object.assign(acc, singleFieldSetAction);
431
420
  }, {});
432
421
  return Object.assign(fieldSetAction, baseSetAction);
@@ -0,0 +1,28 @@
1
+ import { ACTION_TAG } from "akanjs/base";
2
+
3
+ export interface ActionTag {
4
+ /** The `st.do` key this function is. */
5
+ action: string;
6
+ /** The state path it writes, when it writes exactly one — `userForm.name` for a field setter. */
7
+ state?: string;
8
+ }
9
+
10
+ /**
11
+ * Marks a dispatcher with what it does, so a component handed one by reference can say so in the DOM.
12
+ *
13
+ * `onChange={st.do.setNameOnUser}` is the house form for every model field, which means the component already holds
14
+ * everything an annotation needs — it just has no way to read it off a function. This is that way, and it is why
15
+ * `data-akan-*` costs an app no code at all: nobody writes the attribute, the setter carries its own name.
16
+ *
17
+ * Non-enumerable, so it survives neither `{...fn}` nor `JSON.stringify` and shows up in no spread.
18
+ */
19
+ export const tagAction = <T extends (...args: never[]) => unknown>(fn: T, tag: ActionTag): T => {
20
+ Object.defineProperty(fn, ACTION_TAG, { value: tag, configurable: true });
21
+ return fn;
22
+ };
23
+
24
+ export const actionTagOf = (value: unknown): ActionTag | undefined => {
25
+ if (typeof value !== "function") return undefined;
26
+ const tag = (value as unknown as { [key: symbol]: unknown })[ACTION_TAG];
27
+ return tag && typeof tag === "object" ? (tag as ActionTag) : undefined;
28
+ };