@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +822 -135
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/config/portal-route-roots.json +48 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
- package/dist/base-skills/notis-apps/SKILL.md +93 -0
- package/dist/base-skills/notis-apps/references/architecture.md +114 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +97 -0
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +67 -0
- package/dist/base-skills/notis-apps/references/release.md +107 -0
- package/dist/base-skills/notis-apps/references/sdk.md +116 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
- package/dist/base-skills/notis-apps/references/views.md +142 -0
- package/dist/base-skills/notis-cli/SKILL.md +154 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +41 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
- package/dist/base-skills/notis-query/references/documents.md +63 -0
- package/dist/base-skills/notis-query/references/query.md +76 -0
- package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
- package/dist/sdk/src/agentContext.ts +36 -0
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/Dialog.tsx +46 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
- package/dist/sdk/src/components/DocumentPage.tsx +141 -0
- package/dist/sdk/src/components/Markdown.tsx +60 -0
- package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
- package/dist/sdk/src/components/Skeleton.tsx +24 -0
- package/dist/sdk/src/config.ts +239 -0
- package/dist/sdk/src/documents.ts +258 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
- package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
- package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/sdk/src/hooks/useDocument.ts +43 -0
- package/dist/sdk/src/hooks/useDocuments.ts +84 -0
- package/dist/sdk/src/hooks/useHandover.ts +78 -0
- package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +42 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
- package/dist/sdk/src/hooks/useQuery.ts +74 -0
- package/dist/sdk/src/hooks/useTool.ts +65 -0
- package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
- package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +200 -0
- package/dist/sdk/src/interactions/actions.ts +59 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
- package/dist/sdk/src/interactions/visibility.ts +13 -0
- package/dist/sdk/src/interactions.ts +45 -0
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +49 -0
- package/dist/sdk/src/queryCache.ts +172 -0
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +603 -0
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +290 -0
- package/dist/sdk/src/tailwind.ts +66 -0
- package/dist/sdk/src/vite.ts +83 -0
- package/dist/skill-sync/index.js +2143 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +3487 -0
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1042 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +173 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +25 -8
- package/skills/notis-apps/cli.md +406 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +133 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1476 -203
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +730 -0
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +86 -82
- package/src/command-specs/index.js +31 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/skills.js +304 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/spaces.js +572 -0
- package/src/command-specs/tools.js +455 -139
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +700 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +407 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +2752 -237
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +293 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +121 -0
- package/src/runtime/skill-sync/index.ts +905 -0
- package/src/runtime/skill-sync/local-scanner.ts +1259 -0
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +59 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +54 -0
- package/src/runtime/skill-sync/types.ts +157 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/store-screenshot.js +146 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +331 -45
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/src/space-harness-host.ts +103 -0
- package/template/.harness/index.html.tmpl +435 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +29 -3
- package/template/app/layout.tsx +6 -3
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +3642 -0
- package/template/package.json +19 -16
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- package/template/packages/notis-sdk/src/vite.ts +0 -54
- package/template/packages/notis-sdk/tsconfig.json +0 -15
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useQuery } from './useQuery';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { normalizeDatabaseProperty, optionalString } from '../documents';
|
|
6
|
+
import type { DatabaseProperty } from '../runtime';
|
|
7
|
+
|
|
8
|
+
interface GetDatabaseResult {
|
|
9
|
+
database?: {
|
|
10
|
+
name?: string;
|
|
11
|
+
description?: string | null;
|
|
12
|
+
schema?: {
|
|
13
|
+
properties?: unknown[];
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
message?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UseDatabaseSchemaResult {
|
|
21
|
+
name: string | null;
|
|
22
|
+
description: string | null;
|
|
23
|
+
properties: DatabaseProperty[];
|
|
24
|
+
loading: boolean;
|
|
25
|
+
isFetching: boolean;
|
|
26
|
+
hasData: boolean;
|
|
27
|
+
error: Error | null;
|
|
28
|
+
refetch: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Fetch a database's schema (normalized properties + metadata). */
|
|
32
|
+
export function useDatabaseSchema(databaseSlug: string): UseDatabaseSchemaResult {
|
|
33
|
+
const runtime = useNotisRuntime();
|
|
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)),
|
|
44
|
+
};
|
|
45
|
+
}, { readOnly: true });
|
|
46
|
+
return { ...query, name: query.data?.name ?? null, description: query.data?.description ?? null,
|
|
47
|
+
properties: query.data?.properties ?? EMPTY_PROPERTIES };
|
|
48
|
+
}
|
|
49
|
+
const EMPTY_PROPERTIES: DatabaseProperty[] = [];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { useDocuments, type UseDocumentsOptions, type UseDocumentsResult } from './useDocuments';
|
|
6
|
+
import type { DocumentRecord } from '../runtime';
|
|
7
|
+
|
|
8
|
+
export interface UseDatabaseSubscriptionOptions extends UseDocumentsOptions {
|
|
9
|
+
/** Set to false to keep the query but skip the change feed. */
|
|
10
|
+
subscribe?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UseDatabaseSubscriptionResult extends UseDocumentsResult {
|
|
14
|
+
/** Alias of `documents`, for views that think in rows. */
|
|
15
|
+
rows: DocumentRecord[];
|
|
16
|
+
/** True while a live change feed is attached to this database. */
|
|
17
|
+
live: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Query a Notis database and keep it fresh without polling.
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const { rows, live, refetch } = useDatabaseSubscription('workspaces');
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* A change on the database wakes the hook, which then refetches through the
|
|
28
|
+
* usual `LOCAL_NOTIS_DATABASE_QUERY` path — the change feed is a signal only
|
|
29
|
+
* and never carries row data. Hosts without a change feed (the dev harness,
|
|
30
|
+
* the screenshot stub, the vite preview) still return rows; `live` is false
|
|
31
|
+
* there and the app should keep offering its manual refresh.
|
|
32
|
+
*/
|
|
33
|
+
export function useDatabaseSubscription(
|
|
34
|
+
databaseSlug: string,
|
|
35
|
+
options: UseDatabaseSubscriptionOptions = {},
|
|
36
|
+
): UseDatabaseSubscriptionResult {
|
|
37
|
+
const runtime = useNotisRuntime();
|
|
38
|
+
const { subscribe = true, ...documentOptions } = options;
|
|
39
|
+
const { documents, loading, isFetching, hasData, error, refetch } = useDocuments(databaseSlug, documentOptions);
|
|
40
|
+
const [live, setLive] = useState(false);
|
|
41
|
+
|
|
42
|
+
const refetchRef = useRef(refetch);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
refetchRef.current = refetch;
|
|
45
|
+
}, [refetch]);
|
|
46
|
+
|
|
47
|
+
const enabled = options.enabled !== false && subscribe;
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!runtime?.subscribeDatabase || !enabled || !databaseSlug) {
|
|
51
|
+
setLive(false);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
const unsubscribe = runtime.subscribeDatabase(
|
|
57
|
+
databaseSlug,
|
|
58
|
+
() => {
|
|
59
|
+
refetchRef.current();
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
onStatusChange: (isLive) => {
|
|
63
|
+
if (!cancelled) setLive(isLive);
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
cancelled = true;
|
|
70
|
+
setLive(false);
|
|
71
|
+
unsubscribe?.();
|
|
72
|
+
};
|
|
73
|
+
}, [runtime, databaseSlug, enabled]);
|
|
74
|
+
|
|
75
|
+
return { documents, rows: documents, loading, isFetching, hasData, error, refetch, live };
|
|
76
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useQuery } from './useQuery';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { normalizeDocumentRecord } from '../documents';
|
|
6
|
+
import type { DocumentRecord } from '../runtime';
|
|
7
|
+
|
|
8
|
+
interface GetDocumentResult {
|
|
9
|
+
status?: string;
|
|
10
|
+
document?: unknown;
|
|
11
|
+
message?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UseDocumentOptions {
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseDocumentResult {
|
|
20
|
+
document: DocumentRecord | null;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
isFetching: boolean;
|
|
23
|
+
hasData: boolean;
|
|
24
|
+
error: Error | null;
|
|
25
|
+
refetch: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Fetch a single document (with content) by id, normalized. */
|
|
29
|
+
export function useDocument(
|
|
30
|
+
documentId: string | null | undefined,
|
|
31
|
+
options: UseDocumentOptions = {},
|
|
32
|
+
): UseDocumentResult {
|
|
33
|
+
const runtime = useNotisRuntime();
|
|
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 };
|
|
43
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useQuery } from './useQuery';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { normalizeDocumentRecord } from '../documents';
|
|
6
|
+
import type { DocumentRecord, QueryFilter } from '../runtime';
|
|
7
|
+
|
|
8
|
+
interface QueryDatabaseResult {
|
|
9
|
+
documents?: unknown[];
|
|
10
|
+
has_more?: boolean;
|
|
11
|
+
next_offset?: number | null;
|
|
12
|
+
message?: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseDocumentsOptions {
|
|
17
|
+
filter?: QueryFilter;
|
|
18
|
+
pageSize?: number;
|
|
19
|
+
offset?: number;
|
|
20
|
+
/** Fetch every page, starting at offset, instead of returning only one page. */
|
|
21
|
+
fetchAll?: boolean;
|
|
22
|
+
/** App-view lists can omit bodies until opening/searching a document. Default: true. */
|
|
23
|
+
includeContent?: boolean;
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseDocumentsResult {
|
|
28
|
+
documents: DocumentRecord[];
|
|
29
|
+
loading: boolean;
|
|
30
|
+
isFetching: boolean;
|
|
31
|
+
hasData: boolean;
|
|
32
|
+
error: Error | null;
|
|
33
|
+
refetch: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Query a Notis database and get normalized `DocumentRecord`s.
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const { documents, loading, refetch } = useDocuments('notes', {
|
|
41
|
+
* filter: { filters: [{ property: 'Folder', operator: 'contains', type: 'relation', value: folderId }] },
|
|
42
|
+
* pageSize: 250,
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function useDocuments(
|
|
47
|
+
databaseSlug: string,
|
|
48
|
+
options: UseDocumentsOptions = {},
|
|
49
|
+
): UseDocumentsResult {
|
|
50
|
+
const runtime = useNotisRuntime();
|
|
51
|
+
const query = useQuery<DocumentRecord[]>(
|
|
52
|
+
['documents', databaseSlug, options.filter ?? null, options.pageSize ?? null, options.offset ?? 0, Boolean(options.fetchAll), options.includeContent !== false],
|
|
53
|
+
async () => {
|
|
54
|
+
if (!runtime) throw new Error('Notis runtime not available');
|
|
55
|
+
const allDocuments: unknown[] = [];
|
|
56
|
+
let offset = options.offset ?? 0;
|
|
57
|
+
while (true) {
|
|
58
|
+
const result = await runtime.callTool<QueryDatabaseResult>('LOCAL_NOTIS_DATABASE_QUERY', {
|
|
59
|
+
database_slug: databaseSlug,
|
|
60
|
+
query: {
|
|
61
|
+
...(options.filter ?? {}),
|
|
62
|
+
...(options.pageSize !== undefined ? { page_size: options.pageSize } : {}),
|
|
63
|
+
...(options.includeContent === false ? { include_content: false } : {}),
|
|
64
|
+
},
|
|
65
|
+
...(offset > 0 ? { offset } : {}),
|
|
66
|
+
}, { dedupe: true, readOnly: true });
|
|
67
|
+
const message = result.error ?? result.message;
|
|
68
|
+
if (!result.documents && message) throw new Error(message);
|
|
69
|
+
allDocuments.push(...(result.documents ?? []));
|
|
70
|
+
if (!options.fetchAll || !result.has_more) break;
|
|
71
|
+
const nextOffset = result.next_offset;
|
|
72
|
+
if (typeof nextOffset !== 'number' || nextOffset <= offset) {
|
|
73
|
+
throw new Error('Database query returned an invalid pagination offset');
|
|
74
|
+
}
|
|
75
|
+
offset = nextOffset;
|
|
76
|
+
}
|
|
77
|
+
return allDocuments.map(normalizeDocumentRecord).filter((document) => document.id);
|
|
78
|
+
},
|
|
79
|
+
{ readOnly: true, enabled: options.enabled },
|
|
80
|
+
);
|
|
81
|
+
return { documents: query.data ?? EMPTY_DOCUMENTS, loading: query.loading, isFetching: query.isFetching, hasData: query.hasData, error: query.error, refetch: query.refetch };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const EMPTY_DOCUMENTS: DocumentRecord[] = [];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import type { HandoverPayload, HandoverResult } from '../runtime';
|
|
6
|
+
|
|
7
|
+
export interface UseHandoverResult {
|
|
8
|
+
/** Hand the work over. Rejects when the host has no manager chat. */
|
|
9
|
+
handover: (payload: HandoverPayload) => Promise<HandoverResult>;
|
|
10
|
+
/** True while the manager chat is being prepared. */
|
|
11
|
+
pending: boolean;
|
|
12
|
+
error: Error | null;
|
|
13
|
+
/**
|
|
14
|
+
* False when the host cannot hand work over (dev harness, vite preview).
|
|
15
|
+
* Render the app's own fallback — a copyable prompt, say — when it is false.
|
|
16
|
+
*/
|
|
17
|
+
available: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hand a piece of work from app code to the Notis manager chat.
|
|
22
|
+
*
|
|
23
|
+
* An app displays work; the manager runs it. `handover` puts the message in
|
|
24
|
+
* the chat surface that already owns streaming progress, billing, cancellation
|
|
25
|
+
* and the transcript, and the app watches its own databases for the result.
|
|
26
|
+
*
|
|
27
|
+
* ```tsx
|
|
28
|
+
* const { handover, pending, available } = useHandover();
|
|
29
|
+
*
|
|
30
|
+
* return available ? (
|
|
31
|
+
* <Button
|
|
32
|
+
* disabled={pending}
|
|
33
|
+
* onClick={() => { void handover({ prompt: 'Create a workspace on notis to ...' }); }}
|
|
34
|
+
* >
|
|
35
|
+
* Send to Notis
|
|
36
|
+
* </Button>
|
|
37
|
+
* ) : (
|
|
38
|
+
* <CopyablePrompt prompt="Create a workspace on notis to ..." />
|
|
39
|
+
* );
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Omit `prompt` to open a context-only composer, for example when a visible
|
|
43
|
+
* feedback button should let the user write in their own words.
|
|
44
|
+
*
|
|
45
|
+
* Pass `skill` to bind the work to a skill declared in `notis.config.ts`; the
|
|
46
|
+
* host rejects a key the app does not declare. `autoSend` submits the prepared
|
|
47
|
+
* message on an active user gesture; background calls still open a draft.
|
|
48
|
+
*/
|
|
49
|
+
export function useHandover(): UseHandoverResult {
|
|
50
|
+
const runtime = useNotisRuntime();
|
|
51
|
+
const dispatch = runtime?.resource?.kind === 'space' ? runtime.draftHandover : runtime?.handover;
|
|
52
|
+
const [pending, setPending] = useState(false);
|
|
53
|
+
const [error, setError] = useState<Error | null>(null);
|
|
54
|
+
|
|
55
|
+
const handover = useCallback(
|
|
56
|
+
async (payload: HandoverPayload): Promise<HandoverResult> => {
|
|
57
|
+
if (!dispatch) {
|
|
58
|
+
throw new Error('This Notis host cannot hand work to the manager chat.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setPending(true);
|
|
62
|
+
setError(null);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return await dispatch(payload);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
68
|
+
setError(e);
|
|
69
|
+
throw e;
|
|
70
|
+
} finally {
|
|
71
|
+
setPending(false);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[dispatch],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return { handover, pending, error, available: Boolean(dispatch) };
|
|
78
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
MouseEvent as ReactMouseEvent,
|
|
6
|
+
PointerEvent as ReactPointerEvent,
|
|
7
|
+
} from 'react';
|
|
8
|
+
|
|
9
|
+
const LONG_PRESS_MS = 500;
|
|
10
|
+
const MOVE_TOLERANCE_PX = 10;
|
|
11
|
+
const INTERACTIVE_SELECTOR = [
|
|
12
|
+
'button',
|
|
13
|
+
'a[href]',
|
|
14
|
+
'input',
|
|
15
|
+
'textarea',
|
|
16
|
+
'select',
|
|
17
|
+
'summary',
|
|
18
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
19
|
+
'[role="button"]',
|
|
20
|
+
'[role="link"]',
|
|
21
|
+
'[role="menuitem"]',
|
|
22
|
+
'[role="option"]',
|
|
23
|
+
'[role="switch"]',
|
|
24
|
+
'[role="tab"]',
|
|
25
|
+
].join(', ');
|
|
26
|
+
|
|
27
|
+
/** Adds a touch-only long-press entry point without rendering idle checkboxes. */
|
|
28
|
+
export function useLongPressSelection(onSelect: (id: string) => void) {
|
|
29
|
+
const onSelectRef = useRef(onSelect);
|
|
30
|
+
onSelectRef.current = onSelect;
|
|
31
|
+
const timerRef = useRef<number | null>(null);
|
|
32
|
+
const startRef = useRef<{ x: number; y: number } | null>(null);
|
|
33
|
+
const suppressClickRef = useRef(false);
|
|
34
|
+
|
|
35
|
+
const cancel = useCallback(() => {
|
|
36
|
+
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
|
37
|
+
timerRef.current = null;
|
|
38
|
+
startRef.current = null;
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
useEffect(() => cancel, [cancel]);
|
|
42
|
+
|
|
43
|
+
return useCallback((id: string) => ({
|
|
44
|
+
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
|
|
45
|
+
if (event.pointerType !== 'touch' || event.button !== 0) return;
|
|
46
|
+
const target = event.target;
|
|
47
|
+
if (
|
|
48
|
+
target instanceof HTMLElement
|
|
49
|
+
&& target !== event.currentTarget
|
|
50
|
+
&& target.closest(INTERACTIVE_SELECTOR)
|
|
51
|
+
) return;
|
|
52
|
+
cancel();
|
|
53
|
+
startRef.current = { x: event.clientX, y: event.clientY };
|
|
54
|
+
timerRef.current = window.setTimeout(() => {
|
|
55
|
+
timerRef.current = null;
|
|
56
|
+
startRef.current = null;
|
|
57
|
+
suppressClickRef.current = true;
|
|
58
|
+
onSelectRef.current(id);
|
|
59
|
+
}, LONG_PRESS_MS);
|
|
60
|
+
},
|
|
61
|
+
onPointerMove: (event: ReactPointerEvent<HTMLElement>) => {
|
|
62
|
+
const start = startRef.current;
|
|
63
|
+
if (!start) return;
|
|
64
|
+
if (
|
|
65
|
+
Math.abs(event.clientX - start.x) > MOVE_TOLERANCE_PX
|
|
66
|
+
|| Math.abs(event.clientY - start.y) > MOVE_TOLERANCE_PX
|
|
67
|
+
) cancel();
|
|
68
|
+
},
|
|
69
|
+
onPointerUp: cancel,
|
|
70
|
+
onPointerCancel: cancel,
|
|
71
|
+
onClickCapture: (event: ReactMouseEvent<HTMLElement>) => {
|
|
72
|
+
if (!suppressClickRef.current) return false;
|
|
73
|
+
suppressClickRef.current = false;
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
event.stopPropagation();
|
|
76
|
+
return true;
|
|
77
|
+
},
|
|
78
|
+
}), [cancel]);
|
|
79
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, type MouseEvent as ReactMouseEvent } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
useCollectionInteractions,
|
|
6
|
+
type CollectionInteractionController,
|
|
7
|
+
type SelectionMarqueeRect,
|
|
8
|
+
} from './useCollectionInteractions';
|
|
9
|
+
|
|
10
|
+
const ROW_ATTR = 'data-notis-row-id';
|
|
11
|
+
|
|
12
|
+
export type DragRect = SelectionMarqueeRect;
|
|
13
|
+
|
|
14
|
+
export interface UseMultiSelectOptions<T> {
|
|
15
|
+
items: T[];
|
|
16
|
+
getId: (item: T) => string;
|
|
17
|
+
/** When false, suppresses the global Esc / Cmd+A / X / Shift+Arrow listeners. Default true. */
|
|
18
|
+
bindKeyboardShortcuts?: boolean;
|
|
19
|
+
/** When false, disables the rubber-band drag-select rectangle. Default true. */
|
|
20
|
+
enableDragSelect?: boolean;
|
|
21
|
+
/** Distance (px) the cursor must move past mousedown before a drag begins. Default 5. */
|
|
22
|
+
dragThreshold?: number;
|
|
23
|
+
/** Initial head id (the row that Shift+Arrow extends from). */
|
|
24
|
+
initialHeadId?: string | null;
|
|
25
|
+
/** Fires when the head moves via Shift+Arrow so the consumer can scroll the row into view. */
|
|
26
|
+
onHeadChange?: (id: string | null) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MultiSelectController<T> {
|
|
30
|
+
selectedIds: ReadonlySet<string>;
|
|
31
|
+
selectedCount: number;
|
|
32
|
+
lastClickedId: string | null;
|
|
33
|
+
headId: string | null;
|
|
34
|
+
dragRect: DragRect | null;
|
|
35
|
+
isSelected: (id: string) => boolean;
|
|
36
|
+
getSelectedItems: () => T[];
|
|
37
|
+
toggle: (id: string) => void;
|
|
38
|
+
select: (ids: string[]) => void;
|
|
39
|
+
clear: () => void;
|
|
40
|
+
selectAll: () => void;
|
|
41
|
+
selectRange: (anchorId: string, targetId: string) => void;
|
|
42
|
+
setHead: (id: string | null) => void;
|
|
43
|
+
onCheckboxClick: (id: string) => (event: ReactMouseEvent) => void;
|
|
44
|
+
onRowMouseDown: (id: string) => (event: ReactMouseEvent) => void;
|
|
45
|
+
getRowProps: (id: string) => { [ROW_ATTR]: string };
|
|
46
|
+
getItemProps: CollectionInteractionController<T>['getItemProps'];
|
|
47
|
+
getCheckboxProps: (id: string) => {
|
|
48
|
+
isSelected: boolean;
|
|
49
|
+
onClick: (event: ReactMouseEvent) => void;
|
|
50
|
+
};
|
|
51
|
+
getContainerProps: CollectionInteractionController<T>['getContainerProps'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @deprecated Use `useCollectionInteractions` for controlled selection,
|
|
56
|
+
* active-item navigation, scoped shortcuts, and semantic actions. This bridge
|
|
57
|
+
* is retained for the 0.2 minor and scheduled for removal in 0.3.
|
|
58
|
+
*/
|
|
59
|
+
export function useMultiSelect<T>(options: UseMultiSelectOptions<T>): MultiSelectController<T> {
|
|
60
|
+
const controller = useCollectionInteractions({
|
|
61
|
+
...options,
|
|
62
|
+
defaultActiveId: options.initialHeadId,
|
|
63
|
+
onActiveItemChange: options.onHeadChange,
|
|
64
|
+
shortcuts: {
|
|
65
|
+
next: false,
|
|
66
|
+
previous: false,
|
|
67
|
+
activate: false,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const getRowProps = useCallback((id: string) => ({ [ROW_ATTR]: id }), []);
|
|
72
|
+
const getItemProps = controller.getItemProps;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
selectedIds: controller.selectedIds,
|
|
76
|
+
selectedCount: controller.selectedCount,
|
|
77
|
+
lastClickedId: controller.lastClickedId,
|
|
78
|
+
headId: controller.headId,
|
|
79
|
+
dragRect: controller.dragRect,
|
|
80
|
+
isSelected: controller.isSelected,
|
|
81
|
+
getSelectedItems: controller.getSelectedItems,
|
|
82
|
+
toggle: controller.toggle,
|
|
83
|
+
select: controller.select,
|
|
84
|
+
clear: controller.clear,
|
|
85
|
+
selectAll: controller.selectAll,
|
|
86
|
+
selectRange: controller.selectRange,
|
|
87
|
+
setHead: controller.setActive,
|
|
88
|
+
onCheckboxClick: controller.onCheckboxClick,
|
|
89
|
+
onRowMouseDown: controller.onRowMouseDown,
|
|
90
|
+
getRowProps,
|
|
91
|
+
getItemProps,
|
|
92
|
+
getCheckboxProps: controller.getCheckboxProps,
|
|
93
|
+
getContainerProps: controller.getContainerProps,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { useQuery } from './useQuery';
|
|
6
|
+
import { SpaceActionError, spaceQueryKey, type SpaceDocumentBodyRead, type SpaceDocumentBodySave } from '../space';
|
|
7
|
+
|
|
8
|
+
/** Explicit textarea Save. The source owns the draft and retains its request ID
|
|
9
|
+
* until a receipt or unchanged result; background reads never replace that draft. */
|
|
10
|
+
export function useNativeDocumentBody(binding: string, recordKey: string | null, options: {
|
|
11
|
+
readAction: string; writeAction?: string; enabled?: boolean;
|
|
12
|
+
}) {
|
|
13
|
+
const runtime = useNotisRuntime();
|
|
14
|
+
const scope = JSON.stringify(spaceQueryKey(runtime, ['native-body', binding, recordKey, options.readAction, options.writeAction]));
|
|
15
|
+
const currentScope = useRef<string | null>(scope);
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
currentScope.current = scope;
|
|
18
|
+
return () => { currentScope.current = null; };
|
|
19
|
+
}, [scope]);
|
|
20
|
+
const [state, setState] = useState<{ scope: string; pending: number; error: Error | null }>({ scope, pending: 0, error: null });
|
|
21
|
+
const query = useQuery<SpaceDocumentBodyRead>(['native-body', binding, recordKey, options.readAction], async () => {
|
|
22
|
+
if (!recordKey || runtime?.resource?.kind !== 'space' || !runtime.documentBody) throw new Error('Open a Space document first.');
|
|
23
|
+
const result = await runtime.documentBody({ operation: 'read', binding, recordKey, readAction: options.readAction });
|
|
24
|
+
if (!('content_markdown' in result)) throw new Error('The document response is incomplete.');
|
|
25
|
+
return result;
|
|
26
|
+
}, { readOnly: true, enabled: Boolean(recordKey) && options.enabled !== false });
|
|
27
|
+
const save = useCallback(async (draft: {
|
|
28
|
+
contentMarkdown: string; expectedRevision: number; schemaRevision: number; requestId: string;
|
|
29
|
+
}): Promise<SpaceDocumentBodySave> => {
|
|
30
|
+
if (!recordKey || !options.writeAction || runtime?.resource?.kind !== 'space' || !runtime.documentBody) {
|
|
31
|
+
throw new Error('This Space does not expose a body-edit action.');
|
|
32
|
+
}
|
|
33
|
+
setState(previous => ({ scope, pending: (previous.scope === scope ? previous.pending : 0) + 1, error: null }));
|
|
34
|
+
try {
|
|
35
|
+
const result = await runtime.documentBody({ operation: 'save', binding, recordKey,
|
|
36
|
+
readAction: options.readAction, writeAction: options.writeAction, ...draft });
|
|
37
|
+
if (currentScope.current !== scope) throw new SpaceActionError('This document view changed. Keep the original save request.', draft.requestId);
|
|
38
|
+
if (!('status' in result)) throw new Error('The document save receipt is incomplete.');
|
|
39
|
+
return result;
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
42
|
+
if (currentScope.current === scope) setState(previous => ({ ...previous, error }));
|
|
43
|
+
throw error;
|
|
44
|
+
} finally {
|
|
45
|
+
if (currentScope.current === scope) setState(previous => ({ ...previous, pending: Math.max(0, previous.pending - 1) }));
|
|
46
|
+
}
|
|
47
|
+
}, [runtime, binding, recordKey, options.readAction, options.writeAction, scope]);
|
|
48
|
+
return { ...query, body: query.data, save, saving: state.scope === scope && state.pending > 0,
|
|
49
|
+
saveError: state.scope === scope ? state.error : null };
|
|
50
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { normalizeDocumentRecord } from '../documents';
|
|
6
|
+
import type { DocumentRecord, NotisRuntime } from '../runtime';
|
|
7
|
+
import type { NativeDelete, NativeInsert, NativeMutationReceipt, NativeQuery, NativeUpdate } from '../nativeData';
|
|
8
|
+
import { useQuery, type UseQueryResult } from './useQuery';
|
|
9
|
+
import { SdkQueryError } from '../queryErrors';
|
|
10
|
+
|
|
11
|
+
function actionFor(runtime: NotisRuntime | null, binding: string, operation: 'query' | 'insert' | 'update' | 'delete'): string {
|
|
12
|
+
const descriptor = runtime?.space?.bindings[binding];
|
|
13
|
+
const action = descriptor?.kind === 'database' ? descriptor.operations?.[operation] : undefined;
|
|
14
|
+
if (runtime?.resource?.kind !== 'space' || !runtime.callAction || !action) {
|
|
15
|
+
const diagnostic = `This Space does not expose ${operation} for database binding "${binding}".`;
|
|
16
|
+
if (operation === 'query') throw new SdkQueryError('bindingUnavailable', diagnostic);
|
|
17
|
+
throw new Error(`This Space does not expose ${operation} for that database binding.`);
|
|
18
|
+
}
|
|
19
|
+
return action;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A declared query adapter, never an account-wide slug or owner lookup.
|
|
24
|
+
*
|
|
25
|
+
* `subscribe: true` follows the binding's live change feed where the host has one (a
|
|
26
|
+
* signed-in Editor of the Space, owner or teammate): `live` is then true and changes made
|
|
27
|
+
* elsewhere (another device, a teammate, an agent, an automation) re-read the rows on their
|
|
28
|
+
* own. Where `live` is false, keep offering a manual reload.
|
|
29
|
+
*/
|
|
30
|
+
export function useNativeDocuments(binding: string, options: {
|
|
31
|
+
query?: NativeQuery; fetchAll?: boolean; enabled?: boolean; staleTimeMs?: number; subscribe?: boolean;
|
|
32
|
+
} = {}): UseQueryResult<{ documents: DocumentRecord[]; schemaRevision: number }> & { documents: DocumentRecord[]; schemaRevision?: number; live: boolean } {
|
|
33
|
+
const runtime = useNotisRuntime();
|
|
34
|
+
const [live, setLive] = useState(false);
|
|
35
|
+
const follow = options.subscribe === true && options.enabled !== false;
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!follow || !runtime?.subscribeDatabase) { setLive(false); return; }
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
// The Space host re-reads every query of the view when the database changes, so the
|
|
40
|
+
// change callback has nothing left to do; the feed is a signal and never carries rows.
|
|
41
|
+
const stop = runtime.subscribeDatabase(binding, () => {}, { onStatusChange: value => { if (!cancelled) setLive(value); } });
|
|
42
|
+
return () => { cancelled = true; setLive(false); stop?.(); };
|
|
43
|
+
}, [runtime, binding, follow]);
|
|
44
|
+
const query = useQuery(['native-documents', binding, options.query ?? {}, Boolean(options.fetchAll)], async () => {
|
|
45
|
+
const action = actionFor(runtime, binding, 'query');
|
|
46
|
+
if (options.query?.mode && !['rows', 'search'].includes(options.query.mode)) throw new Error('Use useActionQuery for counts and aggregates.');
|
|
47
|
+
const rows: unknown[] = [];
|
|
48
|
+
let offset = options.query?.offset ?? 0;
|
|
49
|
+
let revision: number | undefined;
|
|
50
|
+
while (true) {
|
|
51
|
+
const result = await runtime!.callAction!<{ rows: unknown[]; schema_revision: number; has_more: boolean; next_offset: number | null }>(
|
|
52
|
+
action, { request: { ...options.query, offset } }, { readOnly: true, dedupe: true, schemaRevision: revision });
|
|
53
|
+
if (!result || !Array.isArray(result.rows) || !Number.isSafeInteger(result.schema_revision)) throw new SdkQueryError('responseIncomplete', 'Native query must return rows and a safe integer schema_revision.');
|
|
54
|
+
if (revision !== undefined && revision !== result.schema_revision) throw new SdkQueryError('schemaChanged');
|
|
55
|
+
revision = result.schema_revision;
|
|
56
|
+
rows.push(...result.rows);
|
|
57
|
+
if (!options.fetchAll || !result.has_more) break;
|
|
58
|
+
if (typeof result.next_offset !== 'number' || result.next_offset <= offset) throw new SdkQueryError('responseIncomplete', 'Native query pagination did not advance next_offset.');
|
|
59
|
+
offset = result.next_offset;
|
|
60
|
+
}
|
|
61
|
+
return { documents: rows.map(normalizeDocumentRecord), schemaRevision: revision! };
|
|
62
|
+
}, { readOnly: true, enabled: options.enabled, staleTimeMs: options.staleTimeMs });
|
|
63
|
+
return { ...query, documents: query.data?.documents ?? EMPTY_DOCUMENTS, schemaRevision: query.data?.schemaRevision, live };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Explicit insert/update/trash; update never silently creates a missing row. */
|
|
67
|
+
export function useNativeMutation<TOperation extends 'insert' | 'update' | 'delete'>(binding: string, operation: TOperation): {
|
|
68
|
+
mutate: (input: TOperation extends 'insert' ? NativeInsert : TOperation extends 'update' ? NativeUpdate : NativeDelete,
|
|
69
|
+
options: { schemaRevision: number; requestId: string }) => Promise<NativeMutationReceipt>;
|
|
70
|
+
loading: boolean; error: Error | null;
|
|
71
|
+
} {
|
|
72
|
+
const runtime = useNotisRuntime();
|
|
73
|
+
const [pending, setPending] = useState(0), [error, setError] = useState<Error | null>(null);
|
|
74
|
+
const mutate = useCallback(async (input: NativeInsert | NativeUpdate | NativeDelete, options: { schemaRevision: number; requestId: string }) => {
|
|
75
|
+
const action = actionFor(runtime, binding, operation);
|
|
76
|
+
setPending((value) => value + 1); setError(null);
|
|
77
|
+
try { return await runtime!.callAction!<NativeMutationReceipt>(action, { request: input }, options); }
|
|
78
|
+
catch (cause) { const failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure); throw failure; }
|
|
79
|
+
finally { setPending((value) => value - 1); }
|
|
80
|
+
}, [runtime, binding, operation]);
|
|
81
|
+
return { mutate, loading: pending > 0, error };
|
|
82
|
+
}
|
|
83
|
+
const EMPTY_DOCUMENTS: DocumentRecord[] = [];
|