@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 +1 -1
- package/dist/{chunk-IN4WP24U.js → chunk-XL5LZFGB.js} +3 -0
- package/dist/imports.d.ts +51 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +148 -29
- package/dist/queries.d.ts +6 -0
- package/dist/router.js +1 -1
- package/dist/rpc.d.ts +1 -1
- package/dist/shared_types.d.ts +5 -2
- package/dist/store.d.ts +3 -0
- package/docs/data_fetching.md +9 -4
- package/docs/members_and_options.md +43 -1
- package/docs/security.md +2 -0
- package/package.json +1 -1
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-
|
|
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
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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(
|
|
615
|
+
subscribeQueriesChanged(refetchPushed);
|
|
577
616
|
const refocus = () => {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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"})
|
|
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(", ")}})
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
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;
|
package/dist/shared_types.d.ts
CHANGED
|
@@ -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:
|
|
7
|
-
export type AppAbsence = { member:
|
|
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;
|
package/docs/data_fetching.md
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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.
|
|
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
|
|
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