@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 +1 -1
- package/dist/{chunk-ARV5FAU5.js → chunk-ZOFKWF6V.js} +6 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +88 -27
- package/dist/mock.d.ts +14 -5
- package/dist/queries.d.ts +36 -1
- package/dist/router.js +1 -1
- package/dist/rpc.d.ts +1 -1
- package/dist/shared_types.d.ts +12 -1
- package/docs/ai.md +2 -2
- package/docs/data_fetching.md +5 -4
- package/docs/members_and_options.md +1 -0
- package/docs/mutations.md +4 -5
- package/docs/queries.md +50 -39
- package/docs/recipes.md +4 -4
- package/docs/runtime.md +10 -6
- package/docs/security.md +1 -1
- package/package.json +1 -1
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-
|
|
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,
|
|
25500
|
-
const optionMap = new Map(
|
|
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
|
-
|
|
29526
|
-
|
|
29527
|
-
const
|
|
29528
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
30634
|
-
if (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 =
|
|
30648
|
-
if (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 ? () =>
|
|
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 ? () =>
|
|
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
|
|
23
|
-
|
|
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
|
-
* (
|
|
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
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;
|
package/dist/shared_types.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/docs/data_fetching.md
CHANGED
|
@@ -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
|
|
14
|
-
from the app's live bindings whenever a sandbox session opens on the app
|
|
15
|
-
|
|
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
|
|
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
|
|
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 |
|
|
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
|
|
277
|
-
type (widen or
|
|
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
|
-
|
|
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).
|
|
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.
|
|
86
|
+
all-params shape compiling doesn't prove the pruned one does. Binding validates both.
|
|
79
87
|
|
|
80
|
-
What
|
|
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 **
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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` (
|
|
861
|
-
| Param schema depth | 8 |
|
|
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).
|
|
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
|
|
876
|
-
comparisons (exact and range),
|
|
877
|
-
|
|
878
|
-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
897
|
-
|
|
898
|
-
`
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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;
|
|
227
|
-
of a set
|
|
228
|
-
|
|
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,
|
|
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.
|
|
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": {
|