@lotics/app-sdk 0.101.2 → 0.102.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -15,7 +15,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
15
15
 
16
16
  | Doc | Read it for |
17
17
  |---|---|
18
- | [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; 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
+ | [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. |
19
19
  | [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. |
20
20
  | [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`. |
21
21
  | [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. |
@@ -898,6 +898,8 @@ function rpcStandalone(op, payload) {
898
898
  switch (op) {
899
899
  case "query":
900
900
  return standaloneQuery(payload);
901
+ case "export":
902
+ return standaloneExport(payload);
901
903
  case "field_options":
902
904
  return standaloneFieldOptions(payload);
903
905
  case "workflow":
@@ -1029,6 +1031,10 @@ async function standaloneQuery(payload) {
1029
1031
  const { app_id } = await boot();
1030
1032
  return apiCall("POST", `/v1/apps/${app_id}/query`, payload, { appId: app_id });
1031
1033
  }
1034
+ async function standaloneExport(payload) {
1035
+ const { app_id } = await boot();
1036
+ return apiCall("POST", `/v1/apps/${app_id}/export`, payload, { appId: app_id });
1037
+ }
1032
1038
  async function standaloneFieldOptions(p) {
1033
1039
  const { app_id } = await boot();
1034
1040
  return apiCall("POST", `/v1/apps/${app_id}/field-options`, { alias: p.alias }, { appId: app_id });
package/dist/hooks.d.ts CHANGED
@@ -151,6 +151,8 @@ export interface MembersOptions {
151
151
  * declares. Omit for the whole roster.
152
152
  */
153
153
  group?: string;
154
+ /** `false` reads no roster: `members` stays empty and `loading` false. Default `true`. */
155
+ enabled?: boolean;
154
156
  }
155
157
  /**
156
158
  * The organization's members, for an assign picker. Member-only, and gated on
package/dist/index.d.ts CHANGED
@@ -5,8 +5,8 @@ export { reportAppError } from "./error_report.js";
5
5
  export type { AppErrorKind } from "./error_report.js";
6
6
  export { useWorkflow, useWorkflows, writesSettled, useFileUpload, useAttachments, useAttachmentPiles, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
7
7
  export type { UploadedFile, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunLanding, AgentRunRecord, AgentRunState, AgentUIPart, PendingChoice, ChoiceQuestion, ChoiceOption, AskUserChoiceOutput, } from "./hooks.js";
8
- export { useQuery, useQueries, queryAll, useFieldOptions } from "./queries.js";
9
- export type { QueryRow, QueryCall, QueryState, RowOf, QueryOptions, ColumnKeyOf, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, FieldOptions, FieldOptionsState, FieldOptionsOptions, FigureUnits, } from "./queries.js";
8
+ export { useQuery, useQueries, queryAll, exportQuery, useFieldOptions } from "./queries.js";
9
+ export type { QueryRow, QueryCall, QueryState, RowOf, QueryOptions, ColumnKeyOf, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterFieldCondition, QueryFilterRecordIdCondition, QueryFilterGroup, FieldOptions, FieldOptionsState, FieldOptionsOptions, FigureUnits, ExportCall, ExportFile, ExportReport, } from "./queries.js";
10
10
  export type { AttachedFile, AttachmentPiles, AttachmentPilesOptions, AttachmentsOptions, AttachmentsState } from "./attachments.js";
11
11
  export { useComments, useCommentCounts } from "./comments.js";
12
12
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
@@ -27,7 +27,7 @@ export { readMembers } from "./members.js";
27
27
  export type { ResolvedMember } from "./members.js";
28
28
  export { readSelect } from "./select.js";
29
29
  export type { ResolvedOption } from "./select.js";
30
- export type { AppFixture, MockQuery, MockQueryCall, MockWorkflow } from "./mock.js";
30
+ export type { AppFixture, MockExport, MockQuery, MockQueryCall, MockWorkflow } from "./mock.js";
31
31
  export type { AppWorkflows, AppWorkflowResults, AppQueries, AppQueryColumns, AppAgents, AppAgentResults } from "./types.js";
32
32
  export { row, readLinks, readFiles, readLocked, readCreatedAt, readUpdatedAt, PENDING } from "./row.js";
33
33
  export type { ResolvedLink, AppFile } from "./row.js";
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  subscribeRecordings,
22
22
  subscribeUrlParams,
23
23
  urlParam
24
- } from "./chunk-ARV5FAU5.js";
24
+ } from "./chunk-ZOFKWF6V.js";
25
25
 
26
26
  // src/mount.tsx
27
27
  import { createRoot } from "react-dom/client";
@@ -59,6 +59,10 @@ function getMockWorkflow(alias) {
59
59
  if (!isMockMode()) return null;
60
60
  return registeredFixture?.workflows?.[alias] ?? null;
61
61
  }
62
+ function getMockExport(alias) {
63
+ if (!isMockMode()) return null;
64
+ return registeredFixture?.exports?.[alias] ?? null;
65
+ }
62
66
  function getMockRecordings() {
63
67
  if (!isMockMode()) return null;
64
68
  return registeredFixture?.recordings ?? null;
@@ -24134,6 +24138,7 @@ function appWorkflowOutputDepth(output) {
24134
24138
  if (output.type === "array") return 1 + appWorkflowOutputDepth(output.items);
24135
24139
  return 1;
24136
24140
  }
24141
+ var APP_ALIAS_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
24137
24142
  var MAX_APP_CAPABILITY_DESCRIPTION = 300;
24138
24143
  var APP_ANONYMOUS_REQUEST_MAX_BYTES = 256 * 1024;
24139
24144
  var APP_UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
@@ -24166,6 +24171,13 @@ var appWorkflowContractSchema = zod_default.object({
24166
24171
  inputs: zod_default.record(zod_default.string(), appWorkflowInputSchema).optional(),
24167
24172
  outputs: zod_default.record(zod_default.string(), appWorkflowOutputSchema).optional()
24168
24173
  });
24174
+ var APP_EXPORT_REPORT_KEYS = ["title", "lines", "readings"];
24175
+ var appExportTemplateSchema = zod_default.object({
24176
+ template_id: zod_default.string().min(1).describe("The document template (`dtl_\u2026`) filled: an Excel workbook, or an HTML page made a PDF."),
24177
+ rows: zod_default.string().regex(APP_ALIAS_REGEX).refine((key) => !APP_EXPORT_REPORT_KEYS.some((taken) => taken === key), {
24178
+ message: `The rows are listed under a key of their own, never ${APP_EXPORT_REPORT_KEYS.join(", ")}.`
24179
+ }).describe(`The key the template lists the rows under, beside ${APP_EXPORT_REPORT_KEYS.join(", ")}.`)
24180
+ }).strict();
24169
24181
  var appQueryDeclarationSchema = zod_default.object({
24170
24182
  ast: zod_default.unknown().describe(
24171
24183
  "Query AST template (a QueryNode). Validated server-side via parseQueryNode at deploy. May embed {{params.<name>}} tokens in filter value positions."
@@ -24175,6 +24187,9 @@ var appQueryDeclarationSchema = zod_default.object({
24175
24187
  ),
24176
24188
  description: zod_default.string().optional().describe(
24177
24189
  `What this query returns, in one line \u2014 read by an agent choosing between the app's aliases. An alias is a JS identifier, which names a query without saying what it covers. Capped at ${MAX_APP_CAPABILITY_DESCRIPTION} characters.`
24190
+ ),
24191
+ templates: zod_default.record(zod_default.string().regex(APP_ALIAS_REGEX), appExportTemplateSchema).optional().describe(
24192
+ "Document templates an export of this query's rows may fill, by the name the export asks for. An export names one of these or none; a template it does not declare is refused."
24178
24193
  )
24179
24194
  });
24180
24195
  var appAgentDeclarationSchema = zod_default.object({
@@ -28006,7 +28021,7 @@ function useAiContext(slot, context) {
28006
28021
  function useMembers(opts) {
28007
28022
  const group = opts?.group;
28008
28023
  const roster = useKey(
28009
- JSON.stringify(["members", group ?? null]),
28024
+ opts?.enabled === false ? null : JSON.stringify(["members", group ?? null]),
28010
28025
  async () => {
28011
28026
  const members2 = (await rpc("members", { group })).members ?? [];
28012
28027
  noteMembers(members2);
@@ -30649,23 +30664,27 @@ function overlayAsked(alias, params, narrowing, askedAt, totalsOf, rows, back, a
30649
30664
  }
30650
30665
 
30651
30666
  // src/queries.ts
30652
- function mockedRows(alias, call) {
30653
- const mocked = getMockRows(alias, call);
30654
- if (mocked === null) return void 0;
30667
+ function pageOf(mocked, call) {
30655
30668
  const start = call.keyset === true ? Number(call.cursor ?? 0) : call.offset ?? 0;
30656
30669
  const end = call.limit === void 0 ? mocked.length : start + call.limit;
30657
30670
  const rows = mocked.slice(start, end);
30658
30671
  const more = end < mocked.length;
30659
30672
  return { rows, truncated: call.keyset !== true && more, next_cursor: call.keyset === true && more ? String(end) : null };
30660
30673
  }
30661
- function mockedCount(alias, call) {
30674
+ function countOf(mocked, by) {
30675
+ return { total: mocked.length, ...by === void 0 ? {} : { counts: countsOf(mocked, by) } };
30676
+ }
30677
+ function knownRows(alias, call) {
30678
+ const mocked = getMockRows(alias, call);
30679
+ return Array.isArray(mocked) ? pageOf(mocked, call) : void 0;
30680
+ }
30681
+ function knownCount(alias, call) {
30662
30682
  const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
30663
- if (mocked === null) return void 0;
30664
- return { total: mocked.length, ...call.by === void 0 ? {} : { counts: countsOf(mocked, call.by) } };
30683
+ return Array.isArray(mocked) ? countOf(mocked, call.by) : void 0;
30665
30684
  }
30666
30685
  async function askRows(key, alias, askedAt, call, since = askedAt) {
30667
- const mocked = mockedRows(alias, call);
30668
- if (mocked !== void 0) return mocked;
30686
+ const mocked = getMockRows(alias, call);
30687
+ if (mocked !== null) return pageOf(await mocked, call);
30669
30688
  const answer = await rpc("query", {
30670
30689
  alias,
30671
30690
  params: call.params,
@@ -30678,8 +30697,8 @@ async function askRows(key, alias, askedAt, call, since = askedAt) {
30678
30697
  return answer;
30679
30698
  }
30680
30699
  async function askCount(key, alias, askedAt, call) {
30681
- const mocked = mockedCount(alias, call);
30682
- if (mocked !== void 0) return mocked;
30700
+ const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
30701
+ if (mocked !== null) return countOf(await mocked, call.by);
30683
30702
  const answer = await rpc("query", {
30684
30703
  alias,
30685
30704
  params: call.params,
@@ -30720,7 +30739,7 @@ function useQuery(alias, params = {}, opts = {}) {
30720
30739
  limit: opts.page ?? opts.limit,
30721
30740
  offset: opts.page === void 0 ? 0 : page * opts.page
30722
30741
  }),
30723
- { focus, aliases: [alias], known: mocking ? () => mockedRows(alias, { params, filter: filter2, sort, limit: opts.page ?? opts.limit, offset: opts.page === void 0 ? 0 : page * opts.page }) : void 0 }
30742
+ { focus, aliases: [alias], known: mocking ? () => knownRows(alias, { params, filter: filter2, sort, limit: opts.page ?? opts.limit, offset: opts.page === void 0 ? 0 : page * opts.page }) : void 0 }
30724
30743
  );
30725
30744
  const feedKey = !enabled || !wantsRows || opts.more === void 0 ? null : JSON.stringify(["feed", alias, call, opts.more]);
30726
30745
  const feedSize = opts.more ?? 0;
@@ -30761,7 +30780,7 @@ function useQuery(alias, params = {}, opts = {}) {
30761
30780
  const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, { params, filter: filter2, by }), {
30762
30781
  focus,
30763
30782
  aliases: [alias],
30764
- known: mocking ? () => mockedCount(alias, { params, filter: filter2, by }) : void 0
30783
+ known: mocking ? () => knownCount(alias, { params, filter: filter2, by }) : void 0
30765
30784
  });
30766
30785
  const version3 = useWrittenVersion();
30767
30786
  const drawn2 = useMemo3(() => {
@@ -30823,7 +30842,7 @@ async function settle(read2) {
30823
30842
  }
30824
30843
  async function askGroups(key, alias, askedAt, aggregate, call) {
30825
30844
  const mocked = getMockRows(alias, { ...call, aggregate });
30826
- if (mocked !== null) return { rows: aggregateRows(mocked, aggregate), truncated: false };
30845
+ if (mocked !== null) return { rows: aggregateRows(await mocked, aggregate), truncated: false };
30827
30846
  const answer = await rpc("query", { alias, params: call.params, aggregate, filter: call.filter, sort: call.sort });
30828
30847
  noteAnswer(key, alias, askedAt, []);
30829
30848
  return answer;
@@ -30892,6 +30911,13 @@ async function queryAll(alias, params = {}, opts = {}) {
30892
30911
  if (!page.truncated || page.rows.length === 0) return pages.flat();
30893
30912
  }
30894
30913
  }
30914
+ async function exportQuery(alias, call) {
30915
+ const sort = call.sort === void 0 || call.sort.length === 0 ? void 0 : [...call.sort];
30916
+ const body = { alias, params: call.params ?? {}, filter: call.filter, sort, report: call.report, file: call.file };
30917
+ const mocked = getMockExport(alias);
30918
+ if (mocked !== null) return mocked(body);
30919
+ return (await rpc("export", body)).file;
30920
+ }
30895
30921
  var NO_UNITS = {};
30896
30922
  function useFieldOptions(alias, opts) {
30897
30923
  const enabled = opts?.enabled ?? true;
@@ -31317,6 +31343,7 @@ export {
31317
31343
  askAi,
31318
31344
  buildChoiceOutput,
31319
31345
  downloadFile,
31346
+ exportQuery,
31320
31347
  isEmbedded,
31321
31348
  isWithinZone,
31322
31349
  mount,
package/dist/mock.d.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  * `useFileUpload` is not mocked.
7
7
  */
8
8
  import type { QueryAggregate } from "./shared_types.js";
9
- import type { WorkflowResult } from "./hooks.js";
10
- import type { QuerySortKey } from "./queries.js";
9
+ import type { UploadedFile, WorkflowResult } from "./hooks.js";
10
+ import type { ExportCall, QuerySortKey } from "./queries.js";
11
11
  import type { RecordingState } from "./recording_state.js";
12
12
  /** A result, or a function of the inputs — return a slow promise to review the pending state. */
13
13
  export type MockWorkflow = WorkflowResult | ((inputs: Record<string, unknown>) => WorkflowResult | Promise<WorkflowResult>);
@@ -19,11 +19,18 @@ export interface MockQueryCall {
19
19
  /** The groups the call asks for: a fixture answers the query's rows, and they are folded into these after it. */
20
20
  aggregate?: QueryAggregate;
21
21
  }
22
- /** Rows, or a function of the call — the only way to honour `filter`/`sort`/`limit`, which the fixture path has no engine for. */
23
- export type MockQuery = Array<Record<string, unknown>> | ((call: MockQueryCall) => Array<Record<string, unknown>>);
22
+ /** Rows, or a function of the call — the only way to honour `filter`/`sort`/`limit`, which the fixture path has no
23
+ * engine for. Return a promise to hold the read loading until it settles. */
24
+ export type MockQuery = Array<Record<string, unknown>> | ((call: MockQueryCall) => Array<Record<string, unknown>> | Promise<Array<Record<string, unknown>>>);
25
+ /** The file an export of a query makes, as a function of the call — return a slow promise to review the pending state. */
26
+ export type MockExport = (call: ExportCall & {
27
+ alias: string;
28
+ }) => UploadedFile | Promise<UploadedFile>;
24
29
  export interface AppFixture {
25
30
  queries?: Record<string, MockQuery>;
26
31
  workflows?: Record<string, MockWorkflow>;
32
+ /** By the query exported. */
33
+ exports?: Record<string, MockExport>;
27
34
  /** `useRecording(alias)` reads as available; `start`/`stop` change nothing. */
28
35
  recordings?: Record<string, RecordingState>;
29
36
  }
@@ -31,7 +38,9 @@ export declare function registerMockFixture(fixture: AppFixture | undefined): vo
31
38
  /** The raw `?__mock=1` flag, fixture or not; false where `window.location` is absent. */
32
39
  export declare function hasMockFlag(): boolean;
33
40
  /** Null for an unmocked alias, which reads real data. */
34
- export declare function getMockRows(alias: string, call: MockQueryCall): Array<Record<string, unknown>> | null;
41
+ export declare function getMockRows(alias: string, call: MockQueryCall): Array<Record<string, unknown>> | Promise<Array<Record<string, unknown>>> | null;
35
42
  /** Null for an unmocked alias, which executes for real. */
36
43
  export declare function getMockWorkflow(alias: string): MockWorkflow | null;
44
+ /** Null for an unmocked alias, which the server exports. */
45
+ export declare function getMockExport(alias: string): MockExport | null;
37
46
  export declare function getMockRecordings(): Record<string, RecordingState> | null;
package/dist/queries.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { QueryAggregate } from "./shared_types.js";
2
2
  import type { AppQueries, AppQueryColumns } from "./types.js";
3
3
  import type { ResolvedOption } from "./select.js";
4
+ import type { UploadedFile } from "./hooks.js";
5
+ import type { ReportLanguage } from "./shared_types.js";
6
+ import type { ReportColumn, ReportReading } from "./shared_types.js";
4
7
  /**
5
8
  * One row of a query whose columns codegen could not read (a dynamic alias, a
6
9
  * bare `from_table`). Values stay `unknown` — the cell readers narrow them.
@@ -169,12 +172,44 @@ export interface QueryCall {
169
172
  export declare function useQueries(calls: readonly QueryCall[], opts?: Pick<QueryOptions, "enabled" | "revalidateOnFocus">): QueryState<QueryRow>[];
170
173
  /**
171
174
  * Every row a declared query matches, read a page at a time to the end — for what must hold every row
172
- * (a saved sheet), never for a screen, which pages. The server's rows as stored: no write is drawn over them.
175
+ * (an act over every row in view), never for a screen, which pages, nor for a file, which `exportQuery` makes on the
176
+ * server. The server's rows as stored: no write is drawn over them.
173
177
  */
174
178
  export declare function queryAll(alias: string, params?: Record<string, unknown>, opts?: {
175
179
  filter?: QueryFilter;
176
180
  sort?: readonly QuerySortKey[];
177
181
  }): Promise<QueryRow[]>;
182
+ /** What an export states of its rows beside them: the page's title, one line per thing narrowing them, and the
183
+ * figures over them as the screen drew them. */
184
+ export interface ExportReport {
185
+ title: string;
186
+ lines: string[];
187
+ readings: ReportReading[];
188
+ }
189
+ /** The file an export makes: the default report workbook — its words in `language`, the rows under `columns` on the
190
+ * tab `sheet` — or a template the query declares under `templates`, by its name there. */
191
+ export type ExportFile = {
192
+ kind: "workbook";
193
+ language: ReportLanguage;
194
+ sheet: string;
195
+ columns: ReportColumn[];
196
+ } | {
197
+ kind: "template";
198
+ template: string;
199
+ };
200
+ export interface ExportCall {
201
+ params?: Record<string, unknown>;
202
+ filter?: QueryFilter;
203
+ sort?: readonly QuerySortKey[];
204
+ report: ExportReport;
205
+ file: ExportFile;
206
+ }
207
+ /**
208
+ * Every row a declared query matches — narrowed by `filter` and ordered by `sort` as a page of it is — made into one
209
+ * file by the server, so no row reaches the browser. Past the most rows one export holds it is refused, never cut.
210
+ * Resolves the stored file, its URL signed for the caller.
211
+ */
212
+ export declare function exportQuery(alias: string, call: ExportCall): Promise<UploadedFile>;
178
213
  /** One select column's full option set. */
179
214
  export interface FieldOptions {
180
215
  /** The source field's display name. */
package/dist/router.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  isEmbedded,
3
3
  setUrlParams
4
- } from "./chunk-ARV5FAU5.js";
4
+ } from "./chunk-ZOFKWF6V.js";
5
5
 
6
6
  // src/router.tsx
7
7
  import { useEffect } from "react";
package/dist/rpc.d.ts CHANGED
@@ -8,7 +8,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
8
8
  * app → host: { id, op, payload }
9
9
  * host → app: { id, type: "result", data } | { id, type: "error", message }
10
10
  */
11
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "write_model" | "prediction_miss" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "recording.start" | "recording.stop";
11
+ export type RpcOp = "query" | "export" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "file.rename" | "members" | "context" | "write_model" | "prediction_miss" | "openExternal" | "openApp" | "askAi" | "urlState.get" | "urlState.set" | "folder.get" | "folder.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts" | "recording.start" | "recording.stop";
12
12
  export interface AgentRunPayload {
13
13
  alias: string;
14
14
  session_id: string;
@@ -4,5 +4,16 @@
4
4
  * tarball. Written at every build, never edited.
5
5
  */
6
6
  export type QueryAggregate = { by?: readonly string[]; day?: string; sum?: string; };
7
+ export type ReportColumn = { key: string; header: string; type: "number" | "text" | "date"; };
8
+ export type ReportLanguage = "vi" | "en";
9
+ export type ReportPart = { label: string; value: number; share: number | null; };
10
+ export type ReportPivotColumn = { label: string; total: number | null; };
11
+ export type ReportPivotRow = { label: string; values: number[]; total: number | null; };
12
+ export type ReportPoint = { label: string; value: number; partial: boolean; };
13
+ export type ReportReading = (ReportReadingHead & { kind: "metric"; value: number | null; }) | (ReportReadingHead & { kind: "breakdown"; byLabel: string; parts: ReportPart[]; total: number | null; }) | (ReportReadingHead & { kind: "trend"; points: ReportPoint[]; }) | (ReportReadingHead & { kind: "pivot"; rowsLabel: string; columnsLabel: string; columns: ReportPivotColumn[]; rows: ReportPivotRow[]; total: number | null; }) | { kind: "failed"; label: string; };
14
+ export type ReportReadingHead = { label: string; valueLabel: string; unit: string | null; truncated: boolean; };
7
15
  export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; } | { key: string; name: string; description: string; type: "text"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; unique?: boolean | undefined; format?: "text" | "link" | "markdown" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "date"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; format?: "date" | "datetime" | "date_range" | "datetime_range" | undefined; timezone?: string | undefined; derive_from?: "created_at" | "updated_at" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; } | { key: string; name: string; description: string; type: "select"; options: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_member"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; display_field_keys: string[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; display_field_widths?: Record<string, number> | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; } | { key: string; name: string; description: string; type: "rollup"; source_field_key: string; aggregate_option: { operation: "count"; field_key?: string | undefined; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique"; field_key: string; } | { operation: "min" | "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range"; field_key: string; } | { operation: "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "earliest" | "latest"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest" | "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest"; field_key: string; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; filter?: TableRecordFiltersGroupNode | undefined; aggregate_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_format?: string | undefined; aggregate_field_currency?: string | undefined; aggregate_field_unit?: string | undefined; aggregate_field_unit_field?: string | undefined; aggregate_field_currency_field?: string | undefined; } | { key: string; name: string; description: string; type: "lookup"; source_field_key: string; lookup_field_key: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; lookup_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_format?: string | undefined; lookup_field_currency?: string | undefined; lookup_field_unit?: string | undefined; lookup_field_options?: { key: string; name: string; color?: string | undefined; mark?: { kind: string; name: string; } | undefined; }[] | undefined; order_by?: { field_key: string; direction: "asc" | "desc"; } | undefined; } | { key: string; name: string; description: string; type: "files"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "formula"; formula: { expression: string; format?: "number" | "currency" | "percentage" | "link" | undefined; currency?: string | undefined; unit?: string | undefined; unit_field?: string | undefined; currency_field?: string | undefined; options?: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[] | undefined; output_type?: "number" | "boolean" | "text" | "date" | "datetime" | "select" | undefined; volatile?: boolean | undefined; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "button"; text: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; workflow_id?: string | null | undefined; } | { key: string; name: string; description: string; type: "autonumber"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; prefix?: string | undefined; padding?: number | undefined; template?: string | undefined; };
8
16
  export type TableRecordFilters = TableRecordFiltersConditionNode | TableRecordFiltersTraversalNode | TableRecordFiltersGroupNode;
17
+ export type TableRecordFiltersConditionNode = { node_type: "condition"; field_key?: string | undefined; type?: string | undefined; operator: string; value?: unknown; unit_option?: string | undefined; };
18
+ export type TableRecordFiltersGroupNode = { node_type: "group"; logic: "and" | "or"; children: (TableRecordFilters)[]; };
19
+ export type TableRecordFiltersTraversalNode = { node_type: "traversal"; path: string[]; condition: TableRecordFiltersConditionNode; };
@@ -23,7 +23,8 @@ and by `lotics app create --custom` and `lotics app deploy`. Every hook reads th
23
23
  | `useQuery(alias, params?, { more: n })` | accumulated `rows` + `loadMore` | infinite scroll / "load more" feeds |
24
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 |
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
- | `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set (an export): 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 |
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
+ | `exportQuery(alias, { params, filter, sort, 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. `report` is `{ title, lines, readings }`; `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, 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) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows 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` |
27
28
 
28
29
  Every hook answers the same `QueryState`: `rows`, `truncated`, `total`, `counts`, `loading`,
29
30
  `isValidating`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
@@ -170,6 +170,7 @@ in it, while a resolved CELL keeps naming them.
170
170
  `image` is the avatar URL (a presigned URL valid 24 hours, or the member's external OAuth photo) and
171
171
  may be `null`. `name` may be null/empty for members without a display name — fall back to `email`. On failure the hook does not throw: it
172
172
  resolves `{ members: [], loading: false, error }`.
173
+ `{ enabled: false }` reads nothing until the screen needs the roster.
173
174
 
174
175
  Every hook reading one `group` shares one roster: it is read again when a screen reading it mounts, never on focus.
175
176
 
package/docs/queries.md CHANGED
@@ -45,6 +45,14 @@ Queries live on the app, as an alias → declaration map. `set_app_query` binds
45
45
  - **`description`** — one line saying what the query returns, capped at 300 characters. No app
46
46
  code reads it: it is what the **agents** that reach this app's data (its declared agents, a
47
47
  member's chat while the app is open) read to choose between your aliases.
48
+ - **`templates`** — the document templates an `exportQuery` of this alias may fill, by the name
49
+ the export asks for: `{ "<name>": { "template_id": "dtl_…", "rows": "<key>" } }`. The template
50
+ is filled with `{ title, lines, readings, <key>: every row }`, each row keyed by output column —
51
+ a figure, a yes/no, a date or moment as stored (ISO), anything else its words (options,
52
+ members, linked rows and files by name, comma-joined). An excel template makes a workbook, an
53
+ html one a PDF. Binding refuses a template that is not a live excel or html template of the
54
+ app's workspace, one the app's owner may not use, or one reading at its root anything but
55
+ those keys; `rows` is never `title`, `lines` or `readings`.
48
56
  - Aliases must be valid JS identifiers (`useQuery("openOrders")` and the generated types depend on it).
49
57
 
50
58
  A deploy never touches the bindings; `get_app_query` reads one back. The **server holds the
@@ -56,7 +64,7 @@ query's reach (a token in a `table_id` or field-key position fails validation).
56
64
 
57
65
  Binding fails — with the compiler's own message, never raw SQL/Postgres text — when any alias:
58
66
 
59
- 1. isn't a valid identifier, or the declaration isn't `{ ast, params?, description? }` (a
67
+ 1. isn't a valid identifier, or the declaration isn't `{ ast, params?, description?, templates? }` (a
60
68
  description over 300 characters is refused here too);
61
69
  2. references `{{params.x}}` without declaring param `x` (typos can't silently widen);
62
70
  3. doesn't parse as a `QueryNode` **as the raw template** (the runtime parses the stored
@@ -226,7 +234,7 @@ supports the full §5 operator matrix **except**: `traversal` nodes, `locked`, a
226
234
  `record_id` works on any row-level derived query (rejected over a `group`, which has no row
227
235
  identity). When a `filter` directly wraps a `union` of `project(from_table)` arms and every
228
236
  condition targets bare passthrough columns, the engine pushes the predicate into each arm's
229
- source filter automatically (making it index-servable); otherwise it evaluates post-union.
237
+ source filter automatically, where it runs once per record; otherwise it evaluates post-union.
230
238
 
231
239
  ### `join` — combine two row sets
232
240
 
@@ -537,9 +545,9 @@ Negation-shaped inner operators (`has_none_of`, `not_equals`, `is_none_of`,
537
545
 
538
546
  Traversals are **source-layer only** (rejected on derived columns — push them into
539
547
  `from_table.filter`). Each hop reaches the linked rows by primary key from the ids in the link
540
- cell. Under an OR beside column conditions a traversal runs once per row and takes the column
541
- conditions off their indexes with it; give it a `union` arm of its own when the other arms must
542
- stay index-served. Two access classes:
548
+ cell. Under an OR beside column conditions a traversal runs once per row and takes a membership
549
+ condition off its index with it; give it a `union` arm of its own when the other arms must keep
550
+ theirs. Two access classes:
543
551
 
544
552
  - **Self-scoped** — inner operator `is_current_member` / `is_not_current_member`: tests only
545
553
  the viewer's own membership on the linked row, leaks nothing, and is exempt from the linked
@@ -863,7 +871,7 @@ ordering and scoping into the template or its params.
863
871
  Any other execution failure returns a generic `query execution failed` (the real error — which
864
872
  may embed SQL — is server-logged only). Bind-time and validation errors are always specific.
865
873
 
866
- ### Index reality, in plain terms
874
+ ### What a query reads, in plain terms
867
875
 
868
876
  Records are stored partitioned by workspace, so a query reads only its own workspace's slice; a
869
877
  `from_table` narrows that slice to the table's rows, and within them:
@@ -871,39 +879,18 @@ Records are stored partitioned by workspace, so a query reads only its own works
871
879
  - **GIN-served (fast at any size):** *positive* exact-membership filters at the **source
872
880
  layer** — `select`/`select_member`/`select_record_link` `has_any_of` / `has_all_of`, and
873
881
  `is_current_member`. These compile to containment the JSONB GIN index serves.
874
- - **Trigram-served:** `from_table.search` (§7).
875
- - **B-tree-served (automatic for bound queries):** text `equals`, number and date
876
- comparisons (exact and range), `from_table.sort` fields, and a one-key `sort` node over a
877
- number or date field the rows pass unchanged — for fields referenced in a **bound named
878
- query's template**. The platform provisions a partial expression index per referenced
879
- field automatically: built online whenever `set_app_query` or `set_app_queries` binds a query, re-synced daily,
880
- capped at 8 per table (fields past the cap fall back to the scan tier, with a server WARN).
881
- A `{{params.…}}` value hole doesn't change this — the field key is static in the template,
882
- so it still gets its index. Index-seek speed at any table size once provisioned.
883
- - **Table scan (linear in table size):** everything else — text `contains`, negations
884
- (`has_none_of`, `is_none_of`, `not_*`), emptiness, files predicates, and predicates/sorts on
885
- fields that appear **only** in the runtime `filter`/`sort` options rather than the bound
886
- template. Fine on thousands of rows; on very large tables these dominate latency and are the
887
- usual timeout cause.
888
-
889
- **Filter shape drives latency.** Equality/range/sort predicates in the bound template are
890
- index-served; lead with those or a GIN-served membership filter / `search`, and let scan-shaped
891
- predicates refine the already-narrowed set. Derived-layer filters run over the subquery result
892
- (no index), so **filter at the source layer whenever the field exists there** — the runtime
893
- filter is for caller-driven refinement, not for the main cut (a runtime-only field gets no
894
- managed index). (The engine pushes eligible filter-over-union predicates down automatically,
895
- but don't rely on that for other shapes.)
896
-
897
- **A sorted page reads in its sort index — declare the order it is read in.** A page sorted by one
898
- column that passes a number or date field unchanged (a bare `project` source with no `type`
899
- override, an `unpivot` passthrough, or a row column that reads the same field in every row) reads
900
- the table in that field's sort index and stops at the page, instead of reading every row and
901
- sorting. Over a `union`, each arm is paged this way on its own and the arms are merged, so a
902
- register over several tables or `unpivot` sides is fast at any size — don't hand-split it into
903
- per-table queries you merge client-side. The index exists when a bound template sorts that field
904
- in the same direction and blank position: give the query its default order as a top-level `sort`,
905
- and a page the app sorts the same way is served. A text column, a computed, literal or cast column,
906
- a sort on more than one key, and a cursor (`keyset`) page sort every row.
882
+ - **Trigram-served:** `from_table.search` (§7), within the one table.
883
+ - **Every row of the table:** everything else — text equality and `contains`, number and date
884
+ comparisons, sorts, negations, emptiness, files predicates. Fine on thousands of rows; the cost
885
+ grows with the table, and on very large tables these dominate latency and are the usual
886
+ timeout cause.
887
+
888
+ **Filter shape drives latency.** Lead with a GIN-served membership filter or `search`, which
889
+ narrow the table before its rows are read, and let the other conditions refine that set.
890
+ Derived-layer filters run over the subquery result, after any `unpivot` fan-out, so **filter at
891
+ the source layer whenever the field exists there** — the runtime filter is for caller-driven
892
+ refinement, not for the main cut. (The engine moves eligible filter-over-union predicates into
893
+ each arm's source automatically, but don't rely on that for other shapes.)
907
894
 
908
895
  **An aggregate arm you cannot filter costs its whole table, every execution.** A `join` whose right
909
896
  side is a `group` over entire tables, keyed on a value the LEFT side supplies at run time, has no
package/docs/runtime.md CHANGED
@@ -62,15 +62,18 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
62
62
  bundle never leaks into normal traffic:
63
63
 
64
64
  1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
65
- `dist/mock.d.ts` — `{ queries?, workflows?, recordings? }`).
65
+ `dist/mock.d.ts` — `{ queries?, workflows?, exports?, recordings? }`).
66
66
  2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
67
67
  is completely inert.
68
68
 
69
69
  When active, the [query hooks](./data_fetching.md) return `fixture.queries[alias]`
70
- instead of making any request (`loading` stays `false`); a read with `enabled: false` asks
70
+ instead of making any request (`loading` stays `false`, except for a function fixture that
71
+ returns a promise: the read stays loading until it settles, to review a pending state); a read with `enabled: false` asks
71
72
  the fixture nothing, as it asks the server nothing. **Partial mocking is
72
73
  supported**: an alias absent from the fixture still flows through the real
73
- transport. Calling `mount` again (HMR) replaces the registration last-write-wins.
74
+ transport. `fixture.exports[alias]` answers `exportQuery(alias, call)` — a
75
+ function of the call returning the file, or a promise of it to review the
76
+ pending state. Calling `mount` again (HMR) replaces the registration last-write-wins.
74
77
 
75
78
  - **Fixture rows stand in for wire rows.** They pass through the same code path
76
79
  as fetched rows, so shape them exactly like the query's real output — the same
@@ -223,9 +226,9 @@ const { rows } = await rpc<{ rows: Record<string, unknown>[] }>("query", {
223
226
 
224
227
  `rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>` sends one op over
225
228
  whichever transport is active. **Prefer the hooks** — they add the SDK's cache,
226
- loading/error state, analytics, and typing from your declared aliases; every row
227
- of a set for a full export is `queryAll` ([data_fetching](./data_fetching.md)), fed
228
- to `downloadFile` (below). `rpc()` exists for the imperative cases neither
229
+ loading/error state, analytics, and typing from your declared aliases; a file of
230
+ every row of a set is `exportQuery`, made by the server ([data_fetching](./data_fetching.md)).
231
+ `rpc()` exists for the imperative cases neither
229
232
  models. `T` is *your* assertion — the SDK does not validate the result shape.
230
233
 
231
234
  The full `RpcOp` union (`dist/rpc.d.ts`), each op's payload, and where its
@@ -234,6 +237,7 @@ semantics are documented:
234
237
  | Op | Payload | Resolves to | Owning doc |
235
238
  |---|---|---|---|
236
239
  | `query` | `{ alias, params?, limit?, offset?, … }` | `{ rows }` | [queries](./queries.md), [data fetching](./data_fetching.md) |
240
+ | `export` | `{ alias, params?, filter?, sort?, report, file }` | `{ file }` | [data fetching](./data_fetching.md) |
237
241
  | `field_options` | `{ alias }` | `{ fields }` | [members & options](./members_and_options.md) |
238
242
  | `workflow` | `{ alias, inputs }` | `WorkflowResult` | [mutations](./mutations.md) |
239
243
  | `upload` | `{ file: File }` (the `File` crosses the bridge by structured clone) | uploaded-file object | [files](./files.md) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.101.2",
3
+ "version": "0.102.1",
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": {