@lotics/app-sdk 0.101.1 → 0.102.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
@@ -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;
@@ -16355,6 +16359,7 @@ function numberUnitRefusal(format2, unit) {
16355
16359
  function effectiveSelectOptions(field) {
16356
16360
  if (field === void 0) return null;
16357
16361
  if (field.type === "select") return field.options ?? null;
16362
+ if (field.type === "formula") return field.formula?.options ?? null;
16358
16363
  if (field.type === "lookup" && field.lookup_field_type === "select") {
16359
16364
  return field.lookup_field_options ?? null;
16360
16365
  }
@@ -19152,6 +19157,9 @@ function resolveFilterType(field) {
19152
19157
  return "date";
19153
19158
  case "boolean":
19154
19159
  return "boolean";
19160
+ // Its cell is a single select's: `[key]`.
19161
+ case "select":
19162
+ return "select";
19155
19163
  default:
19156
19164
  return "text";
19157
19165
  }
@@ -19399,7 +19407,7 @@ var tableLookupFieldSchema = tableFieldBaseSchema.extend({
19399
19407
  var tableFilesFieldSchema = tableFieldBaseSchema.extend({
19400
19408
  type: zod_default.literal("files").describe("Field type for file attachments (images, PDFs, documents)")
19401
19409
  });
19402
- var formulaOutputTypeSchema = zod_default.enum(["number", "text", "date", "datetime", "boolean"]);
19410
+ var formulaOutputTypeSchema = zod_default.enum(["number", "text", "date", "datetime", "boolean", "select"]);
19403
19411
  var formulaInputSchema = zod_default.object({
19404
19412
  expression: zod_default.string().describe(
19405
19413
  "JavaScript expression. Uses {Field Name} for field references."
@@ -19410,10 +19418,13 @@ var formulaInputSchema = zod_default.object({
19410
19418
  currency: zod_default.string().optional().describe("ISO 4217 currency code, e.g. USD, VND, EUR"),
19411
19419
  unit: zod_default.string().optional().describe(UNIT_DESCRIPTION),
19412
19420
  unit_field: zod_default.string().optional().describe(UNIT_FIELD_DESCRIPTION),
19413
- currency_field: zod_default.string().optional().describe(CURRENCY_FIELD_DESCRIPTION)
19421
+ currency_field: zod_default.string().optional().describe(CURRENCY_FIELD_DESCRIPTION),
19422
+ options: zod_default.array(tableSelectFieldOptionSchema).min(1).optional().describe(
19423
+ "The categories the formula yields, drawn as a single select's options are. The expression yields one option's `key` (text) or null; a key it does not declare computes an error. Omit for a formula yielding a plain value."
19424
+ )
19414
19425
  });
19415
19426
  var formulaSchema = formulaInputSchema.extend({
19416
- output_type: formulaOutputTypeSchema.optional().describe("Output type of the formula. Inferred by the backend from the expression \u2014 read-only on input. `datetime` when the result carries a time of day (references a datetime field or a time-bearing helper)."),
19427
+ output_type: formulaOutputTypeSchema.optional().describe("Output type of the formula. Inferred by the backend from the expression \u2014 read-only on input. `datetime` when the result carries a time of day (references a datetime field or a time-bearing helper); `select` when the formula declares `options`."),
19417
19428
  volatile: zod_default.boolean().optional().describe("Whether the formula's result depends on `now()` or other non-deterministic helpers. Inferred by the backend \u2014 read-only on input.")
19418
19429
  });
19419
19430
  var tableFormulaFieldSchema = tableFieldBaseSchema.extend({
@@ -19490,6 +19501,11 @@ var tableFieldInputSchema = zod_default.discriminatedUnion("type", [
19490
19501
  tableAutonumberFieldSchema
19491
19502
  ]);
19492
19503
  var tableFieldsSchema = zod_default.array(tableFieldSchema);
19504
+ function selectOptionsOf(field) {
19505
+ if (field.type === "select") return field.options;
19506
+ if (field.type === "formula") return field.formula.options;
19507
+ return void 0;
19508
+ }
19493
19509
  var tableFieldCreateBaseSchema = zod_default.object({
19494
19510
  name: zod_default.string().describe("Display name"),
19495
19511
  description: zod_default.string().describe("Field description"),
@@ -19634,6 +19650,10 @@ function projectCell(field, value) {
19634
19650
  case "select_member":
19635
19651
  if (field.multi === true || !Array.isArray(value)) return value;
19636
19652
  return value.length === 0 ? null : value[0];
19653
+ // A formula yielding one of its options holds a single select's cell.
19654
+ case "formula":
19655
+ if (field.formula.options === void 0 || !Array.isArray(value)) return value;
19656
+ return value.length === 0 ? null : value[0];
19637
19657
  case "select_record_link":
19638
19658
  return Array.isArray(value) ? extractRecordLinkIds(value) : value;
19639
19659
  default:
@@ -24118,6 +24138,7 @@ function appWorkflowOutputDepth(output) {
24118
24138
  if (output.type === "array") return 1 + appWorkflowOutputDepth(output.items);
24119
24139
  return 1;
24120
24140
  }
24141
+ var APP_ALIAS_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
24121
24142
  var MAX_APP_CAPABILITY_DESCRIPTION = 300;
24122
24143
  var APP_ANONYMOUS_REQUEST_MAX_BYTES = 256 * 1024;
24123
24144
  var APP_UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
@@ -24150,6 +24171,13 @@ var appWorkflowContractSchema = zod_default.object({
24150
24171
  inputs: zod_default.record(zod_default.string(), appWorkflowInputSchema).optional(),
24151
24172
  outputs: zod_default.record(zod_default.string(), appWorkflowOutputSchema).optional()
24152
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();
24153
24181
  var appQueryDeclarationSchema = zod_default.object({
24154
24182
  ast: zod_default.unknown().describe(
24155
24183
  "Query AST template (a QueryNode). Validated server-side via parseQueryNode at deploy. May embed {{params.<name>}} tokens in filter value positions."
@@ -24159,6 +24187,9 @@ var appQueryDeclarationSchema = zod_default.object({
24159
24187
  ),
24160
24188
  description: zod_default.string().optional().describe(
24161
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."
24162
24193
  )
24163
24194
  });
24164
24195
  var appAgentDeclarationSchema = zod_default.object({
@@ -25463,6 +25494,7 @@ async function evaluateRead(source, path, coerce, ctx) {
25463
25494
  return current;
25464
25495
  }
25465
25496
  function isScalarWrappedField(field) {
25497
+ if (field.type === "formula") return field.formula.options !== void 0;
25466
25498
  return (field.type === "select" || field.type === "select_member") && field.multi !== true;
25467
25499
  }
25468
25500
  function unwrapScalarField(value) {
@@ -25485,7 +25517,10 @@ async function coerceFieldDisplay(value, field, ctx) {
25485
25517
  if (value === null || value === void 0) return value;
25486
25518
  switch (field.type) {
25487
25519
  case "select":
25488
- return coerceSelectDisplay(value, field);
25520
+ return coerceSelectDisplay(value, field.options);
25521
+ // One of its own options, named as a select's is.
25522
+ case "formula":
25523
+ return field.formula.options === void 0 ? value : coerceSelectDisplay(value, field.formula.options);
25489
25524
  case "select_record_link":
25490
25525
  return coerceRecordLinkDisplay(value, field, ctx);
25491
25526
  case "select_member":
@@ -25496,8 +25531,8 @@ async function coerceFieldDisplay(value, field, ctx) {
25496
25531
  return value;
25497
25532
  }
25498
25533
  }
25499
- function coerceSelectDisplay(value, field) {
25500
- const optionMap = new Map(field.options.map((o) => [o.key, o.name]));
25534
+ function coerceSelectDisplay(value, options) {
25535
+ const optionMap = new Map(options.map((o) => [o.key, o.name]));
25501
25536
  const resolve = (key) => {
25502
25537
  if (typeof key !== "string") return "";
25503
25538
  return optionMap.get(key) ?? key;
@@ -27317,6 +27352,10 @@ function getFieldDisplayValue(field, value) {
27317
27352
  return option?.name ?? selectValues[0] ?? "";
27318
27353
  }
27319
27354
  case "formula": {
27355
+ if (field.formula.options !== void 0) {
27356
+ const [key] = Array.isArray(value) ? value : [];
27357
+ return key === void 0 ? "" : field.formula.options.find((option) => option.key === key)?.name ?? key;
27358
+ }
27320
27359
  const formulaVal = getFormulaValue(value);
27321
27360
  return formulaVal !== null ? String(formulaVal) : "";
27322
27361
  }
@@ -27982,7 +28021,7 @@ function useAiContext(slot, context) {
27982
28021
  function useMembers(opts) {
27983
28022
  const group = opts?.group;
27984
28023
  const roster = useKey(
27985
- JSON.stringify(["members", group ?? null]),
28024
+ opts?.enabled === false ? null : JSON.stringify(["members", group ?? null]),
27986
28025
  async () => {
27987
28026
  const members2 = (await rpc("members", { group })).members ?? [];
27988
28027
  noteMembers(members2);
@@ -29522,11 +29561,10 @@ function compareFieldValues(aValue, bValue, field, blankPosition = "bottom", ord
29522
29561
  if (aValue === bValue) {
29523
29562
  return 0;
29524
29563
  }
29525
- if (field.type === "select") {
29526
- const selectField = field;
29527
- const options = selectField.options || [];
29528
- const aSelectArray = aValue;
29529
- const bSelectArray = bValue;
29564
+ const options = selectOptionsOf(field);
29565
+ if (options !== void 0) {
29566
+ const aSelectArray = Array.isArray(aValue) ? aValue : null;
29567
+ const bSelectArray = Array.isArray(bValue) ? bValue : null;
29530
29568
  const aSelectValue = aSelectArray?.[0];
29531
29569
  const bSelectValue = bSelectArray?.[0];
29532
29570
  const aIsEmpty2 = isArrayFieldEmpty(aSelectArray);
@@ -29683,6 +29721,10 @@ function referencedFieldKeys(expression, fields) {
29683
29721
  return prepareFormulaExpression(expression, fields).referencedKeys;
29684
29722
  }
29685
29723
 
29724
+ // ../shared/src/formula_language.ts
29725
+ var HELPERS2 = new Set(Object.keys(createTimezoneAwareFunctions()));
29726
+ var BY_LOWERCASE = new Map([...HELPERS2].map((name) => [name.toLowerCase(), name]));
29727
+
29686
29728
  // ../shared/src/formula.ts
29687
29729
  function prepareFormulaContext(fields) {
29688
29730
  const formulaFields = fields.filter((field) => field.type === "formula");
@@ -29833,12 +29875,13 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
29833
29875
  continue;
29834
29876
  }
29835
29877
  const everyReferenceEmpty = referencedKeys.length > 0 && referencedKeys.every((fieldKey) => evaluationContext[fieldKey] === null);
29836
- const formulaResult = evaluateFormulaWithJS(
29878
+ const evaluated2 = evaluateFormulaWithJS(
29837
29879
  prepared.refs.evaluable,
29838
29880
  { data: evaluationContext },
29839
29881
  timezone,
29840
29882
  everyReferenceEmpty
29841
29883
  );
29884
+ const formulaResult = formulaField.formula.options === void 0 ? evaluated2 : selectResult(formulaField.formula.options, evaluated2);
29842
29885
  result[formulaField.key] = formulaResult;
29843
29886
  if (isFormulaError(formulaResult)) {
29844
29887
  erroredFormulaKeys.add(formulaField.key);
@@ -29857,6 +29900,12 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
29857
29900
  }
29858
29901
  return result;
29859
29902
  }
29903
+ function selectResult(options, evaluated2) {
29904
+ if (evaluated2 === null || evaluated2 === "") return null;
29905
+ if (isFormulaError(evaluated2)) return evaluated2;
29906
+ if (typeof evaluated2 === "string" && options.some((option) => option.key === evaluated2)) return [evaluated2];
29907
+ return `${FORMULA_ERROR_PREFIX}yields ${JSON.stringify(evaluated2)}, which is none of its options (${options.map((option) => option.key).join(", ")})`;
29908
+ }
29860
29909
  function normalizeEmptyToNull(value) {
29861
29910
  return isEmptyFieldValue(value) ? null : value;
29862
29911
  }
@@ -30615,23 +30664,27 @@ function overlayAsked(alias, params, narrowing, askedAt, totalsOf, rows, back, a
30615
30664
  }
30616
30665
 
30617
30666
  // src/queries.ts
30618
- function mockedRows(alias, call) {
30619
- const mocked = getMockRows(alias, call);
30620
- if (mocked === null) return void 0;
30667
+ function pageOf(mocked, call) {
30621
30668
  const start = call.keyset === true ? Number(call.cursor ?? 0) : call.offset ?? 0;
30622
30669
  const end = call.limit === void 0 ? mocked.length : start + call.limit;
30623
30670
  const rows = mocked.slice(start, end);
30624
30671
  const more = end < mocked.length;
30625
30672
  return { rows, truncated: call.keyset !== true && more, next_cursor: call.keyset === true && more ? String(end) : null };
30626
30673
  }
30627
- 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) {
30628
30682
  const mocked = getMockRows(alias, { params: call.params, filter: call.filter });
30629
- if (mocked === null) return void 0;
30630
- return { total: mocked.length, ...call.by === void 0 ? {} : { counts: countsOf(mocked, call.by) } };
30683
+ return Array.isArray(mocked) ? countOf(mocked, call.by) : void 0;
30631
30684
  }
30632
30685
  async function askRows(key, alias, askedAt, call, since = askedAt) {
30633
- const mocked = mockedRows(alias, call);
30634
- if (mocked !== void 0) return mocked;
30686
+ const mocked = getMockRows(alias, call);
30687
+ if (mocked !== null) return pageOf(await mocked, call);
30635
30688
  const answer = await rpc("query", {
30636
30689
  alias,
30637
30690
  params: call.params,
@@ -30644,8 +30697,8 @@ async function askRows(key, alias, askedAt, call, since = askedAt) {
30644
30697
  return answer;
30645
30698
  }
30646
30699
  async function askCount(key, alias, askedAt, call) {
30647
- const mocked = mockedCount(alias, call);
30648
- 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);
30649
30702
  const answer = await rpc("query", {
30650
30703
  alias,
30651
30704
  params: call.params,
@@ -30686,7 +30739,7 @@ function useQuery(alias, params = {}, opts = {}) {
30686
30739
  limit: opts.page ?? opts.limit,
30687
30740
  offset: opts.page === void 0 ? 0 : page * opts.page
30688
30741
  }),
30689
- { 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 }
30690
30743
  );
30691
30744
  const feedKey = !enabled || !wantsRows || opts.more === void 0 ? null : JSON.stringify(["feed", alias, call, opts.more]);
30692
30745
  const feedSize = opts.more ?? 0;
@@ -30727,7 +30780,7 @@ function useQuery(alias, params = {}, opts = {}) {
30727
30780
  const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, { params, filter: filter2, by }), {
30728
30781
  focus,
30729
30782
  aliases: [alias],
30730
- known: mocking ? () => mockedCount(alias, { params, filter: filter2, by }) : void 0
30783
+ known: mocking ? () => knownCount(alias, { params, filter: filter2, by }) : void 0
30731
30784
  });
30732
30785
  const version3 = useWrittenVersion();
30733
30786
  const drawn2 = useMemo3(() => {
@@ -30789,7 +30842,7 @@ async function settle(read2) {
30789
30842
  }
30790
30843
  async function askGroups(key, alias, askedAt, aggregate, call) {
30791
30844
  const mocked = getMockRows(alias, { ...call, aggregate });
30792
- if (mocked !== null) return { rows: aggregateRows(mocked, aggregate), truncated: false };
30845
+ if (mocked !== null) return { rows: aggregateRows(await mocked, aggregate), truncated: false };
30793
30846
  const answer = await rpc("query", { alias, params: call.params, aggregate, filter: call.filter, sort: call.sort });
30794
30847
  noteAnswer(key, alias, askedAt, []);
30795
30848
  return answer;
@@ -30858,6 +30911,13 @@ async function queryAll(alias, params = {}, opts = {}) {
30858
30911
  if (!page.truncated || page.rows.length === 0) return pages.flat();
30859
30912
  }
30860
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
+ }
30861
30921
  var NO_UNITS = {};
30862
30922
  function useFieldOptions(alias, opts) {
30863
30923
  const enabled = opts?.enabled ?? true;
@@ -31283,6 +31343,7 @@ export {
31283
31343
  askAi,
31284
31344
  buildChoiceOutput,
31285
31345
  downloadFile,
31346
+ exportQuery,
31286
31347
  isEmbedded,
31287
31348
  isWithinZone,
31288
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 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; output_type?: "number" | "boolean" | "text" | "date" | "datetime" | 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; };
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; };
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; };
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`, and a deploy warns about any alias the code calls that is not bound.
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`, and a sandbox deploy warns about any alias the code calls that is not bound.
17
17
 
18
18
  A declaration carries:
19
19
 
@@ -38,7 +38,7 @@ A declaration carries:
38
38
 
39
39
  So an agent that reads or writes records needs a named query or workflow for each thing it touches — the same declaration the app's own UI uses. Declare only what that agent needs; a second agent in the same app can declare a narrower set.
40
40
 
41
- **Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings whenever a sandbox session opens on the app, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry — every alias, in a project on your own machine — falls back to `Record<string, unknown>` input / `unknown` output.
41
+ **Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings whenever a sandbox session opens on the app and by `lotics app create --custom` and `lotics app deploy`, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry falls back to `Record<string, unknown>` input / `unknown` output.
42
42
 
43
43
  ---
44
44
 
@@ -10,9 +10,9 @@ The read model in one paragraph: an app never sends a raw query. It invokes a **
10
10
  alias** (bound with `set_app_query`) 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
- so an unbound alias is a compile-time error and params are typed per the binding. It is written
14
- from the app's live bindings whenever a sandbox session opens on the app; a project on your own
15
- machine has none, so every alias is accepted there as a plain string, untyped. Every hook reads through the SDK's own cache, over the host RPC bridge.
13
+ so params are typed per the binding; an alias it does not carry is accepted as a plain string,
14
+ untyped. It is written from the app's live bindings whenever a sandbox session opens on the app,
15
+ and by `lotics app create --custom` and `lotics app deploy`. Every hook reads through the SDK's own cache, over the host RPC bridge.
16
16
 
17
17
  ## Choosing a read
18
18
 
@@ -23,7 +23,8 @@ machine has none, so every alias is accepted there as a plain string, untyped. E
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/mutations.md CHANGED
@@ -41,12 +41,11 @@ render: one callable per alias, each run exactly as `useWorkflow`'s.
41
41
 
42
42
  Typing comes from `.lotics/app_workflows.d.ts`, which augments the SDK's `AppWorkflows` and
43
43
  `AppWorkflowResults` interfaces from the app's bound declarations. The declarations are generated from the app's live bindings into `.lotics/` whenever a sandbox
44
- session opens on the app. A project on your own machine has none, so every alias is accepted as a
45
- plain string there, untyped. The result:
44
+ session opens on the app, and by `lotics app create --custom` and `lotics app deploy`. The result:
46
45
 
47
46
  | Declaration | Call-site type |
48
47
  |---|---|
49
- | Alias not declared | Compile-time error at `useWorkflow("...")` |
48
+ | Alias not declared | Accepted as a plain string: untyped `Record<string, unknown>` payload, `unknown` result |
50
49
  | Declared with `inputs` | `(inputs: <declared shape>) => Promise<WorkflowResult<TData>>` — inputs required and shaped |
51
50
  | Declared with an empty `inputs: {}` | `(inputs?: Record<string, never>)` — call with `{}` or nothing |
52
51
  | Declared without `inputs` | Untyped `Record<string, unknown>` payload (and unvalidated server-side — declare inputs for any real workflow) |
@@ -273,8 +272,8 @@ When the alias declares `inputs`, the server validates the payload before the wo
273
272
  - **`select` names its option set one of two ways — declare exactly one (both or neither is a
274
273
  set-time error).** *Inline* `options: [{label, value}]` freezes the set into the generated
275
274
  literal union and is **format-only at run time**: any well-formed key is accepted, and an
276
- option added to the live field after deploy is valid at run time but fails the compile-time
277
- type (widen or redeploy the declaration when the frozen union gets in the way).
275
+ option added to the live field after binding is valid at run time but fails the compile-time
276
+ type (widen or rebind the declaration when the frozen union gets in the way).
278
277
  *Field-referenced* `field: "fld_…"` (a select field's global key — `fld_…`, no table prefix)
279
278
  is **drift-proof**: the union is resolved from the field's *current* options at type-gen, and
280
279
  the submitted value is validated against the field's *current* options at run time — an option
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
@@ -54,9 +62,9 @@ query's reach (a token in a `table_id` or field-key position fails validation).
54
62
 
55
63
  ### What deploy validates
56
64
 
57
- Deploy fails — with the compiler's own message, never raw SQL/Postgres text — when any alias:
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
@@ -72,12 +80,12 @@ Deploy fails — with the compiler's own message, never raw SQL/Postgres text
72
80
  overrides, `writable_target` on a computed column;
73
81
  7. fails to **compile to SQL** — several rejections exist only in the compiler (the expression
74
82
  allowlist, link extraction over a composed parent, derived-filter operators with no
75
- translation). Deploy compiles every query so these fail at deploy, not at first run;
83
+ translation). Binding compiles every query so these fail at bind, not at first run;
76
84
  8. has optional params and the **pruned** shape (all optional params omitted) fails any of the
77
85
  above — each omitted optional param executes a pruned variant of the template, and the
78
- all-params shape compiling doesn't prove the pruned one does. Deploy validates both.
86
+ all-params shape compiling doesn't prove the pruned one does. Binding validates both.
79
87
 
80
- What deploy **cannot** catch: data-dependent failures (a statement timeout on a huge unindexed
88
+ What binding **cannot** catch: data-dependent failures (a statement timeout on a huge unindexed
81
89
  scan, the file-presign ceiling) — those surface at run time as clean, safe errors (§10).
82
90
 
83
91
  ### Execution
@@ -162,7 +170,7 @@ only. Read what a row holds through your projection and the `read*` helpers, nev
162
170
  ## 2. The AST node reference
163
171
 
164
172
  Eleven node kinds. Every node except `from_table` wraps one or more child nodes; each subtree
165
- compiles to a derived `SELECT`. All structural errors below fail at **deploy** (the output
173
+ compiles to a derived `SELECT`. All structural errors below fail at **bind** (the output
166
174
  schema + compile passes run there); only data-dependent errors fail at run time.
167
175
 
168
176
  ### `from_table` — the only leaf
@@ -179,7 +187,7 @@ Scans one table. Output = one column per field on the table (named by field key,
179
187
  plus the system columns. `filter`, `search` (AND-ed with `filter`), `sort`, and `limit` apply
180
188
  inside the scan — this is the **source layer**, where the richest operator support and all
181
189
  indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank_position?:
182
- "top"|"bottom" }` (blanks default to bottom). Unknown filter/sort field keys fail deploy.
190
+ "top"|"bottom" }` (blanks default to bottom). Unknown filter/sort field keys fail binding.
183
191
 
184
192
  ### `project` — choose and compute columns
185
193
 
@@ -200,7 +208,7 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
200
208
  `type`, is always nullable, and is never writable.
201
209
  - Output names must be unique and non-reserved.
202
210
  - A passthrough may declare a `type` **override** — realized as a *real, guarded SQL cast*
203
- (§4), never a relabel. An uncastable combination is rejected at deploy.
211
+ (§4), never a relabel. An uncastable combination is rejected at bind.
204
212
  - **Project only what you render.** A bare `from_table` ships every column — including `files`
205
213
  cells with storage keys — to the client (over-exposure + the presign ceiling at scale).
206
214
  - **A files column takes `limit` — bound it when the surface renders a THUMBNAIL, not the
@@ -209,7 +217,7 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
209
217
  without it the response pays to resolve every entry toward the presign ceiling (§10). The bound
210
218
  is per CELL, caps at `FILES_PROJECTION_MAX_LIMIT` (10), and is a different axis from the
211
219
  query's own `limit`, which counts ROWS; `limit` on any non-`files` column or computed source is
212
- rejected at deploy. **On a UNION, set it on every arm** — arms align by name and type, not by
220
+ rejected at bind. **On a UNION, set it on every arm** — arms align by name and type, not by
213
221
  `limit`, so an arm that omits it still yields whole cells.
214
222
 
215
223
  ### `filter` — predicate over derived columns
@@ -238,7 +246,7 @@ source filter automatically (making it index-servable); otherwise it evaluates p
238
246
 
239
247
  Single **equality** predicate on one output column per side — no multi-column ON, no
240
248
  inequality/range joins. The two ON columns must share a SQL value category (text/number/…);
241
- mismatches are rejected at deploy (cast one side with a projection type override). Column-name
249
+ mismatches are rejected at bind (cast one side with a projection type override). Column-name
242
250
  collisions between the sides are rejected — `project`-rename first. A `left` join makes every
243
251
  right-side column nullable. Addressing (write-through, `record_id` filtering) follows the
244
252
  **left** side only.
@@ -268,7 +276,7 @@ came from.
268
276
  ```
269
277
 
270
278
  Full semantics in §8. `by` may be empty (a single-row aggregate); `aggregates` needs ≥ 1 entry.
271
- Aggregate operation × input-column type is validated at deploy. Grouping collapses rows —
279
+ Aggregate operation × input-column type is validated at bind. Grouping collapses rows —
272
280
  addressing is dropped.
273
281
 
274
282
  ### `window` — aggregate without collapsing
@@ -414,7 +422,7 @@ must be valid over `field`'s type — a refusal lists the valid ones.
414
422
  (`round`/`floor`/`ceil`/`abs` are 1-arg; `round` rounds to an integer.)
415
423
  - **Operators**: `+ - * / %`, `== === != !==`, `< > <= >=`, `&& ||`, `??` (→ `COALESCE`),
416
424
  unary `- + !`, and the ternary `cond ? a : b`.
417
- - **Not supported** (rejected at deploy): indexing `[…]`, nested access (`input.a.b`), list/map
425
+ - **Not supported** (rejected at bind): indexing `[…]`, nested access (`input.a.b`), list/map
418
426
  literals, arrow functions, method calls, any other function. Project the raw columns and
419
427
  compute client-side instead.
420
428
 
@@ -424,7 +432,7 @@ must be valid over `field`'s type — a refusal lists the valid ones.
424
432
  ```
425
433
 
426
434
  Prefer the typed variants where one exists — expressions are strings and get less structural
427
- validation (though they still compile-check at deploy).
435
+ validation (though they still compile-check at bind).
428
436
 
429
437
  ---
430
438
 
@@ -445,7 +453,7 @@ a **real SQL cast**, guarded so malformed values become NULL instead of aborting
445
453
  | `text` → `number` / `boolean` / `date` / `datetime` | guarded cast; a non-conforming value → NULL |
446
454
  | `json` → any scalar | via the JSON scalar text, guarded the same way |
447
455
  | scalar → `text` / `json` | plain cast |
448
- | array-valued (`select`/`member`/`link`/`files`) ↔ scalar | **impossible — rejected at deploy** |
456
+ | array-valued (`select`/`member`/`link`/`files`) ↔ scalar | **impossible — rejected at bind** |
449
457
 
450
458
  The guarded-NULL rule means a query never aborts because one row holds `"n/a"` in a text column
451
459
  you cast to number — that row's cell is NULL. Use overrides to align UNION arms or to make a
@@ -469,7 +477,7 @@ derived surfaces share one implementation.
469
477
  | select_member | `select_member` | select ops + `is_current_member`, `is_not_current_member` (field-scoped, no value; `is_not_current_member` matches every cell not holding the requester, an empty one included) | same — `is_current_member` **works at the runtime layer** too | `unnest` fans member ids. Anonymous public request: current-member binds the app owner (§1). |
470
478
  | select_record_link | `select_record_link` | membership by linked-record **id**: `has_any_of`, `has_none_of`, `has_all_of`; text over the cached **display**: `contains`, `not_contains`, `starts_with`, `ends_with`; `is_empty`, `is_not_empty` | same — id-membership works at the runtime layer (converged `[{id}]` containment) | Sorting orders by raw JSON, not display text — project the display (link extraction / `display_output`) and sort that. `unnest` fans link ids (+ display). |
471
479
  | files | `files` | `has_filename`, `has_mime_type` (substring), `has_file_count` (exact count), `is_empty`, `is_not_empty` | same | Presign-enriched at delivery (§1); `unnest` fans file ids. Only presence-counting aggregates. |
472
- | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`; `json` until inferred) | filtered by the output type's operators; `#ERROR:` cells are guarded — they count as empty and never match value operators | same | Extracted as a real scalar, so numeric formulas feed `sum`/`avg`/sorts. A **datetime-output** formula matches day-level date filters (full-day expansion). |
480
+ | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`, or `select` for one stating its own options, its cell a single select's; `json` until inferred) | filtered by the output type's operators; `#ERROR:` cells are guarded — they count as empty and never match value operators | same | Extracted as a real scalar, so numeric formulas feed `sum`/`avg`/sorts. A **datetime-output** formula matches day-level date filters (full-day expansion). |
473
481
  | rollup | decided by the OPERATION, never by the aggregated field: `earliest`/`latest` → `date`/`datetime` per that field's format; **every** other op → `number` | number or date operators per output type; text operators route as text | same | Datetime-format rollups match day-level date filters. `date_range` counts DAYS between the extremes, so it is a number despite its date input. A presence count (`filled`/`empty`/`percent_*`) is a number whatever it counts — including over `files`. |
474
482
  | lookup | the type of the VALUE the looked-up field yields — a formula's output type, a rollup's operation result, an autonumber's display string (`text`); never the word `formula`/`rollup` | operators of that type, evaluated with ANY-element semantics over the linked values (a row matches if *any* linked value matches) | array-typed lookups follow their column type's rules | **Projection returns the first element only.** Emptiness on a files-lookup checks the inner file arrays (a linked record with zero files counts empty). For all values across links: unnest the link column + join (§8). |
475
483
  | autonumber | `text` | text operators — filter by the visible composed string (`"ORD-0042"`) | same | Sorts numerically when the stored value is a bare integer, lexicographically otherwise — so `"ORD-0042"` orders by its padding and an unprefixed `"124"` orders after `"99"`, not before it. |
@@ -554,7 +562,7 @@ stay index-served. Two access classes:
554
562
  Tokens interpolate in **value positions**. A string that *is* exactly one token is replaced by
555
563
  the param's typed value (an array param yields an array); a token embedded in a larger string
556
564
  interpolates as text. Declared-but-unreferenced params are fine; referenced-but-undeclared
557
- tokens fail deploy.
565
+ tokens fail binding.
558
566
 
559
567
  **Composable optional filters (one query, many scopes).** Expose several independent filter
560
568
  axes from ONE named query — don't shard into a query-per-combination. Mark each scoping param
@@ -653,7 +661,7 @@ anywhere in the record*.
653
661
  ### The 21 aggregate operations
654
662
 
655
663
  `group` and `window` share one operation vocabulary. `count` is `COUNT(*)` (no `input_column`);
656
- everything else requires an `input_column` whose type must be compatible — checked at deploy:
664
+ everything else requires an `input_column` whose type must be compatible — checked at bind:
657
665
 
658
666
  | Operation | Valid input column types | Result | Notes |
659
667
  | --- | --- | --- | --- |
@@ -680,7 +688,7 @@ sizes on a shipment, the tags on a ticket) without a second query.
680
688
  "distinct": true, "separator": ", ", "max_values": 20 }
681
689
  ```
682
690
 
683
- - **`type` must be `text`** — declaring anything else is rejected at deploy.
691
+ - **`type` must be `text`** — declaring anything else is rejected at bind.
684
692
  - **`distinct`** defaults to **true**: three containers sized 40HC/40HC/20DC give `20DC, 40HC`.
685
693
  Pass `false` to keep every occurrence. Values are always sorted, so the column doesn't
686
694
  reshuffle between reads.
@@ -713,7 +721,7 @@ over a `link` read of it is `true`.
713
721
  { "bucket": { "source": "created", "granularity": "month", "output": "period" } }
714
722
  ```
715
723
 
716
- - `source` must be a `date` or `datetime` **column** of the input (deploy-checked).
724
+ - `source` must be a `date` or `datetime` **column** of the input (checked at bind).
717
725
  - `granularity`: `day` | `week` | `month` | `quarter` | `year`. **Weeks are ISO — Monday
718
726
  start.**
719
727
  - Buckets are computed on the stored **wall-clock** value in the field's timezone (a 23:30
@@ -737,7 +745,7 @@ the timeout for nothing.
737
745
  legal SQL window call are accepted: **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
738
746
  `filled`, `checked`, `unchecked`.** The rest cannot take an OVER clause (`median` is an
739
747
  ordered-set aggregate; `unique`/`percent_unique`/`string_agg` need DISTINCT;
740
- `range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at deploy. The optional `frame` applies **only**
748
+ `range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at bind. The optional `frame` applies **only**
741
749
  to these; a `frame` on a window with no `aggregates` is rejected as dead config.
742
750
 
743
751
  **Window `functions` — ranking / navigation.** Each is `{ "output", "fn", … }` (its own arg shape, no
@@ -751,7 +759,7 @@ to these; a `frame` on a window with no `aggregates` is rejected as dead config.
751
759
  | `dense_rank` | — | `number`, non-null. Ties share a rank; the next rank does **not** skip (1,2,2,3). |
752
760
  | `percent_rank` / `cume_dist` | — | `number`, non-null. Relative position in [0, 1]. |
753
761
  | `ntile` | `buckets` (positive int) | `number`, non-null. The row's bucket (1..buckets) splitting the partition into equal groups. |
754
- | `lag` / `lead` | `input_column`, `offset?` (int ≥ 1, default 1), `default?` (literal) | the input column's type, **nullable**. The value `offset` rows before / after this one; at the partition edge, `default` if given else NULL. `default`'s type must match the input column (checked at deploy). |
762
+ | `lag` / `lead` | `input_column`, `offset?` (int ≥ 1, default 1), `default?` (literal) | the input column's type, **nullable**. The value `offset` rows before / after this one; at the partition edge, `default` if given else NULL. `default`'s type must match the input column (checked at bind). |
755
763
 
756
764
  **Top-N per group** — rank within each partition, then filter on the derived rank column:
757
765
 
@@ -791,7 +799,7 @@ first. For a fixed, small set of columns, N filtered aggregate queries also work
791
799
 
792
800
  ## 9. Runtime refinement — filter / sort / limit / offset / count
793
801
 
794
- Each query request carries optional refinement the server wraps **around** the deployed
802
+ Each query request carries optional refinement the server wraps **around** the bound
795
803
  template as derived nodes, in this order: `filter` (narrow) → `sort` (order) → `limit`+`offset`
796
804
  (page):
797
805
 
@@ -857,11 +865,11 @@ ordering and scoping into the template or its params.
857
865
  | Statement timeout | **15 s** per query execution | 400: `query timed out after 15s — narrow the filter or simplify the query` |
858
866
  | Concurrent query executions | server-configured bulkhead (bounded slots + bounded queue wait) | **503**: `The app is handling too many requests right now. Please retry in a moment.` — a distinct busy-retry outcome; back off and retry |
859
867
  | Signed file URLs per response | server-configured, default **2,000 file entries** | 400 naming the alias, the count, and the ceiling — project files columns only where rendered, narrow, or paginate |
860
- | Traversal depth | 3 hops | **silent match-nothing** — a longer path compiles to `FALSE` (deploys green, returns zero rows) |
861
- | Param schema depth | 8 | deploy reject |
868
+ | Traversal depth | 3 hops | **silent match-nothing** — a longer path compiles to `FALSE` (binds green, returns zero rows) |
869
+ | Param schema depth | 8 | bind reject |
862
870
 
863
871
  Any other execution failure returns a generic `query execution failed` (the real error — which
864
- may embed SQL — is server-logged only). Deploy-time and validation errors are always specific.
872
+ may embed SQL — is server-logged only). Bind-time and validation errors are always specific.
865
873
 
866
874
  ### Index reality, in plain terms
867
875
 
@@ -872,20 +880,21 @@ Records are stored partitioned by workspace, so a query reads only its own works
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
882
  - **Trigram-served:** `from_table.search` (§7).
875
- - **B-tree-served (automatic for deployed queries):** text `equals`, number and date
876
- comparisons (exact and range), and `sort` fields — for fields referenced in a **deployed
877
- named query's template**. The platform provisions a partial expression index per referenced
878
- field automatically: built online whenever a query is bound (`set_app_query` or `apply_model`), re-synced daily,
883
+ - **B-tree-served (automatic for bound queries):** text `equals`, number and date
884
+ comparisons (exact and range), `from_table.sort` fields, and a one-key `sort` node over a
885
+ number or date field the rows pass unchanged — for fields referenced in a **bound named
886
+ query's template**. The platform provisions a partial expression index per referenced
887
+ field automatically: built online whenever `set_app_query` or `set_app_queries` binds a query, re-synced daily,
879
888
  capped at 8 per table (fields past the cap fall back to the scan tier, with a server WARN).
880
889
  A `{{params.…}}` value hole doesn't change this — the field key is static in the template,
881
890
  so it still gets its index. Index-seek speed at any table size once provisioned.
882
891
  - **Table scan (linear in table size):** everything else — text `contains`, negations
883
892
  (`has_none_of`, `is_none_of`, `not_*`), emptiness, files predicates, and predicates/sorts on
884
- fields that appear **only** in the runtime `filter`/`sort` options rather than the deployed
893
+ fields that appear **only** in the runtime `filter`/`sort` options rather than the bound
885
894
  template. Fine on thousands of rows; on very large tables these dominate latency and are the
886
895
  usual timeout cause.
887
896
 
888
- **Filter shape drives latency.** Equality/range/sort predicates in the deployed template are
897
+ **Filter shape drives latency.** Equality/range/sort predicates in the bound template are
889
898
  index-served; lead with those or a GIN-served membership filter / `search`, and let scan-shaped
890
899
  predicates refine the already-narrowed set. Derived-layer filters run over the subquery result
891
900
  (no index), so **filter at the source layer whenever the field exists there** — the runtime
@@ -893,14 +902,16 @@ filter is for caller-driven refinement, not for the main cut (a runtime-only fie
893
902
  managed index). (The engine pushes eligible filter-over-union predicates down automatically,
894
903
  but don't rely on that for other shapes.)
895
904
 
896
- **Sorted, paginated unions are index-served — write them plainly.** A `limit(sort(union(project(from_table) …)))`
897
- register (the "search all three tables, sort by one column, paginate" shape) is compiled to push
898
- `ORDER BY … LIMIT` into each arm and `MergeAppend` the per-arm sort-index scans — it does **not**
899
- scan every arm whole and heap-sort the union. So a single-key sort over a union of tables, each
900
- projecting that key as a direct field passthrough, is fast at any size. Don't hand-split it into
901
- per-table queries you merge client-side; the merge already happens in the index. (Requires the
902
- sort key to be a bare field projection — a computed/literal/type-overridden sort column falls back
903
- to the whole-union sort.)
905
+ **A sorted page reads in its sort index — declare the order it is read in.** A page sorted by one
906
+ column that passes a number or date field unchanged (a bare `project` source with no `type`
907
+ override, an `unpivot` passthrough, or a row column that reads the same field in every row) reads
908
+ the table in that field's sort index and stops at the page, instead of reading every row and
909
+ sorting. Over a `union`, each arm is paged this way on its own and the arms are merged, so a
910
+ register over several tables or `unpivot` sides is fast at any size — don't hand-split it into
911
+ per-table queries you merge client-side. The index exists when a bound template sorts that field
912
+ in the same direction and blank position: give the query its default order as a top-level `sort`,
913
+ and a page the app sorts the same way is served. A text column, a computed, literal or cast column,
914
+ a sort on more than one key, and a cursor (`keyset`) page sort every row.
904
915
 
905
916
  **An aggregate arm you cannot filter costs its whole table, every execution.** A `join` whose right
906
917
  side is a `group` over entire tables, keyed on a value the LEFT side supplies at run time, has no
package/docs/recipes.md CHANGED
@@ -85,14 +85,14 @@ Filter **server-side** so the app never receives another row.
85
85
  ```
86
86
 
87
87
  - `{{params.<name>}}` resolves **only in value positions**. A token in `table_id` or `field_key` is
88
- not a real identifier and fails the deploy.
88
+ not a real identifier and fails the binding.
89
89
  - `useQuery("lookupShipment", { code })` is reactive on `code`. Gate on a *submitted* code — an
90
90
  empty one matches nothing, so render a prompt rather than an empty table.
91
91
  - An **autonumber** field stores the composed string (`DL-2026-0006`), so `equals` / `contains`
92
92
  filter it directly as text. No reverse mapping.
93
93
 
94
94
  **A code lookup carries no viewer identity, so it is an enumerable IDOR.** Fine for an internal or
95
- embedded app; never ship it as a public per-user portal — see `lotics docs security`.
95
+ embedded app; never ship it as a public per-user portal — see [security](./security.md).
96
96
 
97
97
  ## Compose many optional filter axes in one query
98
98
 
@@ -103,7 +103,7 @@ whose `{{params.x}}` the caller did not pass, and collapses emptied groups to ma
103
103
  So `useQuery("search", { keyword })` filters by keyword alone, and `{}` returns everything. A
104
104
  missing *required* param still fails.
105
105
 
106
- Full pattern, including the date-param trick for range filters: `lotics docs queries` →
106
+ Full pattern, including the date-param trick for range filters: [queries](./queries.md) →
107
107
  "Composable optional filters".
108
108
 
109
109
  ## Decode cells with the accessors, never by hand
@@ -128,7 +128,7 @@ lotics run search_app_icons '{"query":"shipping"}' → ["truck","ship","packag
128
128
 
129
129
  `theme.color` is a named palette colour: `red orange amber yellow lime green emerald teal cyan sky
130
130
  blue indigo violet purple fuchsia pink rose slate gray zinc neutral stone`. `null` clears either.
131
- `update_app` sets both; a deploy warns while they are unset.
131
+ `update_app` sets both.
132
132
 
133
133
  **In-app palette** — the app's own `src/theme.ts`, exact hex. This is what the app *renders* with;
134
134
  the launcher colour does not feed it. Pick the named colour closest to your brand hex.
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/docs/security.md CHANGED
@@ -22,7 +22,7 @@ Comments are the one deliberate exception: access is app-authority (any member w
22
22
 
23
23
  ## The caller boundary
24
24
 
25
- Callers never submit query ASTs or workflow definitions — the server holds the canonical, deploy-validated template and the caller supplies only an **alias plus typed values**. Three per-input constraints are enforced server-side on **workflow and agent-run inputs** at invocation time, so a hand-crafted request can't redirect a run executing under owner authority:
25
+ Callers never submit query ASTs or workflow definitions — the server holds the canonical, bind-validated template and the caller supplies only an **alias plus typed values**. Three per-input constraints are enforced server-side on **workflow and agent-run inputs** at invocation time, so a hand-crafted request can't redirect a run executing under owner authority:
26
26
 
27
27
  | Input type | Server-enforced bound |
28
28
  |---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.101.1",
3
+ "version": "0.102.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": {