@lotics/app-sdk 0.117.1 → 0.118.1

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`). |
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`). |
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. |
@@ -992,6 +992,9 @@ function rpcStandalone(op, payload) {
992
992
  return rejectCommentsStandalone();
993
993
  case "activity.list":
994
994
  return Promise.reject(new Error("A record's activity is available only in embedded apps \u2014 a signed-in member is required."));
995
+ case "imports.list":
996
+ case "imports.run":
997
+ return Promise.reject(new Error("A register's imports log is available only in embedded apps \u2014 a signed-in member is required."));
995
998
  case "recording.start":
996
999
  case "recording.stop":
997
1000
  return Promise.reject(
@@ -0,0 +1,51 @@
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;
package/dist/index.d.ts CHANGED
@@ -13,6 +13,9 @@ 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";
16
19
  export type { ActivityActor, ActivityActorKind, ActivityChange, ActivityComment, ActivityEntry, ActivityValue, RecordActivityPage, } from "./shared_types.js";
17
20
  export { useAppContext, useViewer, useWorkspaceCurrency, useWorkspaceTimezone } from "./viewer.js";
18
21
  export type { AppAbsence } from "./shared_types.js";
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  subscribeRecordings,
26
26
  subscribeUrlParams,
27
27
  urlParam
28
- } from "./chunk-IN4WP24U.js";
28
+ } from "./chunk-XL5LZFGB.js";
29
29
 
30
30
  // src/mount.tsx
31
31
  import { createRoot } from "react-dom/client";
@@ -493,6 +493,17 @@ var slots = /* @__PURE__ */ new Map();
493
493
  var clock = 0;
494
494
  var nextStaging = 1;
495
495
  var FOCUS_THROTTLE_MS = 5e3;
496
+ var PAINT_CAP_MS = 1e3;
497
+ function draw(paint) {
498
+ if (paint.drawn) return;
499
+ paint.drawn = true;
500
+ clearTimeout(paint.cap);
501
+ for (const settle2 of paint.held.splice(0)) settle2();
502
+ }
503
+ function release(paint, slot) {
504
+ paint.waiting.delete(slot);
505
+ if (paint.waiting.size === 0) draw(paint);
506
+ }
496
507
  function slotOf(key, fetcher, aliases) {
497
508
  const held2 = slots.get(key);
498
509
  if (held2 !== void 0) {
@@ -507,6 +518,7 @@ function slotOf(key, fetcher, aliases) {
507
518
  inflight: null,
508
519
  stale: false,
509
520
  outdated: false,
521
+ paint: null,
510
522
  stagings: [],
511
523
  fetcher,
512
524
  aliases,
@@ -532,6 +544,8 @@ function ask(slot, fresh) {
532
544
  return slot.inflight;
533
545
  }
534
546
  const askedAt = tick();
547
+ const paint = slot.paint;
548
+ slot.paint = null;
535
549
  slot.outdated = false;
536
550
  slot.lastAskedMs = Date.now();
537
551
  const land = () => {
@@ -542,8 +556,21 @@ function ask(slot, fresh) {
542
556
  void ask(slot, false);
543
557
  }
544
558
  };
559
+ const settle2 = (apply) => {
560
+ if (paint === null || paint.drawn) {
561
+ apply();
562
+ return void 0;
563
+ }
564
+ return new Promise((resolve) => {
565
+ paint.held.push(() => {
566
+ apply();
567
+ resolve();
568
+ });
569
+ release(paint, slot);
570
+ });
571
+ };
545
572
  const inflight = slot.fetcher(askedAt, slot.answer).then(
546
- (answer) => {
573
+ (answer) => settle2(() => {
547
574
  if (askedAt >= slot.askedAt) {
548
575
  slot.answer = answer;
549
576
  slot.askedAt = askedAt;
@@ -551,35 +578,45 @@ function ask(slot, fresh) {
551
578
  slot.stagings = slot.stagings.filter((one) => one.settledAt === null || one.settledAt >= askedAt);
552
579
  }
553
580
  land();
554
- },
555
- (cause) => {
581
+ }),
582
+ (cause) => settle2(() => {
556
583
  slot.error = cause instanceof Error ? cause : new Error(String(cause));
557
584
  land();
558
- }
585
+ })
559
586
  );
560
587
  slot.inflight = inflight;
561
588
  changed(slot);
562
589
  return inflight;
563
590
  }
591
+ function readNow(aliases) {
592
+ const named = [...slots.values()].filter((slot) => slot.aliases.size > 0 && (aliases === void 0 || aliases.some((alias) => slot.aliases.has(alias))));
593
+ for (const slot of named) if (slot.mounts.size === 0) slot.outdated = true;
594
+ return named.filter((slot) => slot.mounts.size > 0);
595
+ }
564
596
  function refetchReads(aliases) {
565
- for (const slot of slots.values()) {
566
- if (slot.aliases.size === 0) continue;
567
- if (aliases !== void 0 && !aliases.some((alias) => slot.aliases.has(alias))) continue;
568
- if (slot.mounts.size === 0) slot.outdated = true;
569
- else void ask(slot, true);
597
+ for (const slot of readNow(aliases)) void ask(slot, true);
598
+ }
599
+ function askInOnePaint(read2) {
600
+ if (read2.length === 0) return;
601
+ const paint = { waiting: new Set(read2), held: [], drawn: false, cap: setTimeout(() => draw(paint), PAINT_CAP_MS) };
602
+ for (const slot of read2) {
603
+ if (slot.paint !== null) release(slot.paint, slot);
604
+ slot.paint = paint;
570
605
  }
606
+ for (const slot of read2) void ask(slot, true);
607
+ }
608
+ function refetchPushed(aliases) {
609
+ askInOnePaint(readNow(aliases));
571
610
  }
572
611
  var listening = false;
573
612
  function listen() {
574
613
  if (listening || typeof window === "undefined") return;
575
614
  listening = true;
576
- subscribeQueriesChanged((aliases) => refetchReads(aliases));
615
+ subscribeQueriesChanged(refetchPushed);
577
616
  const refocus = () => {
578
- for (const slot of slots.values()) {
579
- if (![...slot.mounts.values()].some((mount2) => mount2.focus)) continue;
580
- if (Date.now() - slot.lastAskedMs < FOCUS_THROTTLE_MS) continue;
581
- void ask(slot, false);
582
- }
617
+ askInOnePaint(
618
+ [...slots.values()].filter((slot) => slot.inflight === null && [...slot.mounts.values()].some((mount2) => mount2.focus) && Date.now() - slot.lastAskedMs >= FOCUS_THROTTLE_MS)
619
+ );
583
620
  };
584
621
  window.addEventListener("focus", refocus);
585
622
  window.addEventListener("online", refocus);
@@ -25159,7 +25196,7 @@ var tableFilesFieldSchema = tableFieldBaseSchema.extend({
25159
25196
  var formulaOutputTypeSchema = zod_default.enum(["number", "text", "date", "datetime", "boolean", "select"]);
25160
25197
  var formulaInputSchema = zod_default.object({
25161
25198
  expression: zod_default.string().describe(
25162
- "JavaScript expression. Uses {Field Name} for field references."
25199
+ "JavaScript expression. A field is referenced as {fld_key}, stored so; a write also takes {Field Name}, stored as its key."
25163
25200
  ),
25164
25201
  notation: notationSchema.optional().describe(`${NOTATION_DESCRIPTION} For a formula yielding a number.`),
25165
25202
  link: zod_default.literal(true).optional().describe("The formula yields a URL as text, drawn as a link that opens."),
@@ -25207,6 +25244,9 @@ var workflowSchema = zod_default.object({
25207
25244
  metadata: workflowMetadataSchema.nullable().optional().describe("Metadata for the workflow"),
25208
25245
  current_version: zod_default.number().int().describe("Current version number"),
25209
25246
  enabled: zod_default.boolean().describe("Whether this workflow is active"),
25247
+ imports_into: zod_default.string().nullable().optional().describe(
25248
+ "The table this workflow brings data into: its runs are the imports log of a register over that table that keeps one (`register.imports`). Null, it imports into none."
25249
+ ),
25210
25250
  workspace_id: zod_default.string().describe("ID of the workspace this workflow belongs to"),
25211
25251
  created_at: zod_default.string().describe("Timestamp when workflow was created"),
25212
25252
  updated_at: zod_default.string().describe("Timestamp of last update")
@@ -32244,6 +32284,9 @@ function useQuery(alias, params = {}, opts = {}) {
32244
32284
  if (modes.length > 1) throw new Error(`useQuery("${alias}") takes one of \`limit\`, \`page\` and \`more\`; it was given ${modes.length}.`);
32245
32285
  const enabled = opts.enabled ?? true;
32246
32286
  const focus = opts.revalidateOnFocus ?? true;
32287
+ const live = opts.revalidateOnChange ?? true;
32288
+ const aliases = live ? [alias] : [];
32289
+ const apart = live ? [] : ["held"];
32247
32290
  const sort = opts.sort !== void 0 && opts.sort.length > 0 ? opts.sort : void 0;
32248
32291
  const filter2 = opts.filter;
32249
32292
  const wantsRows = opts.rows ?? true;
@@ -32257,7 +32300,7 @@ function useQuery(alias, params = {}, opts = {}) {
32257
32300
  const page = held2 !== void 0 ? Math.max(0, held2.at) : opts.page === void 0 || paging.call !== call ? 0 : paging.page;
32258
32301
  const turn2 = held2?.onChange;
32259
32302
  const setPage = useCallback5((next) => turn2 === void 0 ? setPaging({ call, page: Math.max(0, next) }) : turn2(Math.max(0, next)), [call, turn2]);
32260
- const rowsKey = !enabled || !wantsRows || opts.more !== void 0 ? null : JSON.stringify(["rows", alias, call, opts.limit ?? null, opts.page ?? null, page]);
32303
+ const rowsKey = !enabled || !wantsRows || opts.more !== void 0 ? null : JSON.stringify(["rows", alias, call, opts.limit ?? null, opts.page ?? null, page, ...apart]);
32261
32304
  const rows = useKey(
32262
32305
  rowsKey,
32263
32306
  (askedAt) => askRows(rowsKey ?? "", alias, askedAt, {
@@ -32267,9 +32310,9 @@ function useQuery(alias, params = {}, opts = {}) {
32267
32310
  limit: opts.page ?? opts.limit,
32268
32311
  offset: opts.page === void 0 ? 0 : page * opts.page
32269
32312
  }),
32270
- { focus, aliases: [alias], known: mocking ? () => knownRows(alias, { params, filter: filter2, sort, limit: opts.page ?? opts.limit, offset: opts.page === void 0 ? 0 : page * opts.page }) : void 0 }
32313
+ { focus, aliases, known: mocking ? () => knownRows(alias, { params, filter: filter2, sort, limit: opts.page ?? opts.limit, offset: opts.page === void 0 ? 0 : page * opts.page }) : void 0 }
32271
32314
  );
32272
- const feedKey = !enabled || !wantsRows || opts.more === void 0 ? null : JSON.stringify(["feed", alias, call, opts.more]);
32315
+ const feedKey = !enabled || !wantsRows || opts.more === void 0 ? null : JSON.stringify(["feed", alias, call, opts.more, ...apart]);
32273
32316
  const feedSize = opts.more ?? 0;
32274
32317
  const feed = useKey(
32275
32318
  feedKey,
@@ -32294,7 +32337,7 @@ function useQuery(alias, params = {}, opts = {}) {
32294
32337
  return { pages };
32295
32338
  },
32296
32339
  // A feed re-read on arrival would reshuffle it under a reader scrolling it.
32297
- { focus, arrival: false, aliases: [alias] }
32340
+ { focus, arrival: false, aliases }
32298
32341
  );
32299
32342
  const heldPages = feed.data?.pages.length ?? 0;
32300
32343
  const feedHasMore = feed.data?.pages.at(-1)?.next_cursor != null;
@@ -32304,11 +32347,11 @@ function useQuery(alias, params = {}, opts = {}) {
32304
32347
  feedWants.set(feedKey, heldPages + 1);
32305
32348
  feed.refetch();
32306
32349
  }, [feedKey, feedHasMore, heldPages, feed]);
32307
- const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null, where ?? null]);
32350
+ const countKey = !enabled || totalAsked === false ? null : JSON.stringify(["count", alias, JSON.stringify([params, filter2 ?? null]), by ?? null, where ?? null, ...apart]);
32308
32351
  const countCall = { params, ...filter2 === void 0 ? {} : { filter: filter2 }, ...by === void 0 ? {} : { by }, ...where === void 0 ? {} : { where } };
32309
32352
  const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, countCall), {
32310
32353
  focus,
32311
- aliases: [alias],
32354
+ aliases,
32312
32355
  known: mocking ? () => knownCount(alias, countCall) : void 0
32313
32356
  });
32314
32357
  const version3 = useWrittenVersion();
@@ -32607,6 +32650,75 @@ function useRecordActivity(entity, record_id, options = {}) {
32607
32650
  };
32608
32651
  }
32609
32652
 
32653
+ // src/imports.ts
32654
+ import { useEffect as useEffect5 } from "react";
32655
+ var NO_WORKFLOWS = [];
32656
+ var NO_RUNS = [];
32657
+ var NO_ROWS = [];
32658
+ var IMPORTS_BURST_QUIET_MS = 2e3;
32659
+ function useImportsLog(entity, options = {}) {
32660
+ const { after } = options;
32661
+ const { memberId, resolved } = useAppContext();
32662
+ const member = memberId !== null;
32663
+ const page = useKey(
32664
+ member ? JSON.stringify(["imports", entity, after ?? null]) : null,
32665
+ () => rpc("imports.list", after === void 0 ? {} : { after })
32666
+ );
32667
+ useAfterBurst(member ? entity : null, page.refetch);
32668
+ const bridged = !refusedByHost(page.error, "imports.list");
32669
+ const log = page.data?.kind === "log" ? page.data : void 0;
32670
+ return {
32671
+ workflows: log?.workflows ?? NO_WORKFLOWS,
32672
+ runs: log?.runs ?? NO_RUNS,
32673
+ next: log?.next,
32674
+ // Waiting on the context counts as loading.
32675
+ loading: member ? page.loading : !resolved,
32676
+ error: bridged ? page.error?.message ?? null : null,
32677
+ available: member && bridged && page.data?.kind !== "none",
32678
+ refetch: page.refetch
32679
+ };
32680
+ }
32681
+ function useImportRun(entity, run_id, options = {}) {
32682
+ const { after } = options;
32683
+ const { memberId, resolved } = useAppContext();
32684
+ const member = memberId !== null;
32685
+ const page = useKey(
32686
+ member ? JSON.stringify(["import_run", entity, run_id, after ?? null]) : null,
32687
+ () => rpc("imports.run", { run_id, ...after === void 0 ? {} : { after } })
32688
+ );
32689
+ const read2 = page.data?.kind === "run" ? page.data : void 0;
32690
+ useAfterBurst(member && read2?.run.end === "running" ? entity : null, page.refetch);
32691
+ const bridged = !refusedByHost(page.error, "imports.run");
32692
+ return {
32693
+ run: read2?.run,
32694
+ rows: read2?.rows ?? NO_ROWS,
32695
+ next: read2?.next,
32696
+ loading: member ? page.loading : !resolved,
32697
+ error: bridged ? page.error?.message ?? null : null,
32698
+ available: member && bridged && page.data?.kind !== "none",
32699
+ refetch: page.refetch
32700
+ };
32701
+ }
32702
+ function useAfterBurst(entity, ask2) {
32703
+ useEffect5(() => {
32704
+ if (entity === null) return;
32705
+ const alias = listQueryAlias(entity);
32706
+ let quiet;
32707
+ const unsubscribe = subscribeQueriesChanged((aliases) => {
32708
+ if (!aliases.includes(alias)) return;
32709
+ clearTimeout(quiet);
32710
+ quiet = setTimeout(ask2, IMPORTS_BURST_QUIET_MS);
32711
+ });
32712
+ return () => {
32713
+ clearTimeout(quiet);
32714
+ unsubscribe();
32715
+ };
32716
+ }, [entity, ask2]);
32717
+ }
32718
+ function refusedByHost(error51, op) {
32719
+ return error51?.message === `Unknown RPC op: ${op}`;
32720
+ }
32721
+
32610
32722
  // src/recording.ts
32611
32723
  import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
32612
32724
  var NO_MOCKS = {};
@@ -32805,7 +32917,7 @@ function readSelect(value) {
32805
32917
  }
32806
32918
 
32807
32919
  // src/use_recents.ts
32808
- import { useCallback as useCallback8, useEffect as useEffect5, useRef as useRef4, useState as useState4 } from "react";
32920
+ import { useCallback as useCallback8, useEffect as useEffect6, useRef as useRef4, useState as useState4 } from "react";
32809
32921
  var PREFIX = "lotics.recents.";
32810
32922
  function read(storageKey) {
32811
32923
  try {
@@ -32830,7 +32942,7 @@ function useRecents(key, options = {}) {
32830
32942
  const maxRef = useRef4(options.max ?? 5);
32831
32943
  maxRef.current = options.max ?? 5;
32832
32944
  const [recents, setRecents] = useState4(() => read(storageKey).slice(0, maxRef.current));
32833
- useEffect5(() => {
32945
+ useEffect6(() => {
32834
32946
  setRecents(read(storageKey).slice(0, maxRef.current));
32835
32947
  }, [storageKey]);
32836
32948
  const remember = useCallback8(
@@ -32863,13 +32975,13 @@ function useRecents(key, options = {}) {
32863
32975
  }
32864
32976
 
32865
32977
  // src/use_url_state.ts
32866
- import { useCallback as useCallback9, useEffect as useEffect6, useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
32978
+ import { useCallback as useCallback9, useEffect as useEffect7, useMemo as useMemo5, useRef as useRef5, useState as useState5 } from "react";
32867
32979
  function useUrlState(defs) {
32868
32980
  const defsRef = useRef5(defs);
32869
32981
  defsRef.current = defs;
32870
32982
  const [params, setParams] = useState5(peekUrlParams);
32871
32983
  const editedRef = useRef5(false);
32872
- useEffect6(() => {
32984
+ useEffect7(() => {
32873
32985
  let active = true;
32874
32986
  const apply = (next) => {
32875
32987
  if (active) setParams((prev) => paramsEqual(prev, next) ? prev : next);
@@ -32961,6 +33073,8 @@ export {
32961
33073
  useFieldOptions,
32962
33074
  useFileUpload,
32963
33075
  useFolderPick,
33076
+ useImportRun,
33077
+ useImportsLog,
32964
33078
  useMembers,
32965
33079
  useNewRecord,
32966
33080
  useQueries,
package/dist/queries.d.ts CHANGED
@@ -76,6 +76,12 @@ export interface QueryOptions<C extends string = string> {
76
76
  enabled?: boolean;
77
77
  /** `false` skips a re-read on focus and reconnect. Default `true`. */
78
78
  revalidateOnFocus?: boolean;
79
+ /**
80
+ * `false` skips a re-read when the host says the query's tables moved and when this app writes to them: the rows stay
81
+ * as first read until `refetch` — a moment's rows read beside a live screen, never joining the render its pushes
82
+ * draw. Default `true`.
83
+ */
84
+ revalidateOnChange?: boolean;
79
85
  /** Part of the read's key; empty keeps the query's own order. */
80
86
  sort?: QuerySortKey<C>[];
81
87
  /** Part of the read's key. */
package/dist/router.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  isEmbedded,
3
3
  postHostNotification,
4
4
  setUrlParams
5
- } from "./chunk-IN4WP24U.js";
5
+ } from "./chunk-XL5LZFGB.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" | "imports.list" | "imports.run" | "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"; } | { 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[]; };
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[]; };
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,6 +16,9 @@ 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; };
19
22
  /** What an aggregate asks: output columns to group by, an output date grouped by its day, an output number summed. */
20
23
  export type QueryAggregate = {
21
24
  by?: readonly string[];
package/dist/store.d.ts CHANGED
@@ -4,6 +4,9 @@ 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
+ /** The host's push, naming the aliases whose tables moved: each key reading one asked again (`refetchReads`), in one
8
+ * paint. */
9
+ export declare function refetchPushed(aliases: readonly string[]): void;
7
10
  export interface Held<T> {
8
11
  /** The answer shown: each staged value over it, else the answer; the last key's while a new key is first asked. */
9
12
  data: T | undefined;
@@ -93,6 +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
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. |
97
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. |
98
99
  | `limit` | `number` | — | The first `limit` rows only — a **cap**, not pages; omit to read up to the server's row cap. |
@@ -145,9 +146,11 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
145
146
  renders the cached rows instantly *and* re-reads them in the background, so a list reflects what
146
147
  another screen changed while you were away. Identical concurrent reads share one request, and reads asked within a few milliseconds of each other go to the server together, each answered or refused on its own.
147
148
  Freshness comes from four places — arrival, window focus / tab return / network reconnect
148
- (`revalidateOnFocus`, default on; a read asked within the last few seconds is not asked again),
149
- a **realtime push**, and **this app's own successful write**, which re-reads the mounted queries
150
- over the tables its body names without being asked (see [./mutations.md](./mutations.md)).
149
+ (`revalidateOnFocus`, default on; a read asked within the last few seconds is not asked again,
150
+ and the reads it asks again re-render together, as a push's do — so what changed while the tab
151
+ was hidden or offline lands in one piece), a **realtime push**, and **this app's own successful
152
+ write**, which re-reads the mounted queries over the tables its body names without being asked
153
+ (see [./mutations.md](./mutations.md)) — the last two never for a read with `revalidateOnChange: false`.
151
154
  `refetch()` covers what none of those can know about.
152
155
  - **This app's own writes show from the press.** Every read draws each write this app has made
153
156
  over its rows before the server answers — cells, membership, new rows, `total`, `counts` and
@@ -157,7 +160,9 @@ write it as `{ node_type: "condition", type: "record_id", operator, value }`.
157
160
  - **Realtime push keeps an already-open screen current.** When a table one of your queries reads
158
161
  changes — another member, a workflow, the chat agent, or an external agent writing over the CLI or
159
162
  MCP — that query refetches within about a second. Nothing to wire: it follows from the query's own
160
- declaration, and only the aliases whose tables actually moved refetch. It carries RECORD
163
+ declaration, and only the aliases whose tables actually moved refetch. The queries one push names
164
+ re-render together — each answer held until all have landed, never past a second — so a screen never
165
+ shows one change in pieces and a slow query never holds the rest. It carries RECORD
161
166
  changes only (a schema edit does not push), and a **standalone/public app has no host**, so
162
167
  those apps keep focus/reconnect freshness alone. Push is absent whenever the channel cannot
163
168
  connect, so never treat it as a replacement for pull.
@@ -392,7 +392,9 @@ const older = useRecordActivity("job", recordId, { after: next });
392
392
  - **One entry per save.** Edits by one actor on one record less than ten minutes apart read as one
393
393
  entry: each field from its value before the first edit to its value after the last, a field edited
394
394
  back to where it began dropped (and an entry left with nothing). A comment, another actor or a
395
- longer pause starts another entry. `at` is the entry's first write, `until` its last.
395
+ longer pause starts another entry, and the save that made the record is an entry alone (`created`):
396
+ its maker's edits after it, however soon, are entries of their own. `at` is the entry's first
397
+ write, `until` its last.
396
398
  - **An entry** (`ActivityEntry`) is `kind: "change"` with `changes`, or `kind: "comment"` with
397
399
  `comment: { id, body, mentions }`. `actor`: `{ kind, id, name, image? }` — `member` (a person),
398
400
  `agent` (an AI acting for a member: the chat, an app's agent, an MCP client — named by that
@@ -410,6 +412,45 @@ const older = useRecordActivity("job", recordId, { after: next });
410
412
  - **Freshness:** read again when a push or this app's own write names the entity's reads, when a
411
413
  comment is written or deleted through `useComments`, on focus, and on `refetch()`.
412
414
 
415
+ ## A register's imports log: `useImportsLog`, `useImportRun`
416
+
417
+ The workflows marked as importing into the register's table (an automation's `imports_into`) and
418
+ their runs, each with how it ended and how many rows it added, changed and removed; and one run's
419
+ rows, each with its fields before and after (`dist/imports.d.ts`). A JSON app whose model states
420
+ `register.imports: true` keeps it. `entity` is the register's entity alias.
421
+
422
+ ```tsx
423
+ const { workflows, runs, next, loading, error, available, refetch } = useImportsLog("order");
424
+ const { run, rows } = useImportRun("order", runs[0]?.id ?? "");
425
+ ```
426
+
427
+ - **Authority: the app's own reads**, as record activity's: only the fields the app reads appear, and
428
+ a row counts — and is listed — only where the register's own read reaches it for the viewer; a
429
+ removed row, where it reached it before it was removed. A row the viewer does not see is in no
430
+ count and does not make a failed run `partial`.
431
+ - **Members of the app's organization only.** Standalone, in a host that bridges no imports log, and
432
+ where the register keeps none (the server answers `{ kind: "none" }`), `available` is `false`,
433
+ `error` is `null` and nothing is shown. Any other refusal is an `error`.
434
+ - **The workflows** (`ImportWorkflow`): `{ id, name }`, every one importing into the table whether
435
+ or not it has run, by name.
436
+ - **A run** (`ImportRun`): `{ id, workflow: { id, name }, at, until?, end, added, changed, removed,
437
+ stopped? }`. `end` is `running`, `done`, `failed` (it wrote none of the rows the viewer sees) or
438
+ `partial` (it failed after writing some). `until` is absent while it runs. A `failed` or `partial`
439
+ run carries `stopped: { step?, reason? }` (`ImportRunStop`): the description of the step it stopped
440
+ at, for every viewer; and the error it recorded there, only for a viewer who manages the automation
441
+ (it can quote data from tables the viewer cannot open) — each absent where the run recorded none.
442
+ - **A run's row** (`ImportRow`): `{ record_id, kind, at, changes }`, one per row however many times
443
+ the run wrote it — `added`, `removed`, or `changed` (a field the viewer sees left other than the
444
+ run found it). `changes` are record activity's (`ActivityChange`): an added row's fields from
445
+ `null`, a removed row's to `null`. Rows come in the order the run first wrote them.
446
+ - **Pages**: up to twenty runs — fewer where they wrote thousands of rows between them, always at
447
+ least one — or fifty rows; `next` continues — `undefined` at the end. A page of rows may hold fewer
448
+ and still say `next`, where the rows it looked through were ones the viewer does not see.
449
+ - **Freshness:** the log is read again once pushes naming the entity's rows have stopped for two
450
+ seconds — an import's writes landing — never with each push, so the register's own rows never wait
451
+ on it; a run's rows only while it runs. Both are read again on focus and on `refetch()`. A run that
452
+ wrote nothing pushes nothing.
453
+
413
454
  ## Where each hook works
414
455
 
415
456
  | Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
@@ -422,3 +463,4 @@ const older = useRecordActivity("job", recordId, { after: next });
422
463
  | `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
423
464
  | `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
424
465
  | `useRecordActivity` | ✓ the app's organization's members | ✗ `available: false` |
466
+ | `useImportsLog` / `useImportRun` | ✓ the app's organization's members | ✗ `available: false` |
package/docs/security.md CHANGED
@@ -11,6 +11,7 @@ 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 |
14
15
 
15
16
  Consequences of owner authority:
16
17
 
@@ -103,6 +104,7 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
103
104
  | 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) |
104
105
  | Comments (`useComments`) | **No** — members-only |
105
106
  | Record activity (`useRecordActivity`) | **No** — the app's organization's members only |
107
+ | Imports log (`useImportsLog`, `useImportRun`) | **No** — the app's organization's members only |
106
108
  | Member roster (`useMembers`) | **No** — same-org members only (below) |
107
109
 
108
110
  **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/docs/workflows.md CHANGED
@@ -704,7 +704,7 @@ tool calls it makes**, and the two cheapest wins are always the same: don't fetc
704
704
  have, and don't write one row at a time.
705
705
 
706
706
  **A write costs more than a read.** A create or update drags its computed-field cascades, rollups
707
- and `after_*` hooks behind it before the call returns, so trimming reads is worth less than
707
+ and the table's checks behind it before the call returns, so trimming reads is worth less than
708
708
  trimming writes. Reach for the write-side reductions first.
709
709
 
710
710
  ### Batch every write — and build the array INLINE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.117.1",
3
+ "version": "0.118.1",
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": {