@lotics/app-sdk 0.109.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
@@ -14,14 +14,14 @@ This file is the index. The **exact type** of anything is its shipped declaratio
14
14
 
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
- | [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
17
+ | [docs/data_fetching.md](./docs/data_fetching.md) | The reads: `useQuery(alias, params, opts)` — its rows (`limit`), numbered pages (`page`), a keyset feed (`more`), its `total` or the total alone, with `total: { by }` a count per value of one column and `total: { where }` a count per named filter, from one scan — `useQueries` for reads known only at render, `queryAll` outside React, `exportQuery` for a file of them the server makes; every hook answers one `QueryState`. The ROW type (`RowOf` — the alias's projected columns and nothing else), runtime `sort`/`filter` keys, cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`, `readCreatedAt`/`readUpdatedAt`), the SDK's own cache — **arrival revalidates** — **realtime push**, a write drawn on every read from the press, a count as its own full scan, and the search-as-you-type and record-picker patterns. |
18
18
  | [docs/queries.md](./docs/queries.md) | **The query engine reference** — AST node kinds, per-field-type operators, filters/params/pruning, free-text search, combining tables, shaping (aggregates, date buckets, windows), runtime refinement bounds, limits and the efficiency playbook. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path; `useWorkflows` for writes that are data), the `WorkflowResult` resolve-never-throw contract (`field_errors`), typed inputs, every write drawn on every read from the press — predicted from the workflow's own steps, taken back on a refusal, `pending` meanwhile — the re-read a successful write triggers over the tables its body names, diff-before-update, locked records, `useNewRecord`, read-after-write ordering (`writesSettled()`), `useRecording`, `useRecordings` over several aliases. |
20
20
  | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY reference** — the JS subset a body may use, opaque `fld_*`/`opt_*` keys, every step form, the accepted sugar, helpers, record-write surfaces, the traps and the verify loop. |
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);
@@ -30975,13 +31063,25 @@ function pageOf(mocked, call) {
30975
31063
  function countOf(mocked, by) {
30976
31064
  return { total: mocked.length, ...by === void 0 ? {} : { counts: countsOf(mocked, by) } };
30977
31065
  }
31066
+ function within(filter2, named) {
31067
+ return filter2 === void 0 ? named : { node_type: "group", logic: "and", children: [filter2, named] };
31068
+ }
31069
+ function mockCount(alias, call) {
31070
+ const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
31071
+ if (mocked === null) return null;
31072
+ const where = Object.entries(call.where ?? {});
31073
+ const named = where.map(([, one]) => getMockRows(alias, { params: call.params, filter: within(call.filter, one) }) ?? []);
31074
+ const answer = (rows, each) => call.where === void 0 ? countOf(rows, call.by) : { total: rows.length, counts: Object.fromEntries(where.map(([name], at2) => [name, each[at2]?.length ?? 0])) };
31075
+ if (Array.isArray(mocked) && named.every((one) => Array.isArray(one))) return answer(mocked, named.filter((one) => Array.isArray(one)));
31076
+ return Promise.all([mocked, ...named]).then(([rows = [], ...each]) => answer(rows, each));
31077
+ }
30978
31078
  function knownRows(alias, call) {
30979
31079
  const mocked = getMockRows(alias, call);
30980
31080
  return Array.isArray(mocked) ? pageOf(mocked, call) : void 0;
30981
31081
  }
30982
31082
  function knownCount(alias, call) {
30983
- const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
30984
- return Array.isArray(mocked) ? countOf(mocked, call.by) : void 0;
31083
+ const counted = mockCount(alias, call);
31084
+ return counted === null || counted instanceof Promise ? void 0 : counted;
30985
31085
  }
30986
31086
  async function askRows(key, alias, askedAt, call, since = askedAt) {
30987
31087
  const mocked = getMockRows(alias, call);
@@ -30998,18 +31098,22 @@ async function askRows(key, alias, askedAt, call, since = askedAt) {
30998
31098
  return answer;
30999
31099
  }
31000
31100
  async function askCount(key, alias, askedAt, call) {
31001
- const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
31002
- if (mocked !== null) return countOf(await mocked, call.by);
31101
+ const mocked = mockCount(alias, call);
31102
+ if (mocked !== null) return mocked;
31003
31103
  const answer = await rpc("query", {
31004
31104
  alias,
31005
31105
  params: call.params,
31006
31106
  count: true,
31007
31107
  ...call.filter === void 0 ? {} : { filter: call.filter },
31008
- ...call.by === void 0 ? {} : { count_by: call.by }
31108
+ ...call.by === void 0 ? {} : { count_by: call.by },
31109
+ ...call.where === void 0 ? {} : { count_where: call.where }
31009
31110
  });
31010
31111
  noteAnswer(key, alias, askedAt, []);
31011
31112
  return answer;
31012
31113
  }
31114
+ function namedCounts(named) {
31115
+ return { rows: Object.fromEntries(named.map(([name, one]) => [name, one.rows])), pending: named.some(([, one]) => one.pending) };
31116
+ }
31013
31117
  var feedWants = /* @__PURE__ */ new Map();
31014
31118
  function ignore() {
31015
31119
  }
@@ -31022,7 +31126,8 @@ function useQuery(alias, params = {}, opts = {}) {
31022
31126
  const filter2 = opts.filter;
31023
31127
  const wantsRows = opts.rows ?? true;
31024
31128
  const totalAsked = opts.total ?? opts.page !== void 0;
31025
- const by = typeof totalAsked === "object" ? totalAsked.by : void 0;
31129
+ const by = typeof totalAsked === "object" && "by" in totalAsked ? totalAsked.by : void 0;
31130
+ const where = typeof totalAsked === "object" && "where" in totalAsked ? totalAsked.where : void 0;
31026
31131
  const call = JSON.stringify([params, filter2 ?? null, sort ?? null]);
31027
31132
  const mocking = hasMockFlag();
31028
31133
  const [paging, setPaging] = useState3({ call, page: 0 });
@@ -31077,11 +31182,12 @@ function useQuery(alias, params = {}, opts = {}) {
31077
31182
  feedWants.set(feedKey, heldPages + 1);
31078
31183
  feed.refetch();
31079
31184
  }, [feedKey, feedHasMore, heldPages, feed]);
31080
- const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null]);
31081
- const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, { params, filter: filter2, by }), {
31185
+ const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null, where ?? null]);
31186
+ const countCall = { params, ...filter2 === void 0 ? {} : { filter: filter2 }, ...by === void 0 ? {} : { by }, ...where === void 0 ? {} : { where } };
31187
+ const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, countCall), {
31082
31188
  focus,
31083
31189
  aliases: [alias],
31084
- known: mocking ? () => knownCount(alias, { params, filter: filter2, by }) : void 0
31190
+ known: mocking ? () => knownCount(alias, countCall) : void 0
31085
31191
  });
31086
31192
  const version3 = useWrittenVersion();
31087
31193
  const drawn2 = useMemo3(() => {
@@ -31097,9 +31203,10 @@ function useQuery(alias, params = {}, opts = {}) {
31097
31203
  const counted = useMemo3(() => {
31098
31204
  if (count.data === void 0) return void 0;
31099
31205
  const total2 = overlayCount(alias, params, filter2, count.askedAt, count.data.total);
31100
- const counts = by === void 0 || count.data.counts === void 0 ? void 0 : overlayCounts(alias, params, filter2, by, count.askedAt, count.data.counts);
31206
+ const answered2 = count.data.counts;
31207
+ const counts = answered2 === void 0 ? void 0 : where !== void 0 ? namedCounts(Object.entries(where).map(([name, one]) => [name, overlayCount(alias, params, within(filter2, one), count.askedAt, answered2[name] ?? 0)])) : by === void 0 ? void 0 : overlayCounts(alias, params, filter2, by, count.askedAt, answered2);
31101
31208
  return { total: total2.rows, counts: counts?.rows, pending: total2.pending || (counts?.pending ?? false) };
31102
- }, [alias, call, by, count.data, count.askedAt, version3]);
31209
+ }, [alias, call, countKey, count.data, count.askedAt, version3]);
31103
31210
  const total = counted?.total;
31104
31211
  const pageCount = opts.page === void 0 || total === void 0 ? void 0 : Math.max(1, Math.ceil(total / opts.page));
31105
31212
  const pageHasMore = opts.page === void 0 ? false : total !== void 0 ? (page + 1) * opts.page < total : drawn2.rows.length === opts.page;
package/dist/queries.d.ts CHANGED
@@ -89,10 +89,13 @@ export interface QueryOptions<C extends string = string> {
89
89
  more?: number;
90
90
  /**
91
91
  * The total the rows come to — a count of the whole set, its own read, so the rows never wait for it —
92
- * or with `by` a count per value of that column from the same scan. On by default with `page`.
92
+ * with `by` a count per value of that column from the same scan, or with `where` a count per named filter
93
+ * of the rows it keeps within the set, every one from the same scan. On by default with `page`.
93
94
  */
94
95
  total?: boolean | {
95
96
  by: C;
97
+ } | {
98
+ where: Readonly<Record<string, QueryFilter<C>>>;
96
99
  };
97
100
  /** `false` reads no rows: a total alone. Default `true`. */
98
101
  rows?: boolean;
@@ -103,7 +106,8 @@ export interface QueryState<R> {
103
106
  truncated: boolean;
104
107
  /** The whole set's count, where `total` is asked; `undefined` until it answers. */
105
108
  total: number | undefined;
106
- /** Rows per value of `total.by`, keyed as a row carries it (an option's `opt_…`); a value no row holds is absent. */
109
+ /** Rows per value of `total.by`, keyed as a row carries it (an option's `opt_…`), a value no row holds absent;
110
+ * or per name of `total.where`. */
107
111
  counts: Readonly<Record<string, number>> | undefined;
108
112
  /** Only the first read of a key with nothing to show; a re-read and a new key keep the rows on screen. */
109
113
  loading: boolean;
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,
@@ -21,7 +21,7 @@ untyped. It is written from the app's live bindings by `lotics app create --cust
21
21
  | `useQuery(alias, params?, opts?)` | `rows` up to the server's cap, or the first `limit` | a detail read, a dashboard block, a combobox's top-N — anything that is not a long list |
22
22
  | `useQuery(alias, params?, { page: n })` | one numbered page of `n` rows, `total`, `pageCount`, `setPage` | numbered, jumpable pages |
23
23
  | `useQuery(alias, params?, { more: n })` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
24
- | `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
24
+ | `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` or `total: { where: { name: filter } }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
25
25
  | `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
26
26
  | `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set in the browser (the ids an act runs on): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
27
27
  | `exportQuery(alias, { params, filter, sort, tabs?, report, file })` | a promise of the file | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file's `url` is signed for the caller: open it with `openExternal` |
@@ -63,7 +63,15 @@ const { total, counts } = useQuery("orders", { q }, { rows: false, total: { by:
63
63
  // counts?.["opt_shipped"] — the rows in that stage over the whole set, however few are loaded
64
64
  ```
65
65
 
66
- A `useQueries` call's `total` is `true` or absent — it takes no `by`.
66
+ Several counts of one set — a tab each, a share each — are **one read with `total: { where }`**: each
67
+ named filter's rows within the read's `filter`, as `counts` by name, from the same scan.
68
+
69
+ ```tsx
70
+ const { total, counts } = useQuery("orders", {}, { rows: false, total: { where: { late: lateFilter, mine: mineFilter } } });
71
+ // counts?.late, counts?.mine — one scan, however many names
72
+ ```
73
+
74
+ A `useQueries` call's `total` is `true` or absent — it takes no `by` or `where`.
67
75
 
68
76
  ### Why not hand-roll it
69
77
 
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:
@@ -526,7 +526,7 @@ every read before the request answers:
526
526
  leaves it; a created row it admits joins it, standing by the read's order (the call's `sort`,
527
527
  then the query's own) where its sort cells are numbers or dates, else first. A row the read does
528
528
  not hold that a write may move into it waits for the server, and `pending` says so.
529
- - **Totals.** A `total`, a count per value (`total: { by }`), a group asked with `aggregate` or
529
+ - **Totals.** A `total`, a count per value (`total: { by }`) or per named filter (`total: { where }`), a group asked with `aggregate` or
530
530
  declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
531
531
  or takes away; a group the last row leaves is gone, and a count per value gains the value a row
532
532
  first holds. A mean, an extreme, a distinct count, a group by day, an aggregate with its own
@@ -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" } },
@@ -844,7 +844,10 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
844
844
  - **`count: true`** — returns `{ total }` only: a COUNT over the *filtered* set, ignoring
845
845
  sort/limit/offset. Drives "Page 1 of N". With **`count_by: "<column>"`** the same scan also
846
846
  returns `counts` — rows per value of that projected column (a multi-value row under each value,
847
- a row holding none in `total` only); a column whose values are not plain (a link) → 400. It is a
847
+ a row holding none in `total` only); a column whose values are not plain (a link) → 400. With
848
+ **`count_where: { <name>: <filter> }`** (not beside `count_by`) the same scan returns `counts` by
849
+ name — the rows each filter keeps within `filter`, each filter bounded and resolved as `filter` is,
850
+ at most 32. It is a
848
851
  **second full execution** of the template — sent only where a read asks for its `total`, and
849
852
  one count serves every read of the same set ([data_fetching](./data_fetching.md)).
850
853
  - **`aggregate: { by?, day?, sum? }`** — the groups of the *filtered* set in place of its rows: one
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/security.md CHANGED
@@ -111,7 +111,7 @@ Listing members (`useMembers`) takes an authenticated member of the app's own or
111
111
 
112
112
  ## What runtime refinement cannot widen
113
113
 
114
- The query RPC accepts runtime `filter` and `sort` (for search boxes, sortable tables, pickers) — but these are **bounded to the named query's output columns**. A `field_key` naming a column the query does not project is rejected, so a caller can never filter or sort by — and thereby probe — a field the author didn't expose. The caller's `limit` is clamped to the server row cap, params fill the template's *value holes* only — filter values and the search term; tables, joins, and projections are author-fixed — and `count` mode returns a total — with `count_by`, one per value of a projected column — over the same bounded filter. Full mechanics in [queries](./queries.md).
114
+ The query RPC accepts runtime `filter` and `sort` (for search boxes, sortable tables, pickers) — but these are **bounded to the named query's output columns**. A `field_key` naming a column the query does not project is rejected, so a caller can never filter or sort by — and thereby probe — a field the author didn't expose. The caller's `limit` is clamped to the server row cap, params fill the template's *value holes* only — filter values and the search term; tables, joins, and projections are author-fixed — and `count` mode returns a total — with `count_by`, one per value of a projected column; with `count_where`, one per named filter, each bounded as `filter` is — over the same bounded filter. Full mechanics in [queries](./queries.md).
115
115
 
116
116
  **Warning — templated free-text search is not output-bounded.** A `search` term in the query template (typically a `{{params.q}}` hole) matches against the record's **whole search document**: every searchable field of the table — text, numbers, dates (in three formats), select option names, member names, linked-record display text, formula/rollup/lookup values, and autonumbers (only booleans, buttons, and file fields are excluded). It is *not* restricted to the columns the query projects. A caller who controls the search term can therefore probe the *contents* of unprojected fields by watching which rows match — a row-membership oracle. Put a `search` hole only in queries over tables where every searchable field is acceptable to probe for that audience; for a search box over a table with sensitive unprojected fields, use runtime `filter` with `contains` on the projected columns instead, or AND the `search` with a template `contains` OR-group over the fields that may be probed — every row that group matches also matches the search, so the pair answers only for those fields.
117
117
 
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.109.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": {