@lotics/app-sdk 0.122.9 → 0.123.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/dist/queries.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { AppQueries, AppQueryColumns } from "./types.js";
3
3
  import type { ResolvedOption } from "./select.js";
4
4
  import type { ReportLanguage } from "./shared_types.js";
5
5
  import type { ExportedFile } from "./shared_types.js";
6
- import type { ReportColumn, ReportReading } from "./shared_types.js";
6
+ import type { ReportColumn, ReportLabels, ReportReading } from "./shared_types.js";
7
7
  /**
8
8
  * One row of a query whose columns codegen could not read (a dynamic alias, a
9
9
  * bare `from_table`). Values stay `unknown` — the cell readers narrow them.
@@ -249,6 +249,9 @@ export interface ExportCall {
249
249
  tabs?: readonly ExportTab[];
250
250
  report: ExportReport;
251
251
  file: ExportFile;
252
+ /** The words each option key of a select column prints as, by the column, in either file: a cell the server names
253
+ * by its field's option prints as that. */
254
+ labels?: ReportLabels;
252
255
  }
253
256
  /**
254
257
  * Every row a declared query matches — narrowed by `filter` and ordered by `sort` as a page of it is — made into one
@@ -35,9 +35,12 @@ export type ReportColumn = {
35
35
  header: string;
36
36
  /** A `date` cell is an ISO day or moment. */
37
37
  type: "number" | "date" | "text";
38
- /** A select whose cells hold bare option keys, no field of the query naming them: the words each key prints as. */
39
- labels?: { readonly [x: string]: string; };
40
38
  };
39
+ /**
40
+ * The words each option key of a select column prints as, by the column: a cell no field of the query names arrives
41
+ * as its bare key.
42
+ */
43
+ export type ReportLabels = { readonly [x: string]: Readonly<Record<string, string>>; };
41
44
  export type ReportLanguage = "vi" | "en";
42
45
  export type ReportPart = {
43
46
  label: string;
@@ -73,7 +76,7 @@ export type ReportReadingHead = {
73
76
  /** The read behind it was cut: its figures stand, every total over them is left out (`null`). */
74
77
  truncated: boolean;
75
78
  };
76
- export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | null | 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" | "date" | "files" | "text" | "select" | "select_member" | "select_record_link" | "rollup" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | 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" | "date" | "files" | "text" | "select" | "select_member" | "select_record_link" | "rollup" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | 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; lookup_field_autonumber?: boolean | 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; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; link?: true | undefined; 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" | "date" | "text" | "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; };
79
+ export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | 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; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; default_from?: { link_field_key: string; field_key: string; options?: Record<string, string> | undefined; } | null | 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" | "date" | "files" | "text" | "select" | "select_member" | "select_record_link" | "rollup" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | 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" | "date" | "files" | "text" | "select" | "select_member" | "select_record_link" | "rollup" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | 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; lookup_field_autonumber?: boolean | 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; notation?: { style: "decimal"; } | { style: "currency"; currency: string | { per_row: string; }; } | { style: "unit"; unit: string | { per_row: string; }; } | undefined; link?: true | undefined; 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" | "date" | "text" | "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; };
77
80
  export type TableRecordFilters = TableRecordFiltersConditionNode | TableRecordFiltersTraversalNode | TableRecordFiltersGroupNode;
78
81
  export type TableRecordFiltersConditionNode = { node_type: "condition"; field_key?: string | undefined; type?: string | undefined; operator: string; value?: unknown; unit_option?: string | undefined; };
79
82
  export type TableRecordFiltersGroupNode = { node_type: "group"; logic: "and" | "or"; children: (TableRecordFilters)[]; };
package/dist/store.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /** How a key is read: from when the request was sent and the answer it holds now (a feed grows from it). */
2
2
  export type Fetcher<T> = (askedAt: number, current: T | undefined) => Promise<T>;
3
+ /**
4
+ * A focus or reconnect within this long of a key's last ask asks nothing: returns to the tab a minute apart are one
5
+ * visit. A push asks at once whatever this says, so what this bounds is only what no push carries — a dead channel, a
6
+ * value the clock moves.
7
+ */
8
+ export declare const FOCUS_THROTTLE_MS = 60000;
3
9
  /** The read clock: every request stamps when it was sent, so an answer is known to carry what settled before it. */
4
10
  export declare function tick(): number;
5
11
  /** Ask again every key reading one of `aliases` — every query key where none is named; an unmounted one on its next mount. */
@@ -24,7 +24,7 @@ untyped. It is written from the app's live bindings by `lotics app create --cust
24
24
  | `useQuery(alias, params?, { rows: false, total: true })` | `total` only, no rows (`total: { by: column }` or `total: { where: { name: filter } }` adds `counts`) | a facet chip, a queue badge, an "N awaiting approval" tile — the size of a set you are not listing |
25
25
  | `useQueries(calls, opts?)` | one state per `{ alias, params?, filter?, sort?, aggregate?, total?, rows? }`, in order — a `sort` orders that call's rows before the cap cuts them | reads that are DATA — a list known only at render, one read per item, or one count per stage (`rows: false, total: true`, each state's `.total`); the deploy's alias scan reads a list as dynamic, so every alias in it is still declared. With `aggregate` a call answers the groups of its filtered rows instead of the rows ([queries](./queries.md)) |
26
26
  | `queryAll(alias, params?, { filter, sort })` | a promise of every row | outside React, for a job that must hold the whole narrowed set in the browser (the ids an act runs on): it asks page after page until the server says the set ended, never stopping at the 10,000-row cap. The server's rows as stored — no write of this app is drawn over them |
27
- | `exportQuery(alias, { params, filter, sort, tabs?, report, file })` | a promise of an `ExportedFile`: `{ filename, mime_type, url, size, created_at }` | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type, labels? }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; `labels` the words each bare option key of a `select` column no field names prints as, refused on any other column and for a key it lacks; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file has no `id` — it is a download, not a workspace file a workflow or a field takes: its `url` is signed for the caller for at most an hour, so open it with `openExternal` when the promise resolves; the server deletes the file a day after it is made |
27
+ | `exportQuery(alias, { params, filter, sort, tabs?, report, file, labels? })` | a promise of an `ExportedFile`: `{ filename, mime_type, url, size, created_at }` | every row the narrowed set holds, made into one file by the server — no row reaches the browser. `tabs` (2 to 5, each `{ tab, label, filter?, sort? }`) are each read as the export's own rows are — same query and params, under the tab's own filter and sort (absent, the declared order) — and a template reads each one's rows under its `tab`, a name none of the report's keys nor the template's `rows` key holds. `report` is `{ title, lines, readings, dates?, period?, per?, filename? }` — `dates` each date filter's `{ from?, to? }` by field alias, each bound a day (`yyyy-MM-dd`) or a minute of one (`yyyy-MM-ddTHH:mm`) with no zone; `period` the tabs' `{ from, to }`, bounds of the same form; `per` the value the report was picked for, in the reader's words; `filename` the file's name in the template grammar over one value each — `title`, `at` (the moment the server made the file), `dates.<field>.from` or `.to`, `period.from` or `.to`, `per`, `lines | lookup:<n>` — at most 200 bytes once filled (absent, the template's name, or `title`); `file` is `{ kind: "workbook", language, sheet, columns }` (the default report workbook, its head closing with the moment the server made it, each column `{ key, header, type }` an output column of the query — a `number` one on a number column, a `date` one on a date or datetime; with `tabs`, one sheet per tab named by its `label` in place of `sheet`) or `{ kind: "template", template }`, one the query declares under `templates` by name ([queries](./queries.md)). `labels`, by `select` output column, the words each option key prints as in either file — a cell no field of the query names (a union's literal column) arrives as its bare key; a cell its field's option names prints as that, a key the map lacks as itself, and a column no select of the query holds as it is. Past 20,000 rows, in any tab, it rejects, never cuts; each caller makes at most 60 exports a minute. The file has no `id` — it is a download, not a workspace file a workflow or a field takes: its `url` is signed for the caller for at most an hour, so download it with `downloadFileFromUrl(url, filename)` (`@lotics/ui/download`) when the promise resolves — a tab `openExternal` opens seconds after the click is a pop-up the browser blocks; the server deletes the file a day after it is made |
28
28
 
29
29
  Every hook answers the same `QueryState`: `rows`, `truncated`, `total`, `counts`, `loading`,
30
30
  `isValidating`, `answeredAt`, `error`, `pending`, `refetch`, `page`, `setPage`, `pageCount`, `hasMore`,
@@ -146,7 +146,7 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
146
146
  renders the cached rows instantly *and* re-reads them in the background, so a list reflects what
147
147
  another screen changed while you were away. Identical concurrent reads share one request, and reads asked within a few milliseconds of each other go to the server together, each answered or refused on its own.
148
148
  Freshness comes from four places — arrival, window focus / tab return / network reconnect
149
- (`revalidateOnFocus`, default on; a read asked within the last few seconds is not asked again,
149
+ (`revalidateOnFocus`, default on; a read asked within the last minute is not asked again,
150
150
  and the reads it asks again re-render together, as a push's do — so what changed while the tab
151
151
  was hidden or offline lands in one piece), a **realtime push**, and **this app's own successful
152
152
  write**, which re-reads the mounted queries over the tables its body names without being asked
@@ -375,7 +375,7 @@ Wire shapes per output column type:
375
375
  | `datetime` | `"YYYY-MM-DDTHH:mm"` — workspace wall-clock, minute precision |
376
376
  | `select` | `Array<{ key, label }>` — one entry per selected option |
377
377
  | `select_member` | `Array<{ id, name, email?, image?, groups? }>` — the last three only for authenticated viewers of the app's own org; `image` presigned |
378
- | `select_record_link` | `Array<{ id, display }>` — target record id + its display text |
378
+ | `select_record_link` | `Array<{ id, display }>` — target record id + the row's name: the field its table names rows by |
379
379
  | `files` | `Array<{ id, filename, mime_type, url, thumbnail_url?, size?, created_at?, document_template_id? }>` — presigned |
380
380
 
381
381
  Row-level (non-grouped) queries additionally carry system columns: `__source_record_id` /
package/docs/files.md CHANGED
@@ -247,7 +247,7 @@ Decode with `readFiles(cell)` → `AppFile[]` (`dist/row.d.ts`):
247
247
  | `id` | `string` | The stored file id |
248
248
  | `filename` | `string` | Original filename (post-optimization name for converted images) |
249
249
  | `mime_type` | `string` | |
250
- | `url` | `string` | Presigned serving URL — render, or pass to `openExternal` |
250
+ | `url` | `string` | Presigned serving URL — render it, or download it with `downloadFileFromUrl` |
251
251
  | `thumbnail_url` | `string?` | Presigned image thumbnail (see the 404 note below) |
252
252
  | `size` | `number?` | Byte size — **absent on older files not yet backfilled**; render only when present |
253
253
  | `created_at` | `string?` | ISO upload timestamp |
@@ -320,22 +320,25 @@ dedupes them, and returns them on the execute response as `WorkflowResult.files?
320
320
  with `files` absent.
321
321
 
322
322
  ```tsx
323
+ import { downloadFileFromUrl } from "@lotics/ui/download";
324
+
323
325
  const generate = useWorkflow("generateInvoice");
324
326
  // `__source_record_id` is `null` on a grouped row — so narrow it rather than
325
327
  // handing the workflow no id where it declares a record.
326
328
  if (!row.__source_record_id) return;
327
329
  const result = await generate({ record_id: row.__source_record_id });
328
- if (result.status === "success" && result.files?.length) {
329
- await openExternal(result.files[0].url);
330
- }
330
+ const [file] = result.status === "success" ? (result.files ?? []) : [];
331
+ if (file?.url) await downloadFileFromUrl(file.url, file.filename);
331
332
  ```
332
333
 
333
334
  - **Don't hand back a `url` or `file_id` via `return({ data })`** — extraction is automatic, and
334
335
  the extracted entry is presigned while a hand-returned id is not.
335
336
  - The presigned URLs work for **anonymous public-app viewers** — a public visitor can download a
336
337
  document the workflow generated for them.
337
- - Open one with `openExternal(url)`, never `window.open`; bytes the app builds *in the browser*
338
- leave through `downloadFile` — both [runtime](./runtime.md).
338
+ - Download one with `downloadFileFromUrl(url, filename)` (`@lotics/ui/download`): it fetches the
339
+ presigned URL and saves it, and the iframe allows downloads. A tab `openExternal` opens once the
340
+ run answers is a pop-up the browser blocks. Bytes the app builds *in the browser* leave through
341
+ `downloadFile` ([runtime](./runtime.md)).
339
342
 
340
343
  ### Naming what goes into a zip
341
344
 
@@ -428,8 +428,9 @@ const { reverted, kept } = await undoRecordChange("job", recordId, entry.id);
428
428
  before is in neither. The write is the viewer's own save: they hold `write_data` on the table as
429
429
  the call arrives, on a row their own row rule covers, and the record's lock, the table's checks
430
430
  and its write rules answer as for any save of theirs. `undoable` is `false`, and the call
431
- refused, for the entry that made the record, an entry that is an undo, one already put back, and
432
- a viewer who may not write the record; a record in the trash is refused until it is restored.
431
+ refused, for the entry that made the record, an entry that is an undo, one already put back, a
432
+ viewer who may not write the record, and a JSON app that saves no record of the entity (an undo is
433
+ a save); a record in the trash is refused until it is restored.
433
434
  The undo is an entry of its own; the answer arrives once the record's reads are read anew.
434
435
  - **Pages**: newest first, about thirty entries each; `next` is where the trail goes on —
435
436
  `undefined` at its end. A page may hold fewer entries and still say `next` where the trail it read
package/docs/mutations.md CHANGED
@@ -142,13 +142,15 @@ Any step in the run whose tool output carries a `file_id` (most commonly
142
142
  `generate_document`) is collected automatically into
143
143
  `result.files[]` — each an `UploadedFile`
144
144
  `{ id, filename, mime_type, url?, thumbnail_url? }` with a presigned `url` (24-hour TTL) the
145
- app can open directly:
145
+ app downloads directly — a tab opened once the run answers is a pop-up the browser blocks:
146
146
 
147
147
  ```tsx
148
+ import { downloadFileFromUrl } from "@lotics/ui/download";
149
+
148
150
  const gen = useWorkflow("generateInvoice");
149
151
  const { status, files } = await gen({ order_id });
150
- const url = files?.[0]?.url;
151
- if (status === "success" && url) await openExternal(url);
152
+ const file = files?.[0];
153
+ if (status === "success" && file?.url) await downloadFileFromUrl(file.url, file.filename);
152
154
  ```
153
155
 
154
156
  The same run reached through the `run_app_workflow` tool — from chat, from MCP, from an app
package/docs/recipes.md CHANGED
@@ -28,19 +28,21 @@ return({ status: "success", message: `Đã tạo ${code}` }); // the file is e
28
28
  ```
29
29
 
30
30
  ```tsx
31
- import { useWorkflow, openExternal } from "@lotics/app-sdk";
31
+ import { useWorkflow } from "@lotics/app-sdk";
32
+ import { downloadFileFromUrl } from "@lotics/ui/download";
32
33
 
33
34
  const gen = useWorkflow("genDebit");
34
35
  const { files } = await gen({ record_id: rid });
35
- const url = files?.[0]?.url;
36
- if (url) await openExternal(url);
36
+ const file = files?.[0];
37
+ if (file?.url) await downloadFileFromUrl(file.url, file.filename);
37
38
  ```
38
39
 
39
40
  The `dtl_…` is a template you registered **once**, ahead of the app, through the CLI — see
40
41
  `lotics docs document_templates` for the five types and how each is created. An app fills
41
42
  templates; it never authors them.
42
43
 
43
- **`openExternal` is the download primitive, not `window.open`**, which the sandbox drops silently
44
+ **A generated file downloads with `downloadFileFromUrl`, never a tab.** The sandbox drops `window.open`
45
+ silently, and a tab `openExternal` opens seconds after the click is a pop-up the browser blocks
44
46
  ([runtime](./runtime.md)).
45
47
 
46
48
  ## Return structured data from a workflow
package/docs/runtime.md CHANGED
@@ -293,7 +293,7 @@ nobody stated.
293
293
 
294
294
  ```tsx
295
295
  import { openExternal } from "@lotics/app-sdk";
296
- await openExternal(result.files[0].url);
296
+ await openExternal(invoiceUrl);
297
297
  ```
298
298
 
299
299
  `openExternal(url: string): Promise<void>` (`dist/open_external.d.ts`). The
@@ -376,7 +376,7 @@ function onExportClick() {
376
376
  string): void` (`dist/download.d.ts`) saves bytes the app generated **in the
377
377
  browser** (an exported .xlsx, a CSV, generated text) to the visitor's device.
378
378
  It is the client-side counterpart to the server path (workflow generates a file
379
- → `WorkflowResult.files[].url` → `openExternal`).
379
+ → `WorkflowResult.files[].url` → `downloadFileFromUrl` from `@lotics/ui/download`).
380
380
 
381
381
  - **Pure DOM, no RPC.** The embed sandbox includes `allow-downloads`, so a
382
382
  same-origin blob download triggered from a user gesture needs no host
package/docs/security.md CHANGED
@@ -103,7 +103,7 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
103
103
  | Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
104
104
  | Comments (`useComments`) | **No** — members-only |
105
105
  | Record activity (`useRecordActivity`) | **No** — the app's organization's members only |
106
- | Undo of a record's change (`undoRecordChange`) | **No** — a member of the app's organization holding `write_data` on the record's table, on a row their own row rule covers |
106
+ | Undo of a record's change (`undoRecordChange`) | **No** — a member of the app's organization holding `write_data` on the record's table, on a row their own row rule covers, in an app that saves records of that entity |
107
107
  | Member roster (`useMembers`) | **No** — same-org members only (below) |
108
108
 
109
109
  **Warning — `is_current_member` on a public app resolves to the app owner.** An anonymous request has no viewer to bind, so both `is_current_member` and `current_member in_any_group` fall back to the owner's identity and the owner's groups. A "my items" query shows every anonymous visitor the *owner's* items, and a manager-only read template gated on a group the owner belongs to **passes for every anonymous visitor**. Never rely on viewer-bound predicates in a public app.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.122.9",
3
+ "version": "0.123.1",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through — typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {