@lotics/app-sdk 0.110.0 → 0.111.0

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/AGENTS.md CHANGED
@@ -21,7 +21,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
21
21
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
22
22
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload` and its `fidelity`, `renameFile` (a new file over the same bytes), `useAttachments`/`useAttachmentPiles`, `readFiles` and presigned URLs (a bearer credential — never logged or persisted), workflow-generated files, naming a zip's entries, the delivery bounds. |
23
23
  | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`. |
24
- | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
24
+ | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model, the screens and `drawers` it reports to the host), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming parts, the agent's ask-back), `askAi`, `useAiContext`, and what the member's own chat agent can do with the app while it is open. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, why a per-input bound is a tenancy floor rather than an authorization check. |
27
27
  | [docs/runtime.md](./docs/runtime.md) | `mount()` and the errors it reports to the host (`reportAppError`), the two transports, the API address a standalone bundle reads out of its own page (`<meta name="lotics-api-base">`), `rpc()`, the mock harness (`fixture` + `?__mock=1` — queries, workflows and agents, a mocked agent's run played line by line), `openExternal`/`openApp`/`downloadFile`, geofencing, the peer dependencies. |
package/dist/index.js CHANGED
@@ -18911,7 +18911,7 @@ var kitGlyphSchema = zod_default.string().superRefine((name, ctx) => {
18911
18911
  var optionMarkSchema = zod_default.discriminatedUnion("kind", [
18912
18912
  zod_default.object({
18913
18913
  kind: zod_default.literal("brand"),
18914
- name: zod_default.enum(BRAND_NAMES).describe(`The channel the option IS, drawn as its logo: ${BRAND_NAMES.join(", ")}`)
18914
+ name: zod_default.enum(BRAND_NAMES).describe("The channel the option IS, drawn as its logo")
18915
18915
  }).strict(),
18916
18916
  zod_default.object({
18917
18917
  kind: zod_default.literal("icon"),
@@ -20794,7 +20794,40 @@ function viReadGroup(n, leading) {
20794
20794
  }
20795
20795
  return parts.join(" ");
20796
20796
  }
20797
- function numberToWords(value) {
20797
+ var EN_ONES = [
20798
+ "zero",
20799
+ "one",
20800
+ "two",
20801
+ "three",
20802
+ "four",
20803
+ "five",
20804
+ "six",
20805
+ "seven",
20806
+ "eight",
20807
+ "nine",
20808
+ "ten",
20809
+ "eleven",
20810
+ "twelve",
20811
+ "thirteen",
20812
+ "fourteen",
20813
+ "fifteen",
20814
+ "sixteen",
20815
+ "seventeen",
20816
+ "eighteen",
20817
+ "nineteen"
20818
+ ];
20819
+ var EN_TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
20820
+ var EN_SCALES = ["", "thousand", "million", "billion", "trillion", "quadrillion", "quintillion"];
20821
+ function enReadGroup(n) {
20822
+ const hundreds = Math.floor(n / 100);
20823
+ const rest = n % 100;
20824
+ const parts = [];
20825
+ if (hundreds > 0) parts.push(EN_ONES[hundreds], "hundred");
20826
+ if (rest >= 20) parts.push(rest % 10 === 0 ? EN_TENS[rest / 10] : `${EN_TENS[Math.floor(rest / 10)]}-${EN_ONES[rest % 10]}`);
20827
+ else if (rest > 0) parts.push(EN_ONES[rest]);
20828
+ return parts.join(" ");
20829
+ }
20830
+ function numberToWords(value, language = "vi") {
20798
20831
  if (value == null) return "";
20799
20832
  let n;
20800
20833
  if (typeof value === "number") {
@@ -20807,9 +20840,13 @@ function numberToWords(value) {
20807
20840
  if (!isFinite(n)) {
20808
20841
  throw new Error("numberToWords: value must be a finite number");
20809
20842
  }
20843
+ if (language !== "vi" && language !== "en") {
20844
+ throw new Error(`numberToWords: language must be "vi" or "en", got ${JSON.stringify(language)}`);
20845
+ }
20810
20846
  n = Math.round(n);
20811
20847
  const negative = n < 0;
20812
20848
  n = Math.abs(n);
20849
+ if (language === "en") return enNumberToWords(n, negative);
20813
20850
  if (n === 0) return "kh\xF4ng";
20814
20851
  const groups = [];
20815
20852
  for (let rem = n; rem > 0; rem = Math.floor(rem / 1e3)) {
@@ -20825,6 +20862,19 @@ function numberToWords(value) {
20825
20862
  const result = parts.join(" ");
20826
20863
  return negative ? "\xE2m " + result : result;
20827
20864
  }
20865
+ function enNumberToWords(n, negative) {
20866
+ if (n === 0) return "zero";
20867
+ const parts = [];
20868
+ let group = 0;
20869
+ for (let rem = n; rem > 0; rem = Math.floor(rem / 1e3), group++) {
20870
+ const value = rem % 1e3;
20871
+ if (value === 0) continue;
20872
+ if (group >= EN_SCALES.length) throw new Error("numberToWords: value is too large to read in English");
20873
+ parts.unshift(EN_SCALES[group] ? `${enReadGroup(value)} ${EN_SCALES[group]}` : enReadGroup(value));
20874
+ }
20875
+ const result = parts.join(" ");
20876
+ return negative ? "minus " + result : result;
20877
+ }
20828
20878
  function formatNumber(value, decimals) {
20829
20879
  if (value == null) return "";
20830
20880
  if (typeof value !== "number" || Number.isNaN(value)) {
@@ -23112,7 +23162,23 @@ var TZDate = class _TZDate extends TZDateMini {
23112
23162
 
23113
23163
  // ../shared/src/expression_date.ts
23114
23164
  function hasTimezone(dateStr) {
23115
- return /[Zz]|[+-]\d{2}:\d{2}$/.test(dateStr);
23165
+ return /[Zz]|[+-]\d{2}:?\d{2}$/.test(dateStr);
23166
+ }
23167
+ var LONG_FORMAT_TOKENS = /P+p+|P+|p+|''|'(''|[^'])+('|$)|./g;
23168
+ var FORMAT_TOKENS = /[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g;
23169
+ var FORMAT_TOKEN_LETTERS = new Set("GyYRuQqMLwIdDEecihabBHKkmsSXxOztT");
23170
+ var PROTECTED_TOKENS = { D: "d", DD: "dd", YY: "yy", YYYY: "yyyy" };
23171
+ function datePatternProblem(pattern) {
23172
+ const tokens = (pattern.match(LONG_FORMAT_TOKENS) ?? []).map((token2) => /^[Pp]/.test(token2) ? "" : token2).join("").match(FORMAT_TOKENS) ?? [];
23173
+ for (const token2 of tokens) {
23174
+ if (token2.startsWith("'")) continue;
23175
+ const instead = PROTECTED_TOKENS[token2];
23176
+ if (instead !== void 0) return `"${token2}" is not a day of the month or a calendar year \u2014 write "${instead}"`;
23177
+ if (!FORMAT_TOKEN_LETTERS.has(token2.charAt(0)) && /[a-zA-Z]/.test(token2.charAt(0))) {
23178
+ return `"${token2.charAt(0)}" is no date token \u2014 quote literal text, as in "'Ng\xE0y' dd 'th\xE1ng' MM 'n\u0103m' yyyy"`;
23179
+ }
23180
+ }
23181
+ return void 0;
23116
23182
  }
23117
23183
  function parseDateStringInTimezone(dateStr, timezone) {
23118
23184
  const match2 = dateStr.match(
@@ -23214,13 +23280,18 @@ function getDateExpressionFunctions(defaultTimezone) {
23214
23280
  throw new Error("formatDate: formatStr must be a string");
23215
23281
  }
23216
23282
  validateOptionalTimezone(timezone, "formatDate");
23283
+ const problem = datePatternProblem(formatStr);
23284
+ if (problem !== void 0) {
23285
+ throw new Error(`formatDate: ${problem}`);
23286
+ }
23217
23287
  const tz = getTimezone(timezone);
23218
23288
  if (typeof date6 === "string" && !hasTimezone(date6)) {
23219
23289
  const testDate = new Date(date6);
23220
23290
  if (isNaN(testDate.getTime())) {
23221
23291
  throw new Error("formatDate: invalid date");
23222
23292
  }
23223
- return format(date6, formatStr);
23293
+ const iso = parseISO(date6);
23294
+ return format(isNaN(iso.getTime()) ? testDate : iso, formatStr);
23224
23295
  }
23225
23296
  const dateObj = date6 instanceof Date ? date6 : new Date(date6);
23226
23297
  if (isNaN(dateObj.getTime())) {
@@ -24500,16 +24571,16 @@ var appSchema = zod_default.object({
24500
24571
  "Whether a shared password gates the public binding. True \u2192 anonymous visitors must authenticate at `/v1/apps/{app_id}/public/authenticate` before any publicAppAccess endpoint resolves. The hash itself is never sent over the wire; only this flag is exposed (and only on authenticated owner-side reads \u2014 the public by-subdomain response surfaces the same fact as `requires_password`)."
24501
24572
  ),
24502
24573
  workflows: zod_default.record(zod_default.string(), appBoundWorkflowSchema).nullable().optional().describe(
24503
- "Alias \u2192 workflow declaration map. Each alias resolves to a workflow_id and an optional typed inputs schema, set by `set_app_workflow` / `remove_app_workflow` or a model apply; a deploy carries it forward. The iframe SDK's useWorkflow(alias) resolves through this map; when an inputs schema is declared, the server validates payloads against it before invocation. The workflow always executes under the app's IAM principal."
24574
+ "Alias \u2192 workflow declaration map. Each alias resolves to a workflow_id and an optional typed inputs schema, set by `set_app_workflow` / `remove_app_binding` or a model apply; a deploy carries it forward. The iframe SDK's useWorkflow(alias) resolves through this map; when an inputs schema is declared, the server validates payloads against it before invocation. The workflow always executes under the app's IAM principal."
24504
24575
  ),
24505
24576
  queries: zod_default.record(zod_default.string(), appQueryDeclarationSchema).nullable().optional().describe(
24506
- "Alias \u2192 query declaration map. Each alias resolves to a fixed query AST template with a typed param schema, set by `set_app_query` / `set_app_queries` or a model apply; a deploy carries it forward. The iframe SDK's useQuery(alias, params) resolves through this map; custom-code apps never send a raw AST. The query runs under the app's IAM principal."
24577
+ "Alias \u2192 query declaration map. Each alias resolves to a fixed query AST template with a typed param schema, set by `set_app_queries` or a model apply; a deploy carries it forward. The iframe SDK's useQuery(alias, params) resolves through this map; custom-code apps never send a raw AST. The query runs under the app's IAM principal."
24507
24578
  ),
24508
24579
  capabilities: appCapabilitiesSchema.nullable().optional().describe(
24509
24580
  "Opt-in app capabilities, set by `update_app` or a model apply; a deploy carries them forward. Capabilities are off unless declared \u2014 least ambient authority. `comments` gates the members-only `useComments` primitive: only an app that declares it can read/write record comments (each under the VIEWING member's own authority)."
24510
24581
  ),
24511
24582
  agents: zod_default.record(zod_default.string(), appAgentDeclarationSchema).nullable().optional().describe(
24512
- "Alias \u2192 agent declaration map. Each alias binds a streaming tool-loop agent the app runs via `useAgentRun(alias)` (an SSE stream), with declared tools, model, and typed inputs/outputs, set by `set_app_agent` / `remove_app_agent` or a model apply; a deploy carries it forward. The agent runs under the app's IAM principal; runs persist a flat history per session."
24583
+ "Alias \u2192 agent declaration map. Each alias binds a streaming tool-loop agent the app runs via `useAgentRun(alias)` (an SSE stream), with declared tools, model, and typed inputs/outputs, set by `set_app_agent` / `remove_app_binding` or a model apply; a deploy carries it forward. The agent runs under the app's IAM principal; runs persist a flat history per session."
24513
24584
  ),
24514
24585
  theme: appThemeSchema.nullable().optional().describe("Theme settings for the app"),
24515
24586
  can_author: zod_default.boolean().optional().describe(
@@ -24709,8 +24780,15 @@ var cForStepSchema = zod_default.lazy(
24709
24780
  );
24710
24781
  var workflowStepsSchema = zod_default.array(workflowStepSchema).min(1);
24711
24782
 
24783
+ // ../shared/src/retired_tools.ts
24784
+ var RETIRED_TOOLS = /* @__PURE__ */ new Map([
24785
+ ["generate_pdf_from_template", "generate_document"],
24786
+ ["generate_excel_from_template", "generate_document"],
24787
+ ["generate_word_from_template", "generate_document"]
24788
+ ]);
24789
+
24712
24790
  // ../shared/src/tool_input_semantics_registry.ts
24713
- var TOOL_INPUT_SEMANTICS = {
24791
+ var CURRENT_TOOL_INPUT_SEMANTICS = {
24714
24792
  // ─── Bulk record mutations ─────────────────────────────────────────────
24715
24793
  update_records: {
24716
24794
  table_id: { kind: "table_ref", rows: "change" },
@@ -24935,7 +25013,7 @@ var TOOL_INPUT_SEMANTICS = {
24935
25013
  force: { kind: "scalar" }
24936
25014
  },
24937
25015
  // ─── Document-generation tools ────────────────────────────────────────
24938
- generate_pdf_from_template: {
25016
+ generate_document: {
24939
25017
  data: { kind: "template_variables" },
24940
25018
  document_template_id: { kind: "scalar" },
24941
25019
  filename: { kind: "text" }
@@ -25242,6 +25320,15 @@ var TOOL_INPUT_SEMANTICS = {
25242
25320
  take: { kind: "scalar" }
25243
25321
  }
25244
25322
  };
25323
+ var TOOL_INPUT_SEMANTICS = {
25324
+ ...CURRENT_TOOL_INPUT_SEMANTICS,
25325
+ ...Object.fromEntries(
25326
+ [...RETIRED_TOOLS].flatMap(([retired, replacement]) => {
25327
+ const semantics = CURRENT_TOOL_INPUT_SEMANTICS[replacement];
25328
+ return semantics ? [[retired, semantics]] : [];
25329
+ })
25330
+ )
25331
+ };
25245
25332
 
25246
25333
  // ../shared/src/workflow_step_helpers.ts
25247
25334
  function stepChildStepArrays(step) {
@@ -27134,6 +27221,7 @@ var TABLE_ID_PATTERN = new RegExp(
27134
27221
  `^tbl_[${ID_ALPHABET}]+$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
27135
27222
  "i"
27136
27223
  );
27224
+ var PREFIXED_ID_PATTERN = new RegExp(`^[a-z]{3}_[${ID_ALPHABET}]{${ID_LENGTH}}$`);
27137
27225
 
27138
27226
  // ../shared/src/table_field_keys.ts
27139
27227
  var nanoid4 = customAlphabet(ID_ALPHABET, 6);
package/dist/router.d.ts CHANGED
@@ -4,7 +4,9 @@ export interface NotFoundWords {
4
4
  message: (path: string) => string;
5
5
  firstScreen: string;
6
6
  }
7
- export declare function AppRouter({ routes, notFound }: {
7
+ export declare function AppRouter({ routes, notFound, drawers }: {
8
8
  routes: RouteObject[];
9
9
  notFound?: NotFoundWords;
10
+ /** The query keys a drawer opens at, the one drawn lowest first: each open counts as a screen over the path's. */
11
+ drawers?: readonly string[];
10
12
  }): import("react").JSX.Element;
package/dist/router.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  isEmbedded,
3
+ postHostNotification,
3
4
  setUrlParams
4
5
  } from "./chunk-CRJQYSDI.js";
5
6
 
@@ -8,6 +9,7 @@ import { useEffect } from "react";
8
9
  import {
9
10
  BrowserRouter,
10
11
  Link,
12
+ matchRoutes,
11
13
  useLocation,
12
14
  useRoutes
13
15
  } from "react-router";
@@ -20,12 +22,40 @@ function screenHref(loc) {
20
22
  const qs = search.toString();
21
23
  return loc.pathname + (qs ? `?${qs}` : "") + loc.hash;
22
24
  }
23
- function HostScreenMirror() {
25
+ function routePattern(matches, pathname) {
26
+ const concrete = pathname.split("/").filter((segment) => segment !== "");
27
+ let shape = [];
28
+ for (const { route, params } of matches) {
29
+ const path = route.path ?? "";
30
+ if (path.startsWith("/")) shape = [];
31
+ for (const segment of path.split("/")) {
32
+ if (segment === "") continue;
33
+ if (!segment.endsWith("?")) {
34
+ shape.push(segment);
35
+ continue;
36
+ }
37
+ const base = segment.slice(0, -1);
38
+ const present = base.startsWith(":") ? params[base.slice(1)] !== void 0 : concrete[shape.length]?.toLowerCase() === base.toLowerCase();
39
+ if (present) shape.push(base);
40
+ }
41
+ }
42
+ return `/${shape.join("/")}`;
43
+ }
44
+ function HostScreenMirror({ routes, drawers }) {
24
45
  const location = useLocation();
25
46
  const href = screenHref(location);
26
47
  useEffect(() => {
27
48
  void setUrlParams({ [LOC_KEY]: href });
28
49
  }, [href]);
50
+ const { pathname, search } = location;
51
+ const params = new URLSearchParams(search);
52
+ const opened = drawers.flatMap((key) => params.getAll(key).map((value) => ({ key, value })));
53
+ const matches = matchRoutes(routes, pathname);
54
+ const route = matches === null ? null : routePattern(matches, pathname) + opened.map(({ key }) => `+${key}`).join("");
55
+ const openedAt = JSON.stringify(opened.map(({ value }) => value));
56
+ useEffect(() => {
57
+ if (route !== null) postHostNotification({ type: "screen_view", route });
58
+ }, [pathname, openedAt, route]);
29
59
  return null;
30
60
  }
31
61
  function RoutedRoutes({ routes }) {
@@ -76,20 +106,17 @@ function firstScreenPath(routes) {
76
106
  }
77
107
  function AppRouter({
78
108
  routes,
79
- notFound = NOT_FOUND_ENGLISH
109
+ notFound = NOT_FOUND_ENGLISH,
110
+ drawers = []
80
111
  }) {
81
112
  const embedded = isEmbedded();
113
+ const claimed = withNotFound(
114
+ routes,
115
+ /* @__PURE__ */ jsx(NotFoundScreen, { home: firstScreenPath(routes), words: notFound })
116
+ );
82
117
  return /* @__PURE__ */ jsxs(BrowserRouter, { children: [
83
- embedded ? /* @__PURE__ */ jsx(HostScreenMirror, {}) : null,
84
- /* @__PURE__ */ jsx(
85
- RoutedRoutes,
86
- {
87
- routes: withNotFound(
88
- routes,
89
- /* @__PURE__ */ jsx(NotFoundScreen, { home: firstScreenPath(routes), words: notFound })
90
- )
91
- }
92
- )
118
+ embedded ? /* @__PURE__ */ jsx(HostScreenMirror, { routes: claimed, drawers }) : null,
119
+ /* @__PURE__ */ jsx(RoutedRoutes, { routes: claimed })
93
120
  ] });
94
121
  }
95
122
  export {
package/dist/rpc.d.ts CHANGED
@@ -49,7 +49,11 @@ export type HostNotification = {
49
49
  type: "aiContext";
50
50
  slot: string;
51
51
  context: AiContextValue | null;
52
- } | AppErrorReport;
52
+ } | AppErrorReport | {
53
+ type: "screen_view";
54
+ /** The route pattern that claims the screen (`/item/:id`), a `+<key>` per drawer open over it; never its address. */
55
+ route: string;
56
+ };
53
57
  /** The app's identity: the host's context when bridged, `/by-subdomain` (no member) standalone. */
54
58
  export interface AppContext {
55
59
  member_id: string | null;
package/docs/ai.md CHANGED
@@ -13,7 +13,7 @@ Don't run a structured extraction through `askAi` (the result is stranded in a c
13
13
 
14
14
  ## Declared agents — what `useAgentRun` runs
15
15
 
16
- An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`.
16
+ An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_binding`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`.
17
17
 
18
18
  A declaration carries:
19
19
 
@@ -7,7 +7,7 @@ every write goes through a workflow — [./mutations.md](./mutations.md). Exact
7
7
  `dist/queries.d.ts`, `dist/row.d.ts`, `dist/select.d.ts`, `dist/members.d.ts`.
8
8
 
9
9
  The read model in one paragraph: an app never sends a raw query. It invokes a **named query by
10
- alias** (bound with `set_app_query`) and fills the template's declared `{{params.x}}`
10
+ alias** (bound with `set_app_queries`) and fills the template's declared `{{params.x}}`
11
11
  value holes; the server holds the canonical AST and runs it under the **app owner's** authority
12
12
  ([./security.md](./security.md)). The generated `.lotics/app_queries.d.ts` augments `AppQueries`,
13
13
  so params are typed per the binding; an alias it does not carry is accepted as a plain string,
package/docs/mutations.md CHANGED
@@ -138,8 +138,8 @@ neither, so the refusal arrives and marks no control.
138
138
 
139
139
  ### Generated files come back in `files[]`
140
140
 
141
- Any step in the run whose tool output carries a `file_id` (most commonly the
142
- `generate_*_from_template` document tools) is collected automatically into
141
+ Any step in the run whose tool output carries a `file_id` (most commonly
142
+ `generate_document`) is collected automatically into
143
143
  `result.files[]` — each an `UploadedFile`
144
144
  `{ id, filename, mime_type, url?, thumbnail_url? }` with a presigned `url` (24-hour TTL) the
145
145
  app can open directly:
@@ -121,6 +121,25 @@ Observable details:
121
121
  to the framework. Never declare them in a `useUrlState` shape or write them
122
122
  yourself; undeclared keys are already preserved automatically (see below).
123
123
 
124
+ ### Which screens are used (embedded)
125
+
126
+ Each time the path changes, `AppRouter` tells the host which route claims the
127
+ new screen, as that route is declared: `/item/:id`, never `/item/rec_8f2…`, and
128
+ never the query or hash. The host counts one view per change, so a
129
+ `useUrlState` filter is not a new screen and a step to the next item is. A
130
+ static segment is reported as written, so never build a route's `path` from
131
+ your data: a record's id or name goes in a `:param`.
132
+
133
+ A drawer the app opens at a query key rather than a path is a screen too when
134
+ `drawers` names its key, listed from the one drawn lowest:
135
+
136
+ ```tsx
137
+ <AppRouter routes={routes} drawers={["item"]} />
138
+ ```
139
+
140
+ `/?item=rec_8f2…` is then counted as `/+item`, one `+<key>` per drawer open, and
141
+ opening, stepping or closing one is a new view; the key's value is never sent.
142
+
124
143
  ## `useUrlState` — typed view-state in the address bar
125
144
 
126
145
  Save a declared slice of view-state into the address bar so a filtered view
package/docs/queries.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Queries — the query engine authoring reference
2
2
 
3
3
  Every read an app performs is a **named query**: a fixed AST template bound to the app by alias
4
- (`set_app_query`), validated and compiled when it is written, and invoked by alias
4
+ (`set_app_queries`), validated and compiled when it is written, and invoked by alias
5
5
  through the read hooks ([data_fetching.md](./data_fetching.md)). This is the authoring reference
6
6
  for the query engine itself.
7
7
 
@@ -11,11 +11,11 @@ for the query engine itself.
11
11
 
12
12
  ### Declaration
13
13
 
14
- Queries live on the app, as an alias → declaration map. `set_app_query` binds one alias at a time
15
- (`set_app_queries` several), each as a new version of the app:
14
+ Queries live on the app, as an alias → declaration map. `set_app_queries` binds one alias or
15
+ several, each call a new version of the app:
16
16
 
17
17
  ```jsonc
18
- // set_app_query { "app_id": "app_…", "alias": "openOrders", "declaration": … }
18
+ // set_app_queries { "app_id": "app_…", "queries": { "openOrders": … } }
19
19
  {
20
20
  "ast": {
21
21
  "kind": "project",
@@ -28,7 +28,7 @@ Queries live on the app, as an alias → declaration map. `set_app_query` binds
28
28
  "columns": ["order_code", "customer", "total", "created"]
29
29
  }
30
30
  }
31
- // set_app_query { "app_id": "app_…", "alias": "orderByCode", "declaration": … }
31
+ // set_app_queries { "app_id": "app_…", "queries": { "orderByCode": … } }
32
32
  {
33
33
  "ast": { /* … a filter with "{{params.code}}" … */ },
34
34
  "params": { "code": { "type": "text" } },
package/docs/recipes.md CHANGED
@@ -11,8 +11,7 @@ A button that produces a file — a quotation, a debit note, a label — and han
11
11
  **no** record.
12
12
 
13
13
  **The mechanism is the return channel.** Any workflow tool
14
- whose step output carries a `file_id` (`generate_pdf_from_template`, `generate_excel_from_template`,
15
- `generate_word_from_template`) is auto-collected by the execute endpoint and comes back in
14
+ whose step output carries a `file_id` (`generate_document`) is auto-collected by the execute endpoint and comes back in
16
15
  **`result.files[]`**, each with a servable `url`.
17
16
 
18
17
  So the workflow only *generates*; attaching the file to a record is a separate, optional step, and
@@ -22,7 +21,7 @@ So the workflow only *generates*; attaching the file to a record is a separate,
22
21
  // workflow body — alias `genDebit`, input `record_id`
23
22
  const rid = trigger.app_workflow.inputs.record_id;
24
23
  const rec = await get_record({ table_id: "tbl_x", record_id: rid });
25
- await generate_excel_from_template({
24
+ await generate_document({
26
25
  data: { /* … */ }, filename: `Debit_${code}`, document_template_id: "dtl_x",
27
26
  });
28
27
  return({ status: "success", message: `Đã tạo ${code}` }); // the file is extracted, not returned
package/docs/workflows.md CHANGED
@@ -506,7 +506,7 @@ full menu by category, so a miss is one informed retry.
506
506
  | **Type / null** | `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`, `isObject`, `coalesce`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson` |
507
507
  | **Array** | `size`, `first`, `requireFirst`, `last`, `nth`, `at`, `slice`, `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`, `uniqueBy`, `compact`, `flatten`, `reverse`, `concat`, `difference`, `differenceBy`, `intersection`, `intersectionBy`, `list`, `range`, `reduce` |
508
508
  | **Number** | `sum`, `sumBy`, `mean`, `meanBy`, `min`, `max`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage` |
509
- | **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `formatNumber(value, decimals)` (fixed-decimal, ungrouped), `formatDecimal(value, decimals, locale)` (grouped for a reader — `formatDecimal(151000, 0, "vi-VN")` → `151.000`), `numberToWords` |
509
+ | **String** | `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`, `replaceAll`, `substring`, `length`, `split`, `join`, `padStart`, `padEnd`, `formatNumber(value, decimals)` (fixed-decimal, ungrouped), `formatDecimal(value, decimals, locale)` (grouped for a reader — `formatDecimal(151000, 0, "vi-VN")` → `151.000`), `numberToWords(x, lang?)` (`"vi"` or `"en"`) |
510
510
  | **Object** | `keys`, `values`, `entries`, `get`, `pick`, `omit`, `merge`, `nonNullKeys` |
511
511
  | **Date** | `now`, `formatDate`, `parseDate`, `addDays`, `subDays`, `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`, `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`, `isSameDay`, `isToday`, `isWithinRange` |
512
512
  | **Other** | `formatCurrency(amount, locale, currency)`, `randomNumber(len)`, `randomAlphaNumeric(len)`, `sample(items)`, `current_member_in_any_group(["grp_…"])` |
@@ -755,7 +755,7 @@ values and put the writing after it.
755
755
  matches the same filter.
756
756
  ### Keep expensive steps off the path the caller waits on
757
757
 
758
- `generate_pdf_from_template` and `agent` are the two steps that dominate a body's wall clock. Ask
758
+ `generate_document` and `agent` are the two steps that dominate a body's wall clock. Ask
759
759
  whether the person pressing the button needs that artifact **at that instant**. A document that is
760
760
  printed later belongs in the workflow that prints it — moving it there also removes the reads that
761
761
  existed only to feed it, which is usually where the round trips were hiding.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.110.0",
3
+ "version": "0.111.0",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {