@lotics/app-sdk 0.101.0 → 0.101.2
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/index.js +80 -24
- package/dist/shared_types.d.ts +1 -1
- package/dist/written.d.ts +2 -0
- package/docs/ai.md +2 -2
- package/docs/data_fetching.md +3 -3
- package/docs/mutations.md +6 -6
- package/docs/queries.md +41 -38
- package/docs/recipes.md +4 -4
- package/docs/security.md +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -16355,6 +16355,7 @@ function numberUnitRefusal(format2, unit) {
|
|
|
16355
16355
|
function effectiveSelectOptions(field) {
|
|
16356
16356
|
if (field === void 0) return null;
|
|
16357
16357
|
if (field.type === "select") return field.options ?? null;
|
|
16358
|
+
if (field.type === "formula") return field.formula?.options ?? null;
|
|
16358
16359
|
if (field.type === "lookup" && field.lookup_field_type === "select") {
|
|
16359
16360
|
return field.lookup_field_options ?? null;
|
|
16360
16361
|
}
|
|
@@ -19152,6 +19153,9 @@ function resolveFilterType(field) {
|
|
|
19152
19153
|
return "date";
|
|
19153
19154
|
case "boolean":
|
|
19154
19155
|
return "boolean";
|
|
19156
|
+
// Its cell is a single select's: `[key]`.
|
|
19157
|
+
case "select":
|
|
19158
|
+
return "select";
|
|
19155
19159
|
default:
|
|
19156
19160
|
return "text";
|
|
19157
19161
|
}
|
|
@@ -19399,7 +19403,7 @@ var tableLookupFieldSchema = tableFieldBaseSchema.extend({
|
|
|
19399
19403
|
var tableFilesFieldSchema = tableFieldBaseSchema.extend({
|
|
19400
19404
|
type: zod_default.literal("files").describe("Field type for file attachments (images, PDFs, documents)")
|
|
19401
19405
|
});
|
|
19402
|
-
var formulaOutputTypeSchema = zod_default.enum(["number", "text", "date", "datetime", "boolean"]);
|
|
19406
|
+
var formulaOutputTypeSchema = zod_default.enum(["number", "text", "date", "datetime", "boolean", "select"]);
|
|
19403
19407
|
var formulaInputSchema = zod_default.object({
|
|
19404
19408
|
expression: zod_default.string().describe(
|
|
19405
19409
|
"JavaScript expression. Uses {Field Name} for field references."
|
|
@@ -19410,10 +19414,13 @@ var formulaInputSchema = zod_default.object({
|
|
|
19410
19414
|
currency: zod_default.string().optional().describe("ISO 4217 currency code, e.g. USD, VND, EUR"),
|
|
19411
19415
|
unit: zod_default.string().optional().describe(UNIT_DESCRIPTION),
|
|
19412
19416
|
unit_field: zod_default.string().optional().describe(UNIT_FIELD_DESCRIPTION),
|
|
19413
|
-
currency_field: zod_default.string().optional().describe(CURRENCY_FIELD_DESCRIPTION)
|
|
19417
|
+
currency_field: zod_default.string().optional().describe(CURRENCY_FIELD_DESCRIPTION),
|
|
19418
|
+
options: zod_default.array(tableSelectFieldOptionSchema).min(1).optional().describe(
|
|
19419
|
+
"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."
|
|
19420
|
+
)
|
|
19414
19421
|
});
|
|
19415
19422
|
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)
|
|
19423
|
+
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
19424
|
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
19425
|
});
|
|
19419
19426
|
var tableFormulaFieldSchema = tableFieldBaseSchema.extend({
|
|
@@ -19490,6 +19497,11 @@ var tableFieldInputSchema = zod_default.discriminatedUnion("type", [
|
|
|
19490
19497
|
tableAutonumberFieldSchema
|
|
19491
19498
|
]);
|
|
19492
19499
|
var tableFieldsSchema = zod_default.array(tableFieldSchema);
|
|
19500
|
+
function selectOptionsOf(field) {
|
|
19501
|
+
if (field.type === "select") return field.options;
|
|
19502
|
+
if (field.type === "formula") return field.formula.options;
|
|
19503
|
+
return void 0;
|
|
19504
|
+
}
|
|
19493
19505
|
var tableFieldCreateBaseSchema = zod_default.object({
|
|
19494
19506
|
name: zod_default.string().describe("Display name"),
|
|
19495
19507
|
description: zod_default.string().describe("Field description"),
|
|
@@ -19634,6 +19646,10 @@ function projectCell(field, value) {
|
|
|
19634
19646
|
case "select_member":
|
|
19635
19647
|
if (field.multi === true || !Array.isArray(value)) return value;
|
|
19636
19648
|
return value.length === 0 ? null : value[0];
|
|
19649
|
+
// A formula yielding one of its options holds a single select's cell.
|
|
19650
|
+
case "formula":
|
|
19651
|
+
if (field.formula.options === void 0 || !Array.isArray(value)) return value;
|
|
19652
|
+
return value.length === 0 ? null : value[0];
|
|
19637
19653
|
case "select_record_link":
|
|
19638
19654
|
return Array.isArray(value) ? extractRecordLinkIds(value) : value;
|
|
19639
19655
|
default:
|
|
@@ -25463,6 +25479,7 @@ async function evaluateRead(source, path, coerce, ctx) {
|
|
|
25463
25479
|
return current;
|
|
25464
25480
|
}
|
|
25465
25481
|
function isScalarWrappedField(field) {
|
|
25482
|
+
if (field.type === "formula") return field.formula.options !== void 0;
|
|
25466
25483
|
return (field.type === "select" || field.type === "select_member") && field.multi !== true;
|
|
25467
25484
|
}
|
|
25468
25485
|
function unwrapScalarField(value) {
|
|
@@ -25485,7 +25502,10 @@ async function coerceFieldDisplay(value, field, ctx) {
|
|
|
25485
25502
|
if (value === null || value === void 0) return value;
|
|
25486
25503
|
switch (field.type) {
|
|
25487
25504
|
case "select":
|
|
25488
|
-
return coerceSelectDisplay(value, field);
|
|
25505
|
+
return coerceSelectDisplay(value, field.options);
|
|
25506
|
+
// One of its own options, named as a select's is.
|
|
25507
|
+
case "formula":
|
|
25508
|
+
return field.formula.options === void 0 ? value : coerceSelectDisplay(value, field.formula.options);
|
|
25489
25509
|
case "select_record_link":
|
|
25490
25510
|
return coerceRecordLinkDisplay(value, field, ctx);
|
|
25491
25511
|
case "select_member":
|
|
@@ -25496,8 +25516,8 @@ async function coerceFieldDisplay(value, field, ctx) {
|
|
|
25496
25516
|
return value;
|
|
25497
25517
|
}
|
|
25498
25518
|
}
|
|
25499
|
-
function coerceSelectDisplay(value,
|
|
25500
|
-
const optionMap = new Map(
|
|
25519
|
+
function coerceSelectDisplay(value, options) {
|
|
25520
|
+
const optionMap = new Map(options.map((o) => [o.key, o.name]));
|
|
25501
25521
|
const resolve = (key) => {
|
|
25502
25522
|
if (typeof key !== "string") return "";
|
|
25503
25523
|
return optionMap.get(key) ?? key;
|
|
@@ -27317,6 +27337,10 @@ function getFieldDisplayValue(field, value) {
|
|
|
27317
27337
|
return option?.name ?? selectValues[0] ?? "";
|
|
27318
27338
|
}
|
|
27319
27339
|
case "formula": {
|
|
27340
|
+
if (field.formula.options !== void 0) {
|
|
27341
|
+
const [key] = Array.isArray(value) ? value : [];
|
|
27342
|
+
return key === void 0 ? "" : field.formula.options.find((option) => option.key === key)?.name ?? key;
|
|
27343
|
+
}
|
|
27320
27344
|
const formulaVal = getFormulaValue(value);
|
|
27321
27345
|
return formulaVal !== null ? String(formulaVal) : "";
|
|
27322
27346
|
}
|
|
@@ -27408,14 +27432,22 @@ var appWriteModelSchema = zod_default.object({
|
|
|
27408
27432
|
tables: zod_default.array(zod_default.object({ id: zod_default.string(), name: zod_default.string(), fields: tableFieldsSchema })).describe("The tables the plans and queries touch, with only the fields they name.")
|
|
27409
27433
|
});
|
|
27410
27434
|
var PREDICTION_MISSES_MAX = 50;
|
|
27411
|
-
var predictionMissSchema = zod_default.
|
|
27412
|
-
|
|
27413
|
-
|
|
27414
|
-
|
|
27415
|
-
|
|
27416
|
-
|
|
27417
|
-
|
|
27418
|
-
|
|
27435
|
+
var predictionMissSchema = zod_default.union([
|
|
27436
|
+
zod_default.object({
|
|
27437
|
+
kind: zod_default.enum(["cell", "not_removed"]).describe("A predicted value the stored one differs from, or a row predicted gone that the server still holds."),
|
|
27438
|
+
workflow: zod_default.string().max(200).describe("The alias of the workflow whose run was predicted."),
|
|
27439
|
+
table_id: zod_default.string().max(64),
|
|
27440
|
+
field_key: zod_default.string().max(64).nullable().describe("The field that missed; null for a whole row."),
|
|
27441
|
+
column: zod_default.enum(["own", "via", "row"]).describe("A field of the row itself, one read through its link, or the row."),
|
|
27442
|
+
count: zod_default.number().int().min(1).max(1e3)
|
|
27443
|
+
}).strict(),
|
|
27444
|
+
zod_default.object({
|
|
27445
|
+
kind: zod_default.enum(["model_unavailable", "predict_error"]).describe("The app's write model could not be read, or predicting a run threw: its writes wait for the server."),
|
|
27446
|
+
workflow: zod_default.string().max(200).nullable().describe("The workflow whose run was not predicted; null for the model."),
|
|
27447
|
+
error: zod_default.string().regex(/^[A-Za-z_$][\w$]{0,63}$/).describe("The error's class name alone (`TypeError`, `WorkflowEvalError`)."),
|
|
27448
|
+
count: zod_default.number().int().min(1).max(1e3)
|
|
27449
|
+
}).strict()
|
|
27450
|
+
]);
|
|
27419
27451
|
var predictionMissesBodySchema = zod_default.object({ misses: zod_default.array(predictionMissSchema).min(1).max(PREDICTION_MISSES_MAX) });
|
|
27420
27452
|
|
|
27421
27453
|
// src/new_record.ts
|
|
@@ -27526,6 +27558,7 @@ function loadModel() {
|
|
|
27526
27558
|
attempt2.catch((cause) => {
|
|
27527
27559
|
console.error("The app's write model could not be read; writes show once the server answers", cause);
|
|
27528
27560
|
if (modelPromise === attempt2) failedAtMs = Date.now();
|
|
27561
|
+
reportMisses([{ kind: "model_unavailable", workflow: null, error: errorName(cause), count: 1 }]);
|
|
27529
27562
|
});
|
|
27530
27563
|
modelPromise = attempt2;
|
|
27531
27564
|
}
|
|
@@ -27687,7 +27720,7 @@ async function stageWrite(workflow, inputs) {
|
|
|
27687
27720
|
});
|
|
27688
27721
|
} catch (cause) {
|
|
27689
27722
|
if (cause instanceof UnknownValue) return null;
|
|
27690
|
-
|
|
27723
|
+
unpredicted(workflow, cause);
|
|
27691
27724
|
return null;
|
|
27692
27725
|
}
|
|
27693
27726
|
if (prediction.outcome === "refused") return null;
|
|
@@ -27788,8 +27821,21 @@ function judgePredictions(shape, rows, askedAt) {
|
|
|
27788
27821
|
}
|
|
27789
27822
|
if (checked.size > 0) entries2 = entries2.map((entry) => checked.has(entry.token) ? { ...entry, checked: /* @__PURE__ */ new Set([...entry.checked, ...checked.get(entry.token) ?? []]) } : entry);
|
|
27790
27823
|
if (counts.size === 0) return;
|
|
27791
|
-
|
|
27792
|
-
|
|
27824
|
+
reportMisses([...counts.values()]);
|
|
27825
|
+
}
|
|
27826
|
+
function errorName(cause) {
|
|
27827
|
+
const name = cause instanceof Error ? cause.name : "";
|
|
27828
|
+
return /^[A-Za-z_$][\w$]{0,63}$/.test(name) ? name : "Error";
|
|
27829
|
+
}
|
|
27830
|
+
function unpredicted(workflow, cause) {
|
|
27831
|
+
console.error(`The write "${workflow}" could not be predicted; it shows once the server answers`, cause);
|
|
27832
|
+
reportMisses([{ kind: "predict_error", workflow, error: errorName(cause), count: 1 }]);
|
|
27833
|
+
}
|
|
27834
|
+
function reportMisses(misses) {
|
|
27835
|
+
if (misses.length === 0 || hasMockFlag()) return;
|
|
27836
|
+
rpc("prediction_miss", { misses: misses.slice(0, PREDICTION_MISSES_MAX) }).catch(
|
|
27837
|
+
(cause) => console.warn("Where the app's writes were drawn wrong or not at all could not be reported", cause)
|
|
27838
|
+
);
|
|
27793
27839
|
}
|
|
27794
27840
|
function readsAfter(workflow, token2) {
|
|
27795
27841
|
const described = loaded?.model.workflows[workflow];
|
|
@@ -27893,7 +27939,7 @@ async function runStaged(alias, inputs) {
|
|
|
27893
27939
|
}
|
|
27894
27940
|
const given = inputs ?? {};
|
|
27895
27941
|
const staged = await stageWrite(alias, given).catch((cause) => {
|
|
27896
|
-
|
|
27942
|
+
unpredicted(alias, cause);
|
|
27897
27943
|
return null;
|
|
27898
27944
|
});
|
|
27899
27945
|
const token2 = staged?.token ?? null;
|
|
@@ -29500,11 +29546,10 @@ function compareFieldValues(aValue, bValue, field, blankPosition = "bottom", ord
|
|
|
29500
29546
|
if (aValue === bValue) {
|
|
29501
29547
|
return 0;
|
|
29502
29548
|
}
|
|
29503
|
-
|
|
29504
|
-
|
|
29505
|
-
const
|
|
29506
|
-
const
|
|
29507
|
-
const bSelectArray = bValue;
|
|
29549
|
+
const options = selectOptionsOf(field);
|
|
29550
|
+
if (options !== void 0) {
|
|
29551
|
+
const aSelectArray = Array.isArray(aValue) ? aValue : null;
|
|
29552
|
+
const bSelectArray = Array.isArray(bValue) ? bValue : null;
|
|
29508
29553
|
const aSelectValue = aSelectArray?.[0];
|
|
29509
29554
|
const bSelectValue = bSelectArray?.[0];
|
|
29510
29555
|
const aIsEmpty2 = isArrayFieldEmpty(aSelectArray);
|
|
@@ -29661,6 +29706,10 @@ function referencedFieldKeys(expression, fields) {
|
|
|
29661
29706
|
return prepareFormulaExpression(expression, fields).referencedKeys;
|
|
29662
29707
|
}
|
|
29663
29708
|
|
|
29709
|
+
// ../shared/src/formula_language.ts
|
|
29710
|
+
var HELPERS2 = new Set(Object.keys(createTimezoneAwareFunctions()));
|
|
29711
|
+
var BY_LOWERCASE = new Map([...HELPERS2].map((name) => [name.toLowerCase(), name]));
|
|
29712
|
+
|
|
29664
29713
|
// ../shared/src/formula.ts
|
|
29665
29714
|
function prepareFormulaContext(fields) {
|
|
29666
29715
|
const formulaFields = fields.filter((field) => field.type === "formula");
|
|
@@ -29811,12 +29860,13 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
|
|
|
29811
29860
|
continue;
|
|
29812
29861
|
}
|
|
29813
29862
|
const everyReferenceEmpty = referencedKeys.length > 0 && referencedKeys.every((fieldKey) => evaluationContext[fieldKey] === null);
|
|
29814
|
-
const
|
|
29863
|
+
const evaluated2 = evaluateFormulaWithJS(
|
|
29815
29864
|
prepared.refs.evaluable,
|
|
29816
29865
|
{ data: evaluationContext },
|
|
29817
29866
|
timezone,
|
|
29818
29867
|
everyReferenceEmpty
|
|
29819
29868
|
);
|
|
29869
|
+
const formulaResult = formulaField.formula.options === void 0 ? evaluated2 : selectResult(formulaField.formula.options, evaluated2);
|
|
29820
29870
|
result[formulaField.key] = formulaResult;
|
|
29821
29871
|
if (isFormulaError(formulaResult)) {
|
|
29822
29872
|
erroredFormulaKeys.add(formulaField.key);
|
|
@@ -29835,6 +29885,12 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
|
|
|
29835
29885
|
}
|
|
29836
29886
|
return result;
|
|
29837
29887
|
}
|
|
29888
|
+
function selectResult(options, evaluated2) {
|
|
29889
|
+
if (evaluated2 === null || evaluated2 === "") return null;
|
|
29890
|
+
if (isFormulaError(evaluated2)) return evaluated2;
|
|
29891
|
+
if (typeof evaluated2 === "string" && options.some((option) => option.key === evaluated2)) return [evaluated2];
|
|
29892
|
+
return `${FORMULA_ERROR_PREFIX}yields ${JSON.stringify(evaluated2)}, which is none of its options (${options.map((option) => option.key).join(", ")})`;
|
|
29893
|
+
}
|
|
29838
29894
|
function normalizeEmptyToNull(value) {
|
|
29839
29895
|
return isEmptyFieldValue(value) ? null : value;
|
|
29840
29896
|
}
|
package/dist/shared_types.d.ts
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* tarball. Written at every build, never edited.
|
|
5
5
|
*/
|
|
6
6
|
export type QueryAggregate = { by?: readonly string[]; day?: string; sum?: string; };
|
|
7
|
-
export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; } | { key: string; name: string; description: string; type: "text"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; unique?: boolean | undefined; format?: "text" | "link" | "markdown" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "date"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; format?: "date" | "datetime" | "date_range" | "datetime_range" | undefined; timezone?: string | undefined; derive_from?: "created_at" | "updated_at" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; } | { key: string; name: string; description: string; type: "select"; options: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_member"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; display_field_keys: string[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; display_field_widths?: Record<string, number> | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; } | { key: string; name: string; description: string; type: "rollup"; source_field_key: string; aggregate_option: { operation: "count"; field_key?: string | undefined; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique"; field_key: string; } | { operation: "min" | "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range"; field_key: string; } | { operation: "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "earliest" | "latest"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest" | "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest"; field_key: string; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; filter?: TableRecordFiltersGroupNode | undefined; aggregate_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_format?: string | undefined; aggregate_field_currency?: string | undefined; aggregate_field_unit?: string | undefined; aggregate_field_unit_field?: string | undefined; aggregate_field_currency_field?: string | undefined; } | { key: string; name: string; description: string; type: "lookup"; source_field_key: string; lookup_field_key: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; lookup_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_format?: string | undefined; lookup_field_currency?: string | undefined; lookup_field_unit?: string | undefined; lookup_field_options?: { key: string; name: string; color?: string | undefined; mark?: { kind: string; name: string; } | undefined; }[] | undefined; order_by?: { field_key: string; direction: "asc" | "desc"; } | undefined; } | { key: string; name: string; description: string; type: "files"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "formula"; formula: { expression: string; format?: "number" | "currency" | "percentage" | "link" | undefined; currency?: string | undefined; unit?: string | undefined; unit_field?: string | undefined; currency_field?: string | undefined; output_type?: "number" | "boolean" | "text" | "date" | "datetime" | undefined; volatile?: boolean | undefined; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "button"; text: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; workflow_id?: string | null | undefined; } | { key: string; name: string; description: string; type: "autonumber"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; prefix?: string | undefined; padding?: number | undefined; template?: string | undefined; };
|
|
7
|
+
export type 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
8
|
export type TableRecordFilters = TableRecordFiltersConditionNode | TableRecordFiltersTraversalNode | TableRecordFiltersGroupNode;
|
package/dist/written.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export declare function writesRecords(workflow: string): boolean;
|
|
|
44
44
|
* different times, when its oldest was (`since`).
|
|
45
45
|
*/
|
|
46
46
|
export declare function noteAnswer(key: string, alias: string, askedAt: number, rows: readonly Row[], since?: number): void;
|
|
47
|
+
/** A run whose writes wait for the server because predicting it threw: said in the console, reported by class. */
|
|
48
|
+
export declare function unpredicted(workflow: string, cause: unknown): void;
|
|
47
49
|
/**
|
|
48
50
|
* The queries a successful run of `workflow` re-reads: those over a table its body names. Undefined — every
|
|
49
51
|
* mounted query — until the model is read, or where a write names a table only at run time.
|
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
|
|
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
|
|
@@ -539,7 +538,8 @@ every read before the request answers:
|
|
|
539
538
|
asked after the write settled answers — the stored value then replaces the prediction, whatever
|
|
540
539
|
it said, so a server that rounds or trims wins with no flicker back to the old value. Where the
|
|
541
540
|
stored value differs from the one drawn, the SDK tells the server where — the workflow, table,
|
|
542
|
-
field and kind of miss, never the value.
|
|
541
|
+
field and kind of miss, never the value. A write model that cannot be read and a prediction that
|
|
542
|
+
throws are reported too, by the error's class name alone.
|
|
543
543
|
|
|
544
544
|
What the steps cannot tell before they run is never guessed:
|
|
545
545
|
|
package/docs/queries.md
CHANGED
|
@@ -54,7 +54,7 @@ query's reach (a token in a `table_id` or field-key position fails validation).
|
|
|
54
54
|
|
|
55
55
|
### What deploy validates
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
Binding fails — with the compiler's own message, never raw SQL/Postgres text — when any alias:
|
|
58
58
|
|
|
59
59
|
1. isn't a valid identifier, or the declaration isn't `{ ast, params?, description? }` (a
|
|
60
60
|
description over 300 characters is refused here too);
|
|
@@ -72,12 +72,12 @@ Deploy fails — with the compiler's own message, never raw SQL/Postgres text
|
|
|
72
72
|
overrides, `writable_target` on a computed column;
|
|
73
73
|
7. fails to **compile to SQL** — several rejections exist only in the compiler (the expression
|
|
74
74
|
allowlist, link extraction over a composed parent, derived-filter operators with no
|
|
75
|
-
translation).
|
|
75
|
+
translation). Binding compiles every query so these fail at bind, not at first run;
|
|
76
76
|
8. has optional params and the **pruned** shape (all optional params omitted) fails any of the
|
|
77
77
|
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.
|
|
78
|
+
all-params shape compiling doesn't prove the pruned one does. Binding validates both.
|
|
79
79
|
|
|
80
|
-
What
|
|
80
|
+
What binding **cannot** catch: data-dependent failures (a statement timeout on a huge unindexed
|
|
81
81
|
scan, the file-presign ceiling) — those surface at run time as clean, safe errors (§10).
|
|
82
82
|
|
|
83
83
|
### Execution
|
|
@@ -162,7 +162,7 @@ only. Read what a row holds through your projection and the `read*` helpers, nev
|
|
|
162
162
|
## 2. The AST node reference
|
|
163
163
|
|
|
164
164
|
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 **
|
|
165
|
+
compiles to a derived `SELECT`. All structural errors below fail at **bind** (the output
|
|
166
166
|
schema + compile passes run there); only data-dependent errors fail at run time.
|
|
167
167
|
|
|
168
168
|
### `from_table` — the only leaf
|
|
@@ -179,7 +179,7 @@ Scans one table. Output = one column per field on the table (named by field key,
|
|
|
179
179
|
plus the system columns. `filter`, `search` (AND-ed with `filter`), `sort`, and `limit` apply
|
|
180
180
|
inside the scan — this is the **source layer**, where the richest operator support and all
|
|
181
181
|
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
|
|
182
|
+
"top"|"bottom" }` (blanks default to bottom). Unknown filter/sort field keys fail binding.
|
|
183
183
|
|
|
184
184
|
### `project` — choose and compute columns
|
|
185
185
|
|
|
@@ -200,7 +200,7 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
|
|
|
200
200
|
`type`, is always nullable, and is never writable.
|
|
201
201
|
- Output names must be unique and non-reserved.
|
|
202
202
|
- 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
|
|
203
|
+
(§4), never a relabel. An uncastable combination is rejected at bind.
|
|
204
204
|
- **Project only what you render.** A bare `from_table` ships every column — including `files`
|
|
205
205
|
cells with storage keys — to the client (over-exposure + the presign ceiling at scale).
|
|
206
206
|
- **A files column takes `limit` — bound it when the surface renders a THUMBNAIL, not the
|
|
@@ -209,7 +209,7 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
|
|
|
209
209
|
without it the response pays to resolve every entry toward the presign ceiling (§10). The bound
|
|
210
210
|
is per CELL, caps at `FILES_PROJECTION_MAX_LIMIT` (10), and is a different axis from the
|
|
211
211
|
query's own `limit`, which counts ROWS; `limit` on any non-`files` column or computed source is
|
|
212
|
-
rejected at
|
|
212
|
+
rejected at bind. **On a UNION, set it on every arm** — arms align by name and type, not by
|
|
213
213
|
`limit`, so an arm that omits it still yields whole cells.
|
|
214
214
|
|
|
215
215
|
### `filter` — predicate over derived columns
|
|
@@ -238,7 +238,7 @@ source filter automatically (making it index-servable); otherwise it evaluates p
|
|
|
238
238
|
|
|
239
239
|
Single **equality** predicate on one output column per side — no multi-column ON, no
|
|
240
240
|
inequality/range joins. The two ON columns must share a SQL value category (text/number/…);
|
|
241
|
-
mismatches are rejected at
|
|
241
|
+
mismatches are rejected at bind (cast one side with a projection type override). Column-name
|
|
242
242
|
collisions between the sides are rejected — `project`-rename first. A `left` join makes every
|
|
243
243
|
right-side column nullable. Addressing (write-through, `record_id` filtering) follows the
|
|
244
244
|
**left** side only.
|
|
@@ -268,7 +268,7 @@ came from.
|
|
|
268
268
|
```
|
|
269
269
|
|
|
270
270
|
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
|
|
271
|
+
Aggregate operation × input-column type is validated at bind. Grouping collapses rows —
|
|
272
272
|
addressing is dropped.
|
|
273
273
|
|
|
274
274
|
### `window` — aggregate without collapsing
|
|
@@ -414,7 +414,7 @@ must be valid over `field`'s type — a refusal lists the valid ones.
|
|
|
414
414
|
(`round`/`floor`/`ceil`/`abs` are 1-arg; `round` rounds to an integer.)
|
|
415
415
|
- **Operators**: `+ - * / %`, `== === != !==`, `< > <= >=`, `&& ||`, `??` (→ `COALESCE`),
|
|
416
416
|
unary `- + !`, and the ternary `cond ? a : b`.
|
|
417
|
-
- **Not supported** (rejected at
|
|
417
|
+
- **Not supported** (rejected at bind): indexing `[…]`, nested access (`input.a.b`), list/map
|
|
418
418
|
literals, arrow functions, method calls, any other function. Project the raw columns and
|
|
419
419
|
compute client-side instead.
|
|
420
420
|
|
|
@@ -424,7 +424,7 @@ must be valid over `field`'s type — a refusal lists the valid ones.
|
|
|
424
424
|
```
|
|
425
425
|
|
|
426
426
|
Prefer the typed variants where one exists — expressions are strings and get less structural
|
|
427
|
-
validation (though they still compile-check at
|
|
427
|
+
validation (though they still compile-check at bind).
|
|
428
428
|
|
|
429
429
|
---
|
|
430
430
|
|
|
@@ -445,7 +445,7 @@ a **real SQL cast**, guarded so malformed values become NULL instead of aborting
|
|
|
445
445
|
| `text` → `number` / `boolean` / `date` / `datetime` | guarded cast; a non-conforming value → NULL |
|
|
446
446
|
| `json` → any scalar | via the JSON scalar text, guarded the same way |
|
|
447
447
|
| scalar → `text` / `json` | plain cast |
|
|
448
|
-
| array-valued (`select`/`member`/`link`/`files`) ↔ scalar | **impossible — rejected at
|
|
448
|
+
| array-valued (`select`/`member`/`link`/`files`) ↔ scalar | **impossible — rejected at bind** |
|
|
449
449
|
|
|
450
450
|
The guarded-NULL rule means a query never aborts because one row holds `"n/a"` in a text column
|
|
451
451
|
you cast to number — that row's cell is NULL. Use overrides to align UNION arms or to make a
|
|
@@ -469,7 +469,7 @@ derived surfaces share one implementation.
|
|
|
469
469
|
| 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
470
|
| 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
471
|
| 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
|
|
472
|
+
| 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
473
|
| 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
474
|
| 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
475
|
| 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 +554,7 @@ stay index-served. Two access classes:
|
|
|
554
554
|
Tokens interpolate in **value positions**. A string that *is* exactly one token is replaced by
|
|
555
555
|
the param's typed value (an array param yields an array); a token embedded in a larger string
|
|
556
556
|
interpolates as text. Declared-but-unreferenced params are fine; referenced-but-undeclared
|
|
557
|
-
tokens fail
|
|
557
|
+
tokens fail binding.
|
|
558
558
|
|
|
559
559
|
**Composable optional filters (one query, many scopes).** Expose several independent filter
|
|
560
560
|
axes from ONE named query — don't shard into a query-per-combination. Mark each scoping param
|
|
@@ -653,7 +653,7 @@ anywhere in the record*.
|
|
|
653
653
|
### The 21 aggregate operations
|
|
654
654
|
|
|
655
655
|
`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
|
|
656
|
+
everything else requires an `input_column` whose type must be compatible — checked at bind:
|
|
657
657
|
|
|
658
658
|
| Operation | Valid input column types | Result | Notes |
|
|
659
659
|
| --- | --- | --- | --- |
|
|
@@ -680,7 +680,7 @@ sizes on a shipment, the tags on a ticket) without a second query.
|
|
|
680
680
|
"distinct": true, "separator": ", ", "max_values": 20 }
|
|
681
681
|
```
|
|
682
682
|
|
|
683
|
-
- **`type` must be `text`** — declaring anything else is rejected at
|
|
683
|
+
- **`type` must be `text`** — declaring anything else is rejected at bind.
|
|
684
684
|
- **`distinct`** defaults to **true**: three containers sized 40HC/40HC/20DC give `20DC, 40HC`.
|
|
685
685
|
Pass `false` to keep every occurrence. Values are always sorted, so the column doesn't
|
|
686
686
|
reshuffle between reads.
|
|
@@ -713,7 +713,7 @@ over a `link` read of it is `true`.
|
|
|
713
713
|
{ "bucket": { "source": "created", "granularity": "month", "output": "period" } }
|
|
714
714
|
```
|
|
715
715
|
|
|
716
|
-
- `source` must be a `date` or `datetime` **column** of the input (
|
|
716
|
+
- `source` must be a `date` or `datetime` **column** of the input (checked at bind).
|
|
717
717
|
- `granularity`: `day` | `week` | `month` | `quarter` | `year`. **Weeks are ISO — Monday
|
|
718
718
|
start.**
|
|
719
719
|
- Buckets are computed on the stored **wall-clock** value in the field's timezone (a 23:30
|
|
@@ -737,7 +737,7 @@ the timeout for nothing.
|
|
|
737
737
|
legal SQL window call are accepted: **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
|
|
738
738
|
`filled`, `checked`, `unchecked`.** The rest cannot take an OVER clause (`median` is an
|
|
739
739
|
ordered-set aggregate; `unique`/`percent_unique`/`string_agg` need DISTINCT;
|
|
740
|
-
`range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at
|
|
740
|
+
`range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at bind. The optional `frame` applies **only**
|
|
741
741
|
to these; a `frame` on a window with no `aggregates` is rejected as dead config.
|
|
742
742
|
|
|
743
743
|
**Window `functions` — ranking / navigation.** Each is `{ "output", "fn", … }` (its own arg shape, no
|
|
@@ -751,7 +751,7 @@ to these; a `frame` on a window with no `aggregates` is rejected as dead config.
|
|
|
751
751
|
| `dense_rank` | — | `number`, non-null. Ties share a rank; the next rank does **not** skip (1,2,2,3). |
|
|
752
752
|
| `percent_rank` / `cume_dist` | — | `number`, non-null. Relative position in [0, 1]. |
|
|
753
753
|
| `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
|
|
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 bind). |
|
|
755
755
|
|
|
756
756
|
**Top-N per group** — rank within each partition, then filter on the derived rank column:
|
|
757
757
|
|
|
@@ -791,7 +791,7 @@ first. For a fixed, small set of columns, N filtered aggregate queries also work
|
|
|
791
791
|
|
|
792
792
|
## 9. Runtime refinement — filter / sort / limit / offset / count
|
|
793
793
|
|
|
794
|
-
Each query request carries optional refinement the server wraps **around** the
|
|
794
|
+
Each query request carries optional refinement the server wraps **around** the bound
|
|
795
795
|
template as derived nodes, in this order: `filter` (narrow) → `sort` (order) → `limit`+`offset`
|
|
796
796
|
(page):
|
|
797
797
|
|
|
@@ -857,11 +857,11 @@ ordering and scoping into the template or its params.
|
|
|
857
857
|
| Statement timeout | **15 s** per query execution | 400: `query timed out after 15s — narrow the filter or simplify the query` |
|
|
858
858
|
| 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
859
|
| 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 |
|
|
860
|
+
| Traversal depth | 3 hops | **silent match-nothing** — a longer path compiles to `FALSE` (binds green, returns zero rows) |
|
|
861
|
+
| Param schema depth | 8 | bind reject |
|
|
862
862
|
|
|
863
863
|
Any other execution failure returns a generic `query execution failed` (the real error — which
|
|
864
|
-
may embed SQL — is server-logged only).
|
|
864
|
+
may embed SQL — is server-logged only). Bind-time and validation errors are always specific.
|
|
865
865
|
|
|
866
866
|
### Index reality, in plain terms
|
|
867
867
|
|
|
@@ -872,20 +872,21 @@ Records are stored partitioned by workspace, so a query reads only its own works
|
|
|
872
872
|
layer** — `select`/`select_member`/`select_record_link` `has_any_of` / `has_all_of`, and
|
|
873
873
|
`is_current_member`. These compile to containment the JSONB GIN index serves.
|
|
874
874
|
- **Trigram-served:** `from_table.search` (§7).
|
|
875
|
-
- **B-tree-served (automatic for
|
|
876
|
-
comparisons (exact and range),
|
|
877
|
-
|
|
878
|
-
|
|
875
|
+
- **B-tree-served (automatic for bound queries):** text `equals`, number and date
|
|
876
|
+
comparisons (exact and range), `from_table.sort` fields, and a one-key `sort` node over a
|
|
877
|
+
number or date field the rows pass unchanged — for fields referenced in a **bound named
|
|
878
|
+
query's template**. The platform provisions a partial expression index per referenced
|
|
879
|
+
field automatically: built online whenever `set_app_query` or `set_app_queries` binds a query, re-synced daily,
|
|
879
880
|
capped at 8 per table (fields past the cap fall back to the scan tier, with a server WARN).
|
|
880
881
|
A `{{params.…}}` value hole doesn't change this — the field key is static in the template,
|
|
881
882
|
so it still gets its index. Index-seek speed at any table size once provisioned.
|
|
882
883
|
- **Table scan (linear in table size):** everything else — text `contains`, negations
|
|
883
884
|
(`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
|
|
885
|
+
fields that appear **only** in the runtime `filter`/`sort` options rather than the bound
|
|
885
886
|
template. Fine on thousands of rows; on very large tables these dominate latency and are the
|
|
886
887
|
usual timeout cause.
|
|
887
888
|
|
|
888
|
-
**Filter shape drives latency.** Equality/range/sort predicates in the
|
|
889
|
+
**Filter shape drives latency.** Equality/range/sort predicates in the bound template are
|
|
889
890
|
index-served; lead with those or a GIN-served membership filter / `search`, and let scan-shaped
|
|
890
891
|
predicates refine the already-narrowed set. Derived-layer filters run over the subquery result
|
|
891
892
|
(no index), so **filter at the source layer whenever the field exists there** — the runtime
|
|
@@ -893,14 +894,16 @@ filter is for caller-driven refinement, not for the main cut (a runtime-only fie
|
|
|
893
894
|
managed index). (The engine pushes eligible filter-over-union predicates down automatically,
|
|
894
895
|
but don't rely on that for other shapes.)
|
|
895
896
|
|
|
896
|
-
**
|
|
897
|
-
|
|
898
|
-
`
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
897
|
+
**A sorted page reads in its sort index — declare the order it is read in.** A page sorted by one
|
|
898
|
+
column that passes a number or date field unchanged (a bare `project` source with no `type`
|
|
899
|
+
override, an `unpivot` passthrough, or a row column that reads the same field in every row) reads
|
|
900
|
+
the table in that field's sort index and stops at the page, instead of reading every row and
|
|
901
|
+
sorting. Over a `union`, each arm is paged this way on its own and the arms are merged, so a
|
|
902
|
+
register over several tables or `unpivot` sides is fast at any size — don't hand-split it into
|
|
903
|
+
per-table queries you merge client-side. The index exists when a bound template sorts that field
|
|
904
|
+
in the same direction and blank position: give the query its default order as a top-level `sort`,
|
|
905
|
+
and a page the app sorts the same way is served. A text column, a computed, literal or cast column,
|
|
906
|
+
a sort on more than one key, and a cursor (`keyset`) page sort every row.
|
|
904
907
|
|
|
905
908
|
**An aggregate arm you cannot filter costs its whole table, every execution.** A `join` whose right
|
|
906
909
|
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/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.101.
|
|
3
|
+
"version": "0.101.2",
|
|
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": {
|