lemma-sdk 0.2.34 → 0.2.35

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Lemma TypeScript SDK
2
2
 
3
- `lemma-sdk` is the headless TypeScript SDK for Lemma. Use `lemma-sdk` for the core client, `lemma-sdk/react` for hooks and auth primitives, and the Lemma shadcn registry for stock UI blocks.
3
+ `lemma-sdk` is the headless TypeScript SDK for Lemma. Use `lemma-sdk` for the core client and shared helpers, and use `lemma-sdk/react` as the main app-building surface for hooks and auth primitives.
4
4
 
5
- `AuthGuard` intentionally stays in `lemma-sdk/react`. Stock assistant, table, workflow, agent, member, and function UI lives in the registry.
5
+ `AuthGuard` intentionally stays in `lemma-sdk/react`. The product direction is hooks-first and shell-agnostic. The registry remains available, but it is intentionally small and no longer the center of the recommended development model. See [docs/headless-first-direction.md](docs/headless-first-direction.md).
6
6
 
7
7
  ## Install
8
8
 
@@ -10,7 +10,7 @@
10
10
  npm install lemma-sdk
11
11
  ```
12
12
 
13
- If your app uses shadcn/ui, configure the Lemma registry with:
13
+ If your app wants stock Lemma UI installs, configure the registry with:
14
14
 
15
15
  ```bash
16
16
  npx lemma-sdk init-shadcn
@@ -74,10 +74,10 @@ import {
74
74
  | Area | Hooks | Stability | Use when |
75
75
  | --- | --- | --- | --- |
76
76
  | Auth | `AuthGuard`, `useAuth`, `useCurrentUser`, `usePodAccess` | Stable | Gate an app, read signed-in user state, or request pod access. |
77
- | Tables | `useTables`, `useTable`, `useRecords`, `useRecord`, `useJoinedRecords`, `useRelatedRecords`, `useReverseRelatedRecords`, `useReferencingRecords` | Stable | Build custom table browsers, details views, related-record views, and relational reads. |
77
+ | Tables | `useTables`, `useTable`, `useRecords`, `useRecord`, `useJoinedRecords`, `useRelatedRecords`, `useReverseRelatedRecords`, `useReferencingRecords`, `useDatastoreQuery`, `useRecordAggregates` | Stable | Build custom table browsers, details views, related-record views, raw SQL-backed reads, and chart/KPI queries. |
78
78
  | Record mutations | `useCreateRecord`, `useUpdateRecord`, `useDeleteRecord`, `useBulkRecords` | Stable | Create, update, delete, or bulk-delete rows from headless UI. Function-backed mutations via `createVia`/`updateVia` options. |
79
79
  | Record forms | `useRecordSchema`, `useRecordForm`, `useForeignKeyOptions`, `useSchemaForm` | Stable | Render schema-driven record forms, enum fields, and foreign-key selectors. `useRecordForm` is the canonical table-bound form hook; `useSchemaForm` remains available for raw JSON-schema flows. |
80
- | Files | `useFiles`, `useFile`, `useFileSearch`, `useFileTree`, `useFilePreview` | Stable | Browse datastore folders, resolve file metadata, search indexed files, load directory trees, and preview converted or raw file content. |
80
+ | Files | `useFiles`, `useFile`, `useUploadFile`, `useUpdateFile`, `useDeleteFile`, `useCreateFolder`, `useFileSearch`, `useFileTree`, `useFilePreview`, `useGlobalSearch` | Stable | Browse datastore folders, mutate file state, search indexed files, load directory trees, preview content, and compose multi-source desk search. |
81
81
  | Assistant | `useConversations`, `useConversation`, `useConversationMessages`, `useAssistantRun`, `useAssistantSession`, `useAssistantRuntime`, `useAssistantController` | Stable except controller/runtime | Build custom chat, conversation lists, streaming output, and final-output views. |
82
82
  | Agents | `useAgentRun`, `useAgentRuns`, `useAgentInputSchema`, `useTaskSession` | Stable except raw session | Start agent tasks, submit follow-up input, read task history, and inspect input/output schemas. |
83
83
  | Workflows | `useWorkflowStart`, `useWorkflowRun`, `useWorkflowRuns`, `useWorkflowResume` | Stable | Start, poll, resume, cancel, retry, and inspect workflow runs. |
@@ -85,6 +85,13 @@ import {
85
85
  | Functions | `useFunctionRun`, `useFunctionRuns`, `useFunctionSession` | Stable except raw session | Run functions, poll function runs, and list function history. |
86
86
  | Members and org | `useMembers`, `useAddPodMember`, `useUpdatePodMemberRole`, `useRemovePodMember`, `useOrganizationMembers` | Stable | Read pod and organization members, add existing org members into a pod, update pod roles, and remove pod access. The current checked-in client does not yet expose direct email-to-pod invites. |
87
87
 
88
+ ### Headless Helpers
89
+
90
+ Alongside hooks, `lemma-sdk` exports shared headless helpers for common desk logic:
91
+
92
+ - record display helpers: `formatRecordDisplayValue`, `humanizeRecordFieldName`, `detectRecordStatusColumn`
93
+ - form/schema helpers: `buildRecordSchemaFields`, `buildSchemaFormFields`
94
+
88
95
  ### Common Hook Shapes
89
96
 
90
97
  For business-facing examples and a decision guide mapping "I want to..." to the right hook, see [docs/hooks-guide.md](docs/hooks-guide.md).
@@ -209,44 +216,33 @@ function WorkflowButton({ client }: { client: LemmaClient }) {
209
216
  }
210
217
  ```
211
218
 
212
- ## Shadcn Registry
219
+ ## Registry
213
220
 
214
- Lemma UI lives in the registry, not in `lemma-sdk/react`.
221
+ The registry is optional UI scaffolding, not the default product story. Most desks should be built from hooks and app-local UI.
222
+
223
+ If you still want registry installs, they remain available:
215
224
 
216
225
  After running `npx lemma-sdk init-shadcn`, install blocks like:
217
226
 
218
227
  ```bash
219
- npx shadcn@latest add @lemma/lemma-records-view
220
- npx shadcn@latest add @lemma/lemma-detail-panel
221
- npx shadcn@latest add @lemma/lemma-record-form
222
- npx shadcn@latest add @lemma/lemma-global-search
223
- npx shadcn@latest add @lemma/lemma-file-browser
224
- npx shadcn@latest add @lemma/lemma-file-viewer
228
+ npx shadcn@latest add @lemma/lemma-assistant-experience
225
229
  npx shadcn@latest add @lemma/lemma-document-workspace
226
- npx shadcn@latest add @lemma/lemma-document-creator
227
- npx shadcn@latest add @lemma/lemma-document-viewer
228
- npx shadcn@latest add @lemma/lemma-document-editor
230
+ npx shadcn@latest add @lemma/lemma-global-search
229
231
  npx shadcn@latest add @lemma/lemma-members
230
- npx shadcn@latest add @lemma/lemma-comments
231
- npx shadcn@latest add @lemma/lemma-insights
232
- npx shadcn@latest add @lemma/lemma-assistant-experience
232
+ npx shadcn@latest add @lemma/lemma-action-surface
233
233
  ```
234
234
 
235
- Those commands are representative. The registry currently ships 22 canonical blocks.
235
+ The registry is intentionally small now. It currently ships 5 published blocks.
236
236
 
237
237
  Current registry items:
238
238
 
239
239
  | Area | Items |
240
240
  | --- | --- |
241
241
  | Assistant | `lemma-assistant-experience` |
242
- | Navigation | `lemma-breadcrumbs`, `lemma-global-search`, `lemma-page-tree` |
243
- | Records | `lemma-records-view`, `lemma-detail-panel`, `lemma-record-form`, `lemma-status-flow` |
244
- | Files | `lemma-file-browser`, `lemma-file-viewer` |
245
- | Documents | `lemma-document-workspace`, `lemma-document-creator`, `lemma-document-viewer`, `lemma-document-editor`, `lemma-markdown-editor` |
246
- | People | `lemma-members`, `lemma-notification-bell`, `lemma-user-menu` |
247
- | Analytics | `lemma-insights` |
248
- | Collaboration | `lemma-activity-feed`, `lemma-comments` |
249
- | Automation | `lemma-workflow-runner` |
242
+ | Documents | `lemma-document-workspace` |
243
+ | Search | `lemma-global-search` |
244
+ | People | `lemma-members` |
245
+ | Automation | `lemma-action-surface` |
250
246
 
251
247
  The registry is currently served from jsDelivr against this public repo:
252
248
 
@@ -255,60 +251,13 @@ The registry is currently served from jsDelivr against this public repo:
255
251
 
256
252
  For more stable installs, pin the registry URL to a tag or commit SHA instead of `@main`.
257
253
 
258
- Blocks that install a CSS file, such as records view, should be imported by your app's global stylesheet:
254
+ Published blocks:
259
255
 
260
- ```css
261
- @import "@/styles/lemma-records-view.css";
262
- ```
263
-
264
- ### Records Workspace Customization
265
-
266
- The records blocks are meant to be configured with props before you reach for a fork.
267
-
268
- `lemma-records-view` supports:
269
-
270
- - `preset="triage" | "issues" | "crm" | "docs"` for opinionated defaults without installing duplicate workflow blocks
271
- - `defaultView` and `availableViews` across `grid`, `list`, `grouped`, `kanban`, `linear`, `calendar`, `timeline`, and `matrix`
272
- - `tableName`, `visibleColumns`, and `hiddenFields` for schema-aware display
273
- - `pinnedColumns`, `columnWidths`, `columnLabels`, `primaryField`, `defaultSort`, and `paginationMode` for stronger operator-table defaults
274
- - `groupBy`, `calendarField`, `timelineField`, `matrixRowsBy`, and `matrixColumnsBy` for consolidated view configuration
275
- - `renderCell` and `renderCard` for custom record rendering
276
- - `foreignKeyLabels` for human-readable FK values in cards, detail views, and create/edit forms
277
- - `detailTabs`, `detailFieldGroups`, `detailRelatedRecords`, `detailSectionLabels`, and `detailSectionVisibility` for canonical detail composition
278
- - `quickActions`, `bulkActions`, `detailActions`, `quickActionMode`, and `onQuickActionSuccess` for direct, function, or workflow-backed actions
279
- - `onCreateOptions` and `onUpdateOptions` for function-backed mutations, including conditional field and section visibility in the create sheet
280
- - `createMode="sheet" | "modal" | "page"` and `detailMode="sheet" | "modal" | "page" | "inline"` for app-specific interaction patterns
281
- - `headerActions`, `emptyState`, `onRecordClick`, `renderFilesTab`, `renderCommentsTab`, and `renderActivityTab` for app-specific extensions
282
- - `appearance="default" | "minimal" | "borderless" | "contained"` and `density="compact" | "comfortable" | "spacious"` for host-level block chrome; `minimal` is the cardless mode
283
-
284
- `lemma-detail-panel` supports:
285
-
286
- - standalone record detail rendering outside the full records workspace, using the same canonical detail internals as `lemma-records-view`
287
- - `mode`, `variant`, and `layout` controls for embedded, sheet, modal, or full-page use
288
- - built-in detail tabs plus custom `tabs`, `relatedRecords`, and `renderFiles` / `renderComments` / `renderActivity` sections
289
- - direct, function-backed, and workflow-aware `actions`, plus `updateVia` / `updateFunctionName` for inline edits
290
- - shared `appearance`, `density`, and `radius` controls for use in inline, sheet, modal, or page layouts
291
-
292
- `lemma-record-form` supports:
293
-
294
- - `mode="inline" | "modal" | "sheet"`
295
- - `submitVia="direct" | "function"` and `submitFunctionName`
296
- - `submitFunctionInput` when the backing function expects a different payload shape
297
- - `hiddenFields`, `visibleFields`, `fieldOrder`, and `fieldGroups`
298
- - searchable FK inputs through the shared `record-form-fields` control layer
299
- - `fieldVisibility` and `sectionVisibility` for conditional forms
300
- - `foreignKeyLabels` for FK select labels
301
- - `initialValues`, `onSuccess`, and `onClose`
302
- - `appearance`, `density`, and `radius` using the same values as `lemma-records-view`
303
-
304
- `lemma-insights` supports:
305
-
306
- - table-backed count, sum, average, and grouped chart cards
307
- - bar, line, area, pie, and funnel charts with count/sum/avg aggregation
308
- - `aggregationMode="client" | "function"` with optional `aggregateFunctionName` for shared server-side aggregation
309
- - chart descriptions, value/category formatters, limits, sorting, empty states, and optional footers
310
- - function-backed stats and charts
311
- - shared `appearance`, `density`, `radius`, and card-column controls
256
+ - `lemma-assistant-experience` for the hardest assistant/chat runtime surface
257
+ - `lemma-document-workspace` for rich document/file create-read-edit-preview flows
258
+ - `lemma-global-search` for a stock command-bar style omnibox
259
+ - `lemma-members` for stock pod membership management
260
+ - `lemma-action-surface` for long-running function/workflow/agent launches
312
261
 
313
262
  `lemma-global-search` supports:
314
263
 
@@ -318,26 +267,12 @@ The records blocks are meant to be configured with props before you reach for a
318
267
  - `minQueryLength`, `debounceMs`, `appearance`, `density`, trigger label, and placeholder customization
319
268
  - assistant handoff by `assistantName`, with optional query/results message shaping and conversation routing
320
269
 
321
- Navigation blocks support:
322
-
323
- - route, record, and file-path breadcrumb builders through `lemma-breadcrumbs`
324
- - self-referential page hierarchies through `lemma-page-tree`, with selection, expansion, and create/reorder hooks
325
-
326
- File blocks support:
327
-
328
- - datastore folder navigation and path breadcrumbs through `lemma-file-browser`
329
- - pod-level file browsing and search, not only record-linked attachments
330
- - upload, download, search, rename, move, folder creation, picker mode, and composition-friendly link actions
331
- - selection-aware file browsing so `lemma-file-browser` can drive a paired workspace preview
332
- - image, PDF, text, markdown, converted HTML, metadata, and breadcrumb-aware previews through `lemma-file-viewer`
333
-
334
270
  Document blocks support:
335
271
 
336
- - Notion/Coda-style block documents through `lemma-document-workspace`, with Tiptap JSON content, page/modal modes, title and summary chrome, file/reference/assistant blocks, save state, metadata, backlinks, and assistant-context rails
337
- - docstore-native creation through `lemma-document-workspace` or the simpler `lemma-document-creator`, with folder targeting, title/summary setup, pod-file metadata, and `mode="page" | "modal"`
338
- - long-form reading surfaces through `lemma-document-workspace` or `lemma-document-viewer`, with full-page and modal presentation, metadata, backlinks, references, and assistant-context rails
339
- - long-form authoring through `lemma-document-workspace` or `lemma-document-editor`, with full-page and modal presentation, title/summary chrome, save-state affordances, outline, references, and assistant-context rails
340
- - a clean separation between richer document UX and the lower-level pod file workspace, while keeping pod files as the primary document store and avoiding embedded-first document editing
272
+ - Notion/Coda-style block documents through `lemma-document-workspace`, with ProseKit JSON content, page/modal modes, title and summary chrome, file/reference/assistant blocks, save state, metadata, backlinks, and assistant-context rails
273
+ - pod-file-native creation, reading, editing, and preview through one workspace, with folder targeting, title/summary setup, pod-file metadata, and `mode="page" | "modal"`
274
+ - non-document file previews through the same workspace, including image, PDF, text, markdown, converted HTML, and download fallback behavior
275
+ - records and attachments should pass pod file paths into `lemma-document-workspace`; records should not own document bodies directly
341
276
 
342
277
  People blocks support:
343
278
 
@@ -348,50 +283,22 @@ People blocks support:
348
283
 
349
284
  Workflow primitives support:
350
285
 
351
- - lifecycle/status rendering and transitions through `lemma-status-flow`
352
- - read-only tracker layouts and compact step progress through `lemma-status-flow`
353
- - workflow run inspection through `lemma-workflow-runner`
354
- - reusable history and collaboration surfaces through `lemma-activity-feed` and `lemma-comments`
355
- - table-backed defaults with escape hatches for custom action payloads and render slots
356
-
357
- `lemma-markdown-editor` supports:
358
-
359
- - write, preview, and split modes
360
- - controlled and uncontrolled values
361
- - GitHub-flavored markdown preview via `react-markdown` and `remark-gfm`
362
- - a lightweight interim editing lane for plain markdown notes beside the richer block-native `lemma-document-workspace`
286
+ - direct, function-backed, workflow-backed, and agent-backed launches through `lemma-action-surface`
287
+ - inline, row, and panel presentation modes for long-running actions with inspectable progress
288
+ - native workflow/file surfaces plus app-local record UIs where tables are the actual product data model
363
289
 
364
290
  Assistant blocks support:
365
291
 
366
292
  - assistant-name-first configuration through `assistantName`
367
293
  - shared `appearance` and `density` controls on the assistant experience surface
368
294
  - `chromeStyle`, `statusPlacement`, `radius`, model picker, conversation list, and render overrides for deeper customization
369
-
370
- Shell blocks support:
371
-
372
- - `lemma-notification-bell` for unread counts, popover inboxes, and mark-as-read flows
373
- - `lemma-user-menu` for current-user presentation, custom menu actions, and sign-out affordances
295
+ - bounded default heights for `page` and `side-panel` modes so the message viewport scrolls instead of stretching with content; pass `className="h-full min-h-0"` inside an explicit-height parent when you want a fill-layout assistant like inbox CRM
374
296
 
375
297
  ```tsx
376
- import { LemmaRecordsView } from "@/components/lemma/lemma-records-view";
298
+ import { LemmaAssistantExperience } from "@/components/lemma/assistant/assistant-experience";
299
+ import { LemmaActionSurface } from "@/components/lemma/lemma-action-surface";
377
300
  import { LemmaGlobalSearch } from "@/components/lemma/lemma-global-search";
378
301
 
379
- <LemmaRecordsView
380
- client={client}
381
- podId={podId}
382
- tableName="deals"
383
- preset="crm"
384
- hiddenFields={["id", "created_at", "updated_at"]}
385
- foreignKeyLabels={{ company_id: "name" }}
386
- appearance="minimal"
387
- density="compact"
388
- createMode="sheet"
389
- onCreateOptions={{
390
- submitVia: "function",
391
- submitFunctionName: "create-deal",
392
- }}
393
- />;
394
-
395
302
  <LemmaGlobalSearch
396
303
  client={client}
397
304
  podId={podId}
@@ -416,6 +323,20 @@ import { LemmaGlobalSearch } from "@/components/lemma/lemma-global-search";
416
323
  appearance="minimal"
417
324
  density="compact"
418
325
  />;
326
+
327
+ <LemmaActionSurface
328
+ client={client}
329
+ podId={podId}
330
+ label="Run triage"
331
+ kind="workflow"
332
+ workflowName="triage-lead"
333
+ />;
334
+
335
+ <LemmaAssistantExperience
336
+ client={client}
337
+ assistantName="sales-copilot"
338
+ density="compact"
339
+ />;
419
340
  ```
420
341
 
421
342
  ## Auth
@@ -455,14 +376,14 @@ npm run build
455
376
  npm run registry:build
456
377
  ```
457
378
 
458
- To build the canonical example desk:
379
+ To build the single local sandbox app:
459
380
 
460
381
  ```bash
461
382
  cd examples/inbox-crm
462
383
  npm run build
463
384
  ```
464
385
 
465
- `examples/inbox-crm` now mirrors the kept registry surface only. Its local `src/components/lemma` folder is a copied install target of the current canonical registry blocks, and `src/main.tsx` demonstrates those blocks in one routed operator desk.
386
+ `examples/inbox-crm` is the only kept example app in this repo. It is a local sandbox for visualizing the current direction, not a promise that every copied component inside it is a published registry block.
466
387
 
467
388
  This repo includes:
468
389
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export declare enum DatastoreDataType {
5
5
  TEXT = "TEXT",
6
+ FILE_PATH = "FILE_PATH",
6
7
  INTEGER = "INTEGER",
7
8
  FLOAT = "FLOAT",
8
9
  BOOLEAN = "BOOLEAN",
@@ -10,6 +11,7 @@ export declare enum DatastoreDataType {
10
11
  DATE = "DATE",
11
12
  DATETIME = "DATETIME",
12
13
  UUID = "UUID",
14
+ USER = "USER",
13
15
  VECTOR = "VECTOR",
14
16
  SERIAL = "SERIAL",
15
17
  ENUM = "ENUM"
@@ -8,6 +8,7 @@
8
8
  export var DatastoreDataType;
9
9
  (function (DatastoreDataType) {
10
10
  DatastoreDataType["TEXT"] = "TEXT";
11
+ DatastoreDataType["FILE_PATH"] = "FILE_PATH";
11
12
  DatastoreDataType["INTEGER"] = "INTEGER";
12
13
  DatastoreDataType["FLOAT"] = "FLOAT";
13
14
  DatastoreDataType["BOOLEAN"] = "BOOLEAN";
@@ -15,6 +16,7 @@ export var DatastoreDataType;
15
16
  DatastoreDataType["DATE"] = "DATE";
16
17
  DatastoreDataType["DATETIME"] = "DATETIME";
17
18
  DatastoreDataType["UUID"] = "UUID";
19
+ DatastoreDataType["USER"] = "USER";
18
20
  DatastoreDataType["VECTOR"] = "VECTOR";
19
21
  DatastoreDataType["SERIAL"] = "SERIAL";
20
22
  DatastoreDataType["ENUM"] = "ENUM";
@@ -38,18 +38,32 @@ export { useFiles } from "./useFiles.js";
38
38
  export type { UseFilesOptions, UseFilesResult } from "./useFiles.js";
39
39
  export { useFile } from "./useFile.js";
40
40
  export type { UseFileOptions, UseFileResult } from "./useFile.js";
41
+ export { useDatastoreQuery } from "./useDatastoreQuery.js";
42
+ export type { UseDatastoreQueryOptions, UseDatastoreQueryResult, } from "./useDatastoreQuery.js";
43
+ export { useUploadFile } from "./useUploadFile.js";
44
+ export type { UploadFileInput, UseUploadFileOptions, UseUploadFileResult, } from "./useUploadFile.js";
45
+ export { useUpdateFile } from "./useUpdateFile.js";
46
+ export type { UpdateFileInput, UseUpdateFileOptions, UseUpdateFileResult, } from "./useUpdateFile.js";
47
+ export { useDeleteFile } from "./useDeleteFile.js";
48
+ export type { UseDeleteFileOptions, UseDeleteFileResult } from "./useDeleteFile.js";
49
+ export { useCreateFolder } from "./useCreateFolder.js";
50
+ export type { CreateFolderInput, UseCreateFolderOptions, UseCreateFolderResult, } from "./useCreateFolder.js";
41
51
  export { useFileSearch } from "./useFileSearch.js";
42
52
  export type { UseFileSearchOptions, UseFileSearchResult } from "./useFileSearch.js";
43
53
  export { useFileTree } from "./useFileTree.js";
44
54
  export type { UseFileTreeOptions, UseFileTreeResult } from "./useFileTree.js";
45
55
  export { useFilePreview } from "./useFilePreview.js";
46
56
  export type { FilePreviewMode, UseFilePreviewOptions, UseFilePreviewResult, } from "./useFilePreview.js";
57
+ export { useGlobalSearch } from "./useGlobalSearch.js";
58
+ export type { GlobalSearchFileResult, GlobalSearchFilesSource, GlobalSearchRecordResult, GlobalSearchResult, GlobalSearchTableSource, UseGlobalSearchOptions, UseGlobalSearchResult, } from "./useGlobalSearch.js";
47
59
  export { useTables } from "./useTables.js";
48
60
  export type { UseTablesOptions, UseTablesResult } from "./useTables.js";
49
61
  export { useTable } from "./useTable.js";
50
62
  export type { UseTableOptions, UseTableResult } from "./useTable.js";
51
63
  export { useRecords } from "./useRecords.js";
52
64
  export type { UseRecordsOptions, UseRecordsResult } from "./useRecords.js";
65
+ export { useRecordAggregates } from "./useRecordAggregates.js";
66
+ export type { RecordAggregateMetric, RecordAggregateOrderBy, UseRecordAggregatesOptions, UseRecordAggregatesResult, } from "./useRecordAggregates.js";
53
67
  export { useRecord } from "./useRecord.js";
54
68
  export type { UseRecordOptions, UseRecordResult } from "./useRecord.js";
55
69
  export { useCreateRecord } from "./useCreateRecord.js";
@@ -18,12 +18,19 @@ export { useCurrentUser } from "./useCurrentUser.js";
18
18
  export { usePodAccess } from "./usePodAccess.js";
19
19
  export { useFiles } from "./useFiles.js";
20
20
  export { useFile } from "./useFile.js";
21
+ export { useDatastoreQuery } from "./useDatastoreQuery.js";
22
+ export { useUploadFile } from "./useUploadFile.js";
23
+ export { useUpdateFile } from "./useUpdateFile.js";
24
+ export { useDeleteFile } from "./useDeleteFile.js";
25
+ export { useCreateFolder } from "./useCreateFolder.js";
21
26
  export { useFileSearch } from "./useFileSearch.js";
22
27
  export { useFileTree } from "./useFileTree.js";
23
28
  export { useFilePreview } from "./useFilePreview.js";
29
+ export { useGlobalSearch } from "./useGlobalSearch.js";
24
30
  export { useTables } from "./useTables.js";
25
31
  export { useTable } from "./useTable.js";
26
32
  export { useRecords } from "./useRecords.js";
33
+ export { useRecordAggregates } from "./useRecordAggregates.js";
27
34
  export { useRecord } from "./useRecord.js";
28
35
  export { useCreateRecord } from "./useCreateRecord.js";
29
36
  export { useUpdateRecord } from "./useUpdateRecord.js";
@@ -0,0 +1,8 @@
1
+ import type { RecordFilter } from "../types.js";
2
+ export declare function quoteIdentifierPath(value: string): string;
3
+ export declare function isSimpleIdentifierPath(value: string): boolean;
4
+ export declare function renderIdentifierPath(value: string): string;
5
+ export declare function escapeSqlString(value: string): string;
6
+ export declare function encodeSqlValue(value: unknown): string;
7
+ export declare function renderRecordFilter(filter: RecordFilter): string;
8
+ export declare function renderRecordFilters(filters?: RecordFilter[]): string;
@@ -0,0 +1,62 @@
1
+ function quoteIdentifierPart(value) {
2
+ return `"${value.replace(/"/g, "\"\"")}"`;
3
+ }
4
+ export function quoteIdentifierPath(value) {
5
+ return value
6
+ .split(".")
7
+ .map((part) => (part === "*" ? part : quoteIdentifierPart(part)))
8
+ .join(".");
9
+ }
10
+ export function isSimpleIdentifierPath(value) {
11
+ return /^[A-Za-z_][A-Za-z0-9_$]*(\.(\*|[A-Za-z_][A-Za-z0-9_$]*))*$/.test(value);
12
+ }
13
+ export function renderIdentifierPath(value) {
14
+ return isSimpleIdentifierPath(value) ? quoteIdentifierPath(value) : value;
15
+ }
16
+ export function escapeSqlString(value) {
17
+ return value.replace(/'/g, "''");
18
+ }
19
+ export function encodeSqlValue(value) {
20
+ if (value === null || typeof value === "undefined")
21
+ return "NULL";
22
+ if (typeof value === "boolean")
23
+ return value ? "TRUE" : "FALSE";
24
+ if (typeof value === "number") {
25
+ if (!Number.isFinite(value)) {
26
+ throw new Error("SQL values must be finite numbers.");
27
+ }
28
+ return String(value);
29
+ }
30
+ if (typeof value === "bigint")
31
+ return String(value);
32
+ if (value instanceof Date)
33
+ return `'${escapeSqlString(value.toISOString())}'`;
34
+ if (Array.isArray(value)) {
35
+ return `(${value.map((entry) => encodeSqlValue(entry)).join(", ")})`;
36
+ }
37
+ if (typeof value === "object") {
38
+ return `'${escapeSqlString(JSON.stringify(value))}'`;
39
+ }
40
+ return `'${escapeSqlString(String(value))}'`;
41
+ }
42
+ export function renderRecordFilter(filter) {
43
+ const field = filter.field?.trim();
44
+ if (!field) {
45
+ throw new Error("Record filters require a field.");
46
+ }
47
+ const operator = filter.op.trim().toUpperCase();
48
+ const lhs = renderIdentifierPath(field);
49
+ const values = Array.isArray(filter.values) ? filter.values : undefined;
50
+ if ((operator === "IN" || operator === "NOT IN") && values) {
51
+ return `${lhs} ${operator} ${encodeSqlValue(values)}`;
52
+ }
53
+ if ((operator === "IS" || operator === "IS NOT") && typeof filter.value === "undefined") {
54
+ return `${lhs} ${operator} NULL`;
55
+ }
56
+ return `${lhs} ${operator} ${encodeSqlValue(filter.value)}`;
57
+ }
58
+ export function renderRecordFilters(filters) {
59
+ if (!filters?.length)
60
+ return "";
61
+ return filters.map((filter) => `(${renderRecordFilter(filter)})`).join(" AND ");
62
+ }
@@ -0,0 +1,22 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { FileResponse } from "../types.js";
3
+ export interface CreateFolderInput {
4
+ directoryPath?: string;
5
+ parentId?: string;
6
+ description?: string;
7
+ }
8
+ export interface UseCreateFolderOptions {
9
+ client: LemmaClient;
10
+ podId?: string;
11
+ enabled?: boolean;
12
+ onSuccess?: (folder: FileResponse) => void;
13
+ onError?: (error: unknown) => void;
14
+ }
15
+ export interface UseCreateFolderResult<TFile extends FileResponse = FileResponse> {
16
+ createdFolder: TFile | null;
17
+ isSubmitting: boolean;
18
+ error: Error | null;
19
+ createFolder: (name: string, options?: CreateFolderInput) => Promise<TFile | null>;
20
+ reset: () => void;
21
+ }
22
+ export declare function useCreateFolder<TFile extends FileResponse = FileResponse>({ client, podId, enabled, onSuccess, onError, }: UseCreateFolderOptions): UseCreateFolderResult<TFile>;
@@ -0,0 +1,47 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useCreateFolder({ client, podId, enabled = true, onSuccess, onError, }) {
4
+ const [createdFolder, setCreatedFolder] = useState(null);
5
+ const [isSubmitting, setIsSubmitting] = useState(false);
6
+ const [error, setError] = useState(null);
7
+ const onSuccessRef = useRef(onSuccess);
8
+ const onErrorRef = useRef(onError);
9
+ useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
10
+ useEffect(() => { onErrorRef.current = onError; }, [onError]);
11
+ const createFolder = useCallback(async (name, options = {}) => {
12
+ const trimmedName = name.trim();
13
+ if (!enabled || trimmedName.length === 0) {
14
+ return null;
15
+ }
16
+ setIsSubmitting(true);
17
+ setError(null);
18
+ try {
19
+ const scopedClient = resolvePodClient(client, podId);
20
+ const nextFolder = await scopedClient.files.folder.create(trimmedName, options);
21
+ setCreatedFolder(nextFolder);
22
+ onSuccessRef.current?.(nextFolder);
23
+ return nextFolder;
24
+ }
25
+ catch (createError) {
26
+ const normalized = normalizeError(createError, "Failed to create folder.");
27
+ setError(normalized);
28
+ onErrorRef.current?.(createError);
29
+ return null;
30
+ }
31
+ finally {
32
+ setIsSubmitting(false);
33
+ }
34
+ }, [client, enabled, podId]);
35
+ const reset = useCallback(() => {
36
+ setCreatedFolder(null);
37
+ setError(null);
38
+ setIsSubmitting(false);
39
+ }, []);
40
+ return useMemo(() => ({
41
+ createdFolder,
42
+ isSubmitting,
43
+ error,
44
+ createFolder,
45
+ reset,
46
+ }), [createFolder, createdFolder, error, isSubmitting, reset]);
47
+ }
@@ -0,0 +1,22 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { DatastoreQueryResponse } from "../types.js";
3
+ export interface UseDatastoreQueryOptions {
4
+ client: LemmaClient;
5
+ podId?: string;
6
+ query?: string | null;
7
+ enabled?: boolean;
8
+ autoLoad?: boolean;
9
+ }
10
+ export interface UseDatastoreQueryResult<TRow extends Record<string, unknown> = Record<string, unknown>> {
11
+ response: DatastoreQueryResponse | null;
12
+ items: TRow[];
13
+ total: number;
14
+ sql: string;
15
+ isLoading: boolean;
16
+ error: Error | null;
17
+ refresh: (overrides?: {
18
+ query?: string | null;
19
+ }) => Promise<TRow[]>;
20
+ reset: () => void;
21
+ }
22
+ export declare function useDatastoreQuery<TRow extends Record<string, unknown> = Record<string, unknown>>({ client, podId, query, enabled, autoLoad, }: UseDatastoreQueryOptions): UseDatastoreQueryResult<TRow>;
@@ -0,0 +1,67 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useDatastoreQuery({ client, podId, query = null, enabled = true, autoLoad = true, }) {
4
+ const [response, setResponse] = useState(null);
5
+ const [sql, setSql] = useState("");
6
+ const [isLoading, setIsLoading] = useState(false);
7
+ const [error, setError] = useState(null);
8
+ const trimmedQuery = typeof query === "string" ? query.trim() : "";
9
+ const isEnabled = enabled && trimmedQuery.length > 0;
10
+ const reset = useCallback(() => {
11
+ setResponse(null);
12
+ setSql("");
13
+ setError(null);
14
+ setIsLoading(false);
15
+ }, []);
16
+ const refresh = useCallback(async (overrides = {}, signal) => {
17
+ const nextQuery = typeof overrides.query === "string"
18
+ ? overrides.query.trim()
19
+ : trimmedQuery;
20
+ if (!enabled || nextQuery.length === 0) {
21
+ reset();
22
+ return [];
23
+ }
24
+ setIsLoading(true);
25
+ setError(null);
26
+ setSql(nextQuery);
27
+ try {
28
+ const scopedClient = resolvePodClient(client, podId);
29
+ const nextResponse = await scopedClient.datastore.query(nextQuery);
30
+ if (signal?.aborted)
31
+ return [];
32
+ setResponse(nextResponse);
33
+ return (nextResponse.items ?? []);
34
+ }
35
+ catch (queryError) {
36
+ if (signal?.aborted)
37
+ return [];
38
+ setError(normalizeError(queryError, "Failed to execute datastore query."));
39
+ setResponse(null);
40
+ return [];
41
+ }
42
+ finally {
43
+ if (!signal?.aborted)
44
+ setIsLoading(false);
45
+ }
46
+ }, [client, enabled, podId, reset, trimmedQuery]);
47
+ useEffect(() => {
48
+ if (!isEnabled || !autoLoad) {
49
+ if (!isEnabled)
50
+ reset();
51
+ return;
52
+ }
53
+ const controller = new AbortController();
54
+ void refresh({}, controller.signal);
55
+ return () => controller.abort();
56
+ }, [autoLoad, isEnabled, refresh, reset]);
57
+ return useMemo(() => ({
58
+ response,
59
+ items: (response?.items ?? []),
60
+ total: response?.total ?? 0,
61
+ sql,
62
+ isLoading,
63
+ error,
64
+ refresh,
65
+ reset,
66
+ }), [error, isLoading, refresh, reset, response, sql]);
67
+ }
@@ -0,0 +1,19 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ export interface UseDeleteFileOptions {
3
+ client: LemmaClient;
4
+ podId?: string;
5
+ path?: string | null;
6
+ enabled?: boolean;
7
+ onSuccess?: (path: string) => void;
8
+ onError?: (error: unknown) => void;
9
+ }
10
+ export interface UseDeleteFileResult {
11
+ deletedPath: string | null;
12
+ isSubmitting: boolean;
13
+ error: Error | null;
14
+ remove: (overrides?: {
15
+ path?: string | null;
16
+ }) => Promise<boolean>;
17
+ reset: () => void;
18
+ }
19
+ export declare function useDeleteFile({ client, podId, path, enabled, onSuccess, onError, }: UseDeleteFileOptions): UseDeleteFileResult;
@@ -0,0 +1,51 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useDeleteFile({ client, podId, path = null, enabled = true, onSuccess, onError, }) {
4
+ const [deletedPath, setDeletedPath] = useState(null);
5
+ const [isSubmitting, setIsSubmitting] = useState(false);
6
+ const [error, setError] = useState(null);
7
+ const onSuccessRef = useRef(onSuccess);
8
+ const onErrorRef = useRef(onError);
9
+ useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
10
+ useEffect(() => { onErrorRef.current = onError; }, [onError]);
11
+ const trimmedPath = typeof path === "string" ? path.trim() : "";
12
+ const isEnabled = enabled && trimmedPath.length > 0;
13
+ const remove = useCallback(async (overrides = {}) => {
14
+ const nextPath = typeof overrides.path === "string"
15
+ ? overrides.path.trim()
16
+ : trimmedPath;
17
+ if (!isEnabled || nextPath.length === 0) {
18
+ return false;
19
+ }
20
+ setIsSubmitting(true);
21
+ setError(null);
22
+ try {
23
+ const scopedClient = resolvePodClient(client, podId);
24
+ await scopedClient.files.delete(nextPath);
25
+ setDeletedPath(nextPath);
26
+ onSuccessRef.current?.(nextPath);
27
+ return true;
28
+ }
29
+ catch (removeError) {
30
+ const normalized = normalizeError(removeError, "Failed to delete file.");
31
+ setError(normalized);
32
+ onErrorRef.current?.(removeError);
33
+ return false;
34
+ }
35
+ finally {
36
+ setIsSubmitting(false);
37
+ }
38
+ }, [client, isEnabled, podId, trimmedPath]);
39
+ const reset = useCallback(() => {
40
+ setDeletedPath(null);
41
+ setError(null);
42
+ setIsSubmitting(false);
43
+ }, []);
44
+ return useMemo(() => ({
45
+ deletedPath,
46
+ isSubmitting,
47
+ error,
48
+ remove,
49
+ reset,
50
+ }), [deletedPath, error, isSubmitting, remove, reset]);
51
+ }
@@ -0,0 +1,62 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { FileSearchResultSchema, RecordFilter, SearchMethod } from "../types.js";
3
+ export interface GlobalSearchTableSource {
4
+ key?: string;
5
+ tableName: string;
6
+ label?: string;
7
+ searchFields: string[];
8
+ displayField?: string;
9
+ subtitleField?: string;
10
+ limit?: number;
11
+ filters?: RecordFilter[];
12
+ }
13
+ export interface GlobalSearchFilesSource {
14
+ enabled?: boolean;
15
+ label?: string;
16
+ limit?: number;
17
+ searchMethod?: SearchMethod;
18
+ }
19
+ export interface GlobalSearchRecordResult {
20
+ kind: "record";
21
+ sourceKey: string;
22
+ sourceLabel: string;
23
+ tableName: string;
24
+ id: string;
25
+ title: string;
26
+ subtitle: string | null;
27
+ record: Record<string, unknown>;
28
+ }
29
+ export interface GlobalSearchFileResult {
30
+ kind: "file";
31
+ sourceKey: string;
32
+ sourceLabel: string;
33
+ path: string;
34
+ title: string;
35
+ subtitle: string | null;
36
+ result: FileSearchResultSchema;
37
+ }
38
+ export type GlobalSearchResult = GlobalSearchRecordResult | GlobalSearchFileResult;
39
+ export interface UseGlobalSearchOptions {
40
+ client: LemmaClient;
41
+ podId?: string;
42
+ query?: string;
43
+ tables?: GlobalSearchTableSource[];
44
+ files?: GlobalSearchFilesSource | false;
45
+ enabled?: boolean;
46
+ autoLoad?: boolean;
47
+ minQueryLength?: number;
48
+ }
49
+ export interface UseGlobalSearchResult {
50
+ results: GlobalSearchResult[];
51
+ recordResults: GlobalSearchRecordResult[];
52
+ fileResults: GlobalSearchFileResult[];
53
+ totalResults: number;
54
+ sourceErrors: Record<string, Error>;
55
+ isLoading: boolean;
56
+ error: Error | null;
57
+ search: (overrides?: {
58
+ query?: string;
59
+ }) => Promise<GlobalSearchResult[]>;
60
+ reset: () => void;
61
+ }
62
+ export declare function useGlobalSearch({ client, podId, query, tables, files, enabled, autoLoad, minQueryLength, }: UseGlobalSearchOptions): UseGlobalSearchResult;
@@ -0,0 +1,170 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { escapeSqlString, quoteIdentifierPath, renderIdentifierPath, renderRecordFilters } from "./sql-utils.js";
3
+ import { normalizeError, resolvePodClient, stringifyComparable } from "./utils.js";
4
+ function buildTableSearchQuery(source, query) {
5
+ const fields = Array.from(new Set([
6
+ "id",
7
+ ...source.searchFields,
8
+ source.displayField ?? "id",
9
+ source.subtitleField ?? "",
10
+ ].filter((value) => value.trim().length > 0)));
11
+ const searchClauses = source.searchFields.map((field) => `${renderIdentifierPath(field)} ILIKE '%${escapeSqlString(query)}%'`);
12
+ const filterClause = renderRecordFilters(source.filters);
13
+ return [
14
+ `SELECT ${fields.map((field) => renderIdentifierPath(field)).join(", ")}`,
15
+ `FROM ${quoteIdentifierPath(source.tableName)}`,
16
+ `WHERE (${searchClauses.join(" OR ")})${filterClause ? ` AND ${filterClause}` : ""}`,
17
+ `LIMIT ${source.limit ?? 8}`,
18
+ ].join(" ");
19
+ }
20
+ function readString(value) {
21
+ if (typeof value === "string" && value.trim().length > 0)
22
+ return value;
23
+ if (typeof value === "number" || typeof value === "boolean")
24
+ return String(value);
25
+ return null;
26
+ }
27
+ export function useGlobalSearch({ client, podId, query = "", tables = [], files, enabled = true, autoLoad = true, minQueryLength = 1, }) {
28
+ const [results, setResults] = useState([]);
29
+ const [sourceErrors, setSourceErrors] = useState({});
30
+ const [isLoading, setIsLoading] = useState(false);
31
+ const [error, setError] = useState(null);
32
+ const trimmedQuery = query.trim();
33
+ const tablesKey = stringifyComparable(tables);
34
+ const filesKey = stringifyComparable(files);
35
+ const stableTables = useMemo(() => tables, [tablesKey]);
36
+ const stableFiles = useMemo(() => files, [filesKey]);
37
+ const reset = useCallback(() => {
38
+ setResults([]);
39
+ setSourceErrors({});
40
+ setError(null);
41
+ setIsLoading(false);
42
+ }, []);
43
+ const search = useCallback(async (overrides = {}, signal) => {
44
+ const nextQuery = (overrides.query ?? trimmedQuery).trim();
45
+ if (!enabled || nextQuery.length < minQueryLength) {
46
+ reset();
47
+ return [];
48
+ }
49
+ setIsLoading(true);
50
+ setError(null);
51
+ try {
52
+ const scopedClient = resolvePodClient(client, podId);
53
+ const searchTasks = [];
54
+ stableTables.forEach((source, index) => {
55
+ const sourceKey = source.key?.trim() || source.tableName;
56
+ searchTasks.push((async () => {
57
+ try {
58
+ const sql = buildTableSearchQuery(source, nextQuery);
59
+ const response = await scopedClient.datastore.query(sql);
60
+ const rows = response.items ?? [];
61
+ const mapped = rows.map((record) => {
62
+ const displayField = source.displayField ?? source.searchFields[0] ?? "id";
63
+ const subtitleField = source.subtitleField;
64
+ const id = readString(record.id) ?? `${source.tableName}-${index}`;
65
+ return {
66
+ kind: "record",
67
+ sourceKey,
68
+ sourceLabel: source.label ?? source.tableName,
69
+ tableName: source.tableName,
70
+ id,
71
+ title: readString(record[displayField]) ?? id,
72
+ subtitle: subtitleField ? readString(record[subtitleField]) : null,
73
+ record,
74
+ };
75
+ });
76
+ return { key: sourceKey, results: mapped };
77
+ }
78
+ catch (tableError) {
79
+ return {
80
+ key: sourceKey,
81
+ results: [],
82
+ error: normalizeError(tableError, `Failed to search ${source.tableName}.`),
83
+ };
84
+ }
85
+ })());
86
+ });
87
+ if (stableFiles !== false && stableFiles?.enabled !== false) {
88
+ searchTasks.push((async () => {
89
+ const sourceKey = "files";
90
+ try {
91
+ const response = await scopedClient.files.search(nextQuery, {
92
+ limit: stableFiles?.limit ?? 8,
93
+ searchMethod: stableFiles?.searchMethod,
94
+ });
95
+ const mapped = (response.results ?? []).map((result) => ({
96
+ kind: "file",
97
+ sourceKey,
98
+ sourceLabel: stableFiles?.label ?? "Files",
99
+ path: result.path,
100
+ title: result.path.split("/").filter(Boolean).pop() || result.path,
101
+ subtitle: typeof result.content === "string" && result.content.trim().length > 0
102
+ ? result.content.slice(0, 160)
103
+ : null,
104
+ result,
105
+ }));
106
+ return { key: sourceKey, results: mapped };
107
+ }
108
+ catch (fileError) {
109
+ return {
110
+ key: sourceKey,
111
+ results: [],
112
+ error: normalizeError(fileError, "Failed to search files."),
113
+ };
114
+ }
115
+ })());
116
+ }
117
+ const settled = await Promise.all(searchTasks);
118
+ if (signal?.aborted)
119
+ return [];
120
+ const nextErrors = {};
121
+ const nextResults = settled.flatMap((entry) => {
122
+ if (entry.error) {
123
+ nextErrors[entry.key] = entry.error;
124
+ }
125
+ return entry.results;
126
+ });
127
+ setSourceErrors(nextErrors);
128
+ setResults(nextResults);
129
+ return nextResults;
130
+ }
131
+ catch (searchError) {
132
+ if (signal?.aborted)
133
+ return [];
134
+ const normalized = normalizeError(searchError, "Failed to run global search.");
135
+ setError(normalized);
136
+ setResults([]);
137
+ return [];
138
+ }
139
+ finally {
140
+ if (!signal?.aborted)
141
+ setIsLoading(false);
142
+ }
143
+ }, [client, enabled, minQueryLength, podId, reset, stableFiles, stableTables, trimmedQuery]);
144
+ useEffect(() => {
145
+ if (!enabled || !autoLoad)
146
+ return;
147
+ if (trimmedQuery.length < minQueryLength) {
148
+ reset();
149
+ return;
150
+ }
151
+ const controller = new AbortController();
152
+ void search({}, controller.signal);
153
+ return () => controller.abort();
154
+ }, [autoLoad, enabled, minQueryLength, reset, search, trimmedQuery]);
155
+ return useMemo(() => {
156
+ const recordResults = results.filter((entry) => entry.kind === "record");
157
+ const fileResults = results.filter((entry) => entry.kind === "file");
158
+ return {
159
+ results,
160
+ recordResults,
161
+ fileResults,
162
+ totalResults: results.length,
163
+ sourceErrors,
164
+ isLoading,
165
+ error,
166
+ search,
167
+ reset,
168
+ };
169
+ }, [error, isLoading, reset, results, search, sourceErrors]);
170
+ }
@@ -0,0 +1,35 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { RecordFilter } from "../types.js";
3
+ export interface RecordAggregateMetric {
4
+ key: string;
5
+ op: "count" | "sum" | "avg" | "min" | "max";
6
+ field?: string;
7
+ distinct?: boolean;
8
+ }
9
+ export interface RecordAggregateOrderBy {
10
+ field: string;
11
+ direction?: "asc" | "desc";
12
+ }
13
+ export interface UseRecordAggregatesOptions {
14
+ client: LemmaClient;
15
+ podId?: string;
16
+ tableName: string;
17
+ metrics: RecordAggregateMetric[];
18
+ groupBy?: string | string[];
19
+ filters?: RecordFilter[];
20
+ limit?: number;
21
+ offset?: number;
22
+ orderBy?: RecordAggregateOrderBy[];
23
+ enabled?: boolean;
24
+ autoLoad?: boolean;
25
+ }
26
+ export interface UseRecordAggregatesResult<TRow extends Record<string, unknown> = Record<string, unknown>> {
27
+ rows: TRow[];
28
+ row: TRow | null;
29
+ total: number;
30
+ sql: string;
31
+ isLoading: boolean;
32
+ error: Error | null;
33
+ refresh: () => Promise<TRow[]>;
34
+ }
35
+ export declare function useRecordAggregates<TRow extends Record<string, unknown> = Record<string, unknown>>({ client, podId, tableName, metrics, groupBy, filters, limit, offset, orderBy, enabled, autoLoad, }: UseRecordAggregatesOptions): UseRecordAggregatesResult<TRow>;
@@ -0,0 +1,126 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { encodeSqlValue, quoteIdentifierPath, renderIdentifierPath, renderRecordFilters } from "./sql-utils.js";
3
+ import { normalizeError, resolvePodClient, stringifyComparable } from "./utils.js";
4
+ function buildMetricExpression(metric) {
5
+ const fn = metric.op.toUpperCase();
6
+ if (metric.op === "count" && !metric.field) {
7
+ return `${fn}(*) AS ${quoteIdentifierPath(metric.key)}`;
8
+ }
9
+ if (!metric.field) {
10
+ throw new Error(`Aggregate metric "${metric.key}" requires a field.`);
11
+ }
12
+ const renderedField = renderIdentifierPath(metric.field);
13
+ const distinct = metric.distinct ? "DISTINCT " : "";
14
+ return `${fn}(${distinct}${renderedField}) AS ${quoteIdentifierPath(metric.key)}`;
15
+ }
16
+ function buildRecordAggregatesQuery({ tableName, metrics, groupBy, filters, limit, offset, orderBy, }) {
17
+ const groups = (Array.isArray(groupBy) ? groupBy : groupBy ? [groupBy] : [])
18
+ .map((field) => field.trim())
19
+ .filter((field) => field.length > 0);
20
+ const selectParts = [
21
+ ...groups.map((field) => `${renderIdentifierPath(field)} AS ${quoteIdentifierPath(field)}`),
22
+ ...metrics.map((metric) => buildMetricExpression(metric)),
23
+ ];
24
+ const whereClause = renderRecordFilters(filters);
25
+ const orderClause = orderBy?.length
26
+ ? ` ORDER BY ${orderBy.map((entry) => `${renderIdentifierPath(entry.field)} ${(entry.direction ?? "desc").toUpperCase()}`).join(", ")}`
27
+ : "";
28
+ const groupClause = groups.length
29
+ ? ` GROUP BY ${groups.map((field) => renderIdentifierPath(field)).join(", ")}`
30
+ : "";
31
+ const limitClause = typeof limit === "number" ? ` LIMIT ${encodeSqlValue(limit)}` : "";
32
+ const offsetClause = typeof offset === "number" ? ` OFFSET ${encodeSqlValue(offset)}` : "";
33
+ return [
34
+ `SELECT ${selectParts.join(", ")}`,
35
+ `FROM ${quoteIdentifierPath(tableName)}`,
36
+ whereClause ? `WHERE ${whereClause}` : "",
37
+ groupClause,
38
+ orderClause,
39
+ limitClause,
40
+ offsetClause,
41
+ ].filter(Boolean).join(" ");
42
+ }
43
+ export function useRecordAggregates({ client, podId, tableName, metrics, groupBy, filters = [], limit, offset, orderBy, enabled = true, autoLoad = true, }) {
44
+ const [rows, setRows] = useState([]);
45
+ const [total, setTotal] = useState(0);
46
+ const [sql, setSql] = useState("");
47
+ const [isLoading, setIsLoading] = useState(false);
48
+ const [error, setError] = useState(null);
49
+ const metricsKey = stringifyComparable(metrics);
50
+ const filtersKey = stringifyComparable(filters);
51
+ const groupByKey = stringifyComparable(groupBy);
52
+ const orderByKey = stringifyComparable(orderBy);
53
+ const stableMetrics = useMemo(() => metrics, [metricsKey]);
54
+ const stableFilters = useMemo(() => filters, [filtersKey]);
55
+ const stableGroupBy = useMemo(() => groupBy, [groupByKey]);
56
+ const stableOrderBy = useMemo(() => orderBy, [orderByKey]);
57
+ const trimmedTableName = tableName.trim();
58
+ const isEnabled = enabled && trimmedTableName.length > 0 && stableMetrics.length > 0;
59
+ const refresh = useCallback(async (signal) => {
60
+ if (!isEnabled) {
61
+ setRows([]);
62
+ setTotal(0);
63
+ setSql("");
64
+ setError(null);
65
+ setIsLoading(false);
66
+ return [];
67
+ }
68
+ setIsLoading(true);
69
+ setError(null);
70
+ try {
71
+ const scopedClient = resolvePodClient(client, podId);
72
+ const nextSql = buildRecordAggregatesQuery({
73
+ tableName: trimmedTableName,
74
+ metrics: stableMetrics,
75
+ groupBy: stableGroupBy,
76
+ filters: stableFilters,
77
+ limit,
78
+ offset,
79
+ orderBy: stableOrderBy,
80
+ });
81
+ setSql(nextSql);
82
+ const response = await scopedClient.datastore.query(nextSql);
83
+ if (signal?.aborted)
84
+ return [];
85
+ const nextRows = (response.items ?? []);
86
+ setRows(nextRows);
87
+ setTotal(response.total ?? nextRows.length);
88
+ return nextRows;
89
+ }
90
+ catch (aggregateError) {
91
+ if (signal?.aborted)
92
+ return [];
93
+ const normalized = normalizeError(aggregateError, "Failed to load record aggregates.");
94
+ setError(normalized);
95
+ return [];
96
+ }
97
+ finally {
98
+ if (!signal?.aborted)
99
+ setIsLoading(false);
100
+ }
101
+ }, [client, isEnabled, limit, offset, podId, stableFilters, stableGroupBy, stableMetrics, stableOrderBy, trimmedTableName]);
102
+ useEffect(() => {
103
+ if (!isEnabled) {
104
+ setRows([]);
105
+ setTotal(0);
106
+ setSql("");
107
+ setError(null);
108
+ setIsLoading(false);
109
+ return;
110
+ }
111
+ if (!autoLoad)
112
+ return;
113
+ const controller = new AbortController();
114
+ void refresh(controller.signal);
115
+ return () => controller.abort();
116
+ }, [autoLoad, isEnabled, refresh]);
117
+ return useMemo(() => ({
118
+ rows,
119
+ row: rows[0] ?? null,
120
+ total,
121
+ sql,
122
+ isLoading,
123
+ error,
124
+ refresh,
125
+ }), [error, isLoading, refresh, rows, sql, total]);
126
+ }
@@ -30,12 +30,13 @@ export function useTaskSession({ client, podId, taskId: externalTaskId = null, a
30
30
  }
31
31
  }, []);
32
32
  const setTaskId = useCallback((nextTaskId) => {
33
- abortRef.current?.abort();
34
- abortRef.current = null;
35
33
  setTaskIdState((currentTaskId) => {
36
34
  if (currentTaskId === nextTaskId) {
37
35
  return currentTaskId;
38
36
  }
37
+ abortRef.current?.abort();
38
+ abortRef.current = null;
39
+ taskIdRef.current = nextTaskId;
39
40
  setError(null);
40
41
  setIsStreaming(false);
41
42
  if (!nextTaskId) {
@@ -0,0 +1,29 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { FileResponse } from "../types.js";
3
+ export interface UpdateFileInput {
4
+ file?: Blob;
5
+ name?: string;
6
+ description?: string;
7
+ directoryPath?: string;
8
+ parentId?: string;
9
+ newPath?: string;
10
+ searchEnabled?: boolean;
11
+ }
12
+ export interface UseUpdateFileOptions {
13
+ client: LemmaClient;
14
+ podId?: string;
15
+ path?: string | null;
16
+ enabled?: boolean;
17
+ onSuccess?: (file: FileResponse) => void;
18
+ onError?: (error: unknown) => void;
19
+ }
20
+ export interface UseUpdateFileResult<TFile extends FileResponse = FileResponse> {
21
+ updatedFile: TFile | null;
22
+ isSubmitting: boolean;
23
+ error: Error | null;
24
+ update: (input?: UpdateFileInput, overrides?: {
25
+ path?: string | null;
26
+ }) => Promise<TFile | null>;
27
+ reset: () => void;
28
+ }
29
+ export declare function useUpdateFile<TFile extends FileResponse = FileResponse>({ client, podId, path, enabled, onSuccess, onError, }: UseUpdateFileOptions): UseUpdateFileResult<TFile>;
@@ -0,0 +1,51 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useUpdateFile({ client, podId, path = null, enabled = true, onSuccess, onError, }) {
4
+ const [updatedFile, setUpdatedFile] = useState(null);
5
+ const [isSubmitting, setIsSubmitting] = useState(false);
6
+ const [error, setError] = useState(null);
7
+ const onSuccessRef = useRef(onSuccess);
8
+ const onErrorRef = useRef(onError);
9
+ useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
10
+ useEffect(() => { onErrorRef.current = onError; }, [onError]);
11
+ const trimmedPath = typeof path === "string" ? path.trim() : "";
12
+ const isEnabled = enabled && trimmedPath.length > 0;
13
+ const update = useCallback(async (input = {}, overrides = {}) => {
14
+ const nextPath = typeof overrides.path === "string"
15
+ ? overrides.path.trim()
16
+ : trimmedPath;
17
+ if (!isEnabled || nextPath.length === 0) {
18
+ return null;
19
+ }
20
+ setIsSubmitting(true);
21
+ setError(null);
22
+ try {
23
+ const scopedClient = resolvePodClient(client, podId);
24
+ const nextFile = await scopedClient.files.update(nextPath, input);
25
+ setUpdatedFile(nextFile);
26
+ onSuccessRef.current?.(nextFile);
27
+ return nextFile;
28
+ }
29
+ catch (updateError) {
30
+ const normalized = normalizeError(updateError, "Failed to update file.");
31
+ setError(normalized);
32
+ onErrorRef.current?.(updateError);
33
+ return null;
34
+ }
35
+ finally {
36
+ setIsSubmitting(false);
37
+ }
38
+ }, [client, isEnabled, podId, trimmedPath]);
39
+ const reset = useCallback(() => {
40
+ setUpdatedFile(null);
41
+ setError(null);
42
+ setIsSubmitting(false);
43
+ }, []);
44
+ return useMemo(() => ({
45
+ updatedFile,
46
+ isSubmitting,
47
+ error,
48
+ update,
49
+ reset,
50
+ }), [error, isSubmitting, reset, update, updatedFile]);
51
+ }
@@ -0,0 +1,24 @@
1
+ import type { LemmaClient } from "../client.js";
2
+ import type { FileResponse } from "../types.js";
3
+ export interface UploadFileInput {
4
+ name?: string;
5
+ directoryPath?: string;
6
+ parentId?: string;
7
+ searchEnabled?: boolean;
8
+ description?: string;
9
+ }
10
+ export interface UseUploadFileOptions {
11
+ client: LemmaClient;
12
+ podId?: string;
13
+ enabled?: boolean;
14
+ onSuccess?: (file: FileResponse) => void;
15
+ onError?: (error: unknown) => void;
16
+ }
17
+ export interface UseUploadFileResult<TFile extends FileResponse = FileResponse> {
18
+ uploadedFile: TFile | null;
19
+ isSubmitting: boolean;
20
+ error: Error | null;
21
+ upload: (file: Blob, options?: UploadFileInput) => Promise<TFile | null>;
22
+ reset: () => void;
23
+ }
24
+ export declare function useUploadFile<TFile extends FileResponse = FileResponse>({ client, podId, enabled, onSuccess, onError, }: UseUploadFileOptions): UseUploadFileResult<TFile>;
@@ -0,0 +1,46 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { normalizeError, resolvePodClient } from "./utils.js";
3
+ export function useUploadFile({ client, podId, enabled = true, onSuccess, onError, }) {
4
+ const [uploadedFile, setUploadedFile] = useState(null);
5
+ const [isSubmitting, setIsSubmitting] = useState(false);
6
+ const [error, setError] = useState(null);
7
+ const onSuccessRef = useRef(onSuccess);
8
+ const onErrorRef = useRef(onError);
9
+ useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
10
+ useEffect(() => { onErrorRef.current = onError; }, [onError]);
11
+ const upload = useCallback(async (file, options = {}) => {
12
+ if (!enabled) {
13
+ return null;
14
+ }
15
+ setIsSubmitting(true);
16
+ setError(null);
17
+ try {
18
+ const scopedClient = resolvePodClient(client, podId);
19
+ const nextFile = await scopedClient.files.upload(file, options);
20
+ setUploadedFile(nextFile);
21
+ onSuccessRef.current?.(nextFile);
22
+ return nextFile;
23
+ }
24
+ catch (uploadError) {
25
+ const normalized = normalizeError(uploadError, "Failed to upload file.");
26
+ setError(normalized);
27
+ onErrorRef.current?.(uploadError);
28
+ return null;
29
+ }
30
+ finally {
31
+ setIsSubmitting(false);
32
+ }
33
+ }, [client, enabled, podId]);
34
+ const reset = useCallback(() => {
35
+ setUploadedFile(null);
36
+ setError(null);
37
+ setIsSubmitting(false);
38
+ }, []);
39
+ return useMemo(() => ({
40
+ uploadedFile,
41
+ isSubmitting,
42
+ error,
43
+ upload,
44
+ reset,
45
+ }), [error, isSubmitting, reset, uploadedFile, upload]);
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lemma-sdk",
3
- "version": "0.2.34",
3
+ "version": "0.2.35",
4
4
  "description": "Official TypeScript SDK for Lemma pod-scoped APIs",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",