@lotics/app-sdk 0.118.1 → 0.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -20,7 +20,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
20
20
  | [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. |
21
21
  | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
22
22
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload` and its `fidelity` and a photo's `place`, `renameFile` (a new file over the same bytes), `useAttachments`/`useAttachmentPiles`, `readFiles` and presigned URLs (a bearer credential — never logged or persisted), workflow-generated files, naming a zip's entries, the delivery bounds. |
23
- | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`, a record's activity (`useRecordActivity`), a register's imports log (`useImportsLog`, `useImportRun`). |
23
+ | [docs/members_and_options.md](./docs/members_and_options.md) | People, select options and comments — `useMembers`, `useFieldOptions`, `useViewer`, `useWorkspaceTimezone`/`useWorkspaceCurrency`, `useAppContext`, the workspace's zone at the root, `useComments`, a record's activity (`useRecordActivity`). |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model, the screens and `drawers` it reports to the host), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
25
25
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming parts, the agent's ask-back), `askAi`, `useAiContext`, and what the member's own chat agent can do with the app while it is open. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, why a per-input bound is a tenancy floor rather than an authorization check. |
@@ -174,6 +174,41 @@ function transportErrorMessage(status, parsed) {
174
174
  return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
175
175
  }
176
176
 
177
+ // ../shared/src/schemas/app_spec_rules.ts
178
+ function tiersAscend(tiers) {
179
+ return tiers.every((tier, index) => index === 0 || tier > (tiers[index - 1] ?? tier));
180
+ }
181
+ var bandKey = (band) => `${band.min ?? ""}..${band.max ?? ""}`;
182
+ function bandOf(tiers, figure) {
183
+ const above = tiers.findIndex((tier) => figure < tier);
184
+ const at = above < 0 ? tiers.length : above;
185
+ return { min: tiers[at - 1] ?? null, max: tiers[at] ?? null };
186
+ }
187
+
188
+ // ../shared/src/utils.ts
189
+ function decimalPlaces(value) {
190
+ if (Number.isInteger(value)) return 0;
191
+ const [mantissa = "", exponent = "0"] = String(value).split("e");
192
+ const fraction = mantissa.split(".")[1]?.length ?? 0;
193
+ return Math.max(0, fraction - Number(exponent));
194
+ }
195
+ function sumDecimal(values, divisor = 1) {
196
+ const float = values.reduce((total2, value) => total2 + value, 0) / divisor;
197
+ if (!values.every(Number.isFinite)) return float;
198
+ const factor = 10 ** values.reduce((places, value) => Math.max(places, decimalPlaces(value)), 0);
199
+ const scaled = values.map((value) => Math.round(value * factor));
200
+ const total = scaled.reduce((sum, value) => sum + value, 0);
201
+ const exact = Number.isSafeInteger(total) && scaled.every((one, index) => Number.isSafeInteger(one) && one / factor === values[index]);
202
+ return exact ? total / (factor * divisor) : float;
203
+ }
204
+ function sample(arr) {
205
+ if (arr.length === 0) return void 0;
206
+ return arr[Math.floor(Math.random() * arr.length)];
207
+ }
208
+ function assertNever(value) {
209
+ throw new Error(`Unhandled union member: ${JSON.stringify(value)}`);
210
+ }
211
+
177
212
  // ../shared/src/schemas/read_row.ts
178
213
  var RECORD_ID_COLUMN = "__source_record_id";
179
214
 
@@ -189,12 +224,19 @@ function aggregateRows(rows, aggregate) {
189
224
  const by = aggregate.by ?? [];
190
225
  const day = aggregate.day;
191
226
  const sum = aggregate.sum;
227
+ const keyOf = (row, column) => {
228
+ const tiers = aggregate.bands?.[column];
229
+ const cell = row[column] ?? null;
230
+ if (tiers === void 0) return cell;
231
+ return typeof cell === "number" ? bandKey(bandOf(tiers, cell)) : null;
232
+ };
192
233
  const keyed = /* @__PURE__ */ new Map();
193
234
  for (const row of rows) {
194
- const key = JSON.stringify([...by.map((column) => row[column] ?? null), ...day === void 0 ? [] : [dayOf(row[day])]]);
195
- keyed.set(key, [...keyed.get(key) ?? [], row]);
235
+ const keys = by.map((column) => keyOf(row, column));
236
+ const key = JSON.stringify([...keys, ...day === void 0 ? [] : [dayOf(row[day])]]);
237
+ keyed.set(key, { keys, members: [...keyed.get(key)?.members ?? [], row] });
196
238
  }
197
- return [...keyed.values()].map((members) => {
239
+ return [...keyed.values()].map(({ keys, members }) => {
198
240
  const [first2] = members;
199
241
  const figures = members.flatMap((member) => {
200
242
  const value = sum === void 0 ? void 0 : member[sum];
@@ -202,10 +244,10 @@ function aggregateRows(rows, aggregate) {
202
244
  });
203
245
  return {
204
246
  [RECORD_ID_COLUMN]: null,
205
- ...Object.fromEntries(by.map((column) => [column, first2?.[column] ?? null])),
247
+ ...Object.fromEntries(by.map((column, at) => [column, keys[at] ?? null])),
206
248
  ...day === void 0 ? {} : { [AGGREGATE_DAY_COLUMN]: dayOf(first2?.[day]) },
207
249
  [AGGREGATE_COUNT_COLUMN]: members.length,
208
- ...sum === void 0 ? {} : { [AGGREGATE_SUM_COLUMN]: figures.length === 0 ? null : figures.reduce((total, value) => total + value, 0) }
250
+ ...sum === void 0 ? {} : { [AGGREGATE_SUM_COLUMN]: figures.length === 0 ? null : sumDecimal(figures) }
209
251
  };
210
252
  });
211
253
  }
@@ -992,9 +1034,6 @@ function rpcStandalone(op, payload) {
992
1034
  return rejectCommentsStandalone();
993
1035
  case "activity.list":
994
1036
  return Promise.reject(new Error("A record's activity is available only in embedded apps \u2014 a signed-in member is required."));
995
- case "imports.list":
996
- case "imports.run":
997
- return Promise.reject(new Error("A register's imports log is available only in embedded apps \u2014 a signed-in member is required."));
998
1037
  case "recording.start":
999
1038
  case "recording.stop":
1000
1039
  return Promise.reject(
@@ -1200,6 +1239,12 @@ async function standaloneUpload(file, fidelity, place) {
1200
1239
 
1201
1240
  export {
1202
1241
  __export,
1242
+ tiersAscend,
1243
+ bandKey,
1244
+ bandOf,
1245
+ sumDecimal,
1246
+ sample,
1247
+ assertNever,
1203
1248
  AGGREGATE_COUNT_COLUMN,
1204
1249
  AGGREGATE_SUM_COLUMN,
1205
1250
  aggregateRows,
package/dist/index.d.ts CHANGED
@@ -13,9 +13,6 @@ export { useComments, useCommentCounts } from "./comments.js";
13
13
  export type { AppComment, AppCommentAuthor, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
14
14
  export { useRecordActivity } from "./activity.js";
15
15
  export type { RecordActivityOptions, RecordActivityState } from "./activity.js";
16
- export { useImportRun, useImportsLog } from "./imports.js";
17
- export type { ImportRunState, ImportsLogState, ImportsPageOptions } from "./imports.js";
18
- export type { ImportRow, ImportRowKind, ImportRun, ImportRunEnd, ImportRunPage, ImportRunStop, ImportsLogPage, ImportWorkflow } from "./shared_types.js";
19
16
  export type { ActivityActor, ActivityActorKind, ActivityChange, ActivityComment, ActivityEntry, ActivityValue, RecordActivityPage, } from "./shared_types.js";
20
17
  export { useAppContext, useViewer, useWorkspaceCurrency, useWorkspaceTimezone } from "./viewer.js";
21
18
  export type { AppAbsence } from "./shared_types.js";
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ import {
6
6
  aggregateRows,
7
7
  anyRecordingLive,
8
8
  applyPatch,
9
+ assertNever,
10
+ bandKey,
11
+ bandOf,
9
12
  decodeAll,
10
13
  encodePatch,
11
14
  getUrlParams,
@@ -20,12 +23,15 @@ import {
20
23
  rpc,
21
24
  rpcAgentRun,
22
25
  rpcAgentRunContinue,
26
+ sample,
23
27
  setUrlParams,
24
28
  subscribeQueriesChanged,
25
29
  subscribeRecordings,
26
30
  subscribeUrlParams,
31
+ sumDecimal,
32
+ tiersAscend,
27
33
  urlParam
28
- } from "./chunk-XL5LZFGB.js";
34
+ } from "./chunk-NZXJENJ2.js";
29
35
 
30
36
  // src/mount.tsx
31
37
  import { createRoot } from "react-dom/client";
@@ -1723,15 +1729,6 @@ var customRandom = (alphabet, defaultSize, getRandom) => {
1723
1729
  };
1724
1730
  var customAlphabet = (alphabet, size2 = 21) => customRandom(alphabet, size2 | 0, random);
1725
1731
 
1726
- // ../shared/src/utils.ts
1727
- function sample(arr) {
1728
- if (arr.length === 0) return void 0;
1729
- return arr[Math.floor(Math.random() * arr.length)];
1730
- }
1731
- function assertNever(value) {
1732
- throw new Error(`Unhandled union member: ${JSON.stringify(value)}`);
1733
- }
1734
-
1735
1732
  // ../shared/src/expression_array.ts
1736
1733
  var expression_array_exports = {};
1737
1734
  __export(expression_array_exports, {
@@ -1790,6 +1787,9 @@ function getField(obj, field) {
1790
1787
  }
1791
1788
  return value;
1792
1789
  }
1790
+ function addends(values2) {
1791
+ return values2.map((value) => typeof value === "number" ? value : 0);
1792
+ }
1793
1793
  function countInto(result, key) {
1794
1794
  defineOwn(result, key, (Object.hasOwn(result, key) ? result[key] : 0) + 1);
1795
1795
  }
@@ -1847,14 +1847,10 @@ function sumBy(arr, fieldOrCb) {
1847
1847
  if (typeof fieldOrCb !== "string") {
1848
1848
  throw new Error("sumBy: second arg must be a function or field path string");
1849
1849
  }
1850
- return arr.reduce((sum2, item) => {
1851
- const val = getField(item, fieldOrCb);
1852
- return sum2 + (typeof val === "number" ? val : 0);
1853
- }, 0);
1850
+ return sumDecimal(addends(arr.map((item) => getField(item, fieldOrCb))));
1854
1851
  }
1855
1852
  async function sumByCallback(arr, cb) {
1856
- const values2 = await callEach(arr, cb);
1857
- return values2.reduce((sum2, val) => sum2 + (typeof val === "number" ? val : 0), 0);
1853
+ return sumDecimal(addends(await callEach(arr, cb)));
1858
1854
  }
1859
1855
  function meanBy(arr, fieldOrCb) {
1860
1856
  if (!Array.isArray(arr)) {
@@ -1865,19 +1861,13 @@ function meanBy(arr, fieldOrCb) {
1865
1861
  }
1866
1862
  if (isCallback(fieldOrCb)) {
1867
1863
  assertCallbackInput("meanBy", arr);
1868
- return callEach(arr, fieldOrCb).then(
1869
- (vals) => vals.reduce((acc, v) => acc + (typeof v === "number" ? v : 0), 0) / arr.length
1870
- );
1864
+ return callEach(arr, fieldOrCb).then((values2) => sumDecimal(addends(values2), arr.length));
1871
1865
  }
1872
1866
  const field = fieldOrCb;
1873
1867
  if (typeof field !== "string") {
1874
1868
  throw new Error("meanBy: second arg must be a function or field path string");
1875
1869
  }
1876
- const sum2 = arr.reduce((acc, item) => {
1877
- const val = getField(item, field);
1878
- return acc + (typeof val === "number" ? val : 0);
1879
- }, 0);
1880
- return sum2 / arr.length;
1870
+ return sumDecimal(addends(arr.map((item) => getField(item, field))), arr.length);
1881
1871
  }
1882
1872
  function minBy(arr, fieldOrCb) {
1883
1873
  if (!Array.isArray(arr)) {
@@ -2842,9 +2832,7 @@ function mean(arr) {
2842
2832
  if (present.length === 0) {
2843
2833
  return null;
2844
2834
  }
2845
- const numArr = present;
2846
- const total = numArr.reduce((a, b) => a + b, 0);
2847
- const result = total / numArr.length;
2835
+ const result = present.every((item) => typeof item === "number") ? sumDecimal(present, present.length) : NaN;
2848
2836
  if (isNaN(result)) {
2849
2837
  throw new Error("mean: array contains non-numeric values");
2850
2838
  }
@@ -2923,7 +2911,11 @@ function sum(arr) {
2923
2911
  if (!Array.isArray(arr)) {
2924
2912
  throw new Error("sum: expected array, got " + typeof arr);
2925
2913
  }
2926
- return arr.reduce((a, b) => a + b, 0);
2914
+ const present = arr.filter((item) => item != null);
2915
+ if (!present.every((item) => typeof item === "number")) {
2916
+ throw new Error("sum: array contains non-numeric values");
2917
+ }
2918
+ return sumDecimal(present);
2927
2919
  }
2928
2920
  function abs(n) {
2929
2921
  if (n == null) return null;
@@ -5955,11 +5947,10 @@ function parseExpression(expression) {
5955
5947
  return parseWholeExpression(expression, astFactory);
5956
5948
  }
5957
5949
  function evaluateJsExpression(expression, data, additionalFunctions, defaultTimezone) {
5958
- const timezoneAwareFunctions = createTimezoneAwareFunctions(defaultTimezone);
5959
5950
  const expr2 = parseWholeExpression(expression, astFactory);
5960
5951
  const result = expr2?.evaluate({
5961
5952
  ...data,
5962
- ...timezoneAwareFunctions,
5953
+ ...timezoneAwareFunctions(defaultTimezone),
5963
5954
  ...additionalFunctions
5964
5955
  });
5965
5956
  return result;
@@ -6116,6 +6107,16 @@ function createTimezoneAwareFunctions(defaultTimezone) {
6116
6107
  ...expression_type_exports
6117
6108
  };
6118
6109
  }
6110
+ var MAX_HELD_TIMEZONES = 512;
6111
+ var functionsByTimezone = /* @__PURE__ */ new Map();
6112
+ function timezoneAwareFunctions(defaultTimezone) {
6113
+ const held2 = functionsByTimezone.get(defaultTimezone);
6114
+ if (held2 !== void 0) return held2;
6115
+ if (functionsByTimezone.size >= MAX_HELD_TIMEZONES) functionsByTimezone.clear();
6116
+ const built = Object.freeze(createTimezoneAwareFunctions(defaultTimezone));
6117
+ functionsByTimezone.set(defaultTimezone, built);
6118
+ return built;
6119
+ }
6119
6120
  var numericAlphabet = customAlphabet("0123456789");
6120
6121
  function assertRandomLength(helper, length2) {
6121
6122
  if (length2 > MAX_RANDOM_LENGTH) {
@@ -21239,19 +21240,17 @@ function aggregateNumberField(values2, operation) {
21239
21240
  }
21240
21241
  switch (operation) {
21241
21242
  case "sum": {
21242
- const sum2 = numericValues.reduce((acc, val) => acc + val, 0);
21243
- return { result: sum2 };
21243
+ return { result: sumDecimal(numericValues) };
21244
21244
  }
21245
21245
  case "avg": {
21246
- const sum2 = numericValues.reduce((acc, val) => acc + val, 0);
21247
- const average = sum2 / numericValues.length;
21246
+ const average = sumDecimal(numericValues, numericValues.length);
21248
21247
  return { result: average };
21249
21248
  }
21250
21249
  case "median": {
21251
21250
  const sorted = [...numericValues].sort((a, b) => a - b);
21252
21251
  const middle = Math.floor(sorted.length / 2);
21253
21252
  if (sorted.length % 2 === 0) {
21254
- const median = (sorted[middle - 1] + sorted[middle]) / 2;
21253
+ const median = sumDecimal([sorted[middle - 1], sorted[middle]], 2);
21255
21254
  return { result: median };
21256
21255
  } else {
21257
21256
  return { result: sorted[middle] };
@@ -21268,7 +21267,7 @@ function aggregateNumberField(values2, operation) {
21268
21267
  case "range": {
21269
21268
  const min3 = Math.min(...numericValues);
21270
21269
  const max3 = Math.max(...numericValues);
21271
- const range2 = max3 - min3;
21270
+ const range2 = sumDecimal([max3, -min3]);
21272
21271
  return { result: range2 };
21273
21272
  }
21274
21273
  default:
@@ -25244,9 +25243,6 @@ var workflowSchema = zod_default.object({
25244
25243
  metadata: workflowMetadataSchema.nullable().optional().describe("Metadata for the workflow"),
25245
25244
  current_version: zod_default.number().int().describe("Current version number"),
25246
25245
  enabled: zod_default.boolean().describe("Whether this workflow is active"),
25247
- imports_into: zod_default.string().nullable().optional().describe(
25248
- "The table this workflow brings data into: its runs are the imports log of a register over that table that keeps one (`register.imports`). Null, it imports into none."
25249
- ),
25250
25246
  workspace_id: zod_default.string().describe("ID of the workspace this workflow belongs to"),
25251
25247
  created_at: zod_default.string().describe("Timestamp when workflow was created"),
25252
25248
  updated_at: zod_default.string().describe("Timestamp of last update")
@@ -25986,7 +25982,7 @@ var queryFromTableNodeSchema = zod_default.object({
25986
25982
  table_id: zod_default.string().describe("Source table ID"),
25987
25983
  filter: tableRecordFiltersSchema.optional(),
25988
25984
  search: zod_default.string().optional().describe(
25989
- "Free-text search across the record's indexed text (the maintained `search_document`): diacritics- and case-insensitive, trigram-indexed. It matches ALL text-bearing fields of the row, not just the query's projected columns \u2014 bound search-bearing queries accordingly. AND-ed with `filter`. Templatable with `{{params.x}}`. Prefer this over an OR-group of per-field `contains` for a search box \u2014 it can't silently miss a field and matches accented text."
25985
+ "Free-text search across the record's text: every term must appear, as a substring, diacritics- and case-insensitive. It matches ALL text-bearing fields of the row, not just the query's projected columns \u2014 bound search-bearing queries accordingly. AND-ed with `filter`. Templatable with `{{params.x}}`. Prefer this over an OR-group of per-field `contains` for a search box \u2014 it can't silently miss a field and matches accented text."
25990
25986
  ),
25991
25987
  sort: tableRecordSortSchema.optional(),
25992
25988
  limit: zod_default.number().optional()
@@ -26021,12 +26017,20 @@ var queryGroupBucketKeySchema = zod_default.object({
26021
26017
  output: zod_default.string().describe("Output column name for the bucket.")
26022
26018
  })
26023
26019
  });
26024
- var queryGroupKeySchema = zod_default.union([zod_default.string(), queryGroupBucketKeySchema]);
26020
+ var bandTiersSchema = zod_default.array(zod_default.number()).min(1).refine(tiersAscend, "Breakpoints ascend, each above the one before it.");
26021
+ var queryGroupBandKeySchema = zod_default.object({
26022
+ band: zod_default.object({
26023
+ source: zod_default.string().describe("Input number column to band."),
26024
+ tiers: bandTiersSchema.describe("Ascending breakpoints. A value on one is in the band above it."),
26025
+ output: zod_default.string().describe("Output column name for the band's key.")
26026
+ })
26027
+ });
26028
+ var queryGroupKeySchema = zod_default.union([zod_default.string(), queryGroupBucketKeySchema, queryGroupBandKeySchema]);
26025
26029
  var queryGroupNodeSchema = zod_default.object({
26026
26030
  kind: zod_default.literal("group"),
26027
26031
  from: zod_default.lazy(() => queryNodeSchema),
26028
26032
  by: zod_default.array(queryGroupKeySchema).describe(
26029
- "Group keys: input column names, or `{ bucket: { source, granularity, output } }` date buckets. A bucket truncates a date/datetime column to the bucket START as a `date` column (wall-clock semantics; `week` starts on ISO Monday; null source \u2192 null bucket). Empty = single-row aggregate."
26033
+ "Group keys: input column names, `{ bucket: { source, granularity, output } }` date buckets, or `{ band: { source, tiers, output } }` bands of a number. A bucket truncates a date/datetime column to the bucket START as a `date` column (wall-clock semantics; `week` starts on ISO Monday; null source \u2192 null bucket). A band answers a text column holding `from..to`, the two breakpoints the value stands between (the lower held, the upper not; an open end empty: `..10`, `10..20`, `20..`); no number \u2192 null band. Empty = single-row aggregate."
26030
26034
  ),
26031
26035
  sets: zod_default.array(zod_default.array(zod_default.string())).min(1).optional().describe(
26032
26036
  "Several groupings answered by one read: each lists the `by` outputs it groups by (`[]` = one row over every row). A key outside a row's set is null there, and `__grouping` holds the set's bits: one per `by` key it leaves out, the last key the lowest. Absent: one grouping by every key."
@@ -28984,6 +28988,7 @@ function useAppContext() {
28984
28988
  memberId: data?.member_id ?? null,
28985
28989
  groupIds: data?.group_ids ?? null,
28986
28990
  absences: data?.absences ?? null,
28991
+ openableApps: data?.openable_apps ?? null,
28987
28992
  commentsEnabled: data?.comments_enabled ?? false,
28988
28993
  recordingEnabled: data?.recording_enabled === true,
28989
28994
  timezone: data?.timezone,
@@ -31345,6 +31350,13 @@ var HELPERS2 = new Set(Object.keys(createTimezoneAwareFunctions()));
31345
31350
  var BY_LOWERCASE = new Map([...HELPERS2].map((name) => [name.toLowerCase(), name]));
31346
31351
 
31347
31352
  // ../shared/src/formula.ts
31353
+ function parseOnce(evaluable) {
31354
+ try {
31355
+ return { kind: "parsed", expression: parseExpression(evaluable) };
31356
+ } catch (error51) {
31357
+ return { kind: "unparsable", error: error51 };
31358
+ }
31359
+ }
31348
31360
  function prepareFormulaContext(fields) {
31349
31361
  const formulaFields = fields.filter((field) => field.type === "formula");
31350
31362
  const { sortedFields, circularDependencies } = buildDependencyGraph(formulaFields, fields);
@@ -31380,8 +31392,9 @@ function prepareFormulaContext(fields) {
31380
31392
  function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecords = /* @__PURE__ */ new Map(), linkedTableFields = /* @__PURE__ */ new Map(), timezone, loadedLinkKeys, linkExpandedTableIds, fileFacts, memberNames) {
31381
31393
  const result = { ...row2 };
31382
31394
  const mayRecompute = (linkKey) => loadedLinkKeys === void 0 || loadedLinkKeys.has(linkKey) || isEmptyFieldValue(row2[linkKey]);
31395
+ const computes = (key) => context.computedAggregates === void 0 || context.computedAggregates.has(key);
31383
31396
  for (const { field, sourceField } of context.rollupFields) {
31384
- if (!mayRecompute(sourceField.key)) continue;
31397
+ if (!computes(field.key) || !mayRecompute(sourceField.key)) continue;
31385
31398
  if (linkedTableRecords.has(sourceField.table_id) && linkedTableFields.has(sourceField.table_id)) {
31386
31399
  const linkedRecords = linkedTableRecords.get(sourceField.table_id) ?? [];
31387
31400
  const linkedTableFieldsArray = linkedTableFields.get(sourceField.table_id) ?? [];
@@ -31431,7 +31444,7 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
31431
31444
  }
31432
31445
  }
31433
31446
  for (const { field, sourceField } of context.lookupFields) {
31434
- if (!mayRecompute(sourceField.key)) continue;
31447
+ if (!computes(field.key) || !mayRecompute(sourceField.key)) continue;
31435
31448
  if (linkedTableRecords.has(sourceField.table_id)) {
31436
31449
  const linkedRecords = linkedTableRecords.get(sourceField.table_id);
31437
31450
  const linkedFieldsArray = linkedTableFields.get(sourceField.table_id);
@@ -31479,7 +31492,7 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
31479
31492
  if (context.circularDependencies.has(formulaField.key)) {
31480
31493
  continue;
31481
31494
  }
31482
- if (prepared.refs.evaluable === null) {
31495
+ if (prepared.parsed === void 0) {
31483
31496
  const refs = prepared.refs.unresolved.map((ref) => `{${ref}}`).join(", ");
31484
31497
  result[formulaField.key] = `${FORMULA_ERROR_PREFIX}${refs} ${prepared.refs.unresolved.length === 1 ? "is not a field" : "are not fields"} on this table`;
31485
31498
  evaluationContext[formulaField.key] = null;
@@ -31497,7 +31510,7 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
31497
31510
  }
31498
31511
  const everyReferenceEmpty = referencedKeys.length > 0 && referencedKeys.every((fieldKey) => evaluationContext[fieldKey] === null);
31499
31512
  const evaluated2 = evaluateFormulaWithJS(
31500
- prepared.refs.evaluable,
31513
+ prepared.parsed,
31501
31514
  { data: evaluationContext },
31502
31515
  timezone,
31503
31516
  everyReferenceEmpty
@@ -31591,10 +31604,8 @@ function buildDependencyGraph(formulaFields, allFields) {
31591
31604
  for (const field of formulaFields) {
31592
31605
  graph.set(field.key, /* @__PURE__ */ new Set());
31593
31606
  inDegree.set(field.key, 0);
31594
- fieldMap.set(field.key, {
31595
- field,
31596
- refs: prepareFormulaExpression(field.formula.expression, allFields)
31597
- });
31607
+ const refs = prepareFormulaExpression(field.formula.expression, allFields);
31608
+ fieldMap.set(field.key, { field, refs, parsed: refs.evaluable === null ? void 0 : parseOnce(refs.evaluable) });
31598
31609
  }
31599
31610
  for (const field of formulaFields) {
31600
31611
  for (const referencedKey of fieldMap.get(field.key).refs.referencedKeys) {
@@ -31674,17 +31685,18 @@ function readsFieldsOnlyThroughEmptinessPredicates(node, taken) {
31674
31685
  function isFieldRead(node) {
31675
31686
  return node !== void 0 && node.type === "Index" && node.receiver.type === "ID" && node.receiver.value === "data";
31676
31687
  }
31677
- function evaluateFormulaWithJS(preparedExpression, data, timezone, everyReferenceEmpty) {
31688
+ function evaluateFormulaWithJS(parsed, data, timezone, everyReferenceEmpty) {
31678
31689
  try {
31690
+ if (parsed.kind === "unparsable") throw parsed.error;
31691
+ const scope = { ...timezoneAwareFunctions(timezone), data: data.data };
31679
31692
  if (everyReferenceEmpty) {
31680
- const helpers = createTimezoneAwareFunctions(timezone);
31681
31693
  const taken = (condition2) => {
31682
- const answer = condition2.evaluate({ ...data, ...helpers });
31694
+ const answer = condition2.evaluate(scope);
31683
31695
  return typeof answer === "boolean" ? answer : void 0;
31684
31696
  };
31685
- if (!readsFieldsOnlyThroughEmptinessPredicates(parseExpression(preparedExpression), taken)) return null;
31697
+ if (!readsFieldsOnlyThroughEmptinessPredicates(parsed.expression, taken)) return null;
31686
31698
  }
31687
- const result = evaluateJsExpression(preparedExpression, data, void 0, timezone);
31699
+ const result = parsed.expression?.evaluate(scope);
31688
31700
  if (result === null || result === void 0) {
31689
31701
  return null;
31690
31702
  }
@@ -31749,18 +31761,36 @@ function planFilterTraversal(filter2, aggregatedTableId, aggregatedFields, linke
31749
31761
  );
31750
31762
  if (ids.size === 0) return null;
31751
31763
  return {
31752
- records: rows.filter((r) => ids.has(r.id)),
31764
+ records: linkedRowsNamed(rows, [...ids]),
31753
31765
  fields: fieldMapOf(hop.table_id, hopFields)
31754
31766
  };
31755
31767
  }
31756
31768
  }
31757
31769
  };
31758
31770
  }
31771
+ var positionsOfFrozenRows = /* @__PURE__ */ new WeakMap();
31772
+ function linkedRowPositions(rows) {
31773
+ const frozen = Object.isFrozen(rows);
31774
+ const held2 = frozen ? positionsOfFrozenRows.get(rows) : void 0;
31775
+ if (held2 !== void 0) return held2;
31776
+ const positions = /* @__PURE__ */ new Map();
31777
+ for (let at2 = 0; at2 < rows.length; at2++) {
31778
+ const of = positions.get(rows[at2].id);
31779
+ if (of === void 0) positions.set(rows[at2].id, [at2]);
31780
+ else of.push(at2);
31781
+ }
31782
+ if (frozen) positionsOfFrozenRows.set(rows, positions);
31783
+ return positions;
31784
+ }
31785
+ function linkedRowsNamed(rows, ids) {
31786
+ const positions = linkedRowPositions(rows);
31787
+ const found = [...new Set(ids)].flatMap((id) => positions.get(id) ?? []);
31788
+ return found.sort((a, b) => a - b).map((at2) => rows[at2]);
31789
+ }
31759
31790
  function calculateRollupValue(value, fieldType, allLinkedTableRecords, aggregateOption, filter2, linkedTableFields, subject, traversalContext, fileFacts) {
31760
31791
  if (aggregateOption?.operation === "count") {
31761
31792
  if (!value) return 0;
31762
- const recordIds2 = extractRecordLinkIds(value);
31763
- let relevantRecords2 = allLinkedTableRecords.filter((r) => recordIds2.includes(r.id));
31793
+ let relevantRecords2 = linkedRowsNamed(allLinkedTableRecords, extractRecordLinkIds(value));
31764
31794
  if (filter2 && linkedTableFields) {
31765
31795
  relevantRecords2 = filterRecords(relevantRecords2, linkedTableFields, filter2, null, traversalContext, fileFacts);
31766
31796
  }
@@ -31769,8 +31799,7 @@ function calculateRollupValue(value, fieldType, allLinkedTableRecords, aggregate
31769
31799
  if (!value || !allLinkedTableRecords.length || !aggregateOption?.field_key) {
31770
31800
  return null;
31771
31801
  }
31772
- const recordIds = extractRecordLinkIds(value);
31773
- let relevantRecords = allLinkedTableRecords.filter((record2) => recordIds.includes(record2.id));
31802
+ let relevantRecords = linkedRowsNamed(allLinkedTableRecords, extractRecordLinkIds(value));
31774
31803
  if (relevantRecords.length === 0) {
31775
31804
  return null;
31776
31805
  }
@@ -31786,9 +31815,11 @@ function calculateLookupValue(value, linkedRecords, lookupFieldKey, linkedField,
31786
31815
  if (!linkedRecords.length || !lookupFieldKey) {
31787
31816
  return null;
31788
31817
  }
31789
- const recordIds = extractRecordLinkIds(value);
31790
- const recordMap = new Map(linkedRecords.map((record2) => [record2.id, record2]));
31791
- const relevantRecords = recordIds.map((id) => recordMap.get(id)).filter((record2) => record2 !== void 0);
31818
+ const positions = linkedRowPositions(linkedRecords);
31819
+ const relevantRecords = extractRecordLinkIds(value).flatMap((id) => {
31820
+ const at2 = positions.get(id)?.at(-1);
31821
+ return at2 === void 0 ? [] : [linkedRecords[at2]];
31822
+ });
31792
31823
  if (relevantRecords.length === 0) {
31793
31824
  return null;
31794
31825
  }
@@ -32099,7 +32130,15 @@ function groupsOf(totals, side) {
32099
32130
  if (!totals.by.every(({ field_key }) => side.cells.has(field_key) && !side.unknown.has(field_key))) return null;
32100
32131
  const [only] = totals.by;
32101
32132
  if (totals.each && only !== void 0) return Object.keys(countsOf([{ cell: side.cells.get(only.field_key) }], "cell"));
32102
- return [JSON.stringify(totals.by.map(({ field_key }) => storedCellOf(side.cells.get(field_key))))];
32133
+ return [
32134
+ JSON.stringify(
32135
+ totals.by.map(({ field_key, tiers }) => {
32136
+ const cell = side.cells.get(field_key);
32137
+ if (tiers === void 0) return storedCellOf(cell);
32138
+ return typeof cell === "number" ? bandKey(bandOf(tiers, cell)) : null;
32139
+ })
32140
+ )
32141
+ ];
32103
32142
  }
32104
32143
  function groupOfRow(totals, row2) {
32105
32144
  return totals.each ? String(row2[VALUE_COLUMN]) : JSON.stringify(totals.by.map(({ output }) => storedCellOf(row2[output])));
@@ -32153,12 +32192,12 @@ function overlayTotals(over, totals, admits, closed, rows) {
32153
32192
  function totalsAsked(shape, aggregate) {
32154
32193
  const table = shape.rows?.table_id;
32155
32194
  if (table === void 0 || aggregate.day !== void 0) return null;
32156
- const by = (aggregate.by ?? []).map((column) => ({ output: column, field_key: fieldKeyOf(shape, column, table) }));
32195
+ const by = (aggregate.by ?? []).map((column) => ({ output: column, field_key: fieldKeyOf(shape, column, table), tiers: aggregate.bands?.[column] }));
32157
32196
  const sum2 = aggregate.sum === void 0 ? void 0 : fieldKeyOf(shape, aggregate.sum, table);
32158
32197
  if (by.some((one) => one.field_key === void 0) || aggregate.sum !== void 0 && sum2 === void 0) return null;
32159
32198
  return {
32160
32199
  table_id: table,
32161
- by: by.flatMap(({ output, field_key }) => field_key === void 0 ? [] : [{ output, field_key }]),
32200
+ by: by.flatMap(({ output, field_key, tiers }) => field_key === void 0 ? [] : [{ output, field_key, ...tiers === void 0 ? {} : { tiers } }]),
32162
32201
  aggregates: [{ output: AGGREGATE_COUNT_COLUMN, operation: "count" }, ...sum2 === void 0 ? [] : [{ output: AGGREGATE_SUM_COLUMN, operation: "sum", field_key: sum2 }]],
32163
32202
  each: false
32164
32203
  };
@@ -32650,75 +32689,6 @@ function useRecordActivity(entity, record_id, options = {}) {
32650
32689
  };
32651
32690
  }
32652
32691
 
32653
- // src/imports.ts
32654
- import { useEffect as useEffect5 } from "react";
32655
- var NO_WORKFLOWS = [];
32656
- var NO_RUNS = [];
32657
- var NO_ROWS = [];
32658
- var IMPORTS_BURST_QUIET_MS = 2e3;
32659
- function useImportsLog(entity, options = {}) {
32660
- const { after } = options;
32661
- const { memberId, resolved } = useAppContext();
32662
- const member = memberId !== null;
32663
- const page = useKey(
32664
- member ? JSON.stringify(["imports", entity, after ?? null]) : null,
32665
- () => rpc("imports.list", after === void 0 ? {} : { after })
32666
- );
32667
- useAfterBurst(member ? entity : null, page.refetch);
32668
- const bridged = !refusedByHost(page.error, "imports.list");
32669
- const log = page.data?.kind === "log" ? page.data : void 0;
32670
- return {
32671
- workflows: log?.workflows ?? NO_WORKFLOWS,
32672
- runs: log?.runs ?? NO_RUNS,
32673
- next: log?.next,
32674
- // Waiting on the context counts as loading.
32675
- loading: member ? page.loading : !resolved,
32676
- error: bridged ? page.error?.message ?? null : null,
32677
- available: member && bridged && page.data?.kind !== "none",
32678
- refetch: page.refetch
32679
- };
32680
- }
32681
- function useImportRun(entity, run_id, options = {}) {
32682
- const { after } = options;
32683
- const { memberId, resolved } = useAppContext();
32684
- const member = memberId !== null;
32685
- const page = useKey(
32686
- member ? JSON.stringify(["import_run", entity, run_id, after ?? null]) : null,
32687
- () => rpc("imports.run", { run_id, ...after === void 0 ? {} : { after } })
32688
- );
32689
- const read2 = page.data?.kind === "run" ? page.data : void 0;
32690
- useAfterBurst(member && read2?.run.end === "running" ? entity : null, page.refetch);
32691
- const bridged = !refusedByHost(page.error, "imports.run");
32692
- return {
32693
- run: read2?.run,
32694
- rows: read2?.rows ?? NO_ROWS,
32695
- next: read2?.next,
32696
- loading: member ? page.loading : !resolved,
32697
- error: bridged ? page.error?.message ?? null : null,
32698
- available: member && bridged && page.data?.kind !== "none",
32699
- refetch: page.refetch
32700
- };
32701
- }
32702
- function useAfterBurst(entity, ask2) {
32703
- useEffect5(() => {
32704
- if (entity === null) return;
32705
- const alias = listQueryAlias(entity);
32706
- let quiet;
32707
- const unsubscribe = subscribeQueriesChanged((aliases) => {
32708
- if (!aliases.includes(alias)) return;
32709
- clearTimeout(quiet);
32710
- quiet = setTimeout(ask2, IMPORTS_BURST_QUIET_MS);
32711
- });
32712
- return () => {
32713
- clearTimeout(quiet);
32714
- unsubscribe();
32715
- };
32716
- }, [entity, ask2]);
32717
- }
32718
- function refusedByHost(error51, op) {
32719
- return error51?.message === `Unknown RPC op: ${op}`;
32720
- }
32721
-
32722
32692
  // src/recording.ts
32723
32693
  import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
32724
32694
  var NO_MOCKS = {};
@@ -32917,7 +32887,7 @@ function readSelect(value) {
32917
32887
  }
32918
32888
 
32919
32889
  // src/use_recents.ts
32920
- import { useCallback as useCallback8, useEffect as useEffect6, useRef as useRef4, useState as useState4 } from "react";
32890
+ import { useCallback as useCallback8, useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
32921
32891
  var PREFIX = "lotics.recents.";
32922
32892
  function read(storageKey) {
32923
32893
  try {
@@ -32942,7 +32912,7 @@ function useRecents(key, options = {}) {
32942
32912
  const maxRef = useRef4(options.max ?? 5);
32943
32913
  maxRef.current = options.max ?? 5;
32944
32914
  const [recents, setRecents] = useState4(() => read(storageKey).slice(0, maxRef.current));
32945
- useEffect6(() => {
32915
+ useEffect5(() => {
32946
32916
  setRecents(read(storageKey).slice(0, maxRef.current));
32947
32917
  }, [storageKey]);
32948
32918
  const remember = useCallback8(
@@ -32975,13 +32945,13 @@ function useRecents(key, options = {}) {
32975
32945
  }
32976
32946
 
32977
32947
  // src/use_url_state.ts
32978
- import { useCallback as useCallback9, useEffect as useEffect7, useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
32948
+ import { useCallback as useCallback9, useEffect as useEffect6, useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
32979
32949
  function useUrlState(defs) {
32980
32950
  const defsRef = useRef5(defs);
32981
32951
  defsRef.current = defs;
32982
32952
  const [params, setParams] = useState5(peekUrlParams);
32983
32953
  const editedRef = useRef5(false);
32984
- useEffect7(() => {
32954
+ useEffect6(() => {
32985
32955
  let active = true;
32986
32956
  const apply = (next) => {
32987
32957
  if (active) setParams((prev) => paramsEqual(prev, next) ? prev : next);
@@ -33073,8 +33043,6 @@ export {
33073
33043
  useFieldOptions,
33074
33044
  useFileUpload,
33075
33045
  useFolderPick,
33076
- useImportRun,
33077
- useImportsLog,
33078
33046
  useMembers,
33079
33047
  useNewRecord,
33080
33048
  useQueries,
package/dist/router.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  isEmbedded,
3
3
  postHostNotification,
4
4
  setUrlParams
5
- } from "./chunk-XL5LZFGB.js";
5
+ } from "./chunk-NZXJENJ2.js";
6
6
 
7
7
  // src/router.tsx
8
8
  import { useEffect } from "react";
package/dist/rpc.d.ts CHANGED
@@ -9,7 +9,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
9
9
  * app → host: { id, op, payload }
10
10
  * host → app: { id, type: "result", data } | { id, type: "error", message }
11
11
  */
12
- 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" | "activity.list" | "imports.list" | "imports.run" | "recording.start" | "recording.stop";
12
+ 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" | "activity.list" | "recording.start" | "recording.stop";
13
13
  export interface AgentRunPayload {
14
14
  alias: string;
15
15
  session_id: string;
@@ -77,6 +77,12 @@ export interface AppContext {
77
77
  /** Who in the organization is away, or will be, and who stands in — absent from an older host and for a visitor from
78
78
  * outside the organization. */
79
79
  absences?: AppAbsence[];
80
+ /** The apps of the workspace the viewer can open, each by its id and its name — absent from an older host,
81
+ * for a visitor and until the host has listed them. */
82
+ openable_apps?: {
83
+ id: string;
84
+ name: string;
85
+ }[];
80
86
  }
81
87
  /** Whether a Lotics host embeds the app, rather than it standing alone on its own origin. */
82
88
  export declare function isEmbedded(): boolean;
@@ -3,8 +3,8 @@
3
3
  * `scripts/build_sdk.mjs`, because that package is private and ships in no
4
4
  * tarball. Written at every build, never edited.
5
5
  */
6
- export type ActivityEntry = { changes: { field_id: string; label: string; before: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; after: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; removed?: true | undefined; }[]; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "change"; created?: true | undefined; } | { comment: { id: string; body: string; mentions: ImportWorkflow[]; files?: { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | undefined; }; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "comment"; };
7
- export type AppAbsence = { member: ImportWorkflow; stand_in: ImportWorkflow; starts_on: string; ends_on: string; group_ids: string[]; };
6
+ export type ActivityEntry = { changes: { field_id: string; label: string; before: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; after: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; removed?: true | undefined; }[]; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "change"; created?: true | undefined; } | { comment: { id: string; body: string; mentions: { id: string; name: string; }[]; files?: { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | undefined; }; id: string; actor: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; until: string; kind: "comment"; };
7
+ export type AppAbsence = { member: { id: string; name: string; }; stand_in: { id: string; name: string; }; starts_on: string; ends_on: string; group_ids: string[]; };
8
8
  /** The file an app export made, as a link to download it: no workspace file, so no id, and kept a day. */
9
9
  export type ExportedFile = {
10
10
  filename: string;
@@ -16,12 +16,14 @@ export type ExportedFile = {
16
16
  /** When the server made it, ISO 8601. */
17
17
  created_at: string;
18
18
  };
19
- export type ImportRow = { record_id: string; kind: "removed" | "added" | "changed"; at: string; changes: { field_id: string; label: string; before: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; after: string | number | boolean | { key: string; label: string; }[] | { id: string; display: string; }[] | { id: string; name: string | null; image?: string | undefined; }[] | { id: string; filename: string; mime_type: string; file_storage_key?: string | undefined; url?: string | undefined; thumbnail_url?: string | undefined; preview_url?: string | undefined; size?: number | null | undefined; created_at?: string | undefined; document_template_id?: string | undefined; }[] | null; removed?: true | undefined; }[]; };
20
- export type ImportRun = { id: string; workflow: ImportWorkflow; at: string; end: "running" | "done" | "failed" | "partial"; added: number; changed: number; removed: number; until?: string | undefined; stopped?: { step?: string | undefined; reason?: string | undefined; } | undefined; };
21
- export type ImportWorkflow = { id: string; name: string; };
22
19
  /** What an aggregate asks: output columns to group by, an output date grouped by its day, an output number summed. */
23
20
  export type QueryAggregate = {
24
21
  by?: readonly string[];
22
+ /**
23
+ * The `by` columns grouped by the band of their number in place of its value, each with its ascending
24
+ * breakpoints: the group answers the band's key (`bandKey`) under the column, null for the rows holding no number.
25
+ */
26
+ bands?: { readonly [x: string]: readonly number[]; };
25
27
  day?: string;
26
28
  sum?: string;
27
29
  };
package/dist/viewer.d.ts CHANGED
@@ -14,6 +14,11 @@ export declare function useAppContext(): {
14
14
  groupIds: readonly string[] | null;
15
15
  /** Who in the organization is away, or will be, and who stands in; `null` where the host did not say. */
16
16
  absences: readonly AppAbsence[] | null;
17
+ /** The apps the viewer can open, by id and name; `null` where the host did not say. */
18
+ openableApps: readonly {
19
+ id: string;
20
+ name: string;
21
+ }[] | null;
17
22
  commentsEnabled: boolean;
18
23
  recordingEnabled: boolean;
19
24
  timezone: string | undefined;
@@ -93,7 +93,7 @@ all its calls, and each call states its own `filter`, `sort`, `total` and `rows`
93
93
  |---|---|---|---|
94
94
  | `enabled` | `boolean` | `true` | `false` = no request is sent, `rows` is `[]`, `loading` is `false`. Flip to `true` to fetch. The gate for search-as-you-type and on-demand detail. Disabling also **hides** previously loaded rows (the cached answer survives; it re-renders instantly when re-enabled). |
95
95
  | `revalidateOnFocus` | `boolean` | `true` | `false` = no re-read on window focus / tab return / network reconnect (`refetch()` still works). Keep the default for dashboards; turn off for transient queries (a search bound to an ephemeral term) where a refocus re-run is wasted work and a visible reload. |
96
- | `revalidateOnChange` | `boolean` | `true` | `false` = no re-read on a realtime push or this app's own write over the query's tables: the rows stay as first read until `refetch()`, this app's own writes still drawn over them — a moment's rows (what an import touched, read beside a live register) that never join the render a push draws. A read of the same call left on the default is its own read, live. |
96
+ | `revalidateOnChange` | `boolean` | `true` | `false` = no re-read on a realtime push or this app's own write over the query's tables: the rows stay as first read until `refetch()`, this app's own writes still drawn over them — a moment's rows, read beside a live screen, that never join the render a push draws. A read of the same call left on the default is its own read, live. |
97
97
  | `sort` | `QuerySortKey[]` | — | Runtime sort, applied server-side **after** the named query, over its output columns, each key breaking the ties of the ones before: `{ field_key, order: "asc" \| "desc", blank_position? }` a column's order, or `{ first: [filter, …] }` the rows each filter keeps, in turn, before the rest ([queries](./queries.md)). An empty/omitted array leaves the query's own order intact. Part of the read's key — changing it re-queries. |
98
98
  | `filter` | `QueryFilter` | — | Runtime filter, applied server-side after the named query, over its output columns. A single condition or a recursive `{ node_type: "group", logic: "and" \| "or", children }`. Part of the read's key. |
99
99
  | `limit` | `number` | — | The first `limit` rows only — a **cap**, not pages; omit to read up to the server's row cap. |
@@ -261,6 +261,11 @@ host did not say (a visitor from outside the organization, an older host). Both
261
261
  context, read once as the app opens. Display only: the workflow body re-checks who may act
262
262
  (`current_member_stands_in_for(...)`).
263
263
 
264
+ `openableApps` lists the workspace's apps the viewer can open, each by its `id` and its `name` — what a
265
+ way through to a sibling app (`openApp`) is offered by. `null` where the host did not say (standalone, a
266
+ render, an older host, or a page that had not listed its apps when the app opened). Display only: the
267
+ app opened checks the viewer itself.
268
+
264
269
  ## The workspace's zone at the root
265
270
 
266
271
  An app's root draws nothing until `useAppContext()` has `settled` — where it failed, it draws
@@ -412,45 +417,6 @@ const older = useRecordActivity("job", recordId, { after: next });
412
417
  - **Freshness:** read again when a push or this app's own write names the entity's reads, when a
413
418
  comment is written or deleted through `useComments`, on focus, and on `refetch()`.
414
419
 
415
- ## A register's imports log: `useImportsLog`, `useImportRun`
416
-
417
- The workflows marked as importing into the register's table (an automation's `imports_into`) and
418
- their runs, each with how it ended and how many rows it added, changed and removed; and one run's
419
- rows, each with its fields before and after (`dist/imports.d.ts`). A JSON app whose model states
420
- `register.imports: true` keeps it. `entity` is the register's entity alias.
421
-
422
- ```tsx
423
- const { workflows, runs, next, loading, error, available, refetch } = useImportsLog("order");
424
- const { run, rows } = useImportRun("order", runs[0]?.id ?? "");
425
- ```
426
-
427
- - **Authority: the app's own reads**, as record activity's: only the fields the app reads appear, and
428
- a row counts — and is listed — only where the register's own read reaches it for the viewer; a
429
- removed row, where it reached it before it was removed. A row the viewer does not see is in no
430
- count and does not make a failed run `partial`.
431
- - **Members of the app's organization only.** Standalone, in a host that bridges no imports log, and
432
- where the register keeps none (the server answers `{ kind: "none" }`), `available` is `false`,
433
- `error` is `null` and nothing is shown. Any other refusal is an `error`.
434
- - **The workflows** (`ImportWorkflow`): `{ id, name }`, every one importing into the table whether
435
- or not it has run, by name.
436
- - **A run** (`ImportRun`): `{ id, workflow: { id, name }, at, until?, end, added, changed, removed,
437
- stopped? }`. `end` is `running`, `done`, `failed` (it wrote none of the rows the viewer sees) or
438
- `partial` (it failed after writing some). `until` is absent while it runs. A `failed` or `partial`
439
- run carries `stopped: { step?, reason? }` (`ImportRunStop`): the description of the step it stopped
440
- at, for every viewer; and the error it recorded there, only for a viewer who manages the automation
441
- (it can quote data from tables the viewer cannot open) — each absent where the run recorded none.
442
- - **A run's row** (`ImportRow`): `{ record_id, kind, at, changes }`, one per row however many times
443
- the run wrote it — `added`, `removed`, or `changed` (a field the viewer sees left other than the
444
- run found it). `changes` are record activity's (`ActivityChange`): an added row's fields from
445
- `null`, a removed row's to `null`. Rows come in the order the run first wrote them.
446
- - **Pages**: up to twenty runs — fewer where they wrote thousands of rows between them, always at
447
- least one — or fifty rows; `next` continues — `undefined` at the end. A page of rows may hold fewer
448
- and still say `next`, where the rows it looked through were ones the viewer does not see.
449
- - **Freshness:** the log is read again once pushes naming the entity's rows have stopped for two
450
- seconds — an import's writes landing — never with each push, so the register's own rows never wait
451
- on it; a run's rows only while it runs. Both are read again on focus and on `refetch()`. A run that
452
- wrote nothing pushes nothing.
453
-
454
420
  ## Where each hook works
455
421
 
456
422
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
@@ -463,4 +429,3 @@ const { run, rows } = useImportRun("order", runs[0]?.id ?? "");
463
429
  | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
464
430
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
465
431
  | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false` |
466
- | `useImportsLog` / `useImportRun` | ✓ the app's organization's members | ✗ `available: false` |
package/docs/mutations.md CHANGED
@@ -527,10 +527,10 @@ every read before the request answers:
527
527
  leaves it; a created row it admits joins it, standing by the read's order (the call's `sort`,
528
528
  then the query's own) where its sort cells are numbers or dates, else first. A row the read does
529
529
  not hold that a write may move into it waits for the server, and `pending` says so.
530
- - **Totals.** A `total`, a count per value (`total: { by }`) or per named filter (`total: { where }`), a group asked with `aggregate` or
531
- declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
530
+ - **Totals.** A `total`, a count per value (`total: { by }`) or per named filter (`total: { where }`), a group asked with `aggregate` (by a
531
+ number's `bands` too) or declared by the query, and a parent's count or sum rollup move by exactly what the written row adds
532
532
  or takes away; a group the last row leaves is gone, and a count per value gains the value a row
533
- first holds. A mean, an extreme, a distinct count, a group by day, an aggregate with its own
533
+ first holds. A mean, an extreme, a distinct count, a group by day or by a declared band key, an aggregate with its own
534
534
  `filter`, a group none of the read's rows counted in yet, a read whose rows a search or a cap
535
535
  decides, or a row the app never read is left
536
536
  as the server said it until a read asked after the write answers.
package/docs/queries.md CHANGED
@@ -775,6 +775,25 @@ Sort by the bucket column for a time series; filter it with date operators for a
775
775
  window. **Always bucket server-side** — shipping raw rows to bucket in JS burns the row cap and
776
776
  the timeout for nothing.
777
777
 
778
+ ### Band group keys
779
+
780
+ `group.by` also accepts a number read by the bands its breakpoints cut:
781
+
782
+ ```jsonc
783
+ { "band": { "source": "days_late", "tiers": [1, 31, 61], "output": "age" } }
784
+ ```
785
+
786
+ - `source` must be a `number` **column** of the input (checked at bind); `tiers` are its breakpoints,
787
+ ascending, at least one.
788
+ - The output is a new **`text`** column holding the band as `from..to` — the two breakpoints a row's
789
+ number stands between, the lower held and the upper not, an open end empty: `..1`, `1..31`,
790
+ `31..61`, `61..`. A number on a breakpoint is in the band above it. A row holding no number
791
+ yields a NULL band (its rows group together).
792
+ - A band no row falls in is no group: the answer holds only the bands with rows.
793
+
794
+ One group per band, however many distinct numbers the rows hold — grouping by the number itself
795
+ answers a group per value, which the row cap cuts.
796
+
778
797
  **Window `aggregates` — the OVER-legal aggregate subset.** Only operations that compile to a single
779
798
  legal SQL window call are accepted: **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
780
799
  `filled`, `checked`, `unchecked`.** The rest cannot take an OVER clause (`median` is an
@@ -870,10 +889,12 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
870
889
  at most 32. It is a
871
890
  **second full execution** of the template — sent only where a read asks for its `total`, and
872
891
  one count serves every read of the same set ([data_fetching](./data_fetching.md)).
873
- - **`aggregate: { by?, day?, sum? }`** — the groups of the *filtered* set in place of its rows: one
874
- row per value of up to two `by` output columns (a cell holding several values groups by all of
892
+ - **`aggregate: { by?, bands?, day?, sum? }`** — the groups of the *filtered* set in place of its rows: one
893
+ row per value of up to three `by` output columns (a cell holding several values groups by all of
875
894
  them) and per day of a `day` output date, each holding its `by` columns, `__day`, `__count`, and
876
- `__sum` of a `sum` output number (`null` where no row holds one). Every column it names is an
895
+ `__sum` of a `sum` output number (`null` where no row holds one). `bands: { <column>: [breakpoints] }`
896
+ groups a number column of `by` by its band in place of its value — the column then holds `from..to`
897
+ as a band group key does (above), `null` for the rows holding no number. Every column it names is an
877
898
  output column, like `filter`'s, so what leaves is counts and sums of what a page of the same
878
899
  query returns. Sent with `params` and `filter` alone; `truncated` past the row cap. One read
879
900
  over a list's rows serves the figures above it — its filter is the list's own.
package/docs/security.md CHANGED
@@ -11,7 +11,6 @@ Every data operation an app performs — queries, workflows, agent runs — exec
11
11
  | Agent runs (`useAgentRun`) | App owner | Yes — requires an authenticated member; runs are private to that member |
12
12
  | Comments (`useComments`) | App authority for **access**; the **author** is always the real member | Always — members-only, anonymous callers are rejected |
13
13
  | Record activity (`useRecordActivity`) | The app's own read of the record — the entity's read of one row, and only the fields it draws | Always — the app's organization's members only |
14
- | Imports log (`useImportsLog`, `useImportRun`) | The register's own read — only the rows it reaches for the viewer, and only the fields the app reads | Always — the app's organization's members only |
15
14
 
16
15
  Consequences of owner authority:
17
16
 
@@ -104,7 +103,6 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
104
103
  | Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
105
104
  | Comments (`useComments`) | **No** — members-only |
106
105
  | Record activity (`useRecordActivity`) | **No** — the app's organization's members only |
107
- | Imports log (`useImportsLog`, `useImportRun`) | **No** — the app's organization's members only |
108
106
  | Member roster (`useMembers`) | **No** — same-org members only (below) |
109
107
 
110
108
  **Warning — `is_current_member` on a public app resolves to the app owner.** An anonymous request has no viewer to bind, so both `is_current_member` and `current_member in_any_group` fall back to the owner's identity and the owner's groups. A "my items" query shows every anonymous visitor the *owner's* items, and a manager-only read template gated on a group the owner belongs to **passes for every anonymous visitor**. Never rely on viewer-bound predicates in a public app.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.118.1",
3
+ "version": "0.120.0",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through — typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {
package/dist/imports.d.ts DELETED
@@ -1,51 +0,0 @@
1
- import type { ImportRow, ImportRun, ImportWorkflow } from "./shared_types.js";
2
- export interface ImportsLogState {
3
- /** The workflows importing into the register's table, whether or not they have run. */
4
- workflows: ImportWorkflow[];
5
- /** Newest first. */
6
- runs: ImportRun[];
7
- /** Where the log goes on — pass it as `after` to read the page past this one; `undefined` at its end. */
8
- next: string | undefined;
9
- loading: boolean;
10
- error: string | null;
11
- /** A signed-in member, in a host that bridges the log, of a register keeping one; otherwise `runs` stays empty. */
12
- available: boolean;
13
- refetch: () => void;
14
- }
15
- export interface ImportRunState {
16
- /** The run, as the log lists it; `undefined` until read. */
17
- run: ImportRun | undefined;
18
- /** In the order the run first wrote them. */
19
- rows: ImportRow[];
20
- /** Where its rows go on — pass it as `after`; `undefined` at their end. */
21
- next: string | undefined;
22
- loading: boolean;
23
- error: string | null;
24
- available: boolean;
25
- refetch: () => void;
26
- }
27
- export interface ImportsPageOptions {
28
- /** A page's `next`: read the page past it. Absent, the first page. */
29
- after?: string;
30
- }
31
- /** No push over the register's rows for this long ends a burst: longer than the second the server waits at most
32
- * between pushes while a run writes. */
33
- export declare const IMPORTS_BURST_QUIET_MS = 2000;
34
- /**
35
- * One page of the register's imports log. `entity` is the alias of the register's entity: the page is asked again once
36
- * pushes naming its rows have gone quiet.
37
- *
38
- * ```tsx
39
- * const { runs, next } = useImportsLog("order");
40
- * ```
41
- */
42
- export declare function useImportsLog(entity: string, options?: ImportsPageOptions): ImportsLogState;
43
- /**
44
- * One page of one run's rows — `run_id` a run's `id` from `useImportsLog` — with the run beside them. Asked again after
45
- * a burst of pushes over the register's rows only while the run runs: an ended run's rows are what they are.
46
- *
47
- * ```tsx
48
- * const { run, rows, next } = useImportRun("order", runId);
49
- * ```
50
- */
51
- export declare function useImportRun(entity: string, run_id: string, options?: ImportsPageOptions): ImportRunState;