@notis_ai/cli 0.2.0-beta.155.1 → 0.2.0-beta.156.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13830,7 +13830,7 @@ function runtimeCallLabel(call) {
13830
13830
  }
13831
13831
  return call?.op || "runtime call";
13832
13832
  }
13833
- function assertHarnessResult(result, route, databaseSlugs, mode = "stub") {
13833
+ function assertHarnessResult(result, route, databaseSlugs, mode = "stub", capabilities = {}) {
13834
13834
  const assertions = [];
13835
13835
  if (result.tool_error) {
13836
13836
  assertions.push({
@@ -13869,7 +13869,7 @@ function assertHarnessResult(result, route, databaseSlugs, mode = "stub") {
13869
13869
  );
13870
13870
  for (const call of databaseQueries) {
13871
13871
  const databaseSlug = call?.args?.arguments?.database_slug;
13872
- if (databaseSlug && !declaredDatabaseSet.has(databaseSlug)) {
13872
+ if (databaseSlug && !declaredDatabaseSet.has(databaseSlug) && capabilities.workspaceDatabases !== "read") {
13873
13873
  assertions.push({
13874
13874
  ok: false,
13875
13875
  code: "undeclared_database_query",
@@ -14963,7 +14963,8 @@ ${listing.errors.map((error) => ` - ${error}`).join("\n")}`);
14963
14963
  result,
14964
14964
  route,
14965
14965
  declaredDatabaseSlugs(appConfig, manifest, route),
14966
- mode
14966
+ mode,
14967
+ manifest.capabilities || appConfig.capabilities || {}
14967
14968
  );
14968
14969
  return {
14969
14970
  ...result,
@@ -14988,7 +14989,8 @@ ${listing.errors.map((error) => ` - ${error}`).join("\n")}`);
14988
14989
  result,
14989
14990
  route,
14990
14991
  declaredDatabaseSlugs(appConfig, manifest, route),
14991
- mode
14992
+ mode,
14993
+ manifest.capabilities || appConfig.capabilities || {}
14992
14994
  );
14993
14995
  results.push({
14994
14996
  route: route.slug,
@@ -104,7 +104,7 @@ App code never accesses the runtime directly -- it uses SDK hooks (`useTool`, `u
104
104
 
105
105
  1. **React + Vite only** -- No Next.js, no custom server
106
106
  2. **ES module bundle** -- Vite builds a library-mode bundle with React externalized
107
- 3. **Component rendering** -- Apps render as React components directly in the portal. No iframes.
107
+ 3. **Component rendering** -- Apps render as React components directly in the portal. Do not create your own iframe; the host chooses the trusted shadow-root or isolated Store rendering path.
108
108
  The portal owns the `ShadowRoot`, theme tokens, and runtime provider.
109
109
  4. **HTTP bridge** -- Runtime calls use fetch to `/portal_views/runtime_query`
110
110
  5. **Declarative tools** -- Tool access is declared in `notis.config.ts` by the final names returned by tool discovery and enforced server-side. Views can call native Notis, connected integrations, PostForMe, and MCP tools directly; metered calls use the same credit-cap and usage-billing path as the CLI.
@@ -293,40 +293,43 @@ For arbitrary app-owned resources that are not Notis collection rows, set `resou
293
293
  Standard React pages in `app/`. Use generic SDK tool hooks for data and build on top of the scaffolded shadcn components and portal shell classes (`notis-app-shell`, `notis-app-surface`):
294
294
 
295
295
  ```tsx
296
- 'use client';
297
- import { useEffect, useState } from 'react';
298
- import { useTool } from '@notis/sdk';
296
+ import { useDocuments, ViewSkeleton } from '@notis/sdk';
299
297
  import { Card } from '@/components/ui/card';
300
298
 
301
- type QueryTasksArgs = { database_id?: string; database_slug?: string; query: { page_size?: number } };
302
- type TaskDoc = { document_id?: string; id?: string; title?: string; properties?: Record<string, unknown> };
303
- type QueryTasksResult = { documents?: TaskDoc[] };
304
-
305
299
  export default function TasksPage() {
306
- const queryTasks = useTool<QueryTasksArgs, QueryTasksResult>('LOCAL_NOTIS_DATABASE_QUERY');
307
- const [documents, setDocuments] = useState<TaskDoc[]>([]);
308
-
309
- useEffect(() => {
310
- void queryTasks
311
- .call({ database_id: 'tasks-db-id', query: { page_size: 25 } })
312
- .then((result) => setDocuments(result.documents || []));
313
- }, [queryTasks.call]);
314
-
315
- if (queryTasks.loading) return <div>Loading...</div>;
316
-
317
- return (
318
- <div className="p-6 space-y-4">
319
- {documents.map((doc) => (
320
- <Card key={doc.id || doc.document_id} className="p-4">
321
- <h3>{doc.title || 'Untitled'}</h3>
322
- <p className="text-muted-foreground">{String(doc.properties?.status || '')}</p>
323
- </Card>
324
- ))}
325
- </div>
326
- );
300
+ const tasks = useDocuments('tasks', { pageSize: 25 });
301
+ return <section className="space-y-4 p-6">
302
+ <h1 className="text-xl font-semibold">Tasks</h1>
303
+ {tasks.error && <p role="alert">{tasks.error.message} <button onClick={tasks.refetch}>Retry</button></p>}
304
+ {tasks.loading ? <ViewSkeleton variant="table" rows={5} /> : tasks.hasData ? (
305
+ tasks.documents.length ? tasks.documents.map((task) => (
306
+ <Card key={task.id} className="p-4"><h2>{task.title || 'Untitled'}</h2></Card>
307
+ )) : <p>No tasks yet.</p>
308
+ ) : null}
309
+ </section>;
327
310
  }
328
311
  ```
329
312
 
313
+ ### Instant-view loading contract (required)
314
+
315
+ Build a client-side, multi-route app with one persistent `app/layout.tsx` shell. Navigate with `useNotisNavigation`; never use a document reload for an internal route. “SPA” means preserving that shell and reusing reads, **not** mounting every page or fetching every database at startup.
316
+
317
+ | State | Required UI |
318
+ | --- | --- |
319
+ | First read, no successful data | Keep headings/navigation/layout visible; use content-shaped skeletons only in missing regions. No page spinner or whole-page `Loading...`. |
320
+ | Cached view / successful empty result | Render synchronously from the shared SDK cache. Empty results are real cached results. |
321
+ | Background refresh | Keep current content and selection. Never replace populated content with a skeleton; do not drive the top-bar spinner from mount/refetch state. |
322
+ | Explicit Save / Upload / submitted search | Progress belongs in that button or affected section. Disable only the conflicting action. |
323
+ | Failed read | Show a scoped error and Retry; keep usable cached content. Never show an empty-state message before `hasData` is true. |
324
+
325
+ Use `useDocuments`, `useDocument`, `useDatabaseSchema`, and `useDatabaseSubscription` for native reads. `loading` means no first successful response; `isFetching` includes silent refresh. Do not copy their data into mount-only state, clear rows on error, or gate the entire app on `isFetching`.
326
+
327
+ For another **explicitly identified idempotent read**, use `useToolQuery<Result>(toolName, exactArguments, { readOnly: true })`, or `useQuery(keyArray, readCallback, { readOnly: true })`. Include every filter, selected resource, pagination option, and other input in the key. Call tools within a custom read with `{ readOnly: true, dedupe: true }`; the same SQL/shell tool can also perform writes, so never mark a whole toolkit read-only. Leave mutations as ordinary `useTool` actions.
328
+
329
+ `useQueryClient().prefetch(keyArray, readCallback, { readOnly: true })` prepares small known reads after the current view has rendered or on hover/focus. It shares the host's two-request speculative budget. Match the exact foreground query key. Never prefetch a mutation, login/polling action, provider sweep, `fetchAll` query, or an aggregate that fans out into more requests. Do not invent tool names to prepare a view. Older hosts safely fall back to uncached hook-local reads and skip prefetch.
330
+
331
+ Caches belong to the host's in-memory account/environment/app/version/effective-permission scope. Do not add module-global or `localStorage` caches of user data. Writes and realtime events invalidate reads; logout, access loss, and updates retire scopes. Preserve the last successful snapshot on an ordinary network failure.
332
+
330
333
  ### Discovering database schema
331
334
 
332
335
  Before writing app code, inspect the database schema to know what properties exist:
@@ -367,7 +370,7 @@ Do NOT pass Notion-style wrappers (`{select: {name: "Todo"}}`) when upserting.
367
370
  - If a screen looks like a standalone microsite instead of a portal tool, it is too custom.
368
371
  - For Notes-style apps, the folder tree belongs to the portal sidebar when configured via `collection.sidebar`. The page content should complement that chrome, not duplicate or replace it.
369
372
  - Never indicate selected items with a heavy left-border bar (e.g. `border-l-2 border-l-foreground` paired with a muted background). It looks dated and clashes with the portal chrome. Use a single subtle background change (`bg-muted` for selected, `hover:bg-muted/50` for hover) and let typography or an icon carry the rest of the state.
370
- - Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives. The hook also exposes `setLoading` so the standard top-bar spinner reflects in-flight queries.
373
+ - Do not render any search input inside the app (in-page search rails, "Ask Notis…" pills, command-palette-style bars, etc.). The portal already owns the top-bar search field. Wire your view to it with `useTopBarSearch({ value, onChange, placeholder, onSubmit })` from `@notis/sdk` and let the page filter or refetch on the values it receives. Use its `setLoading` only for an explicit submitted search, never initial view loading or background refresh.
371
374
 
372
375
  ### Sidebar invariants
373
376
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.155.1",
3
+ "version": "0.2.0-beta.156.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -516,7 +516,7 @@ function runtimeCallLabel(call) {
516
516
  return call?.op || 'runtime call';
517
517
  }
518
518
 
519
- function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
519
+ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub', capabilities = {}) {
520
520
  const assertions = [];
521
521
  if (result.tool_error) {
522
522
  assertions.push({
@@ -557,7 +557,7 @@ function assertHarnessResult(result, route, databaseSlugs, mode = 'stub') {
557
557
  );
558
558
  for (const call of databaseQueries) {
559
559
  const databaseSlug = call?.args?.arguments?.database_slug;
560
- if (databaseSlug && !declaredDatabaseSet.has(databaseSlug)) {
560
+ if (databaseSlug && !declaredDatabaseSet.has(databaseSlug) && capabilities.workspaceDatabases !== 'read') {
561
561
  assertions.push({
562
562
  ok: false,
563
563
  code: 'undeclared_database_query',
@@ -1842,6 +1842,7 @@ async function appsVerifyHandler(ctx) {
1842
1842
  route,
1843
1843
  declaredDatabaseSlugs(appConfig, manifest, route),
1844
1844
  mode,
1845
+ manifest.capabilities || appConfig.capabilities || {},
1845
1846
  );
1846
1847
  return {
1847
1848
  ...result,
@@ -1867,6 +1868,7 @@ async function appsVerifyHandler(ctx) {
1867
1868
  route,
1868
1869
  declaredDatabaseSlugs(appConfig, manifest, route),
1869
1870
  mode,
1871
+ manifest.capabilities || appConfig.capabilities || {},
1870
1872
  );
1871
1873
  results.push({
1872
1874
  route: route.slug,
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { getDocumentPreview, useDocuments, useNotis } from '@notis/sdk';
3
+ import { getDocumentPreview, useDocuments, useNotis, Skeleton, ViewSkeleton } from '@notis/sdk';
4
4
  import { Badge } from '@/components/ui/badge';
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6
6
 
@@ -8,7 +8,7 @@ export default function HomePage() {
8
8
  const { app, ready } = useNotis();
9
9
  // Documents come back normalized: plain property values, camelCase fields,
10
10
  // and typed content (contentMarkdown / contentBlocknote / plainText).
11
- const { documents, loading } = useDocuments('items', { pageSize: 25 });
11
+ const { documents, loading, hasData, error, refetch } = useDocuments('items', { pageSize: 25 });
12
12
 
13
13
  return (
14
14
  <main className="notis-app-shell space-y-6">
@@ -16,9 +16,9 @@ export default function HomePage() {
16
16
  <CardHeader className="space-y-3">
17
17
  <Badge variant="secondary" className="w-fit">Installed app</Badge>
18
18
  <div className="space-y-2">
19
- <CardTitle>{ready ? app?.name : 'Loading...'}</CardTitle>
19
+ <CardTitle>{ready ? app?.name : <Skeleton style={{ width: 160 }} />}</CardTitle>
20
20
  <CardDescription>
21
- {ready ? app?.description : 'Loading app metadata...'}
21
+ {ready ? app?.description : <Skeleton style={{ width: 240 }} />}
22
22
  </CardDescription>
23
23
  </div>
24
24
  </CardHeader>
@@ -30,11 +30,12 @@ export default function HomePage() {
30
30
  <CardDescription>Use shadcn surfaces and portal tokens so the app feels native inside Notis.</CardDescription>
31
31
  </CardHeader>
32
32
  <CardContent>
33
+ {error && <p role="alert" className="mb-3 text-sm text-destructive">{error.message} <button onClick={refetch}>Retry</button></p>}
33
34
  {loading ? (
34
- <p className="text-sm text-muted-foreground">Loading...</p>
35
- ) : documents.length === 0 ? (
35
+ <ViewSkeleton variant="table" rows={4} />
36
+ ) : !hasData ? null : documents.length === 0 ? (
36
37
  <div className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
37
- No items yet. Deploy the app and create some.
38
+ No items yet. Create your first item.
38
39
  </div>
39
40
  ) : (
40
41
  <div className="space-y-3">
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import React, {
4
+ useEffect,
4
5
  useMemo,
6
+ useRef,
5
7
  useState,
6
8
  type CSSProperties,
7
9
  type ReactElement,
@@ -29,7 +31,7 @@ export interface MultiSelectActionBarProps {
29
31
 
30
32
  const containerBaseStyle: CSSProperties = {
31
33
  position: 'fixed',
32
- bottom: '1rem',
34
+ bottom: 'calc(var(--notis-viewport-bottom, 0px) + max(1rem, var(--notis-safe-area-bottom, env(safe-area-inset-bottom, 0px))))',
33
35
  left: '50%',
34
36
  transform: 'translateX(-50%)',
35
37
  zIndex: 60,
@@ -46,6 +48,8 @@ const containerBaseStyle: CSSProperties = {
46
48
  pointerEvents: 'auto',
47
49
  fontSize: '13px',
48
50
  lineHeight: 1.2,
51
+ width: 'max-content',
52
+ maxWidth: 'calc(100% - 2rem)',
49
53
  };
50
54
 
51
55
  const countStyle: CSSProperties = {
@@ -115,6 +119,26 @@ export function MultiSelectActionBar({
115
119
  shortcutsEnabled = true,
116
120
  shortcutScope = 'collection',
117
121
  }: MultiSelectActionBarProps): ReactElement | null {
122
+ const barRef = useRef<HTMLDivElement>(null);
123
+ const visible = selectedCount > 0;
124
+ useEffect(() => {
125
+ const bar = barRef.current;
126
+ if (!visible || !bar) return;
127
+ // Also reaches a Portal launcher when an app renders inside a shadow root.
128
+ const root = document.documentElement;
129
+ const property = '--notis-bulk-actions-height';
130
+ const previous = root.style.getPropertyValue(property);
131
+ const update = () => root.style.setProperty(property, `${bar.getBoundingClientRect().height + 12}px`);
132
+ update();
133
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
134
+ observer?.observe(bar);
135
+ return () => {
136
+ observer?.disconnect();
137
+ if (previous) root.style.setProperty(property, previous);
138
+ else root.style.removeProperty(property);
139
+ };
140
+ }, [visible]);
141
+
118
142
  const actionShortcuts = useMemo<ShortcutDefinition[]>(() => {
119
143
  return actions.flatMap((action): ShortcutDefinition[] => {
120
144
  if (!action.shortcut || action.disabled || action.pending) return [];
@@ -147,16 +171,33 @@ export function MultiSelectActionBar({
147
171
 
148
172
  return (
149
173
  <div
174
+ ref={barRef}
175
+ data-notis-bulk-actions
150
176
  role="toolbar"
151
177
  aria-label={`Bulk actions for ${selectedCount} selected ${countWord}`}
152
178
  className={className}
153
179
  style={composedStyle}
154
180
  >
155
- <span style={countStyle}>{countLabel}</span>
156
- {actions.length > 0 ? <span aria-hidden style={dividerStyle} /> : null}
157
- {actions.map((action) => (
158
- <ActionButton key={action.id} action={action} />
159
- ))}
181
+ <style>{`
182
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: none !important; }
183
+ @media (max-width: 639px) {
184
+ [data-notis-bulk-actions] { flex-wrap: wrap; width: calc(100% - 2rem) !important; }
185
+ [data-notis-bulk-count] { flex-basis: 100%; padding: 0.375rem 0.5rem !important; font-size: 14px !important; }
186
+ [data-notis-bulk-divider] { display: none; }
187
+ [data-notis-bulk-action-list] { width: 100%; }
188
+ [data-notis-bulk-actions] button { min-height: 48px; font-size: 16px !important; }
189
+ [data-notis-bulk-actions] kbd { display: none !important; }
190
+ [data-notis-bulk-action-icon][data-has-shortcut] { display: inline-flex !important; }
191
+ }
192
+ @media (hover: none) { [data-notis-bulk-actions] kbd { display: none !important; } }
193
+ `}</style>
194
+ <span data-notis-bulk-count style={countStyle}>{countLabel}</span>
195
+ {actions.length > 0 ? <span data-notis-bulk-divider aria-hidden style={dividerStyle} /> : null}
196
+ <div data-notis-bulk-action-list style={{ display: 'flex', minWidth: 0, overflowX: 'auto', overscrollBehaviorX: 'contain', gap: '0.25rem' }}>
197
+ {actions.map((action) => (
198
+ <ActionButton key={action.id} action={action} />
199
+ ))}
200
+ </div>
160
201
  </div>
161
202
  );
162
203
  }
@@ -192,8 +233,8 @@ function ActionButton({ action }: { action: MultiSelectAction }) {
192
233
  onBlur={() => setHover(false)}
193
234
  style={buttonStyle}
194
235
  >
195
- {!action.shortcut && action.icon ? (
196
- <span aria-hidden style={iconSlotStyle}>{action.icon}</span>
236
+ {action.icon ? (
237
+ <span data-notis-bulk-action-icon data-has-shortcut={action.shortcut ? '' : undefined} aria-hidden style={iconSlotStyle}>{action.icon}</span>
197
238
  ) : null}
198
239
  {display ? <kbd aria-hidden style={keycapStyle}>{display}</kbd> : null}
199
240
  <span>{action.pending ? `${action.label}…` : action.label}</span>
@@ -0,0 +1,24 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ /** Content-shaped placeholder. Never use it to replace a successful cached result. */
4
+ export function Skeleton({ className, style }: { className?: string; style?: CSSProperties }) {
5
+ return <span aria-hidden="true" className={className} style={{ display: 'block', height: 16,
6
+ // Apps may define --muted as HSL channels or a complete CSS color. A
7
+ // neutral default remains visible with either token format and no theme.
8
+ borderRadius: 6, background: 'rgba(128,128,128,.18)', ...style }} />;
9
+ }
10
+
11
+ export function ViewSkeleton({ variant = 'table', rows = 5 }: {
12
+ variant?: 'table' | 'cards' | 'graph' | 'detail'; rows?: number;
13
+ }) {
14
+ return <div role="status" aria-label="Loading content" aria-busy="true" data-notis-content-skeleton
15
+ style={{ width: '100%', display: 'grid', gap: 16, padding: 24 }}>
16
+ <Skeleton style={{ width: '32%', height: 28 }} />
17
+ {variant === 'graph' ? <Skeleton style={{ height: 420 }} /> :
18
+ <div style={{ display: 'grid', gap: 12, gridTemplateColumns: variant === 'cards' ? 'repeat(auto-fit,minmax(180px,1fr))' : '1fr' }}>
19
+ {Array.from({ length: rows }, (_, index) => <Skeleton key={index}
20
+ style={{ height: variant === 'cards' ? 112 : variant === 'detail' ? 20 : 44,
21
+ width: variant === 'detail' && index === rows - 1 ? '65%' : '100%' }} />)}
22
+ </div>}
23
+ </div>;
24
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useRef } from 'react';
4
+ import { useQuery } from './useQuery';
4
5
  import { useNotisRuntime } from '../provider';
5
6
  import type { CloudComputerFacts } from '../runtime';
6
7
 
@@ -11,6 +12,7 @@ export interface UseCloudComputerResult {
11
12
  */
12
13
  facts: CloudComputerFacts | null;
13
14
  loading: boolean;
15
+ isFetching: boolean;
14
16
  error: Error | null;
15
17
  /** Re-read the facts. The platform caches them for a few minutes. */
16
18
  refresh: () => Promise<void>;
@@ -47,51 +49,16 @@ const UNAVAILABLE: CloudComputerFacts = {
47
49
  */
48
50
  export function useCloudComputer(): UseCloudComputerResult {
49
51
  const runtime = useNotisRuntime();
50
- const [facts, setFacts] = useState<CloudComputerFacts | null>(null);
51
- // True from the first committed render: the initial read is already queued
52
- // in an effect, and `{ loading: false, facts: null }` would flash a
53
- // consumer's fallback branch before the answer arrives.
54
- const [loading, setLoading] = useState(true);
55
- const [error, setError] = useState<Error | null>(null);
56
- const mounted = useRef(true);
57
-
58
- useEffect(() => {
59
- mounted.current = true;
60
- return () => {
61
- mounted.current = false;
62
- };
63
- }, []);
64
-
65
- const read = useCallback(async (options?: { refresh?: boolean }) => {
66
- if (!runtime?.cloudComputerFacts) {
67
- setFacts(UNAVAILABLE);
68
- setLoading(false);
69
- return;
70
- }
71
-
72
- setLoading(true);
73
- setError(null);
74
- try {
75
- const next = await runtime.cloudComputerFacts(options);
76
- if (mounted.current) setFacts(next);
77
- } catch (err) {
78
- const e = err instanceof Error ? err : new Error(String(err));
79
- if (mounted.current) {
80
- setError(e);
81
- // A refused or failed read is the same product state as a host that
82
- // cannot answer: the app shows its fallback instead of an error.
83
- setFacts(UNAVAILABLE);
84
- }
85
- } finally {
86
- if (mounted.current) setLoading(false);
87
- }
88
- }, [runtime]);
89
-
90
- useEffect(() => {
91
- void read();
92
- }, [read]);
93
-
94
- const refresh = useCallback(() => read({ refresh: true }), [read]);
95
-
96
- return { facts, loading, error, refresh };
52
+ const explicitRefresh = useRef(false);
53
+ const query = useQuery<CloudComputerFacts>(['cloud-computer-facts'], async () => {
54
+ const refresh = explicitRefresh.current;
55
+ explicitRefresh.current = false;
56
+ return runtime?.cloudComputerFacts ? runtime.cloudComputerFacts(refresh ? { refresh: true } : undefined) : UNAVAILABLE;
57
+ }, { readOnly: true });
58
+ const refresh = useCallback(async () => {
59
+ explicitRefresh.current = true;
60
+ await query.refetch();
61
+ }, [query.refetch]);
62
+ return { facts: query.data ?? (query.error ? UNAVAILABLE : null), loading: query.loading,
63
+ isFetching: query.isFetching, error: query.error, refresh };
97
64
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useQuery } from './useQuery';
4
4
  import { useNotisRuntime } from '../provider';
5
5
  import { normalizeDatabaseProperty, optionalString } from '../documents';
6
6
  import type { DatabaseProperty } from '../runtime';
@@ -22,6 +22,8 @@ export interface UseDatabaseSchemaResult {
22
22
  description: string | null;
23
23
  properties: DatabaseProperty[];
24
24
  loading: boolean;
25
+ isFetching: boolean;
26
+ hasData: boolean;
25
27
  error: Error | null;
26
28
  refetch: () => void;
27
29
  }
@@ -29,57 +31,19 @@ export interface UseDatabaseSchemaResult {
29
31
  /** Fetch a database's schema (normalized properties + metadata). */
30
32
  export function useDatabaseSchema(databaseSlug: string): UseDatabaseSchemaResult {
31
33
  const runtime = useNotisRuntime();
32
- const [name, setName] = useState<string | null>(null);
33
- const [description, setDescription] = useState<string | null>(null);
34
- const [properties, setProperties] = useState<DatabaseProperty[]>([]);
35
- const [loading, setLoading] = useState(true);
36
- const [error, setError] = useState<Error | null>(null);
37
- const [fetchKey, setFetchKey] = useState(0);
38
-
39
- const refetch = useCallback(() => {
40
- setFetchKey((key) => key + 1);
41
- }, []);
42
-
43
- useEffect(() => {
44
- if (!runtime) {
45
- setLoading(false);
46
- return;
47
- }
48
-
49
- let cancelled = false;
50
- setLoading(true);
51
- setError(null);
52
-
53
- runtime
54
- .callTool<GetDatabaseResult>('LOCAL_NOTIS_DATABASE_GET_DATABASE', {
55
- database_slug: databaseSlug,
56
- })
57
- .then((result) => {
58
- if (cancelled) return;
59
- const message = result.error ?? result.message;
60
- if (!result.database && message) {
61
- throw new Error(message);
62
- }
63
- setName(optionalString(result.database?.name));
64
- setDescription(optionalString(result.database?.description));
65
- const rawProperties = result.database?.schema?.properties ?? [];
66
- setProperties(
67
- rawProperties
68
- .map(normalizeDatabaseProperty)
69
- .filter((property): property is DatabaseProperty => Boolean(property)),
70
- );
71
- setLoading(false);
72
- })
73
- .catch((err) => {
74
- if (cancelled) return;
75
- setError(err instanceof Error ? err : new Error(String(err)));
76
- setLoading(false);
77
- });
78
-
79
- return () => {
80
- cancelled = true;
34
+ const query = useQuery(['database-schema', databaseSlug], async () => {
35
+ const result = await runtime!.callTool<GetDatabaseResult>('LOCAL_NOTIS_DATABASE_GET_DATABASE', {
36
+ database_slug: databaseSlug,
37
+ }, { dedupe: true, readOnly: true });
38
+ if (!result.database) throw new Error(result.error || result.message || 'Database not found');
39
+ return {
40
+ name: optionalString(result.database.name),
41
+ description: optionalString(result.database.description),
42
+ properties: (result.database.schema?.properties ?? []).map(normalizeDatabaseProperty)
43
+ .filter((property): property is DatabaseProperty => Boolean(property)),
81
44
  };
82
- }, [runtime, databaseSlug, fetchKey]);
83
-
84
- return { name, description, properties, loading, error, refetch };
45
+ }, { readOnly: true });
46
+ return { ...query, name: query.data?.name ?? null, description: query.data?.description ?? null,
47
+ properties: query.data?.properties ?? EMPTY_PROPERTIES };
85
48
  }
49
+ const EMPTY_PROPERTIES: DatabaseProperty[] = [];
@@ -36,7 +36,7 @@ export function useDatabaseSubscription(
36
36
  ): UseDatabaseSubscriptionResult {
37
37
  const runtime = useNotisRuntime();
38
38
  const { subscribe = true, ...documentOptions } = options;
39
- const { documents, loading, error, refetch } = useDocuments(databaseSlug, documentOptions);
39
+ const { documents, loading, isFetching, hasData, error, refetch } = useDocuments(databaseSlug, documentOptions);
40
40
  const [live, setLive] = useState(false);
41
41
 
42
42
  const refetchRef = useRef(refetch);
@@ -72,5 +72,5 @@ export function useDatabaseSubscription(
72
72
  };
73
73
  }, [runtime, databaseSlug, enabled]);
74
74
 
75
- return { documents, rows: documents, loading, error, refetch, live };
75
+ return { documents, rows: documents, loading, isFetching, hasData, error, refetch, live };
76
76
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useQuery } from './useQuery';
4
4
  import { useNotisRuntime } from '../provider';
5
5
  import { normalizeDocumentRecord } from '../documents';
6
6
  import type { DocumentRecord } from '../runtime';
@@ -19,6 +19,8 @@ export interface UseDocumentOptions {
19
19
  export interface UseDocumentResult {
20
20
  document: DocumentRecord | null;
21
21
  loading: boolean;
22
+ isFetching: boolean;
23
+ hasData: boolean;
22
24
  error: Error | null;
23
25
  refetch: () => void;
24
26
  }
@@ -29,50 +31,13 @@ export function useDocument(
29
31
  options: UseDocumentOptions = {},
30
32
  ): UseDocumentResult {
31
33
  const runtime = useNotisRuntime();
32
- const [document, setDocument] = useState<DocumentRecord | null>(null);
33
- const [loading, setLoading] = useState(Boolean(documentId));
34
- const [error, setError] = useState<Error | null>(null);
35
- const [fetchKey, setFetchKey] = useState(0);
36
-
37
- const enabled = options.enabled !== false && Boolean(documentId);
38
-
39
- const refetch = useCallback(() => {
40
- setFetchKey((key) => key + 1);
41
- }, []);
42
-
43
- useEffect(() => {
44
- if (!runtime || !enabled || !documentId) {
45
- setDocument(null);
46
- setLoading(false);
47
- return;
48
- }
49
-
50
- let cancelled = false;
51
- setLoading(true);
52
- setError(null);
53
-
54
- runtime
55
- .callTool<GetDocumentResult>('LOCAL_NOTIS_DATABASE_GET_DOCUMENT', {
56
- document_id: documentId,
57
- })
58
- .then((result) => {
59
- if (cancelled) return;
60
- if (!result.document) {
61
- throw new Error(result.error ?? result.message ?? 'Document not found');
62
- }
63
- setDocument(normalizeDocumentRecord(result.document));
64
- setLoading(false);
65
- })
66
- .catch((err) => {
67
- if (cancelled) return;
68
- setError(err instanceof Error ? err : new Error(String(err)));
69
- setLoading(false);
70
- });
71
-
72
- return () => {
73
- cancelled = true;
74
- };
75
- }, [runtime, documentId, enabled, fetchKey]);
76
-
77
- return { document, loading, error, refetch };
34
+ const query = useQuery<DocumentRecord>(['document', documentId ?? null], async () => {
35
+ if (!runtime || !documentId) throw new Error('Document not found');
36
+ const result = await runtime.callTool<GetDocumentResult>('LOCAL_NOTIS_DATABASE_GET_DOCUMENT', {
37
+ document_id: documentId,
38
+ }, { dedupe: true, readOnly: true });
39
+ if (!result.document) throw new Error(result.error ?? result.message ?? 'Document not found');
40
+ return normalizeDocumentRecord(result.document);
41
+ }, { readOnly: true, enabled: options.enabled !== false && Boolean(documentId) });
42
+ return { document: query.data ?? null, loading: query.loading, isFetching: query.isFetching, hasData: query.hasData, error: query.error, refetch: query.refetch };
78
43
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useQuery } from './useQuery';
4
4
  import { useNotisRuntime } from '../provider';
5
5
  import { normalizeDocumentRecord } from '../documents';
6
6
  import type { DocumentRecord, QueryFilter } from '../runtime';
@@ -25,6 +25,8 @@ export interface UseDocumentsOptions {
25
25
  export interface UseDocumentsResult {
26
26
  documents: DocumentRecord[];
27
27
  loading: boolean;
28
+ isFetching: boolean;
29
+ hasData: boolean;
28
30
  error: Error | null;
29
31
  refetch: () => void;
30
32
  }
@@ -44,78 +46,36 @@ export function useDocuments(
44
46
  options: UseDocumentsOptions = {},
45
47
  ): UseDocumentsResult {
46
48
  const runtime = useNotisRuntime();
47
- const [documents, setDocuments] = useState<DocumentRecord[]>([]);
48
- const [loading, setLoading] = useState(true);
49
- const [error, setError] = useState<Error | null>(null);
50
- const [fetchKey, setFetchKey] = useState(0);
51
-
52
- const enabled = options.enabled !== false;
53
- const filterKey = JSON.stringify(options.filter ?? null);
54
-
55
- const refetch = useCallback(() => {
56
- setFetchKey((key) => key + 1);
57
- }, []);
58
-
59
- useEffect(() => {
60
- if (!runtime || !enabled) {
61
- setLoading(false);
62
- return;
63
- }
64
-
65
- let cancelled = false;
66
- setLoading(true);
67
- setError(null);
68
-
69
- const filter = filterKey === 'null' ? null : (JSON.parse(filterKey) as QueryFilter);
70
-
71
- const fetchDocuments = async (): Promise<unknown[]> => {
49
+ const query = useQuery<DocumentRecord[]>(
50
+ ['documents', databaseSlug, options.filter ?? null, options.pageSize ?? null, options.offset ?? 0, Boolean(options.fetchAll)],
51
+ async () => {
52
+ if (!runtime) throw new Error('Notis runtime not available');
72
53
  const allDocuments: unknown[] = [];
73
54
  let offset = options.offset ?? 0;
74
-
75
55
  while (true) {
76
56
  const result = await runtime.callTool<QueryDatabaseResult>('LOCAL_NOTIS_DATABASE_QUERY', {
77
57
  database_slug: databaseSlug,
78
58
  query: {
79
- ...(filter ?? {}),
59
+ ...(options.filter ?? {}),
80
60
  ...(options.pageSize !== undefined ? { page_size: options.pageSize } : {}),
81
61
  },
82
62
  ...(offset > 0 ? { offset } : {}),
83
- });
63
+ }, { dedupe: true, readOnly: true });
84
64
  const message = result.error ?? result.message;
85
- if (!result.documents && message) {
86
- throw new Error(message);
87
- }
65
+ if (!result.documents && message) throw new Error(message);
88
66
  allDocuments.push(...(result.documents ?? []));
89
- if (!options.fetchAll || !result.has_more) return allDocuments;
90
-
67
+ if (!options.fetchAll || !result.has_more) break;
91
68
  const nextOffset = result.next_offset;
92
69
  if (typeof nextOffset !== 'number' || nextOffset <= offset) {
93
70
  throw new Error('Database query returned an invalid pagination offset');
94
71
  }
95
72
  offset = nextOffset;
96
73
  }
97
- };
98
-
99
- fetchDocuments()
100
- .then((result) => {
101
- if (cancelled) return;
102
- setDocuments(
103
- result
104
- .map(normalizeDocumentRecord)
105
- .filter((document) => document.id),
106
- );
107
- setLoading(false);
108
- })
109
- .catch((err) => {
110
- if (cancelled) return;
111
- setError(err instanceof Error ? err : new Error(String(err)));
112
- setLoading(false);
113
- });
114
-
115
- return () => {
116
- cancelled = true;
117
- };
118
- }, [runtime, databaseSlug, enabled, fetchKey, filterKey, options.fetchAll, options.offset, options.pageSize]);
119
-
120
- return { documents, loading, error, refetch };
74
+ return allDocuments.map(normalizeDocumentRecord).filter((document) => document.id);
75
+ },
76
+ { readOnly: true, enabled: options.enabled },
77
+ );
78
+ return { documents: query.data ?? EMPTY_DOCUMENTS, loading: query.loading, isFetching: query.isFetching, hasData: query.hasData, error: query.error, refetch: query.refetch };
121
79
  }
80
+
81
+ const EMPTY_DOCUMENTS: DocumentRecord[] = [];
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
4
+ import { useNotisRuntime } from '../provider';
5
+ import { createQueryClient, queryKey } from '../queryCache';
6
+
7
+ export interface UseQueryOptions {
8
+ /** Required acknowledgement: only idempotent reads may run on mount or prefetch. */
9
+ readOnly: true;
10
+ enabled?: boolean;
11
+ staleTimeMs?: number;
12
+ }
13
+
14
+ export interface UseQueryResult<T> {
15
+ data: T | undefined;
16
+ hasData: boolean;
17
+ loading: boolean;
18
+ isFetching: boolean;
19
+ error: Error | null;
20
+ refetch: () => Promise<void>;
21
+ }
22
+
23
+ /** Cached read callback; scope and lifetime are provided by the host, never by app globals. */
24
+ export function useQuery<T>(key: readonly unknown[], read: () => Promise<T>, options: UseQueryOptions): UseQueryResult<T> {
25
+ const runtime = useNotisRuntime();
26
+ // Older hosts deliberately get a hook-local cache: no cross-runtime/account reuse.
27
+ const fallback = useMemo(() => createQueryClient(), [runtime]);
28
+ const client = runtime?.queryClient ?? fallback;
29
+ const cacheKey = queryKey(key);
30
+ const enabled = Boolean(runtime) && options.enabled !== false && options.readOnly === true;
31
+ const readRef = useRef(read);
32
+ useLayoutEffect(() => { readRef.current = read; });
33
+ const subscribe = useCallback((listener: () => void) => enabled ? client.subscribe(cacheKey, listener) : () => {}, [cacheKey, client, enabled]);
34
+ const snapshot = useSyncExternalStore(subscribe, () => client.getSnapshot<T>(cacheKey), () => client.getSnapshot<T>(cacheKey));
35
+ const fetch = useCallback(() => {
36
+ const capturedRead = readRef.current;
37
+ return client.fetch(cacheKey, capturedRead, { staleTimeMs: options.staleTimeMs });
38
+ }, [cacheKey, client, options.staleTimeMs]);
39
+ useEffect(() => {
40
+ if (!enabled) return;
41
+ void fetch().catch(() => {}); // Error belongs to the snapshot; no unhandled rejection.
42
+ }, [enabled, fetch, snapshot.invalidation]);
43
+ const refetch = useCallback(async () => {
44
+ if (!enabled) return;
45
+ client.invalidate(cacheKey);
46
+ await fetch().catch(() => {});
47
+ }, [cacheKey, client, enabled, fetch]);
48
+ return {
49
+ data: enabled ? snapshot.data : undefined,
50
+ hasData: enabled && snapshot.hasData,
51
+ loading: enabled && !snapshot.hasData && !snapshot.error,
52
+ isFetching: enabled && snapshot.isFetching,
53
+ error: enabled ? snapshot.error : null,
54
+ refetch,
55
+ };
56
+ }
57
+
58
+ /** Opt-in preparation for small, known read queries. Never use for writes or full-database crawls. */
59
+ export function useQueryClient(): {
60
+ invalidate(key?: readonly unknown[]): void;
61
+ prefetch<T>(key: readonly unknown[], read: () => Promise<T>, options: UseQueryOptions): Promise<T | undefined>;
62
+ } {
63
+ const runtime = useNotisRuntime();
64
+ return useMemo(() => ({
65
+ invalidate: (key?: readonly unknown[]) => runtime?.queryClient?.invalidate(key ? queryKey(key) : undefined),
66
+ prefetch: <T,>(key: readonly unknown[], read: () => Promise<T>, options: UseQueryOptions) => {
67
+ if (!runtime?.queryClient || options.enabled === false || options.readOnly !== true) return Promise.resolve(undefined);
68
+ return runtime.queryClient.prefetch(queryKey(key), read, { staleTimeMs: options.staleTimeMs });
69
+ },
70
+ }), [runtime]);
71
+ }
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+
3
+ import { useNotisRuntime } from '../provider';
4
+ import { useQuery, type UseQueryOptions } from './useQuery';
5
+
6
+ /** An explicitly identified read-only tool, cached by tool name and exact arguments. */
7
+ export function useToolQuery<T = unknown>(name: string, args: Record<string, unknown> = {}, options: UseQueryOptions) {
8
+ const runtime = useNotisRuntime();
9
+ return useQuery<T>(['tool', name, args], () => runtime!.callTool<T>(name, args, {
10
+ readOnly: true, dedupe: true,
11
+ }), options);
12
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect } from 'react';
3
+ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
4
4
  import { useNotisRuntime } from '../provider';
5
5
 
6
6
  interface TopBarSearchOptions {
@@ -24,8 +24,9 @@ interface TopBarSearchActions {
24
24
  * While the component is mounted, typing in the top bar calls `onChange`,
25
25
  * pressing Enter calls `onSubmit`, and the `value` prop drives the input.
26
26
  *
27
- * Call `setLoading(true)` to show the standard spinner in the top bar while a
28
- * query is running; `setLoading(false)` restores the search icon.
27
+ * Use `setLoading(true)` only for an explicitly submitted search action, then
28
+ * restore the icon with `setLoading(false)`. Automatic reads use content
29
+ * skeletons initially and refresh populated content silently.
29
30
  *
30
31
  * Without a portal runtime, this hook is a safe no-op.
31
32
  *
@@ -42,21 +43,28 @@ interface TopBarSearchActions {
42
43
  * });
43
44
  * ```
44
45
  *
45
- * Stabilize `onChange` and `onSubmit` with `useCallback` to avoid re-registering
46
- * on every render.
46
+ * Callback refs retain the latest committed handlers without re-registering
47
+ * search on every app render. Inline callbacks are safe.
47
48
  */
48
49
  export function useTopBarSearch(opts: TopBarSearchOptions): TopBarSearchActions {
49
50
  const runtime = useNotisRuntime();
50
51
  const { value, onChange, placeholder, onSubmit } = opts;
52
+ const callbacks = useRef({ onChange, onSubmit });
53
+ useLayoutEffect(() => { callbacks.current = { onChange, onSubmit }; });
54
+ const hasSubmit = Boolean(onSubmit);
51
55
 
52
56
  useEffect(() => {
53
57
  const register = runtime?.registerTopBarSearch;
54
58
  if (!register) return;
55
- register({ onChange, placeholder, onSubmit });
59
+ register({
60
+ onChange: (next) => callbacks.current.onChange(next),
61
+ placeholder,
62
+ onSubmit: hasSubmit ? () => callbacks.current.onSubmit?.() : undefined,
63
+ });
56
64
  return () => {
57
65
  register(null);
58
66
  };
59
- }, [runtime, onChange, placeholder, onSubmit]);
67
+ }, [runtime, placeholder, hasSubmit]);
60
68
 
61
69
  useEffect(() => {
62
70
  runtime?.setTopBarSearchValue?.(value);
@@ -10,6 +10,10 @@
10
10
  export { NotisProvider, useNotisRuntime } from './provider';
11
11
 
12
12
  // Hooks
13
+ export { useQuery, useQueryClient } from './hooks/useQuery';
14
+ export type { UseQueryOptions, UseQueryResult } from './hooks/useQuery';
15
+ export { createQueryClient, createPrefetchQueue, queryKey } from './queryCache';
16
+ export type { NotisQueryClient, QuerySnapshot, QueryFetchOptions } from './queryCache';
13
17
  export { useNotis } from './hooks/useNotis';
14
18
  export { useDocuments } from './hooks/useDocuments';
15
19
  export type { UseDocumentsOptions, UseDocumentsResult } from './hooks/useDocuments';
@@ -143,3 +147,7 @@ export type {
143
147
  ToolCallOptions,
144
148
  ToolInputSchema,
145
149
  } from './runtime';
150
+
151
+ export { useToolQuery } from './hooks/useToolQuery';
152
+
153
+ export { Skeleton, ViewSkeleton } from './components/Skeleton';
@@ -0,0 +1,162 @@
1
+ /** In-memory read snapshots. The host owns the lifetime and authorization scope. */
2
+ export interface QuerySnapshot<T = unknown> {
3
+ data: T | undefined;
4
+ hasData: boolean;
5
+ isFetching: boolean;
6
+ error: Error | null;
7
+ updatedAt: number;
8
+ invalidation: number;
9
+ }
10
+
11
+ export interface QueryFetchOptions {
12
+ staleTimeMs?: number;
13
+ force?: boolean;
14
+ }
15
+
16
+ export interface NotisQueryClient {
17
+ getSnapshot<T>(key: string): QuerySnapshot<T>;
18
+ subscribe(key: string, listener: () => void): () => void;
19
+ fetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
20
+ prefetch<T>(key: string, read: () => Promise<T>, options?: QueryFetchOptions): Promise<T>;
21
+ invalidate(key?: string): void;
22
+ clear(): void;
23
+ /** Host-only: permanently retire a revoked security scope. */
24
+ dispose?(): void;
25
+ getRevision?(): number;
26
+ }
27
+
28
+ const EMPTY: QuerySnapshot = Object.freeze({
29
+ data: undefined, hasData: false, isFetching: false, error: null, updatedAt: 0, invalidation: 0,
30
+ });
31
+
32
+ /** Stable keys distinguish filters, pagination, and resource identities. */
33
+ export function queryKey(value: unknown): string {
34
+ if (value instanceof Date) return JSON.stringify(value.toISOString());
35
+ if (Array.isArray(value)) return `[${value.map(queryKey).join(',')}]`;
36
+ if (value && typeof value === 'object') {
37
+ const record = value as Record<string, unknown>;
38
+ return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort()
39
+ .map((key) => `${JSON.stringify(key)}:${queryKey(record[key])}`).join(',')}}`;
40
+ }
41
+ return JSON.stringify(value) ?? 'null';
42
+ }
43
+
44
+ /** One shared queue can bound descriptor and data speculation together. */
45
+ export function createPrefetchQueue(concurrency = 2) {
46
+ let active = 0;
47
+ const waiting: Array<() => void> = [];
48
+ function drain() {
49
+ while (active < concurrency && waiting.length) waiting.shift()!();
50
+ }
51
+ return <T>(read: () => Promise<T>): Promise<T> => new Promise<T>((resolve, reject) => {
52
+ waiting.push(() => {
53
+ active += 1;
54
+ Promise.resolve().then(read).then(resolve, reject).finally(() => { active -= 1; drain(); });
55
+ });
56
+ drain();
57
+ });
58
+ }
59
+
60
+ type Entry = {
61
+ snapshot: QuerySnapshot;
62
+ listeners: Set<() => void>;
63
+ generation: number;
64
+ pending?: Promise<unknown>;
65
+ };
66
+
67
+ export function createQueryClient(options: {
68
+ maxEntries?: number;
69
+ now?: () => number;
70
+ schedule?: ReturnType<typeof createPrefetchQueue>;
71
+ } = {}): NotisQueryClient {
72
+ const entries = new Map<string, Entry>();
73
+ const now = options.now ?? Date.now;
74
+ const maxEntries = options.maxEntries ?? 100;
75
+ const schedule = options.schedule ?? createPrefetchQueue(2);
76
+ let epoch = 0;
77
+ let disposed = false;
78
+ let revision = 0;
79
+
80
+ function prune(keep?: string) {
81
+ for (const [oldKey, old] of entries) {
82
+ if (entries.size <= maxEntries) break;
83
+ if (oldKey !== keep && !old.listeners.size && !old.pending) entries.delete(oldKey);
84
+ }
85
+ }
86
+ function entry(key: string): Entry {
87
+ let value = entries.get(key);
88
+ if (!value) {
89
+ value = { snapshot: EMPTY, listeners: new Set(), generation: 0 };
90
+ entries.set(key, value);
91
+ }
92
+ // Only operations, not getSnapshot during render, update the LRU.
93
+ entries.delete(key);
94
+ entries.set(key, value);
95
+ prune(key);
96
+ return value;
97
+ }
98
+ function publish(value: Entry, snapshot: QuerySnapshot) {
99
+ value.snapshot = snapshot;
100
+ for (const listener of value.listeners) listener();
101
+ }
102
+ const client: NotisQueryClient = {
103
+ getSnapshot<T>(key: string) { return (entries.get(key)?.snapshot ?? EMPTY) as QuerySnapshot<T>; },
104
+ subscribe(key, listener) {
105
+ const value = entry(key);
106
+ value.listeners.add(listener);
107
+ return () => { value.listeners.delete(listener); prune(); };
108
+ },
109
+ fetch<T>(key: string, read: () => Promise<T>, fetchOptions: QueryFetchOptions = {}) {
110
+ if (disposed) return Promise.reject(new Error('App query scope is no longer available.'));
111
+ const value = entry(key);
112
+ if (value.pending) return value.pending as Promise<T>;
113
+ if (!fetchOptions.force && value.snapshot.hasData && value.snapshot.updatedAt > 0
114
+ && now() - value.snapshot.updatedAt < (fetchOptions.staleTimeMs ?? 30_000)) {
115
+ return Promise.resolve(value.snapshot.data as T);
116
+ }
117
+ const generation = ++value.generation;
118
+ const requestEpoch = epoch;
119
+ const canCommit = () => epoch === requestEpoch && value.generation === generation && entries.get(key) === value;
120
+ const request = Promise.resolve().then(read).then((data) => {
121
+ if (canCommit()) publish(value, { ...value.snapshot, data, hasData: true, isFetching: false, error: null, updatedAt: now() });
122
+ return data;
123
+ }, (reason) => {
124
+ const error = reason instanceof Error ? reason : new Error(String(reason));
125
+ if (canCommit()) publish(value, { ...value.snapshot, isFetching: false, error });
126
+ throw error;
127
+ }).finally(() => { if (value.pending === request) value.pending = undefined; prune(); });
128
+ value.pending = request;
129
+ publish(value, { ...value.snapshot, isFetching: true, error: null });
130
+ return request;
131
+ },
132
+ prefetch<T>(key: string, read: () => Promise<T>, fetchOptions?: QueryFetchOptions) {
133
+ const requestEpoch = epoch;
134
+ return schedule(() => {
135
+ if (epoch !== requestEpoch) throw new Error('App query scope was cleared.');
136
+ return client.fetch(key, read, fetchOptions);
137
+ });
138
+ },
139
+ getRevision: () => revision,
140
+ invalidate(key) {
141
+ revision += 1;
142
+ for (const [entryKey, value] of entries) {
143
+ if (key !== undefined && entryKey !== key) continue;
144
+ value.generation += 1;
145
+ value.pending = undefined;
146
+ publish(value, { ...value.snapshot, updatedAt: 0, isFetching: false, invalidation: value.snapshot.invalidation + 1 });
147
+ }
148
+ },
149
+ dispose() { disposed = true; client.clear(); },
150
+ clear() {
151
+ revision += 1;
152
+ epoch += 1;
153
+ for (const value of entries.values()) {
154
+ value.generation += 1;
155
+ value.pending = undefined;
156
+ publish(value, { ...EMPTY, invalidation: value.snapshot.invalidation + 1 });
157
+ }
158
+ for (const [key, value] of entries) if (!value.listeners.size) entries.delete(key);
159
+ },
160
+ };
161
+ return client;
162
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ComponentType } from 'react';
11
+ import type { NotisQueryClient } from './queryCache';
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Database types
@@ -106,6 +107,8 @@ export interface ToolDescriptor {
106
107
  }
107
108
 
108
109
  export interface ToolCallOptions {
110
+ /** Explicitly identifies an idempotent read; never set on a mutation. */
111
+ readOnly?: boolean;
109
112
  /**
110
113
  * Coalesce an identical in-flight call. Only opt in for idempotent reads;
111
114
  * mutations must execute once per invocation.
@@ -359,6 +362,8 @@ export interface CloudComputerFacts {
359
362
  }
360
363
 
361
364
  export interface NotisRuntime {
365
+ /** Optional host-scoped in-memory read cache. Older hosts remain supported. */
366
+ queryClient?: NotisQueryClient;
362
367
  app: AppDescriptor;
363
368
  route: RouteDescriptor;
364
369
  databases: DatabaseDescriptor[];