@lotics/app-sdk 0.119.0 → 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,17 @@ 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
+
177
188
  // ../shared/src/utils.ts
178
189
  function decimalPlaces(value) {
179
190
  if (Number.isInteger(value)) return 0;
@@ -213,12 +224,19 @@ function aggregateRows(rows, aggregate) {
213
224
  const by = aggregate.by ?? [];
214
225
  const day = aggregate.day;
215
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
+ };
216
233
  const keyed = /* @__PURE__ */ new Map();
217
234
  for (const row of rows) {
218
- const key = JSON.stringify([...by.map((column) => row[column] ?? null), ...day === void 0 ? [] : [dayOf(row[day])]]);
219
- 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] });
220
238
  }
221
- return [...keyed.values()].map((members) => {
239
+ return [...keyed.values()].map(({ keys, members }) => {
222
240
  const [first2] = members;
223
241
  const figures = members.flatMap((member) => {
224
242
  const value = sum === void 0 ? void 0 : member[sum];
@@ -226,7 +244,7 @@ function aggregateRows(rows, aggregate) {
226
244
  });
227
245
  return {
228
246
  [RECORD_ID_COLUMN]: null,
229
- ...Object.fromEntries(by.map((column) => [column, first2?.[column] ?? null])),
247
+ ...Object.fromEntries(by.map((column, at) => [column, keys[at] ?? null])),
230
248
  ...day === void 0 ? {} : { [AGGREGATE_DAY_COLUMN]: dayOf(first2?.[day]) },
231
249
  [AGGREGATE_COUNT_COLUMN]: members.length,
232
250
  ...sum === void 0 ? {} : { [AGGREGATE_SUM_COLUMN]: figures.length === 0 ? null : sumDecimal(figures) }
@@ -1016,9 +1034,6 @@ function rpcStandalone(op, payload) {
1016
1034
  return rejectCommentsStandalone();
1017
1035
  case "activity.list":
1018
1036
  return Promise.reject(new Error("A record's activity is available only in embedded apps \u2014 a signed-in member is required."));
1019
- case "imports.list":
1020
- case "imports.run":
1021
- return Promise.reject(new Error("A register's imports log is available only in embedded apps \u2014 a signed-in member is required."));
1022
1037
  case "recording.start":
1023
1038
  case "recording.stop":
1024
1039
  return Promise.reject(
@@ -1224,6 +1239,9 @@ async function standaloneUpload(file, fidelity, place) {
1224
1239
 
1225
1240
  export {
1226
1241
  __export,
1242
+ tiersAscend,
1243
+ bandKey,
1244
+ bandOf,
1227
1245
  sumDecimal,
1228
1246
  sample,
1229
1247
  assertNever,
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
@@ -7,6 +7,8 @@ import {
7
7
  anyRecordingLive,
8
8
  applyPatch,
9
9
  assertNever,
10
+ bandKey,
11
+ bandOf,
10
12
  decodeAll,
11
13
  encodePatch,
12
14
  getUrlParams,
@@ -27,8 +29,9 @@ import {
27
29
  subscribeRecordings,
28
30
  subscribeUrlParams,
29
31
  sumDecimal,
32
+ tiersAscend,
30
33
  urlParam
31
- } from "./chunk-UFWVZOJE.js";
34
+ } from "./chunk-NZXJENJ2.js";
32
35
 
33
36
  // src/mount.tsx
34
37
  import { createRoot } from "react-dom/client";
@@ -5944,11 +5947,10 @@ function parseExpression(expression) {
5944
5947
  return parseWholeExpression(expression, astFactory);
5945
5948
  }
5946
5949
  function evaluateJsExpression(expression, data, additionalFunctions, defaultTimezone) {
5947
- const timezoneAwareFunctions = createTimezoneAwareFunctions(defaultTimezone);
5948
5950
  const expr2 = parseWholeExpression(expression, astFactory);
5949
5951
  const result = expr2?.evaluate({
5950
5952
  ...data,
5951
- ...timezoneAwareFunctions,
5953
+ ...timezoneAwareFunctions(defaultTimezone),
5952
5954
  ...additionalFunctions
5953
5955
  });
5954
5956
  return result;
@@ -6105,6 +6107,16 @@ function createTimezoneAwareFunctions(defaultTimezone) {
6105
6107
  ...expression_type_exports
6106
6108
  };
6107
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
+ }
6108
6120
  var numericAlphabet = customAlphabet("0123456789");
6109
6121
  function assertRandomLength(helper, length2) {
6110
6122
  if (length2 > MAX_RANDOM_LENGTH) {
@@ -25231,9 +25243,6 @@ var workflowSchema = zod_default.object({
25231
25243
  metadata: workflowMetadataSchema.nullable().optional().describe("Metadata for the workflow"),
25232
25244
  current_version: zod_default.number().int().describe("Current version number"),
25233
25245
  enabled: zod_default.boolean().describe("Whether this workflow is active"),
25234
- imports_into: zod_default.string().nullable().optional().describe(
25235
- "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."
25236
- ),
25237
25246
  workspace_id: zod_default.string().describe("ID of the workspace this workflow belongs to"),
25238
25247
  created_at: zod_default.string().describe("Timestamp when workflow was created"),
25239
25248
  updated_at: zod_default.string().describe("Timestamp of last update")
@@ -25973,7 +25982,7 @@ var queryFromTableNodeSchema = zod_default.object({
25973
25982
  table_id: zod_default.string().describe("Source table ID"),
25974
25983
  filter: tableRecordFiltersSchema.optional(),
25975
25984
  search: zod_default.string().optional().describe(
25976
- "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."
25977
25986
  ),
25978
25987
  sort: tableRecordSortSchema.optional(),
25979
25988
  limit: zod_default.number().optional()
@@ -26008,12 +26017,20 @@ var queryGroupBucketKeySchema = zod_default.object({
26008
26017
  output: zod_default.string().describe("Output column name for the bucket.")
26009
26018
  })
26010
26019
  });
26011
- 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]);
26012
26029
  var queryGroupNodeSchema = zod_default.object({
26013
26030
  kind: zod_default.literal("group"),
26014
26031
  from: zod_default.lazy(() => queryNodeSchema),
26015
26032
  by: zod_default.array(queryGroupKeySchema).describe(
26016
- "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."
26017
26034
  ),
26018
26035
  sets: zod_default.array(zod_default.array(zod_default.string())).min(1).optional().describe(
26019
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."
@@ -31333,6 +31350,13 @@ var HELPERS2 = new Set(Object.keys(createTimezoneAwareFunctions()));
31333
31350
  var BY_LOWERCASE = new Map([...HELPERS2].map((name) => [name.toLowerCase(), name]));
31334
31351
 
31335
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
+ }
31336
31360
  function prepareFormulaContext(fields) {
31337
31361
  const formulaFields = fields.filter((field) => field.type === "formula");
31338
31362
  const { sortedFields, circularDependencies } = buildDependencyGraph(formulaFields, fields);
@@ -31368,8 +31392,9 @@ function prepareFormulaContext(fields) {
31368
31392
  function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecords = /* @__PURE__ */ new Map(), linkedTableFields = /* @__PURE__ */ new Map(), timezone, loadedLinkKeys, linkExpandedTableIds, fileFacts, memberNames) {
31369
31393
  const result = { ...row2 };
31370
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);
31371
31396
  for (const { field, sourceField } of context.rollupFields) {
31372
- if (!mayRecompute(sourceField.key)) continue;
31397
+ if (!computes(field.key) || !mayRecompute(sourceField.key)) continue;
31373
31398
  if (linkedTableRecords.has(sourceField.table_id) && linkedTableFields.has(sourceField.table_id)) {
31374
31399
  const linkedRecords = linkedTableRecords.get(sourceField.table_id) ?? [];
31375
31400
  const linkedTableFieldsArray = linkedTableFields.get(sourceField.table_id) ?? [];
@@ -31419,7 +31444,7 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
31419
31444
  }
31420
31445
  }
31421
31446
  for (const { field, sourceField } of context.lookupFields) {
31422
- if (!mayRecompute(sourceField.key)) continue;
31447
+ if (!computes(field.key) || !mayRecompute(sourceField.key)) continue;
31423
31448
  if (linkedTableRecords.has(sourceField.table_id)) {
31424
31449
  const linkedRecords = linkedTableRecords.get(sourceField.table_id);
31425
31450
  const linkedFieldsArray = linkedTableFields.get(sourceField.table_id);
@@ -31467,7 +31492,7 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
31467
31492
  if (context.circularDependencies.has(formulaField.key)) {
31468
31493
  continue;
31469
31494
  }
31470
- if (prepared.refs.evaluable === null) {
31495
+ if (prepared.parsed === void 0) {
31471
31496
  const refs = prepared.refs.unresolved.map((ref) => `{${ref}}`).join(", ");
31472
31497
  result[formulaField.key] = `${FORMULA_ERROR_PREFIX}${refs} ${prepared.refs.unresolved.length === 1 ? "is not a field" : "are not fields"} on this table`;
31473
31498
  evaluationContext[formulaField.key] = null;
@@ -31485,7 +31510,7 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
31485
31510
  }
31486
31511
  const everyReferenceEmpty = referencedKeys.length > 0 && referencedKeys.every((fieldKey) => evaluationContext[fieldKey] === null);
31487
31512
  const evaluated2 = evaluateFormulaWithJS(
31488
- prepared.refs.evaluable,
31513
+ prepared.parsed,
31489
31514
  { data: evaluationContext },
31490
31515
  timezone,
31491
31516
  everyReferenceEmpty
@@ -31579,10 +31604,8 @@ function buildDependencyGraph(formulaFields, allFields) {
31579
31604
  for (const field of formulaFields) {
31580
31605
  graph.set(field.key, /* @__PURE__ */ new Set());
31581
31606
  inDegree.set(field.key, 0);
31582
- fieldMap.set(field.key, {
31583
- field,
31584
- refs: prepareFormulaExpression(field.formula.expression, allFields)
31585
- });
31607
+ const refs = prepareFormulaExpression(field.formula.expression, allFields);
31608
+ fieldMap.set(field.key, { field, refs, parsed: refs.evaluable === null ? void 0 : parseOnce(refs.evaluable) });
31586
31609
  }
31587
31610
  for (const field of formulaFields) {
31588
31611
  for (const referencedKey of fieldMap.get(field.key).refs.referencedKeys) {
@@ -31662,17 +31685,18 @@ function readsFieldsOnlyThroughEmptinessPredicates(node, taken) {
31662
31685
  function isFieldRead(node) {
31663
31686
  return node !== void 0 && node.type === "Index" && node.receiver.type === "ID" && node.receiver.value === "data";
31664
31687
  }
31665
- function evaluateFormulaWithJS(preparedExpression, data, timezone, everyReferenceEmpty) {
31688
+ function evaluateFormulaWithJS(parsed, data, timezone, everyReferenceEmpty) {
31666
31689
  try {
31690
+ if (parsed.kind === "unparsable") throw parsed.error;
31691
+ const scope = { ...timezoneAwareFunctions(timezone), data: data.data };
31667
31692
  if (everyReferenceEmpty) {
31668
- const helpers = createTimezoneAwareFunctions(timezone);
31669
31693
  const taken = (condition2) => {
31670
- const answer = condition2.evaluate({ ...data, ...helpers });
31694
+ const answer = condition2.evaluate(scope);
31671
31695
  return typeof answer === "boolean" ? answer : void 0;
31672
31696
  };
31673
- if (!readsFieldsOnlyThroughEmptinessPredicates(parseExpression(preparedExpression), taken)) return null;
31697
+ if (!readsFieldsOnlyThroughEmptinessPredicates(parsed.expression, taken)) return null;
31674
31698
  }
31675
- const result = evaluateJsExpression(preparedExpression, data, void 0, timezone);
31699
+ const result = parsed.expression?.evaluate(scope);
31676
31700
  if (result === null || result === void 0) {
31677
31701
  return null;
31678
31702
  }
@@ -31737,18 +31761,36 @@ function planFilterTraversal(filter2, aggregatedTableId, aggregatedFields, linke
31737
31761
  );
31738
31762
  if (ids.size === 0) return null;
31739
31763
  return {
31740
- records: rows.filter((r) => ids.has(r.id)),
31764
+ records: linkedRowsNamed(rows, [...ids]),
31741
31765
  fields: fieldMapOf(hop.table_id, hopFields)
31742
31766
  };
31743
31767
  }
31744
31768
  }
31745
31769
  };
31746
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
+ }
31747
31790
  function calculateRollupValue(value, fieldType, allLinkedTableRecords, aggregateOption, filter2, linkedTableFields, subject, traversalContext, fileFacts) {
31748
31791
  if (aggregateOption?.operation === "count") {
31749
31792
  if (!value) return 0;
31750
- const recordIds2 = extractRecordLinkIds(value);
31751
- let relevantRecords2 = allLinkedTableRecords.filter((r) => recordIds2.includes(r.id));
31793
+ let relevantRecords2 = linkedRowsNamed(allLinkedTableRecords, extractRecordLinkIds(value));
31752
31794
  if (filter2 && linkedTableFields) {
31753
31795
  relevantRecords2 = filterRecords(relevantRecords2, linkedTableFields, filter2, null, traversalContext, fileFacts);
31754
31796
  }
@@ -31757,8 +31799,7 @@ function calculateRollupValue(value, fieldType, allLinkedTableRecords, aggregate
31757
31799
  if (!value || !allLinkedTableRecords.length || !aggregateOption?.field_key) {
31758
31800
  return null;
31759
31801
  }
31760
- const recordIds = extractRecordLinkIds(value);
31761
- let relevantRecords = allLinkedTableRecords.filter((record2) => recordIds.includes(record2.id));
31802
+ let relevantRecords = linkedRowsNamed(allLinkedTableRecords, extractRecordLinkIds(value));
31762
31803
  if (relevantRecords.length === 0) {
31763
31804
  return null;
31764
31805
  }
@@ -31774,9 +31815,11 @@ function calculateLookupValue(value, linkedRecords, lookupFieldKey, linkedField,
31774
31815
  if (!linkedRecords.length || !lookupFieldKey) {
31775
31816
  return null;
31776
31817
  }
31777
- const recordIds = extractRecordLinkIds(value);
31778
- const recordMap = new Map(linkedRecords.map((record2) => [record2.id, record2]));
31779
- 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
+ });
31780
31823
  if (relevantRecords.length === 0) {
31781
31824
  return null;
31782
31825
  }
@@ -32087,7 +32130,15 @@ function groupsOf(totals, side) {
32087
32130
  if (!totals.by.every(({ field_key }) => side.cells.has(field_key) && !side.unknown.has(field_key))) return null;
32088
32131
  const [only] = totals.by;
32089
32132
  if (totals.each && only !== void 0) return Object.keys(countsOf([{ cell: side.cells.get(only.field_key) }], "cell"));
32090
- 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
+ ];
32091
32142
  }
32092
32143
  function groupOfRow(totals, row2) {
32093
32144
  return totals.each ? String(row2[VALUE_COLUMN]) : JSON.stringify(totals.by.map(({ output }) => storedCellOf(row2[output])));
@@ -32141,12 +32192,12 @@ function overlayTotals(over, totals, admits, closed, rows) {
32141
32192
  function totalsAsked(shape, aggregate) {
32142
32193
  const table = shape.rows?.table_id;
32143
32194
  if (table === void 0 || aggregate.day !== void 0) return null;
32144
- 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] }));
32145
32196
  const sum2 = aggregate.sum === void 0 ? void 0 : fieldKeyOf(shape, aggregate.sum, table);
32146
32197
  if (by.some((one) => one.field_key === void 0) || aggregate.sum !== void 0 && sum2 === void 0) return null;
32147
32198
  return {
32148
32199
  table_id: table,
32149
- 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 } }]),
32150
32201
  aggregates: [{ output: AGGREGATE_COUNT_COLUMN, operation: "count" }, ...sum2 === void 0 ? [] : [{ output: AGGREGATE_SUM_COLUMN, operation: "sum", field_key: sum2 }]],
32151
32202
  each: false
32152
32203
  };
@@ -32638,75 +32689,6 @@ function useRecordActivity(entity, record_id, options = {}) {
32638
32689
  };
32639
32690
  }
32640
32691
 
32641
- // src/imports.ts
32642
- import { useEffect as useEffect5 } from "react";
32643
- var NO_WORKFLOWS = [];
32644
- var NO_RUNS = [];
32645
- var NO_ROWS = [];
32646
- var IMPORTS_BURST_QUIET_MS = 2e3;
32647
- function useImportsLog(entity, options = {}) {
32648
- const { after } = options;
32649
- const { memberId, resolved } = useAppContext();
32650
- const member = memberId !== null;
32651
- const page = useKey(
32652
- member ? JSON.stringify(["imports", entity, after ?? null]) : null,
32653
- () => rpc("imports.list", after === void 0 ? {} : { after })
32654
- );
32655
- useAfterBurst(member ? entity : null, page.refetch);
32656
- const bridged = !refusedByHost(page.error, "imports.list");
32657
- const log = page.data?.kind === "log" ? page.data : void 0;
32658
- return {
32659
- workflows: log?.workflows ?? NO_WORKFLOWS,
32660
- runs: log?.runs ?? NO_RUNS,
32661
- next: log?.next,
32662
- // Waiting on the context counts as loading.
32663
- loading: member ? page.loading : !resolved,
32664
- error: bridged ? page.error?.message ?? null : null,
32665
- available: member && bridged && page.data?.kind !== "none",
32666
- refetch: page.refetch
32667
- };
32668
- }
32669
- function useImportRun(entity, run_id, options = {}) {
32670
- const { after } = options;
32671
- const { memberId, resolved } = useAppContext();
32672
- const member = memberId !== null;
32673
- const page = useKey(
32674
- member ? JSON.stringify(["import_run", entity, run_id, after ?? null]) : null,
32675
- () => rpc("imports.run", { run_id, ...after === void 0 ? {} : { after } })
32676
- );
32677
- const read2 = page.data?.kind === "run" ? page.data : void 0;
32678
- useAfterBurst(member && read2?.run.end === "running" ? entity : null, page.refetch);
32679
- const bridged = !refusedByHost(page.error, "imports.run");
32680
- return {
32681
- run: read2?.run,
32682
- rows: read2?.rows ?? NO_ROWS,
32683
- next: read2?.next,
32684
- loading: member ? page.loading : !resolved,
32685
- error: bridged ? page.error?.message ?? null : null,
32686
- available: member && bridged && page.data?.kind !== "none",
32687
- refetch: page.refetch
32688
- };
32689
- }
32690
- function useAfterBurst(entity, ask2) {
32691
- useEffect5(() => {
32692
- if (entity === null) return;
32693
- const alias = listQueryAlias(entity);
32694
- let quiet;
32695
- const unsubscribe = subscribeQueriesChanged((aliases) => {
32696
- if (!aliases.includes(alias)) return;
32697
- clearTimeout(quiet);
32698
- quiet = setTimeout(ask2, IMPORTS_BURST_QUIET_MS);
32699
- });
32700
- return () => {
32701
- clearTimeout(quiet);
32702
- unsubscribe();
32703
- };
32704
- }, [entity, ask2]);
32705
- }
32706
- function refusedByHost(error51, op) {
32707
- return error51?.message === `Unknown RPC op: ${op}`;
32708
- }
32709
-
32710
32692
  // src/recording.ts
32711
32693
  import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
32712
32694
  var NO_MOCKS = {};
@@ -32905,7 +32887,7 @@ function readSelect(value) {
32905
32887
  }
32906
32888
 
32907
32889
  // src/use_recents.ts
32908
- 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";
32909
32891
  var PREFIX = "lotics.recents.";
32910
32892
  function read(storageKey) {
32911
32893
  try {
@@ -32930,7 +32912,7 @@ function useRecents(key, options = {}) {
32930
32912
  const maxRef = useRef4(options.max ?? 5);
32931
32913
  maxRef.current = options.max ?? 5;
32932
32914
  const [recents, setRecents] = useState4(() => read(storageKey).slice(0, maxRef.current));
32933
- useEffect6(() => {
32915
+ useEffect5(() => {
32934
32916
  setRecents(read(storageKey).slice(0, maxRef.current));
32935
32917
  }, [storageKey]);
32936
32918
  const remember = useCallback8(
@@ -32963,13 +32945,13 @@ function useRecents(key, options = {}) {
32963
32945
  }
32964
32946
 
32965
32947
  // src/use_url_state.ts
32966
- 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";
32967
32949
  function useUrlState(defs) {
32968
32950
  const defsRef = useRef5(defs);
32969
32951
  defsRef.current = defs;
32970
32952
  const [params, setParams] = useState5(peekUrlParams);
32971
32953
  const editedRef = useRef5(false);
32972
- useEffect7(() => {
32954
+ useEffect6(() => {
32973
32955
  let active = true;
32974
32956
  const apply = (next) => {
32975
32957
  if (active) setParams((prev) => paramsEqual(prev, next) ? prev : next);
@@ -33061,8 +33043,6 @@ export {
33061
33043
  useFieldOptions,
33062
33044
  useFileUpload,
33063
33045
  useFolderPick,
33064
- useImportRun,
33065
- useImportsLog,
33066
33046
  useMembers,
33067
33047
  useNewRecord,
33068
33048
  useQueries,
package/dist/router.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  isEmbedded,
3
3
  postHostNotification,
4
4
  setUrlParams
5
- } from "./chunk-UFWVZOJE.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;
@@ -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
  };
@@ -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. |
@@ -417,45 +417,6 @@ const older = useRecordActivity("job", recordId, { after: next });
417
417
  - **Freshness:** read again when a push or this app's own write names the entity's reads, when a
418
418
  comment is written or deleted through `useComments`, on focus, and on `refetch()`.
419
419
 
420
- ## A register's imports log: `useImportsLog`, `useImportRun`
421
-
422
- The workflows marked as importing into the register's table (an automation's `imports_into`) and
423
- their runs, each with how it ended and how many rows it added, changed and removed; and one run's
424
- rows, each with its fields before and after (`dist/imports.d.ts`). A JSON app whose model states
425
- `register.imports: true` keeps it. `entity` is the register's entity alias.
426
-
427
- ```tsx
428
- const { workflows, runs, next, loading, error, available, refetch } = useImportsLog("order");
429
- const { run, rows } = useImportRun("order", runs[0]?.id ?? "");
430
- ```
431
-
432
- - **Authority: the app's own reads**, as record activity's: only the fields the app reads appear, and
433
- a row counts — and is listed — only where the register's own read reaches it for the viewer; a
434
- removed row, where it reached it before it was removed. A row the viewer does not see is in no
435
- count and does not make a failed run `partial`.
436
- - **Members of the app's organization only.** Standalone, in a host that bridges no imports log, and
437
- where the register keeps none (the server answers `{ kind: "none" }`), `available` is `false`,
438
- `error` is `null` and nothing is shown. Any other refusal is an `error`.
439
- - **The workflows** (`ImportWorkflow`): `{ id, name }`, every one importing into the table whether
440
- or not it has run, by name.
441
- - **A run** (`ImportRun`): `{ id, workflow: { id, name }, at, until?, end, added, changed, removed,
442
- stopped? }`. `end` is `running`, `done`, `failed` (it wrote none of the rows the viewer sees) or
443
- `partial` (it failed after writing some). `until` is absent while it runs. A `failed` or `partial`
444
- run carries `stopped: { step?, reason? }` (`ImportRunStop`): the description of the step it stopped
445
- at, for every viewer; and the error it recorded there, only for a viewer who manages the automation
446
- (it can quote data from tables the viewer cannot open) — each absent where the run recorded none.
447
- - **A run's row** (`ImportRow`): `{ record_id, kind, at, changes }`, one per row however many times
448
- the run wrote it — `added`, `removed`, or `changed` (a field the viewer sees left other than the
449
- run found it). `changes` are record activity's (`ActivityChange`): an added row's fields from
450
- `null`, a removed row's to `null`. Rows come in the order the run first wrote them.
451
- - **Pages**: up to twenty runs — fewer where they wrote thousands of rows between them, always at
452
- least one — or fifty rows; `next` continues — `undefined` at the end. A page of rows may hold fewer
453
- and still say `next`, where the rows it looked through were ones the viewer does not see.
454
- - **Freshness:** the log is read again once pushes naming the entity's rows have stopped for two
455
- seconds — an import's writes landing — never with each push, so the register's own rows never wait
456
- on it; a run's rows only while it runs. Both are read again on focus and on `refetch()`. A run that
457
- wrote nothing pushes nothing.
458
-
459
420
  ## Where each hook works
460
421
 
461
422
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
@@ -468,4 +429,3 @@ const { run, rows } = useImportRun("order", runs[0]?.id ?? "");
468
429
  | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
469
430
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
470
431
  | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false` |
471
- | `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.119.0",
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;