@lotics/app-sdk 0.117.0 → 0.118.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`). |
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";
@@ -126,7 +126,7 @@ function installVisibleErrorHandlers(container) {
126
126
  container.parentElement?.insertBefore(banner, container);
127
127
  };
128
128
  window.addEventListener("error", (e) => {
129
- showError(`Uncaught error: ${e.message}
129
+ if (e.error !== null && e.error !== void 0) showError(`Uncaught error: ${e.message}
130
130
  ${e.error?.stack ?? ""}`);
131
131
  reportAppError("uncaught", e.error ?? e.message);
132
132
  });
@@ -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);
@@ -24277,6 +24314,7 @@ var kitGlyphSchema = zod_default.string().superRefine((name, ctx) => {
24277
24314
  const refused = kitGlyphRefusal(name);
24278
24315
  if (refused !== void 0) ctx.addIssue({ code: "custom", message: refused });
24279
24316
  });
24317
+ var AVATAR_SUBJECTS = ["person", "organization"];
24280
24318
  var optionMarkSchema = zod_default.discriminatedUnion("kind", [
24281
24319
  zod_default.object({
24282
24320
  kind: zod_default.literal("brand"),
@@ -24285,12 +24323,16 @@ var optionMarkSchema = zod_default.discriminatedUnion("kind", [
24285
24323
  zod_default.object({
24286
24324
  kind: zod_default.literal("icon"),
24287
24325
  name: kitGlyphSchema.describe(`A kebab-case glyph from the kit's curated Lucide set, e.g. "wrench"`)
24326
+ }).strict(),
24327
+ zod_default.object({
24328
+ kind: zod_default.literal("avatar"),
24329
+ name: zod_default.enum(AVATAR_SUBJECTS).describe("Who the option IS, drawn as a face from its label: a person's disc or an organization's square")
24288
24330
  }).strict()
24289
24331
  ]).describe(
24290
- `The option's own mark, drawn in place of its colour dot wherever the option is shown: the brand it is ({kind: "brand"}) or a kit glyph ({kind: "icon"}). Every option of a field has one, or none does`
24332
+ `The option's own mark, drawn in place of its colour dot wherever the option is shown: the brand it is ({kind: "brand"}), a kit glyph ({kind: "icon"}) or the face of who it is ({kind: "avatar"}). Every option of a field has one, or none does`
24291
24333
  );
24292
24334
  var storedOptionMarkSchema = zod_default.object({ kind: zod_default.string(), name: zod_default.string() }).describe(
24293
- `The option's own mark, drawn in place of its colour dot wherever the option is shown: the brand it is ({kind: "brand", name: one of ${BRAND_NAMES.join(", ")}}) or a kit glyph ({kind: "icon", name: "wrench"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot`
24335
+ `The option's own mark, drawn in place of its colour dot wherever the option is shown: the brand it is ({kind: "brand", name: one of ${BRAND_NAMES.join(", ")}}) a kit glyph ({kind: "icon", name: "wrench"}) or the face of who it is, drawn from its label ({kind: "avatar", name: "person" or "organization"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot`
24294
24336
  );
24295
24337
  function unevenMarks(options, nameOf2) {
24296
24338
  const bare = options.flatMap((option, index) => option.mark === void 0 ? [nameOf2(index)] : []);
@@ -25202,6 +25244,9 @@ var workflowSchema = zod_default.object({
25202
25244
  metadata: workflowMetadataSchema.nullable().optional().describe("Metadata for the workflow"),
25203
25245
  current_version: zod_default.number().int().describe("Current version number"),
25204
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
+ ),
25205
25250
  workspace_id: zod_default.string().describe("ID of the workspace this workflow belongs to"),
25206
25251
  created_at: zod_default.string().describe("Timestamp when workflow was created"),
25207
25252
  updated_at: zod_default.string().describe("Timestamp of last update")
@@ -32239,6 +32284,9 @@ function useQuery(alias, params = {}, opts = {}) {
32239
32284
  if (modes.length > 1) throw new Error(`useQuery("${alias}") takes one of \`limit\`, \`page\` and \`more\`; it was given ${modes.length}.`);
32240
32285
  const enabled = opts.enabled ?? true;
32241
32286
  const focus = opts.revalidateOnFocus ?? true;
32287
+ const live = opts.revalidateOnChange ?? true;
32288
+ const aliases = live ? [alias] : [];
32289
+ const apart = live ? [] : ["held"];
32242
32290
  const sort = opts.sort !== void 0 && opts.sort.length > 0 ? opts.sort : void 0;
32243
32291
  const filter2 = opts.filter;
32244
32292
  const wantsRows = opts.rows ?? true;
@@ -32252,7 +32300,7 @@ function useQuery(alias, params = {}, opts = {}) {
32252
32300
  const page = held2 !== void 0 ? Math.max(0, held2.at) : opts.page === void 0 || paging.call !== call ? 0 : paging.page;
32253
32301
  const turn2 = held2?.onChange;
32254
32302
  const setPage = useCallback5((next) => turn2 === void 0 ? setPaging({ call, page: Math.max(0, next) }) : turn2(Math.max(0, next)), [call, turn2]);
32255
- 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]);
32256
32304
  const rows = useKey(
32257
32305
  rowsKey,
32258
32306
  (askedAt) => askRows(rowsKey ?? "", alias, askedAt, {
@@ -32262,9 +32310,9 @@ function useQuery(alias, params = {}, opts = {}) {
32262
32310
  limit: opts.page ?? opts.limit,
32263
32311
  offset: opts.page === void 0 ? 0 : page * opts.page
32264
32312
  }),
32265
- { 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 }
32266
32314
  );
32267
- 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]);
32268
32316
  const feedSize = opts.more ?? 0;
32269
32317
  const feed = useKey(
32270
32318
  feedKey,
@@ -32289,7 +32337,7 @@ function useQuery(alias, params = {}, opts = {}) {
32289
32337
  return { pages };
32290
32338
  },
32291
32339
  // A feed re-read on arrival would reshuffle it under a reader scrolling it.
32292
- { focus, arrival: false, aliases: [alias] }
32340
+ { focus, arrival: false, aliases }
32293
32341
  );
32294
32342
  const heldPages = feed.data?.pages.length ?? 0;
32295
32343
  const feedHasMore = feed.data?.pages.at(-1)?.next_cursor != null;
@@ -32299,11 +32347,11 @@ function useQuery(alias, params = {}, opts = {}) {
32299
32347
  feedWants.set(feedKey, heldPages + 1);
32300
32348
  feed.refetch();
32301
32349
  }, [feedKey, feedHasMore, heldPages, feed]);
32302
- 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]);
32303
32351
  const countCall = { params, ...filter2 === void 0 ? {} : { filter: filter2 }, ...by === void 0 ? {} : { by }, ...where === void 0 ? {} : { where } };
32304
32352
  const count = useKey(countKey, (askedAt) => askCount(countKey ?? "", alias, askedAt, countCall), {
32305
32353
  focus,
32306
- aliases: [alias],
32354
+ aliases,
32307
32355
  known: mocking ? () => knownCount(alias, countCall) : void 0
32308
32356
  });
32309
32357
  const version3 = useWrittenVersion();
@@ -32602,6 +32650,75 @@ function useRecordActivity(entity, record_id, options = {}) {
32602
32650
  };
32603
32651
  }
32604
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
+
32605
32722
  // src/recording.ts
32606
32723
  import { useCallback as useCallback7, useMemo as useMemo4, useSyncExternalStore as useSyncExternalStore3 } from "react";
32607
32724
  var NO_MOCKS = {};
@@ -32800,7 +32917,7 @@ function readSelect(value) {
32800
32917
  }
32801
32918
 
32802
32919
  // src/use_recents.ts
32803
- 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";
32804
32921
  var PREFIX = "lotics.recents.";
32805
32922
  function read(storageKey) {
32806
32923
  try {
@@ -32825,7 +32942,7 @@ function useRecents(key, options = {}) {
32825
32942
  const maxRef = useRef4(options.max ?? 5);
32826
32943
  maxRef.current = options.max ?? 5;
32827
32944
  const [recents, setRecents] = useState4(() => read(storageKey).slice(0, maxRef.current));
32828
- useEffect5(() => {
32945
+ useEffect6(() => {
32829
32946
  setRecents(read(storageKey).slice(0, maxRef.current));
32830
32947
  }, [storageKey]);
32831
32948
  const remember = useCallback8(
@@ -32858,13 +32975,13 @@ function useRecents(key, options = {}) {
32858
32975
  }
32859
32976
 
32860
32977
  // src/use_url_state.ts
32861
- 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";
32862
32979
  function useUrlState(defs) {
32863
32980
  const defsRef = useRef5(defs);
32864
32981
  defsRef.current = defs;
32865
32982
  const [params, setParams] = useState5(peekUrlParams);
32866
32983
  const editedRef = useRef5(false);
32867
- useEffect6(() => {
32984
+ useEffect7(() => {
32868
32985
  let active = true;
32869
32986
  const apply = (next) => {
32870
32987
  if (active) setParams((prev) => paramsEqual(prev, next) ? prev : next);
@@ -32956,6 +33073,8 @@ export {
32956
33073
  useFieldOptions,
32957
33074
  useFileUpload,
32958
33075
  useFolderPick,
33076
+ useImportRun,
33077
+ useImportsLog,
32959
33078
  useMembers,
32960
33079
  useNewRecord,
32961
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.117.0",
3
+ "version": "0.118.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": {