@lotics/app-sdk 0.120.2 → 0.121.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/dist/index.js CHANGED
@@ -3,35 +3,44 @@ import {
3
3
  AGGREGATE_SUM_COLUMN,
4
4
  DEFAULT_IMAGE_FIDELITY,
5
5
  __export,
6
+ addDecimal,
6
7
  aggregateRows,
7
8
  anyRecordingLive,
8
9
  applyPatch,
9
- assertNever,
10
10
  bandKey,
11
11
  bandOf,
12
+ compareDecimal,
12
13
  decodeAll,
14
+ divideDecimal,
13
15
  encodePatch,
16
+ fixedDecimal,
14
17
  getUrlParams,
15
18
  isEmbedded,
19
+ moduloDecimal,
20
+ multiplyDecimal,
21
+ multiplyExactly,
16
22
  paramsEqual,
17
23
  peekUrlParams,
24
+ percentOf,
18
25
  postHostNotification,
26
+ powerDecimal,
19
27
  readRecordingSnapshot,
20
28
  recordingStateOf,
21
29
  recordingsVersion,
22
30
  replaceRecordings,
31
+ roundDecimal,
23
32
  rpc,
24
33
  rpcAgentRun,
25
34
  rpcAgentRunContinue,
26
- sample,
27
35
  setUrlParams,
28
36
  subscribeQueriesChanged,
29
37
  subscribeRecordings,
30
38
  subscribeUrlParams,
39
+ subtractDecimal,
31
40
  sumDecimal,
32
41
  tiersAscend,
33
42
  urlParam
34
- } from "./chunk-NZXJENJ2.js";
43
+ } from "./chunk-MK4DBY6R.js";
35
44
 
36
45
  // src/mount.tsx
37
46
  import { createRoot } from "react-dom/client";
@@ -602,6 +611,14 @@ function readNow(aliases) {
602
611
  function refetchReads(aliases) {
603
612
  for (const slot of readNow(aliases)) void ask(slot, true);
604
613
  }
614
+ async function readsAnswered(aliases) {
615
+ await Promise.all(
616
+ readNow(aliases).map(async (slot) => {
617
+ await ask(slot, true);
618
+ while (slot.inflight !== null) await slot.inflight;
619
+ })
620
+ );
621
+ }
605
622
  function askInOnePaint(read2) {
606
623
  if (read2.length === 0) return;
607
624
  const paint = { waiting: new Set(read2), held: [], drawn: false, cap: setTimeout(() => draw(paint), PAINT_CAP_MS) };
@@ -1729,6 +1746,15 @@ var customRandom = (alphabet, defaultSize, getRandom) => {
1729
1746
  };
1730
1747
  var customAlphabet = (alphabet, size2 = 21) => customRandom(alphabet, size2 | 0, random);
1731
1748
 
1749
+ // ../shared/src/utils.ts
1750
+ function sample(arr) {
1751
+ if (arr.length === 0) return void 0;
1752
+ return arr[Math.floor(Math.random() * arr.length)];
1753
+ }
1754
+ function assertNever(value) {
1755
+ throw new Error(`Unhandled union member: ${JSON.stringify(value)}`);
1756
+ }
1757
+
1732
1758
  // ../shared/src/expression_array.ts
1733
1759
  var expression_array_exports = {};
1734
1760
  __export(expression_array_exports, {
@@ -2782,7 +2808,7 @@ function formatNumber(value, decimals) {
2782
2808
  throw new Error("formatNumber: expected number, got " + typeof value);
2783
2809
  }
2784
2810
  assertDecimals(decimals, "formatNumber");
2785
- return value.toFixed(decimals);
2811
+ return fixedDecimal(value, decimals);
2786
2812
  }
2787
2813
  function formatDecimal(value, decimals, locale) {
2788
2814
  if (value == null) return "";
@@ -2902,9 +2928,7 @@ function round(value, precision) {
2902
2928
  if (precision != null && typeof precision !== "number") {
2903
2929
  throw new Error("round: precision must be a number");
2904
2930
  }
2905
- const prec = precision != null ? precision : 0;
2906
- const multiplier = Math.pow(10, prec);
2907
- return Math.round(value * multiplier) / multiplier;
2931
+ return roundDecimal(value, precision ?? 0);
2908
2932
  }
2909
2933
  function sum(arr) {
2910
2934
  if (arr == null) return 0;
@@ -2970,12 +2994,8 @@ function percentage(part, total, decimals) {
2970
2994
  if (decimals != null && typeof decimals !== "number") {
2971
2995
  throw new Error("percentage: decimals must be a number");
2972
2996
  }
2973
- const percent = part / total * 100;
2974
- if (decimals != null) {
2975
- const multiplier = Math.pow(10, decimals);
2976
- return Math.round(percent * multiplier) / multiplier;
2977
- }
2978
- return percent;
2997
+ const percent = divideDecimal(multiplyDecimal(part, 100), total);
2998
+ return decimals != null ? roundDecimal(percent, decimals) : percent;
2979
2999
  }
2980
3000
  function pow(base, exponent) {
2981
3001
  if (base == null || exponent == null) return null;
@@ -2985,7 +3005,7 @@ function pow(base, exponent) {
2985
3005
  if (typeof exponent !== "number") {
2986
3006
  throw new Error("pow: exponent must be a number");
2987
3007
  }
2988
- return Math.pow(base, exponent);
3008
+ return powerDecimal(base, exponent);
2989
3009
  }
2990
3010
  function sqrt(n) {
2991
3011
  if (n == null) return null;
@@ -3008,7 +3028,7 @@ function mod(n, divisor) {
3008
3028
  if (divisor === 0) {
3009
3029
  throw new Error("mod: divisor cannot be zero");
3010
3030
  }
3011
- return n % divisor;
3031
+ return moduloDecimal(n, divisor);
3012
3032
  }
3013
3033
 
3014
3034
  // ../shared/src/expression_object.ts
@@ -5815,9 +5835,21 @@ function isNothing(value) {
5815
5835
  return value === null || value === void 0;
5816
5836
  }
5817
5837
  var MULTIPLICATIVE_OPERATORS = /* @__PURE__ */ new Map([
5818
- ["*", (left, right) => left * right],
5819
- ["/", (left, right) => left / right],
5820
- ["%", (left, right) => left % right]
5838
+ ["*", multiplyDecimal],
5839
+ ["/", divideDecimal],
5840
+ ["%", moduloDecimal]
5841
+ ]);
5842
+ var COMPARISONS = /* @__PURE__ */ new Map([
5843
+ // eslint-disable-next-line eqeqeq
5844
+ ["==", { ofNumbers: (order) => order === 0, ofOthers: (left, right) => left == right }],
5845
+ // eslint-disable-next-line eqeqeq
5846
+ ["!=", { ofNumbers: (order) => order !== 0, ofOthers: (left, right) => left != right }],
5847
+ ["===", { ofNumbers: (order) => order === 0, ofOthers: (left, right) => left === right }],
5848
+ ["!==", { ofNumbers: (order) => order !== 0, ofOthers: (left, right) => left !== right }],
5849
+ ["<", { ofNumbers: (order) => order < 0, ofOthers: (left, right) => left < right }],
5850
+ ["<=", { ofNumbers: (order) => order <= 0, ofOthers: (left, right) => left <= right }],
5851
+ [">", { ofNumbers: (order) => order > 0, ofOthers: (left, right) => left > right }],
5852
+ [">=", { ofNumbers: (order) => order >= 0, ofOthers: (left, right) => left >= right }]
5821
5853
  ]);
5822
5854
  var SafeEvalAstFactory = class extends EvalAstFactory {
5823
5855
  id(name) {
@@ -5856,6 +5888,26 @@ var SafeEvalAstFactory = class extends EvalAstFactory {
5856
5888
  }
5857
5889
  };
5858
5890
  }
5891
+ const comparison = COMPARISONS.get(op);
5892
+ if (comparison) {
5893
+ return {
5894
+ type: "Binary",
5895
+ operator: op,
5896
+ left,
5897
+ right,
5898
+ evaluate(scope) {
5899
+ const leftValue = this.left.evaluate(scope);
5900
+ const rightValue = this.right.evaluate(scope);
5901
+ if (typeof leftValue === "number" && typeof rightValue === "number") return comparison.ofNumbers(compareDecimal(leftValue, rightValue));
5902
+ return comparison.ofOthers(leftValue, rightValue);
5903
+ },
5904
+ getIds(idents) {
5905
+ this.left.getIds(idents);
5906
+ this.right.getIds(idents);
5907
+ return idents;
5908
+ }
5909
+ };
5910
+ }
5859
5911
  if (op !== "+" && op !== "-") {
5860
5912
  return super.binary(left, op, right);
5861
5913
  }
@@ -5871,7 +5923,8 @@ var SafeEvalAstFactory = class extends EvalAstFactory {
5871
5923
  if (isNothing(leftValue) && isNothing(rightValue)) return null;
5872
5924
  const leftOperand = leftValue ?? 0;
5873
5925
  const rightOperand = rightValue ?? 0;
5874
- return op === "+" ? leftOperand + rightOperand : Number(leftOperand) - Number(rightOperand);
5926
+ if (op === "-") return subtractDecimal(Number(leftOperand), Number(rightOperand));
5927
+ return typeof leftOperand === "number" && typeof rightOperand === "number" ? addDecimal(leftOperand, rightOperand) : leftOperand + rightOperand;
5875
5928
  };
5876
5929
  return op === "+" ? addOrConcat(leftValue, rightValue, combine) : combine();
5877
5930
  },
@@ -21164,7 +21217,7 @@ function aggregateTextField(values2, operation) {
21164
21217
  const emptyCount = values2.filter(
21165
21218
  (value) => value === null || value === void 0 || value === ""
21166
21219
  ).length;
21167
- const percentage2 = Math.round(emptyCount / totalCount * 100);
21220
+ const percentage2 = percentOf(emptyCount, totalCount);
21168
21221
  return { result: percentage2 };
21169
21222
  }
21170
21223
  case "percent_filled": {
@@ -21172,7 +21225,7 @@ function aggregateTextField(values2, operation) {
21172
21225
  const filledCount = values2.filter(
21173
21226
  (value) => value !== null && value !== void 0 && value !== ""
21174
21227
  ).length;
21175
- const percentage2 = Math.round(filledCount / totalCount * 100);
21228
+ const percentage2 = percentOf(filledCount, totalCount);
21176
21229
  return { result: percentage2 };
21177
21230
  }
21178
21231
  case "unique": {
@@ -21188,7 +21241,7 @@ function aggregateTextField(values2, operation) {
21188
21241
  );
21189
21242
  if (filledValues.length === 0) return { result: 0 };
21190
21243
  const uniqueValues = new Set(filledValues);
21191
- const percentage2 = Math.round(uniqueValues.size / filledValues.length * 100);
21244
+ const percentage2 = percentOf(uniqueValues.size, filledValues.length);
21192
21245
  return { result: percentage2 };
21193
21246
  }
21194
21247
  default:
@@ -21212,13 +21265,13 @@ function aggregateNumberField(values2, operation) {
21212
21265
  case "percent_empty": {
21213
21266
  if (totalCount === 0) return { result: 0 };
21214
21267
  const emptyCount = values2.filter((value) => !hasNumberValue(value)).length;
21215
- const percentage2 = Math.round(emptyCount / totalCount * 100);
21268
+ const percentage2 = percentOf(emptyCount, totalCount);
21216
21269
  return { result: percentage2 };
21217
21270
  }
21218
21271
  case "percent_filled": {
21219
21272
  if (totalCount === 0) return { result: 0 };
21220
21273
  const filledCount = values2.filter(hasNumberValue).length;
21221
- const percentage2 = Math.round(filledCount / totalCount * 100);
21274
+ const percentage2 = percentOf(filledCount, totalCount);
21222
21275
  return { result: percentage2 };
21223
21276
  }
21224
21277
  case "unique": {
@@ -21230,7 +21283,7 @@ function aggregateNumberField(values2, operation) {
21230
21283
  const filledValues = values2.filter(hasNumberValue);
21231
21284
  if (filledValues.length === 0) return { result: 0 };
21232
21285
  const uniqueValues = new Set(filledValues);
21233
- const percentage2 = Math.round(uniqueValues.size / filledValues.length * 100);
21286
+ const percentage2 = percentOf(uniqueValues.size, filledValues.length);
21234
21287
  return { result: percentage2 };
21235
21288
  }
21236
21289
  }
@@ -21288,13 +21341,13 @@ function aggregateBooleanField(values2, operation) {
21288
21341
  case "percent_checked": {
21289
21342
  if (totalCount === 0) return { result: 0 };
21290
21343
  const checkedCount = values2.filter((value) => value === true).length;
21291
- const percentage2 = Math.round(checkedCount / totalCount * 100);
21344
+ const percentage2 = percentOf(checkedCount, totalCount);
21292
21345
  return { result: percentage2 };
21293
21346
  }
21294
21347
  case "percent_unchecked": {
21295
21348
  if (totalCount === 0) return { result: 0 };
21296
21349
  const uncheckedCount = values2.filter((value) => !value).length;
21297
- const percentage2 = Math.round(uncheckedCount / totalCount * 100);
21350
+ const percentage2 = percentOf(uncheckedCount, totalCount);
21298
21351
  return { result: percentage2 };
21299
21352
  }
21300
21353
  default:
@@ -21321,7 +21374,7 @@ function aggregateDateField(values2, operation) {
21321
21374
  const emptyCount = values2.filter(
21322
21375
  (value) => value === null || value === void 0 || value === ""
21323
21376
  ).length;
21324
- const percentage2 = Math.round(emptyCount / totalCount * 100);
21377
+ const percentage2 = percentOf(emptyCount, totalCount);
21325
21378
  return { result: percentage2 };
21326
21379
  }
21327
21380
  case "percent_filled": {
@@ -21329,7 +21382,7 @@ function aggregateDateField(values2, operation) {
21329
21382
  const filledCount = values2.filter(
21330
21383
  (value) => value !== null && value !== void 0 && value !== ""
21331
21384
  ).length;
21332
- const percentage2 = Math.round(filledCount / totalCount * 100);
21385
+ const percentage2 = percentOf(filledCount, totalCount);
21333
21386
  return { result: percentage2 };
21334
21387
  }
21335
21388
  case "unique": {
@@ -21345,7 +21398,7 @@ function aggregateDateField(values2, operation) {
21345
21398
  );
21346
21399
  if (filledValues.length === 0) return { result: 0 };
21347
21400
  const uniqueValues = new Set(filledValues);
21348
- const percentage2 = Math.round(uniqueValues.size / filledValues.length * 100);
21401
+ const percentage2 = percentOf(uniqueValues.size, filledValues.length);
21349
21402
  return { result: percentage2 };
21350
21403
  }
21351
21404
  }
@@ -21451,13 +21504,13 @@ function aggregateGenericField(values2, operation) {
21451
21504
  case "percent_empty": {
21452
21505
  if (totalCount === 0) return { result: 0 };
21453
21506
  const emptyCount = values2.filter(isEmpty2).length;
21454
- const percentage2 = Math.round(emptyCount / totalCount * 100);
21507
+ const percentage2 = percentOf(emptyCount, totalCount);
21455
21508
  return { result: percentage2 };
21456
21509
  }
21457
21510
  case "percent_filled": {
21458
21511
  if (totalCount === 0) return { result: 0 };
21459
21512
  const filledCount = values2.filter((value) => !isEmpty2(value)).length;
21460
- const percentage2 = Math.round(filledCount / totalCount * 100);
21513
+ const percentage2 = percentOf(filledCount, totalCount);
21461
21514
  return { result: percentage2 };
21462
21515
  }
21463
21516
  case "unique": {
@@ -21484,7 +21537,7 @@ function aggregateGenericField(values2, operation) {
21484
21537
  uniqueValues.add(value);
21485
21538
  }
21486
21539
  }
21487
- const percentage2 = Math.round(uniqueValues.size / filledValues.length * 100);
21540
+ const percentage2 = percentOf(uniqueValues.size, filledValues.length);
21488
21541
  return { result: percentage2 };
21489
21542
  }
21490
21543
  case "selected":
@@ -21513,13 +21566,13 @@ function aggregateSelectRecordLinkField(values2, operation) {
21513
21566
  case "percent_empty": {
21514
21567
  if (totalCount === 0) return { result: 0 };
21515
21568
  const emptyCount = values2.filter(isEmpty2).length;
21516
- const percentage2 = Math.round(emptyCount / totalCount * 100);
21569
+ const percentage2 = percentOf(emptyCount, totalCount);
21517
21570
  return { result: percentage2 };
21518
21571
  }
21519
21572
  case "percent_filled": {
21520
21573
  if (totalCount === 0) return { result: 0 };
21521
21574
  const filledCount = values2.filter((value) => !isEmpty2(value)).length;
21522
- const percentage2 = Math.round(filledCount / totalCount * 100);
21575
+ const percentage2 = percentOf(filledCount, totalCount);
21523
21576
  return { result: percentage2 };
21524
21577
  }
21525
21578
  case "selected":
@@ -21564,13 +21617,13 @@ function aggregateRollupField(values2, operation) {
21564
21617
  case "percent_empty": {
21565
21618
  if (totalCount === 0) return { result: 0 };
21566
21619
  const emptyCount = values2.filter(isEmpty2).length;
21567
- const percentage2 = Math.round(emptyCount / totalCount * 100);
21620
+ const percentage2 = percentOf(emptyCount, totalCount);
21568
21621
  return { result: percentage2 };
21569
21622
  }
21570
21623
  case "percent_filled": {
21571
21624
  if (totalCount === 0) return { result: 0 };
21572
21625
  const filledCount = values2.filter((value) => !isEmpty2(value)).length;
21573
- const percentage2 = Math.round(filledCount / totalCount * 100);
21626
+ const percentage2 = percentOf(filledCount, totalCount);
21574
21627
  return { result: percentage2 };
21575
21628
  }
21576
21629
  case "unique": {
@@ -21583,7 +21636,7 @@ function aggregateRollupField(values2, operation) {
21583
21636
  const filledValues = values2.filter((value) => !isEmpty2(value));
21584
21637
  if (filledValues.length === 0) return { result: 0 };
21585
21638
  const uniqueValues = new Set(filledValues);
21586
- const percentage2 = Math.round(uniqueValues.size / filledValues.length * 100);
21639
+ const percentage2 = percentOf(uniqueValues.size, filledValues.length);
21587
21640
  return { result: percentage2 };
21588
21641
  }
21589
21642
  }
@@ -21904,9 +21957,8 @@ function currencyCodeOf(written) {
21904
21957
  if (Intl.supportedValuesOf("currency").includes(upper2)) return upper2;
21905
21958
  return DONG_SPELLINGS.includes(unitKey(written)) ? "VND" : void 0;
21906
21959
  }
21907
- var EXACT_DIGITS = 15;
21908
21960
  function convert(value, from, to) {
21909
- return from.code === to.code ? value : Number((value * from.factor / to.factor).toPrecision(EXACT_DIGITS));
21961
+ return from.code === to.code ? value : divideDecimal(multiplyDecimal(value, from.factor), to.factor);
21910
21962
  }
21911
21963
  function scaleIn(own, pack) {
21912
21964
  const kin = MEASURED_UNITS.filter((one) => one.dimension === own.dimension);
@@ -26351,6 +26403,9 @@ var appQueryDeclarationSchema = zod_default.object({
26351
26403
  ),
26352
26404
  templates: zod_default.record(zod_default.string().regex(APP_ALIAS_REGEX), appExportTemplateSchema).optional().describe(
26353
26405
  "Document templates an export of this query's rows may fill, by the name the export asks for. An export names one of these or none; a template it does not declare is refused."
26406
+ ),
26407
+ screen_only: zod_default.literal(true).optional().describe(
26408
+ "The query feeds one control of the app's screen and answers no question a person asks. It runs like any other; a catalog of what an agent may ask leaves it out."
26354
26409
  )
26355
26410
  });
26356
26411
  var appAgentWritesSchema = zod_default.object({
@@ -26692,6 +26747,15 @@ var TOOL_INPUT_SEMANTICS = {
26692
26747
  // same position `query_records.field_keys` holds.
26693
26748
  return_fields: { kind: "field_key_refs", table_from: "table_id" }
26694
26749
  },
26750
+ import_rows: {
26751
+ table_id: { kind: "table_ref", rows: "change" },
26752
+ match: { kind: "field_key_refs", table_from: "table_id" },
26753
+ // Each row's `values` are keyed by field, a link cell possibly `{ lookup }`; the tool resolves both at execute.
26754
+ rows: { kind: "dynamic" },
26755
+ create: { kind: "scalar" },
26756
+ // `fields` is keyed by field; the tool resolves it at execute.
26757
+ conflicts: { kind: "dynamic" }
26758
+ },
26695
26759
  delete_records: {
26696
26760
  // THE ONE CALL THAT TAKES ROWS. Every column of the row goes with it, so
26697
26761
  // nothing field-keyed can carry the fact and it rides the table position —
@@ -26727,6 +26791,22 @@ var TOOL_INPUT_SEMANTICS = {
26727
26791
  changes: { kind: "dynamic" },
26728
26792
  reason: { kind: "text" }
26729
26793
  },
26794
+ // The record names its own table; an entry's id and a cursor are the trail's.
26795
+ get_record_activity: {
26796
+ record_id: { kind: "record_ref" },
26797
+ after: { kind: "scalar" }
26798
+ },
26799
+ undo_record_change: {
26800
+ record_id: { kind: "record_ref" },
26801
+ entry_id: { kind: "scalar" }
26802
+ },
26803
+ // `table_id` names the table whose rows the undo changes, moves to the trash and brings back; a run's id is the
26804
+ // trail's.
26805
+ undo_record_changes: {
26806
+ table_id: { kind: "table_ref", rows: "remove" },
26807
+ record_ids: { kind: "record_refs" },
26808
+ workflow_execution_id: { kind: "scalar" }
26809
+ },
26730
26810
  // ─── Query surfaces ────────────────────────────────────────────────────
26731
26811
  query_records: {
26732
26812
  table_id: { kind: "table_ref", rows: "none" },
@@ -28163,33 +28243,34 @@ async function evaluateBinary(op, leftExpr, rightExpr, ctx) {
28163
28243
  assertCheap(ctx.evaluation_budget, left, right);
28164
28244
  assertTextWithin("+", typeof left === "string" ? null : left, typeof right === "string" ? null : right);
28165
28245
  }
28166
- const sum2 = addOrConcat(left, right, () => assertNumber(left, "add", "left") + assertNumber(right, "add", "right"));
28246
+ const sum2 = addOrConcat(left, right, () => addDecimal(assertNumber(left, "add", "left"), assertNumber(right, "add", "right")));
28167
28247
  charge(ctx, builtSize(sum2));
28168
28248
  return sum2;
28169
28249
  }
28170
28250
  case "sub":
28171
- return assertNumber(left, "sub", "left") - assertNumber(right, "sub", "right");
28251
+ return subtractDecimal(assertNumber(left, "sub", "left"), assertNumber(right, "sub", "right"));
28172
28252
  case "mul":
28173
- return assertNumber(left, "mul", "left") * assertNumber(right, "mul", "right");
28253
+ return multiplyDecimal(assertNumber(left, "mul", "left"), assertNumber(right, "mul", "right"));
28174
28254
  case "div": {
28175
28255
  const r = assertNumber(right, "div", "right");
28176
28256
  if (r === 0) {
28177
28257
  throw new WorkflowEvalError("Division by zero", { kind: "binary", at: "div" });
28178
28258
  }
28179
- return assertNumber(left, "div", "left") / r;
28259
+ return divideDecimal(assertNumber(left, "div", "left"), r);
28180
28260
  }
28181
28261
  case "mod": {
28182
28262
  const r = assertNumber(right, "mod", "right");
28183
28263
  if (r === 0) {
28184
28264
  throw new WorkflowEvalError("Modulo by zero", { kind: "binary", at: "mod" });
28185
28265
  }
28186
- return assertNumber(left, "mod", "left") % r;
28266
+ return moduloDecimal(assertNumber(left, "mod", "left"), r);
28187
28267
  }
28188
28268
  }
28189
28269
  }
28190
28270
  function looseEq(a, b) {
28191
28271
  if (isComposite(a) && b !== null && b !== void 0 && !isComposite(b)) return looseEq(textOf(a), b);
28192
28272
  if (isComposite(b) && a !== null && a !== void 0 && !isComposite(a)) return looseEq(a, textOf(b));
28273
+ if (typeof a === "number" && typeof b === "number") return compareDecimal(a, b) === 0;
28193
28274
  return a == b;
28194
28275
  }
28195
28276
  function isComposite(value) {
@@ -28204,6 +28285,7 @@ function isScalar(value) {
28204
28285
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint";
28205
28286
  }
28206
28287
  function strictEq(a, b) {
28288
+ if (typeof a === "number" && typeof b === "number") return compareDecimal(a, b) === 0;
28207
28289
  return a === b;
28208
28290
  }
28209
28291
  function compareOrdered(a, b) {
@@ -28211,7 +28293,7 @@ function compareOrdered(a, b) {
28211
28293
  return NaN;
28212
28294
  }
28213
28295
  if (typeof a === "number" && typeof b === "number") {
28214
- return a - b;
28296
+ return compareDecimal(a, b);
28215
28297
  }
28216
28298
  if (typeof a === "string" && typeof b === "string") {
28217
28299
  return a < b ? -1 : a > b ? 1 : 0;
@@ -28986,6 +29068,7 @@ function useAppContext() {
28986
29068
  const { data, error: error51, refetch } = useKey(APP_CONTEXT_KEY, readContext, { focus: false, arrival: false });
28987
29069
  return {
28988
29070
  memberId: data?.member_id ?? null,
29071
+ insider: data !== void 0 && data.member_id !== null && data.group_ids !== void 0,
28989
29072
  groupIds: data?.group_ids ?? null,
28990
29073
  absences: data?.absences ?? null,
28991
29074
  openableApps: data?.openable_apps ?? null,
@@ -30510,7 +30593,7 @@ function evaluateCondition(record2, fieldByKey, looseCondition, memberContext, f
30510
30593
  if (reading.unit !== void 0 && filterCondition.value !== null && filterCondition.value !== void 0) {
30511
30594
  const factor = rowUnitFactor(reading.unit, data);
30512
30595
  if (factor === void 0) return false;
30513
- const inBase = { ...filterCondition, value: filterCondition.value * reading.unit.factor };
30596
+ const inBase = { ...filterCondition, value: multiplyExactly(filterCondition.value, reading.unit.factor) };
30514
30597
  return matchCell(columnValue, field, inBase, looseCondition, memberContext, fileFacts, factor);
30515
30598
  }
30516
30599
  }
@@ -30619,7 +30702,7 @@ function matchCell(cell, field, filterCondition, looseCondition, memberContext,
30619
30702
  matches = foldedValue !== void 0 && needles.some((needle) => foldedValue.includes(needle));
30620
30703
  }
30621
30704
  } else if (filterCondition.type === "number") {
30622
- const numberValue = typeof columnValue === "number" ? columnValue * figureFactor : columnValue;
30705
+ const numberValue = typeof columnValue === "number" ? multiplyExactly(columnValue, figureFactor) : columnValue;
30623
30706
  if (filterCondition.operator === "is_empty") {
30624
30707
  matches = !numberValue && numberValue !== 0;
30625
30708
  } else if (filterCondition.operator === "is_not_empty") {
@@ -32671,8 +32754,7 @@ function oneQueryAlias(entity) {
32671
32754
  var NO_ENTRIES = [];
32672
32755
  function useRecordActivity(entity, record_id, options = {}) {
32673
32756
  const { after } = options;
32674
- const { memberId, resolved } = useAppContext();
32675
- const available = memberId !== null;
32757
+ const { insider: available, resolved } = useAppContext();
32676
32758
  const page = useKey(
32677
32759
  available ? JSON.stringify(["activity", entity, record_id, after ?? null]) : null,
32678
32760
  () => rpc("activity.list", { entity, record_id, ...after === void 0 ? {} : { after } }),
@@ -32688,6 +32770,12 @@ function useRecordActivity(entity, record_id, options = {}) {
32688
32770
  refetch: page.refetch
32689
32771
  };
32690
32772
  }
32773
+ async function undoRecordChange(entity, record_id, entry_id) {
32774
+ const result = await rpc("activity.undo", { entity, record_id, entry_id });
32775
+ refetchReads([listQueryAlias(entity)]);
32776
+ await readsAnswered([oneQueryAlias(entity)]);
32777
+ return result;
32778
+ }
32691
32779
 
32692
32780
  // src/recording.ts
32693
32781
  import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
@@ -33031,6 +33119,7 @@ export {
33031
33119
  requestGeofencedLocation,
33032
33120
  row,
33033
33121
  rpc,
33122
+ undoRecordChange,
33034
33123
  urlParam,
33035
33124
  useAgentRun,
33036
33125
  useAgentRuns,
package/dist/router.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  isEmbedded,
3
3
  postHostNotification,
4
4
  setUrlParams
5
- } from "./chunk-NZXJENJ2.js";
5
+ } from "./chunk-MK4DBY6R.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" | "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" | "activity.undo" | "recording.start" | "recording.stop";
13
13
  export interface AgentRunPayload {
14
14
  alias: string;
15
15
  session_id: string;
@@ -3,7 +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: { 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"; };
6
+ export type ActivityEntry = { undoable: boolean; 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; undo?: true | undefined; undone?: { by: { kind: "member" | "agent" | "workflow" | "import"; id: string; name: string; image?: string | undefined; }; at: string; } | 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 ActivityUndoResult = { reverted: { field_id: string; label: string; }[]; kept: { field_id: string; label: string; reason: "changed_since" | "refused"; }[]; };
7
8
  export type AppAbsence = { member: { id: string; name: string; }; stand_in: { id: string; name: string; }; starts_on: string; ends_on: string; group_ids: string[]; };
8
9
  /** The file an app export made, as a link to download it: no workspace file, so no id, and kept a day. */
9
10
  export type ExportedFile = {
package/dist/store.d.ts CHANGED
@@ -4,6 +4,8 @@ export type Fetcher<T> = (askedAt: number, current: T | undefined) => Promise<T>
4
4
  export declare function tick(): number;
5
5
  /** Ask again every key reading one of `aliases` — every query key where none is named; an unmounted one on its next mount. */
6
6
  export declare function refetchReads(aliases: readonly string[] | undefined): void;
7
+ /** `refetchReads`, settled once every key a screen reads now holds an answer asked after this call — or its refusal. */
8
+ export declare function readsAnswered(aliases: readonly string[]): Promise<void>;
7
9
  /** The host's push, naming the aliases whose tables moved: each key reading one asked again (`refetchReads`), in one
8
10
  * paint. */
9
11
  export declare function refetchPushed(aliases: readonly string[]): void;
package/dist/viewer.d.ts CHANGED
@@ -10,6 +10,8 @@ export declare function readContext(): Promise<AppContext>;
10
10
  */
11
11
  export declare function useAppContext(): {
12
12
  memberId: string | null;
13
+ /** The viewer is a signed-in member of the app's organization: the host named them and told their groups. */
14
+ insider: boolean;
13
15
  /** The viewer's member groups, by id; `null` where the host did not say. */
14
16
  groupIds: readonly string[] | null;
15
17
  /** Who in the organization is away, or will be, and who stands in; `null` where the host did not say. */
@@ -373,7 +373,7 @@ table's id: read it off a row of the query that renders the badges (`rows[0]?.__
373
373
  rather than pasting a `tbl_` id, which a copy of the app cannot carry. Pass `undefined` while no
374
374
  row is on screen — no request is sent and `counts` is `{}`.
375
375
 
376
- ## Record activity: `useRecordActivity`
376
+ ## Record activity: `useRecordActivity`, `undoRecordChange`
377
377
 
378
378
  Who changed which of a record's fields, and who commented on it — one entry per save
379
379
  (`dist/activity.d.ts`). For a JSON app: `entity` is the alias the app's spec names the record's
@@ -383,6 +383,8 @@ entity under.
383
383
  const { entries, next, loading, error, available, refetch } = useRecordActivity("job", recordId);
384
384
  // The page past it, mounted where the reader asks for more:
385
385
  const older = useRecordActivity("job", recordId, { after: next });
386
+ // One change entry put back, as the viewer's own save:
387
+ const { reverted, kept } = await undoRecordChange("job", recordId, entry.id);
386
388
  ```
387
389
 
388
390
  - **Authority: the app's own read of the record.** The server reaches the record through the
@@ -392,8 +394,8 @@ const older = useRecordActivity("job", recordId, { after: next });
392
394
  entry. A field taken out of the app while its table keeps it stays hidden; a field gone from its
393
395
  table appears where the version serving when it was written read it, marked `removed`. Comments
394
396
  appear only where the app declares `comments`.
395
- - **Members of the app's organization only** — the trail names its people. Standalone, `available`
396
- is `false` and nothing is read.
397
+ - **Members of the app's organization only** — the trail names its people. For a public visitor,
398
+ a member of another organization, and standalone, `available` is `false` and nothing is asked.
397
399
  - **One entry per save.** Edits by one actor on one record less than ten minutes apart read as one
398
400
  entry: each field from its value before the first edit to its value after the last, a field edited
399
401
  back to where it began dropped (and an entry left with nothing). A comment, another actor or a
@@ -401,7 +403,9 @@ const older = useRecordActivity("job", recordId, { after: next });
401
403
  its maker's edits after it, however soon, are entries of their own. `at` is the entry's first
402
404
  write, `until` its last.
403
405
  - **An entry** (`ActivityEntry`) is `kind: "change"` with `changes`, or `kind: "comment"` with
404
- `comment: { id, body, mentions }`. `actor`: `{ kind, id, name, image? }` — `member` (a person),
406
+ `comment: { id, body, mentions }`. A change entry also says `undoable` — the viewer may put it
407
+ back — `undone: { by, at }` once someone has, and `undo: true` where it is itself the save that
408
+ put an earlier one back. `actor`: `{ kind, id, name, image? }` — `member` (a person),
405
409
  `agent` (an AI acting for a member: the chat, an app's agent, an MCP client — named by that
406
410
  member), `workflow` (a workflow on its own, named by the workflow) or `import` (a script over the
407
411
  API or the CLI, named by the member whose key ran it). `name` is empty where nothing resolves it.
@@ -411,6 +415,17 @@ const older = useRecordActivity("job", recordId, { after: next });
411
415
  `[{ key, label }]` (`readSelect`; each option's label as it was when written), a member field
412
416
  `[{ id, name, image? }]`, a link `[{ id, display }]` (`readLinks`), files presigned (`readFiles`),
413
417
  `null` for empty.
418
+ - **`undoRecordChange(entity, record_id, entry_id)`** puts one change entry back: each field it
419
+ changed that the app reads returns to what it held before the entry, where the record still holds
420
+ what the entry wrote. It answers `{ reverted, kept }`: the fields put back (`{ field_id, label }`),
421
+ and those left as they stand with a `reason` — `changed_since` (someone changed it since) or
422
+ `refused` (the table refused the write: a lock, a check). A field already holding what it held
423
+ before is in neither. The write is the viewer's own save: they hold `write_data` on the table as
424
+ the call arrives, on a row their own row rule covers, and the record's lock, the table's checks
425
+ and its write rules answer as for any save of theirs. `undoable` is `false`, and the call
426
+ refused, for the entry that made the record, an entry that is an undo, one already put back, and
427
+ a viewer who may not write the record.
428
+ The undo is an entry of its own; the answer arrives once the record's reads are read anew.
414
429
  - **Pages**: newest first, about thirty entries each; `next` is where the trail goes on —
415
430
  `undefined` at its end. A page may hold fewer entries and still say `next` where the trail it read
416
431
  held little the reader may see.
@@ -428,4 +443,5 @@ const older = useRecordActivity("job", recordId, { after: next });
428
443
  | `useViewer` | member id (view-as target) | `null` |
429
444
  | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
430
445
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
431
- | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false` |
446
+ | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false`, nothing asked |
447
+ | `undoRecordChange` | ✓ a member holding the table's write, on a row they may write | ✗ rejects |
package/docs/runtime.md CHANGED
@@ -210,6 +210,7 @@ surfaces:
210
210
  |---|---|---|
211
211
  | `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes |
212
212
  | `comments.*` | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
213
+ | `activity.list`, `activity.undo` | yes, for a member of the app's organization; `activity.undo` for one who may write the record. A host that does not carry the op answers `"Unknown RPC op: activity.undo"` | rejects — `"A record's activity is available only in embedded apps — a signed-in member is required."` |
213
214
  | `agentRun` (streaming, internal to `useAgentRun`) | yes | yes |
214
215
  | `agentRun.get`, `agentRun.cancel` | yes | yes |
215
216
  | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | rejects — a standalone caller is anonymous, and a history is its member's (`"App agent runs require an authenticated member …"`) |