@nivaro/sdk 0.1.2

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 ADDED
@@ -0,0 +1,2505 @@
1
+ # @nivaro/sdk
2
+
3
+ TypeScript SDK for [Nivaro CMS](https://nivaro.dev) — typed REST client, GraphQL, realtime subscriptions, and presence.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @nivaro/sdk
9
+ # or
10
+ pnpm add @nivaro/sdk
11
+ ```
12
+
13
+ All API calls use `nivaro.request(command)` where `command` is a typed descriptor built by one of the helper functions below.
14
+
15
+ ---
16
+
17
+ ## SDK — Setup
18
+
19
+ The `@nivaro/sdk` package is a fully-typed TypeScript client for the Nivaro REST, GraphQL, and realtime APIs. Works in Node.js, browsers, and edge runtimes.
20
+
21
+ #### Installation
22
+
23
+ ```typescript
24
+ pnpm add @nivaro/sdk
25
+ ```
26
+
27
+ #### Create a client
28
+
29
+ ```typescript
30
+ import { createNivaro } from '@nivaro/sdk'
31
+
32
+ // Minimal — use session cookie (browser) or set token later
33
+ const nivaro = createNivaro('https://nivaro.example.com')
34
+
35
+ // With static token
36
+ const nivaro = createNivaro('https://nivaro.example.com', {
37
+ token: 'nvk_abc123...', // Bearer token
38
+ })
39
+
40
+ // Full options
41
+ const nivaro = createNivaro('https://nivaro.example.com', {
42
+ token: 'nvk_abc123...',
43
+ workspace: 'workspace-uuid', // optional: workspace to target
44
+ headers: { 'X-Custom': 'value' }, // optional: extra headers
45
+ })
46
+ ```
47
+
48
+ #### Client methods
49
+
50
+ | Method | Purpose | Example |
51
+ | --- | --- | --- |
52
+ | request(command) | REST operations | await nivaro.request(readItems("articles")) |
53
+ | graphql(query, vars?) | GraphQL queries + mutations | await nivaro.graphql(query, variables) |
54
+ | upload(file) | File upload | await nivaro.upload(file) |
55
+ | fileUrl(fileId) | Get download URL | nivaro.fileUrl("file-uuid") |
56
+ | setToken(token) | Set/clear auth token | nivaro.setToken("nvk_...") |
57
+ | getToken() | Read current token | const t = nivaro.getToken() |
58
+
59
+ #### TypeScript setup
60
+
61
+ All SDK functions are fully typed. For collection-specific item operations, define your data shape and pass it as a generic:
62
+
63
+ ```typescript
64
+ import { readItems, createItem } from '@nivaro/sdk'
65
+
66
+ interface Article {
67
+ id: string
68
+ name: string
69
+ status: 'draft' | 'published'
70
+ author_id: string
71
+ }
72
+
73
+ const items = await nivaro.request(
74
+ readItems<Article>('articles', { filter: { status: { _eq: 'published' } } })
75
+ )
76
+ // items.data → Article[]
77
+ ```
78
+
79
+ ---
80
+
81
+ ## SDK — Authentication
82
+
83
+ The SDK supports two authentication methods: static tokens (for backends/scripts) and session cookies (for browser SPAs).
84
+
85
+ #### Static tokens (server-side / CLI)
86
+
87
+ Use a static API token for automated scripts, cron jobs, and server-to-server communication:
88
+
89
+ ```typescript
90
+ import { createNivaro, readItems } from '@nivaro/sdk'
91
+
92
+ // Set token at creation
93
+ const nivaro = createNivaro('https://nivaro.example.com', {
94
+ token: process.env.NIVARO_TOKEN, // nvk_...
95
+ })
96
+
97
+ // Or set at runtime
98
+ nivaro.setToken(process.env.NIVARO_TOKEN)
99
+
100
+ // Check current token
101
+ const hasToken = nivaro.getToken() !== undefined
102
+
103
+ // Clear token (revert to unauth or session-cookie mode)
104
+ nivaro.setToken(null)
105
+
106
+ // Use the client
107
+ const { data: items } = await nivaro.request(readItems('articles'))
108
+ ```
109
+
110
+ #### Session cookies (browser SPA)
111
+
112
+ In a browser, after a user logs in via the OIDC flow (`/login`), the session cookie is set automatically. The SDK will send it with every request:
113
+
114
+ ```typescript
115
+ import { createNivaro } from '@nivaro/sdk'
116
+
117
+ const nivaro = createNivaro('https://nivaro.example.com')
118
+ // No token needed — session cookie is sent automatically
119
+
120
+ // After login, you can also set a static token if desired
121
+ nivaro.setToken(localStorage.getItem('api_token'))
122
+ ```
123
+
124
+ #### Token priority
125
+
126
+ - If a static token is set, it takes priority (Authorization: Bearer header).
127
+ - Otherwise, the session cookie is sent if available.
128
+ - If neither exists, requests are made as unauthenticated (limits depend on public routes).
129
+
130
+ #### Generate tokens via the API
131
+
132
+ ```typescript
133
+ import { generateToken } from '@nivaro/sdk'
134
+
135
+ // Generate a new token for yourself
136
+ const { data: result } = await nivaro.request(generateToken())
137
+ console.log(result.token) // Show once — never retrievable again
138
+
139
+ // Store in env var or secure storage
140
+ process.env.NIVARO_TOKEN = result.token
141
+ nivaro.setToken(result.token)
142
+
143
+ // Admin: generate token for another user
144
+ const { data: other } = await nivaro.request(generateUserToken('user-uuid'))
145
+
146
+ // Revoke your own token
147
+ await nivaro.request(revokeToken())
148
+ nivaro.setToken(null)
149
+
150
+ // Admin: revoke another user's token
151
+ await nivaro.request(revokeUserToken('user-uuid'))
152
+ ```
153
+
154
+ ---
155
+
156
+ ## SDK — REST Commands
157
+
158
+ All REST operations use `await nivaro.request(command)`. Command functions are factories that return a descriptor — no network call happens until `request()` executes it.
159
+
160
+ #### Read items
161
+
162
+ ```typescript
163
+ import { readItems, readItem } from '@nivaro/sdk'
164
+
165
+ // List with filtering, sorting, and pagination
166
+ const { data, total, limit, offset } = await nivaro.request(
167
+ readItems('articles', {
168
+ filter: {
169
+ status: { _eq: 'published' },
170
+ created_at: { _gte: '2024-01-01' },
171
+ },
172
+ sort: ['-created_at', 'title'], // descending created_at, then ascending title
173
+ limit: 25,
174
+ offset: 0,
175
+ })
176
+ )
177
+ // data → T[]
178
+
179
+ // Single item
180
+ const { data: article } = await nivaro.request(readItem('articles', '123'))
181
+ // data → T
182
+ ```
183
+
184
+ #### Create items
185
+
186
+ ```typescript
187
+ import { createItem } from '@nivaro/sdk'
188
+
189
+ const { data: created } = await nivaro.request(
190
+ createItem('articles', {
191
+ title: 'New Article',
192
+ body: 'Lorem ipsum...',
193
+ status: 'draft',
194
+ author_id: 'user-uuid',
195
+ })
196
+ )
197
+ // data → T (with id, timestamps, and default values filled in)
198
+ ```
199
+
200
+ #### Update items
201
+
202
+ ```typescript
203
+ import { updateItem } from '@nivaro/sdk'
204
+
205
+ // Partial update — only changed fields
206
+ const { data: updated } = await nivaro.request(
207
+ updateItem('articles', '123', {
208
+ status: 'published',
209
+ published_at: new Date().toISOString(),
210
+ })
211
+ )
212
+ // data → T (full record with updates applied)
213
+ ```
214
+
215
+ #### Delete items
216
+
217
+ ```typescript
218
+ import { deleteItem } from '@nivaro/sdk'
219
+
220
+ await nivaro.request(deleteItem('articles', '123'))
221
+ ```
222
+
223
+ #### Bulk operations
224
+
225
+ ```typescript
226
+ import { bulkCreateItems, bulkUpdateItems, bulkDeleteItems } from '@nivaro/sdk'
227
+
228
+ // Bulk create
229
+ const { data: created } = await nivaro.request(
230
+ bulkCreateItems('articles', [
231
+ { title: 'Article 1', status: 'draft' },
232
+ { title: 'Article 2', status: 'draft' },
233
+ ])
234
+ )
235
+
236
+ // Bulk update
237
+ const { data: updated } = await nivaro.request(
238
+ bulkUpdateItems('articles', [
239
+ { id: '123', status: 'published' },
240
+ { id: '124', status: 'published' },
241
+ ])
242
+ )
243
+
244
+ // Bulk delete
245
+ await nivaro.request(bulkDeleteItems('articles', ['123', '124']))
246
+ ```
247
+
248
+ #### Singletons
249
+
250
+ For single-record collections (e.g., site settings), use singleton commands:
251
+
252
+ ```typescript
253
+ import { readSingleton, updateSingleton } from '@nivaro/sdk'
254
+
255
+ // Read the singleton record
256
+ const { data: settings } = await nivaro.request(readSingleton('site_settings'))
257
+
258
+ // Update it
259
+ const { data: updated } = await nivaro.request(
260
+ updateSingleton('site_settings', { site_name: 'My App', theme: 'dark' })
261
+ )
262
+ ```
263
+
264
+ #### Current user
265
+
266
+ ```typescript
267
+ import { readMe, updateMe } from '@nivaro/sdk'
268
+
269
+ // Get your own profile
270
+ const { data: me } = await nivaro.request(readMe())
271
+ // me → { id, email, first_name, last_name, role, current_workspace, ... }
272
+
273
+ // Update your profile
274
+ const { data: updated } = await nivaro.request(
275
+ updateMe({ first_name: 'Jane', last_name: 'Doe' })
276
+ )
277
+ ```
278
+
279
+ #### Revisions (audit trail)
280
+
281
+ ```typescript
282
+ import { readRevisions, readRevision } from '@nivaro/sdk'
283
+
284
+ // All changes to an item (newest first)
285
+ const { data: revisions } = await nivaro.request(readRevisions('articles', '123'))
286
+ // Each revision: { id, action ('create'|'update'|'delete'), data, delta, timestamp, user_id, first_name, last_name }
287
+
288
+ // Single revision detail
289
+ const { data: rev } = await nivaro.request(readRevision('rev-uuid'))
290
+ // rev.data → full snapshot at that point in time
291
+ // rev.delta → only the fields that changed (for updates)
292
+ ```
293
+
294
+ ---
295
+
296
+ ## SDK — Workflow State Machine
297
+
298
+ Workflows are state machines that control the lifecycle of items. Each transition can be conditional, role-gated, and can trigger automations. Use these commands to read and drive workflow states.
299
+
300
+ #### Read workflow state
301
+
302
+ ```typescript
303
+ import { readWorkflowInstance, readWorkflowInstances } from '@nivaro/sdk'
304
+
305
+ // Get full workflow context for an item
306
+ const { data: wf } = await nivaro.request(
307
+ readWorkflowInstance('inventory_requests', itemId)
308
+ )
309
+ // wf === null → no workflow bound to this collection
310
+
311
+ // If bound:
312
+ // wf.instance → { current_state, started_at, completed_at, transitioned_at }
313
+ // wf.states → all states with { id, key, label, color, is_initial, is_terminal, lock_record }
314
+ // wf.available_transitions → transitions the user's role can execute from current state
315
+ // wf.history → immutable log: [{ transitioned_at, from_state, to_state, comment, user }]
316
+
317
+ // Find current state label
318
+ const currentState = wf.states.find(s => s.id === wf.instance.current_state)
319
+ console.log(currentState.label) // e.g., "In Progress"
320
+
321
+ // List all workflow instances for a collection (admin)
322
+ const { data: instances } = await nivaro.request(
323
+ readWorkflowInstances('inventory_requests', { limit: 100 })
324
+ )
325
+ ```
326
+
327
+ #### Start and transition workflows
328
+
329
+ ```typescript
330
+ import { startWorkflow, transitionWorkflow } from '@nivaro/sdk'
331
+
332
+ // Start a workflow on an item (moves to initial state)
333
+ await nivaro.request(startWorkflow('inventory_requests', itemId))
334
+
335
+ // Execute a transition with optional comment
336
+ const { data: updated } = await nivaro.request(
337
+ transitionWorkflow('inventory_requests', itemId, transitionId, {
338
+ comment: 'Approved — ready to ship',
339
+ })
340
+ )
341
+ // updated → full item with updated workflow state
342
+
343
+ // Check available transitions before showing UI
344
+ const { data: wf } = await nivaro.request(
345
+ readWorkflowInstance('inventory_requests', itemId)
346
+ )
347
+ wf.available_transitions.forEach(tx => {
348
+ // Render a button per transition
349
+ console.log(tx.label, tx.id)
350
+ })
351
+ ```
352
+
353
+ #### Conditional transitions
354
+
355
+ Some transitions have conditions (field values that must match) before they can execute:
356
+
357
+ ```typescript
358
+ // Before transitioning, check if conditions are met
359
+ const { data: wf } = await nivaro.request(
360
+ readWorkflowInstance('orders', orderId)
361
+ )
362
+
363
+ const transition = wf.available_transitions.find(t => t.id === selectedTxId)
364
+
365
+ // Attempt transition — if conditions not met, API returns 409
366
+ try {
367
+ await nivaro.request(
368
+ transitionWorkflow('orders', orderId, transition.id, { comment: 'Approved' })
369
+ )
370
+ } catch (err) {
371
+ if (err.status === 409) {
372
+ console.error('Transition conditions no longer met:', err.message)
373
+ }
374
+ }
375
+ ```
376
+
377
+ | Function | Purpose | Auth |
378
+ | --- | --- | --- |
379
+ | readWorkflowInstance(collection, itemId) | Get current state + available transitions | Authenticated |
380
+ | startWorkflow(collection, itemId) | Initialize workflow on an item | Authenticated |
381
+ | transitionWorkflow(col, itemId, txId, opts?) | Execute a state transition | Role-gated per transition |
382
+ | readWorkflowInstances(collection) | List all workflow instances | Authenticated |
383
+
384
+ ---
385
+
386
+ ## SDK — Pipeline & Ownership Matrix
387
+
388
+ The Pipeline Owner Matrix extends workflows with multi-dimensional ownership. It resolves which users own each workflow state based on dimensional rules (e.g., "If region=North and status=urgent, then assign to @jane").
389
+
390
+ #### Read ownership
391
+
392
+ ```typescript
393
+ import {
394
+ readInstanceOwners, readStateOwners, readAllStateOwners
395
+ } from '@nivaro/sdk'
396
+
397
+ // Get owners for the CURRENT state (primary API)
398
+ const { data: owners } = await nivaro.request(
399
+ readInstanceOwners('inventory_requests', itemId)
400
+ )
401
+ // owners → User[] — { id, email, first_name, last_name, is_inherited }
402
+
403
+ // Get owners for a SPECIFIC state (non-current)
404
+ const { data: result } = await nivaro.request(
405
+ readStateOwners('inventory_requests', itemId, stateId)
406
+ )
407
+ // result.state → { id, key, label, color }
408
+ // result.owners → User[] (resolved via matrix rules)
409
+
410
+ // Get owners for ALL states at once (no N+1)
411
+ const { data: allOwners } = await nivaro.request(
412
+ readAllStateOwners('inventory_requests', itemId)
413
+ )
414
+ // allOwners → { [stateId]: { state, owners } } | null
415
+
416
+ // Null means no pipeline bound to collection
417
+ if (allOwners) {
418
+ Object.entries(allOwners).forEach(([stateId, { state, owners }]) => {
419
+ console.log(`${state.label}: ${owners.map(o => o.first_name).join(', ')}`)
420
+ })
421
+ }
422
+ ```
423
+
424
+ #### Manual ownership overrides
425
+
426
+ ```typescript
427
+ import { addInstanceOwner, removeInstanceOwner } from '@nivaro/sdk'
428
+
429
+ // Assign a user as an override owner for this item
430
+ const { data: owner } = await nivaro.request(
431
+ addInstanceOwner('inventory_requests', itemId, 'user-uuid', {
432
+ state_id: stateId, // optional: scope to a specific state
433
+ })
434
+ )
435
+
436
+ // Remove an override
437
+ await nivaro.request(removeInstanceOwner(owner.id))
438
+ ```
439
+
440
+ #### Admin: Pipeline template configuration
441
+
442
+ ```typescript
443
+ import {
444
+ readPipelineTemplates, readPipelineTemplate,
445
+ readOwnerGroups, readDimensions
446
+ } from '@nivaro/sdk'
447
+
448
+ // List all pipeline templates
449
+ const { data: templates } = await nivaro.request(readPipelineTemplates())
450
+
451
+ // Get one template
452
+ const { data: template } = await nivaro.request(readPipelineTemplate(templateId))
453
+ // template → { id, name, states: [], binding: { collection }, ... }
454
+
455
+ // Owner groups per state (configured in admin UI)
456
+ const { data: groups } = await nivaro.request(readOwnerGroups(templateId))
457
+ // groups[stateId] → OwnerGroup[] with { filters: JSON, priority, users: [] }
458
+
459
+ // Dimensions for the matrix (region, product, etc.)
460
+ const { data: dimensions } = await nivaro.request(readDimensions(templateId))
461
+ // dimensions → Dimension[] — { field, label, is_row_axis, sort }
462
+ ```
463
+
464
+ #### How ownership resolution works
465
+
466
+ - Owner Groups are evaluated in priority order (lower = higher priority).
467
+ - Each group has filter rules (e.g., "region == North AND status == urgent").
468
+ - First group where ALL filters match assigns its users.
469
+ - If no rules match, the item has no owner.
470
+ - Manual Instance Owner overrides always apply (bypass rules).
471
+ - Delegation via user.delegate_id also applies (temporary out-of-office reassignments).
472
+
473
+ | Function | Purpose | Auth |
474
+ | --- | --- | --- |
475
+ | readInstanceOwners(col, itemId) | Owners for current state | Authenticated |
476
+ | readStateOwners(col, itemId, stateId) | Owners for a specific state | Authenticated |
477
+ | readAllStateOwners(col, itemId) | Owners for all states (no N+1) | Authenticated |
478
+ | addInstanceOwner(col, itemId, userId, opts?) | Add manual override | Authenticated |
479
+ | removeInstanceOwner(ownerId) | Remove override | Authenticated |
480
+ | readPipelineTemplates() | List pipeline templates | Admin |
481
+ | readPipelineTemplate(id) | Get one template | Admin |
482
+ | readOwnerGroups(templateId) | Owner groups by state | Admin |
483
+ | readDimensions(templateId) | Matrix dimensions | Admin |
484
+
485
+ ---
486
+
487
+ ## SDK — Form Schema
488
+
489
+ The Form Schema API aggregates collection metadata, fields, groups, layouts, and relations into one normalized response. Use it to power dynamic UIs, form generators, and headless form runtimes.
490
+
491
+ #### Load form schema
492
+
493
+ ```typescript
494
+ import { fetchFormSchema } from '@nivaro/sdk'
495
+
496
+ const { data: schema } = await nivaro.request(fetchFormSchema('inventory_requests'))
497
+ // schema → {
498
+ // collection: { id, name, icon, ... },
499
+ // fields: FormField[],
500
+ // groups: FieldGroup[], // section/tab definitions, sorted
501
+ // relations: RelationMeta[], // m2o/o2m/m2m/m2a
502
+ // layout: { id, name, tab_mode, ... },
503
+ // ungroupedSort: 5, // position of Ungrouped zone
504
+ // }
505
+
506
+ // Iterate fields by group
507
+ schema.groups.forEach(group => {
508
+ const fieldsInGroup = schema.fields.filter(f => f.group_key === group.key)
509
+ console.log(group.label, fieldsInGroup.map(f => f.label))
510
+ })
511
+ ```
512
+
513
+ #### Evaluate field rules in real-time
514
+
515
+ ```typescript
516
+ import { evaluateFieldRules } from '@nivaro/sdk'
517
+
518
+ // As the user types, evaluate inline field rules (no save)
519
+ const values = { category: 'hardware', vendor: null }
520
+ const { data: result } = await nivaro.request(
521
+ evaluateFieldRules('inventory_requests', values)
522
+ )
523
+ // result.updates → { priority: 'high' } (only changed fields)
524
+
525
+ // Apply rule updates to form state
526
+ setValues({ ...values, ...result.updates })
527
+ ```
528
+
529
+ #### Load relation options (picker)
530
+
531
+ ```typescript
532
+ import { readRelationOptions } from '@nivaro/sdk'
533
+
534
+ // Load options for an M2O or M2M field picker
535
+ const { data: options } = await nivaro.request(
536
+ readRelationOptions('inventory_requests', 'assigned_to', {
537
+ search: 'jane', // filter by search term
538
+ limit: 25,
539
+ })
540
+ )
541
+ // options → { value, label }[] (label from display template)
542
+
543
+ // Render picker
544
+ options.forEach(opt => console.log(`${opt.label} (${opt.value})`))
545
+ ```
546
+
547
+ #### Submit form item
548
+
549
+ ```typescript
550
+ import { submitFormItem } from '@nivaro/sdk'
551
+
552
+ // Create new item
553
+ const { data: created } = await nivaro.request(
554
+ submitFormItem('inventory_requests', {
555
+ mode: 'create',
556
+ values: {
557
+ title: 'New Request',
558
+ category: 'hardware',
559
+ priority: 'high',
560
+ },
561
+ })
562
+ )
563
+
564
+ // Update existing
565
+ const { data: updated } = await nivaro.request(
566
+ submitFormItem('inventory_requests', {
567
+ mode: 'edit',
568
+ itemId: '123',
569
+ values: { status: 'approved' }, // partial update
570
+ })
571
+ )
572
+ ```
573
+
574
+ #### Form field shape
575
+
576
+ | Property | Type | Description |
577
+ | --- | --- | --- |
578
+ | key | string | Field name (used in values/updates). |
579
+ | label | string | Display name. |
580
+ | type | string | text | number | boolean | date | select | etc. |
581
+ | interface | string | UI hint: text-input | textarea | toggle | date-picker. |
582
+ | required | boolean | If true, value must be provided. |
583
+ | sort | number | null | Display order within group. |
584
+ | hidden | boolean | If true, hidden from UI but readable via API. |
585
+ | validation_rules | Rule[] | Constraints: min_length, pattern, unique, etc. |
586
+ | visibility_rules | Rule[] | Show/hide based on other field values. |
587
+ | lock_condition | Rule[] | Make read-only based on field values. |
588
+ | computed_formula | string | null | If set, field is auto-calculated (read-only). |
589
+
590
+ | Command | Purpose |
591
+ | --- | --- |
592
+ | fetchFormSchema(collection) | Load full schema + layout + relations |
593
+ | evaluateFieldRules(collection, values) | Server-evaluate rules against values (no save) |
594
+ | readRelationOptions(collection, field, opts?) | Get picker options for a relation field |
595
+ | submitFormItem(collection, { mode, itemId?, values }) | Create or update via form |
596
+
597
+ > **Note:** Form Schema uses **snake_case** (`validation_rules`, `visibility_rules`, `computed_formula`). The `@nivaro/react` package wraps these in **camelCase** (`validationRules`) for React — do not mix the two APIs.
598
+
599
+ ---
600
+
601
+ ## SDK — React (@nivaro/react)
602
+
603
+ `@nivaro/react` is a form runtime built on `@nivaro/sdk`. One hook (`useNivaroForm`) handles schema loading, field rules, visibility/lock evaluation, relation options, validation, and submit. Pair it with your own inputs (headless) or use `<NivaroForm>` for auto-rendering fields.
604
+
605
+ #### Installation
606
+
607
+ ```typescript
608
+ pnpm add @nivaro/react @nivaro/sdk react react-dom @tanstack/react-query sonner
609
+ ```
610
+
611
+ #### Styling
612
+
613
+ The styled components (`ItemEditForm`, `QueueWorklist`, panels, sheets) are built with Tailwind. Two ways to get their styles — pick one:
614
+
615
+ **Option A — precompiled CSS (simplest, no Tailwind required).** One import, works in any app:
616
+
617
+ ```typescript
618
+ // app entry
619
+ import '@nivaro/react/full.css'
620
+ ```
621
+
622
+ **Option B — Tailwind v3 preset.** If your app already runs Tailwind 3 and you want one utility pipeline (no duplicated classes):
623
+
624
+ ```typescript
625
+ // tailwind.config.js
626
+ module.exports = {
627
+ presets: [require('@nivaro/react/tailwind-preset')],
628
+ content: [
629
+ './src/**/*.{ts,tsx}',
630
+ './node_modules/@nivaro/react/dist/**/*.js'
631
+ ]
632
+ }
633
+
634
+ // app entry
635
+ import '@nivaro/react/styles.css'
636
+ ```
637
+
638
+ The preset supplies the theme tokens (`nvr-cyan`, semantic color vars, radius, type scale) and the animate plugin; `styles.css` supplies the CSS variables plus class-based dark-mode overrides. Tailwind v4 configs ignore `tailwind.config.js` presets — use Option A there. Dark mode in both options: `dark` class on `<html>`. The headless `useNivaroForm` API needs neither.
639
+
640
+ #### Setup
641
+
642
+ Wrap your app in `<NivaroProvider>` with a configured SDK client. It provides a TanStack Query `QueryClient` automatically when your app does not already run a `QueryClientProvider` — if you do, yours wins.
643
+
644
+ #### Item links in embedded apps
645
+
646
+ Components like `QueueWorklist` open records at the admin route shape (`/collections/:collection/:id`) by default. When embedding in your own app, override link handling on `NavigationContext`:
647
+
648
+ ```typescript
649
+ import { NavigationContext } from '@nivaro/react'
650
+
651
+ <NavigationContext.Provider
652
+ value={{
653
+ navigate: (to) => router.push(to),
654
+ // Map record links onto YOUR routes (row clicks, Open buttons, Work Next):
655
+ itemUrl: ({ collection, itemId, layoutSlug }) =>
656
+ `/records/${collection}/${itemId}${layoutSlug ? `?layout=${layoutSlug}` : ''}`,
657
+ // Or intercept opening entirely (return true = handled — e.g. open your own drawer):
658
+ openItem: ({ collection, itemId }) => {
659
+ openMyDetailDrawer(collection, itemId)
660
+ return true
661
+ }
662
+ }}
663
+ >
664
+ ```
665
+
666
+ `openItem` is checked first; returning `true` skips navigation. Otherwise the component navigates to `itemUrl(target)`, falling back to the admin shape when neither is provided.
667
+
668
+ #### Page renderer
669
+
670
+ `<PageRenderer slug="…" />` renders a page-builder page (widget grid) in your own app — the same surface as the admin's `/p/:slug` viewer: table, KPI, markdown, iframe, recent-activity, query tables (param filters, progress columns, row-click picker/drill/matrix sheets), and matrix editors (inline or drawer-button). Widget data resolves through the client identity, so permissions apply server-side. Requires `NivaroProvider` + a TanStack Query `QueryClientProvider`; supply `NavigationContext` to control where record links go, and an outer `DrilldownContext` if you host your own record sheet (PageRenderer hosts one otherwise).
671
+
672
+ ```typescript
673
+ import { NivaroProvider, PageRenderer } from '@nivaro/react'
674
+
675
+ <NivaroProvider client={nivaro}>
676
+ <PageRenderer slug="budget-allocation" />
677
+ </NivaroProvider>
678
+
679
+ // Props: slug (required), hideHeader (skip the page-name row), className
680
+ ```
681
+
682
+ #### Report Studio viewer
683
+
684
+ `<ReportView reportId="…" />` renders a fully-styled, interactive Report Studio report in your own app — same as `QueueWorklist` / `ItemEditForm`: bring the styles via `@nivaro/react/full.css` (or the Tailwind preset). It fetches the definition and resolves every widget (KPIs, KPI groups, bar/line/donut charts via recharts, tables) as the client identity — collection read permissions apply server-side. Interactive: a global filter bar (date-range switcher + live entity-filter chips) and per-widget refresh. Read-only (no drag/resize edit surface).
685
+
686
+ ```typescript
687
+ import { NivaroProvider, ReportView } from '@nivaro/react'
688
+
689
+ <ReportView
690
+ reportId="…"
691
+ refetchInterval={60_000} // live-refresh; omit for one-shot
692
+ showFilterBar // date range + entity chips (default true)
693
+ dateRange={{ preset: 'last_3_months' }} // override the saved range (optional)
694
+ initialEntityFilters={[{ field: 'division', values: [1, 2] }]}
695
+ />
696
+ ```
697
+
698
+ For custom rendering, skip the component and drive the data commands directly: `readReport`, `readReportWidgetData` / `previewReportWidget`, plus the full CRUD, subscription (`setReportSubscription`), alert (`createReportAlert`), and AI (`aiBuildReport`, `aiReportFilters`) surface from `@nivaro/sdk`.
699
+
700
+ ```typescript
701
+ import { createNivaro } from '@nivaro/sdk'
702
+ import { NivaroProvider } from '@nivaro/react'
703
+
704
+ const nivaro = createNivaro('https://nivaro.example.com', { token: '...' })
705
+
706
+ export function App() {
707
+ return (
708
+ <NivaroProvider client={nivaro}>
709
+ <Routes>
710
+ <Route path="/requests/new" element={<CreateRequestForm />} />
711
+ <Route path="/requests/:id/edit" element={<EditRequestForm />} />
712
+ </Routes>
713
+ </NivaroProvider>
714
+ )
715
+ }
716
+ ```
717
+
718
+ #### useNivaroForm hook
719
+
720
+ ```typescript
721
+ import { useNivaroForm } from '@nivaro/react'
722
+
723
+ function CreateRequestForm() {
724
+ const form = useNivaroForm('inventory_requests', {
725
+ mode: 'create',
726
+ defaultValues: { priority: 'medium', status: 'draft' },
727
+ onSuccess: (item) => navigate(`/requests/${item.id}`),
728
+ onError: (err) => toast.error(err.message),
729
+ })
730
+
731
+ return (
732
+ <form onSubmit={form.handleSubmit}>
733
+ <input
734
+ type="text"
735
+ value={form.values.title}
736
+ onChange={(e) => form.setValue('title', e.target.value)}
737
+ />
738
+ {form.errors.title && <p>{form.errors.title}</p>}
739
+
740
+ <select
741
+ value={form.values.priority}
742
+ onChange={(e) => form.setValue('priority', e.target.value)}
743
+ disabled={form.isLocked('priority')}
744
+ >
745
+ <option value="low">Low</option>
746
+ <option value="medium">Medium</option>
747
+ <option value="high">High</option>
748
+ </select>
749
+
750
+ <button type="submit" disabled={!form.isValid || form.isSubmitting}>
751
+ {form.isSubmitting ? 'Saving...' : 'Create'}
752
+ </button>
753
+ </form>
754
+ )
755
+ }
756
+ ```
757
+
758
+ #### Hook return shape
759
+
760
+ | Property | Type | Description |
761
+ | --- | --- | --- |
762
+ | values | Record<string, unknown> | Current form values. |
763
+ | errors | Record<string, string> | Validation errors (empty when valid). |
764
+ | isValid | boolean | Whether all fields pass validation. |
765
+ | isDirty | boolean | Whether any field differs from initial values. |
766
+ | isLoading | boolean | Schema loading in progress. |
767
+ | isSubmitting | boolean | Form submission in progress. |
768
+ | setValue(field, value) | void | Update one field; re-runs rules/visibility. |
769
+ | handleSubmit(e?) | void | Validate + submit; fires onSuccess/onError. |
770
+ | reset(values?) | void | Reset to initial or new values. |
771
+ | isVisible(field) | boolean | Whether field passes visibility rules. |
772
+ | isLocked(field) | boolean | Whether field is read-only. |
773
+ | schema | FormSchema | Full schema (camelCase: fieldType, validationRules). |
774
+ | fieldsByGroup | Record<string, Field[]> | Fields bucketed by group key. |
775
+ | visibleGroups | Group[] | Groups with at least one visible field. |
776
+
777
+ #### Auto-render form
778
+
779
+ ```typescript
780
+ import { NivaroForm } from '@nivaro/react'
781
+
782
+ function AutoForm() {
783
+ return (
784
+ <NivaroForm
785
+ collection="inventory_requests"
786
+ mode="create"
787
+ onSuccess={(item) => navigate(`/requests/${item.id}`)}
788
+ />
789
+ )
790
+ }
791
+ // <NivaroForm> auto-renders all fields, groups, and validation.
792
+ ```
793
+
794
+ > **Note:** `@nivaro/react` uses **camelCase** (`fieldType`, `validationRules`, `visibilityRules`) — different from the SDK Form Schema snake_case. This is intentional for React conventions. Do not mix the two APIs.
795
+
796
+ ---
797
+
798
+ ## SDK — React Layout Hooks (@nivaro/react)
799
+
800
+ These hooks work alongside `useNivaroForm` and require a form returned by that hook. They expose the active collection layout (tabs, sections, col_span grid, ungrouped zone position) plus field-level state, dirty tracking, and repeater management. Import all hooks from `@nivaro/react`.
801
+
802
+ > **Note:** `FormSchema` now includes `ungroupedSort: number | null` — the configured position of the Ungrouped zone relative to named groups. `fetchFormSchema` and `useFormSchema` fetch this automatically from the active layout endpoint; no extra call is needed.
803
+
804
+ | Export | Kind | Purpose |
805
+ | --- | --- | --- |
806
+ | LayoutForm | Component | Full layout-aware auto-renderer (tabs, sections, col_span grid, ungrouped zone). |
807
+ | useOrderedLayout(form) | Hook | Ordered list of groups + `__ungrouped__` sentinel, reflecting `ungroupedSort`. |
808
+ | useTabState(form) | Hook | Active tab + setter + tabs list; `hasTabs` false when layout has no tab groups. |
809
+ | useSectionState(form, defaultCollapsed?) | Hook | Per-section collapse state; `toggle`, `collapseAll`, `expandAll`. |
810
+ | useFieldState(form, field) | Hook | value, error, visible, locked, required, colSpan, descriptor, onChange for one field. |
811
+ | useWatchFields(form, fields[]) | Hook | Reactive Record<string, unknown> slice — re-renders only when watched values change. |
812
+ | useFormDirty(form, initialValues?) | Hook | isDirty, dirtyFields[], isFieldDirty(field) — compares against initial or mounted values. |
813
+ | useFormStatus(form) | Hook | isDirty, isValid, isSubmitting, isLoading, canSubmit — one-stop status object. |
814
+ | useFieldArray(form, field) | Hook | append, remove, move, update, replace for ordered repeater rows. |
815
+
816
+ > **Note:** All layout hooks read the same `form` object returned by `useNivaroForm`. They do not create extra network requests — schema and layout data are fetched once by the hook and shared.
817
+
818
+ ---
819
+
820
+ ## SDK — Notifications & Inbox
821
+
822
+ Manage your notification inbox. Notifications are created by rules, workflows, comments (@mentions), and alerts — use these endpoints to read, mark as read, and delete them.
823
+
824
+ ```typescript
825
+ import {
826
+ readNotifications, readNotificationCount,
827
+ markNotificationRead, markAllNotificationsRead, deleteNotification
828
+ } from '@nivaro/sdk'
829
+
830
+ // List your inbox notifications (paginated)
831
+ const { data: notifs, total, offset } = await nivaro.request(
832
+ readNotifications({ limit: 50, offset: 0 })
833
+ )
834
+ // notifs → Notification[] — { id, subject, message, status, timestamp, collection, item }
835
+
836
+ // Get unread count (lightweight, for badge)
837
+ const { data: counts } = await nivaro.request(readNotificationCount())
838
+ // counts → { unread: 5, total: 42 }
839
+
840
+ // Mark one as read
841
+ await nivaro.request(markNotificationRead(notif.id))
842
+
843
+ // Mark all as read
844
+ await nivaro.request(markAllNotificationsRead())
845
+
846
+ // Delete one
847
+ await nivaro.request(deleteNotification(notif.id))
848
+ ```
849
+
850
+ #### Real-time notifications via Socket.io
851
+
852
+ For live notifications as they arrive, subscribe to the Socket.io event:
853
+
854
+ ```typescript
855
+ import { createRealtime } from '@nivaro/sdk'
856
+
857
+ const rt = createRealtime()
858
+ await rt.connect('https://nivaro.example.com')
859
+
860
+ // Subscribe to your notifications room
861
+ rt.subscribe(`user:${userId}`, { event: 'notification:new' }, (notif) => {
862
+ console.log('New notification:', notif.subject)
863
+ // Update badge, toast, etc.
864
+ })
865
+ ```
866
+
867
+ | Field | Type | Description |
868
+ | --- | --- | --- |
869
+ | id | string | UUID. |
870
+ | subject | string | Notification title. |
871
+ | message | string | Full message body. |
872
+ | status | string | "inbox" | "read" |
873
+ | timestamp | string | ISO 8601 when created. |
874
+ | collection | string | null | Related collection (if from an item event). |
875
+ | item | string | null | Related item ID. |
876
+ | sender | string | null | User who triggered it (if applicable). |
877
+
878
+ ---
879
+
880
+ ## SDK — Activity & Revisions
881
+
882
+ The activity log records all changes and actions in your CMS. Revisions give you full audit trail and rollback capability.
883
+
884
+ #### Activity log
885
+
886
+ ```typescript
887
+ import { readActivity } from '@nivaro/sdk'
888
+
889
+ // All activity, newest first (system-wide audit log)
890
+ const { data: entries, total } = await nivaro.request(
891
+ readActivity({ limit: 50, offset: 0 })
892
+ )
893
+ // entries → Activity[] — { id, action, collection, item, user_id, timestamp, ... }
894
+
895
+ // Filter by collection, action, or user
896
+ const { data: creates } = await nivaro.request(
897
+ readActivity({
898
+ collection: 'inventory_requests',
899
+ action: 'create', // 'create' | 'update' | 'delete' | 'schema-*'
900
+ user_id: 'user-uuid', // optional
901
+ limit: 25,
902
+ })
903
+ )
904
+ ```
905
+
906
+ #### Revisions (item-level history)
907
+
908
+ ```typescript
909
+ import { readRevisions, readRevision } from '@nivaro/sdk'
910
+
911
+ // All revisions for a specific item (newest first)
912
+ const { data: revisions } = await nivaro.request(
913
+ readRevisions('inventory_requests', itemId, { limit: 50 })
914
+ )
915
+ // revisions → Revision[] — each revision includes action, full snapshot, and delta
916
+
917
+ revisions.forEach(rev => {
918
+ console.log(
919
+ `${rev.action.toUpperCase()} by ${rev.first_name} at ${rev.timestamp}`
920
+ )
921
+ if (rev.delta) {
922
+ console.log('Changed:', Object.keys(rev.delta))
923
+ }
924
+ })
925
+
926
+ // Single revision detail with full snapshot and delta
927
+ const { data: rev } = await nivaro.request(readRevision(revisionId))
928
+ console.log('Full snapshot:', rev.data)
929
+ console.log('Changed fields:', rev.delta)
930
+ ```
931
+
932
+ #### Revision shape
933
+
934
+ | Field | Type | Description |
935
+ | --- | --- | --- |
936
+ | id | string | Revision UUID. |
937
+ | action | string | "create" | "update" | "delete" |
938
+ | collection | string | Collection name. |
939
+ | item_id | string | Item ID (null for create). |
940
+ | data | Record | Full snapshot of the record at that revision. |
941
+ | delta | Record | null | Only changed fields (for updates). Null for create/delete. |
942
+ | timestamp | string | ISO 8601 datetime. |
943
+ | user_id | string | User who made the change. |
944
+ | first_name / last_name / user_email | string | Display info from nivaro_users. |
945
+
946
+ > **Note:** Revisions are immutable — they form a complete audit trail. You can compare any two revisions to see exactly what changed. For rollback, use the delta as a PATCH to the current item.
947
+
948
+ ---
949
+
950
+ ## SDK — External APIs
951
+
952
+ Call configured external APIs without exposing credentials. The SDK handles credential injection server-side. Use these commands to manage API configs, test connections, and call endpoints.
953
+
954
+ #### Calling an external API
955
+
956
+ ```typescript
957
+ import { callExternalApi } from '@nivaro/sdk'
958
+
959
+ // Call any endpoint on a configured API
960
+ const { data: result } = await nivaro.request(
961
+ callExternalApi('slack-api', {
962
+ method: 'POST',
963
+ path: '/chat.postMessage',
964
+ body: { channel: 'C1234', text: 'Task completed' },
965
+ })
966
+ )
967
+ // result.status → 200, 400, 500, etc.
968
+ // result.headers → response headers
969
+ // result.body → parsed JSON response (or text if not JSON)
970
+ ```
971
+
972
+ #### Admin: API config CRUD
973
+
974
+ ```typescript
975
+ import {
976
+ readExternalApis, readExternalApi,
977
+ createExternalApi, updateExternalApi, deleteExternalApi,
978
+ testExternalApi,
979
+ } from '@nivaro/sdk'
980
+
981
+ // List all configured APIs
982
+ const { data: apis } = await nivaro.request(readExternalApis())
983
+
984
+ // Get one
985
+ const { data: api } = await nivaro.request(readExternalApi(apiId))
986
+
987
+ // Create an API config
988
+ const { data: slack } = await nivaro.request(
989
+ createExternalApi({
990
+ name: 'Slack API',
991
+ base_url: 'https://slack.com/api',
992
+ auth_type: 'bearer', // bearer | api_key | basic | oauth2_cc
993
+ auth_config: { token: 'xoxb-...' },
994
+ enabled: true,
995
+ })
996
+ )
997
+
998
+ // Update
999
+ await nivaro.request(updateExternalApi(slack.id, { enabled: false }))
1000
+
1001
+ // Test the connection
1002
+ const { data: testResult } = await nivaro.request(testExternalApi(slack.id, {
1003
+ method: 'GET',
1004
+ path: '/auth.test',
1005
+ }))
1006
+ console.log(testResult.status, testResult.body)
1007
+
1008
+ // Delete
1009
+ await nivaro.request(deleteExternalApi(slack.id))
1010
+ ```
1011
+
1012
+ | Auth Type | Config Fields | Example |
1013
+ | --- | --- | --- |
1014
+ | bearer | token | { token: "sk_live_..." } |
1015
+ | api_key | header_name, value | { header_name: "X-API-Key", value: "key123" } |
1016
+ | basic | username, password | { username: "user", password: "pass" } |
1017
+ | oauth2_cc | client_id, client_secret, token_url | { client_id: "...", ... } |
1018
+
1019
+ #### Admin: Endpoint templates
1020
+
1021
+ Pre-define common endpoints for an API so users can call them by slug instead of writing full path/method each time.
1022
+
1023
+ ```typescript
1024
+ import {
1025
+ readExternalApiEndpoints, createExternalApiEndpoint,
1026
+ updateExternalApiEndpoint, deleteExternalApiEndpoint,
1027
+ } from '@nivaro/sdk'
1028
+
1029
+ // List templates for an API
1030
+ const { data: endpoints } = await nivaro.request(readExternalApiEndpoints(apiId))
1031
+
1032
+ // Create a template
1033
+ const { data: tpl } = await nivaro.request(
1034
+ createExternalApiEndpoint(apiId, {
1035
+ name: 'Post Message',
1036
+ slug: 'post-message', // users call via this slug
1037
+ method: 'POST',
1038
+ path: '/chat.postMessage',
1039
+ default_body: { channel: 'general' },
1040
+ })
1041
+ )
1042
+
1043
+ // Update
1044
+ await nivaro.request(updateExternalApiEndpoint(tpl.id, {
1045
+ default_body: { channel: 'alerts' },
1046
+ }))
1047
+
1048
+ // Delete
1049
+ await nivaro.request(deleteExternalApiEndpoint(tpl.id))
1050
+ ```
1051
+
1052
+ > **Note:** Credentials (tokens, passwords) are never exposed in GET responses — sensitive fields return a masked value like `••••••`. When updating, re-submit the masked value to keep the existing credential; send a plaintext value to change it.
1053
+
1054
+ ---
1055
+
1056
+ ## SDK — GraphQL Transport
1057
+
1058
+ Use `nivaro.graphql()` to send typed GraphQL queries and mutations. The method throws on errors — no need to check `response.errors` manually. Uses the same auth (token/cookie) as REST.
1059
+
1060
+ #### Queries
1061
+
1062
+ ```typescript
1063
+ import { createNivaro } from '@nivaro/sdk'
1064
+
1065
+ const nivaro = createNivaro('https://nivaro.example.com', { token: '...' })
1066
+
1067
+ interface ArticlesResult {
1068
+ articles: {
1069
+ data: Array<{ id: string; name: string; status: string; author_id: string }>
1070
+ total: number
1071
+ }
1072
+ }
1073
+
1074
+ const result = await nivaro.graphql<ArticlesResult>(`
1075
+ query {
1076
+ articles(filter: { status: { _eq: "active" } }, limit: 10) {
1077
+ data { id name status author_id }
1078
+ total
1079
+ }
1080
+ }
1081
+ `)
1082
+
1083
+ result.articles.data.forEach(article => {
1084
+ console.log(`${article.name} by author ${article.author_id}`)
1085
+ })
1086
+ ```
1087
+
1088
+ #### Queries with variables
1089
+
1090
+ ```typescript
1091
+ interface ArticlesResult {
1092
+ articles: { data: Article[]; total: number }
1093
+ }
1094
+
1095
+ const result = await nivaro.graphql<ArticlesResult>(
1096
+ `query GetArticles($filter: JSON, $limit: Int) {
1097
+ articles(filter: $filter, limit: $limit) {
1098
+ data { id name status }
1099
+ total
1100
+ }
1101
+ }`,
1102
+ {
1103
+ filter: { status: { _eq: 'active' }, created_at: { _gte: '2024-01-01' } },
1104
+ limit: 25,
1105
+ },
1106
+ 'GetArticles' // optional operationName (for debugging)
1107
+ )
1108
+ ```
1109
+
1110
+ #### Mutations
1111
+
1112
+ ```typescript
1113
+ interface CreateArticleResult {
1114
+ createArticle: { id: string; name: string; status: string }
1115
+ }
1116
+
1117
+ const result = await nivaro.graphql<CreateArticleResult>(`
1118
+ mutation CreateArticle($name: String!, $status: String) {
1119
+ createArticle(data: { name: $name, status: $status }) {
1120
+ id name status
1121
+ }
1122
+ }
1123
+ `,
1124
+ { name: 'New Article', status: 'draft' }
1125
+ )
1126
+
1127
+ console.log('Created:', result.createArticle.id)
1128
+ ```
1129
+
1130
+ #### Subscriptions
1131
+
1132
+ ```typescript
1133
+ // GraphQL subscriptions require a separate WebSocket transport
1134
+ // Use the Socket.io realtime client instead — it's simpler and handles reconnection
1135
+
1136
+ import { createRealtime } from '@nivaro/sdk'
1137
+
1138
+ const rt = createRealtime()
1139
+ await rt.connect('https://nivaro.example.com')
1140
+
1141
+ rt.subscribe('articles', { event: 'update' }, (article) => {
1142
+ console.log('Article updated:', article)
1143
+ })
1144
+ ```
1145
+
1146
+ | Method | Purpose |
1147
+ | --- | --- |
1148
+ | nivaro.graphql(query, variables?, operationName?) | Send a GraphQL query or mutation |
1149
+ | nivaro.setToken(token) | Set auth token (shared with REST) |
1150
+
1151
+ > **Note:** The GraphQL schema is auto-generated from your collections, fields, and relations at startup. View the full schema at `/graphql` in the admin UI.
1152
+
1153
+ ---
1154
+
1155
+ ## SDK — API Keys & Token Management
1156
+
1157
+ Generate, revoke, and manage static API tokens programmatically. Tokens are prefixed with `nvk_` and can have custom scopes, expiry dates, and IP allowlists.
1158
+
1159
+ #### Generate token for yourself
1160
+
1161
+ ```typescript
1162
+ import { generateToken, revokeToken } from '@nivaro/sdk'
1163
+
1164
+ // Generate a new token
1165
+ const { data: result } = await nivaro.request(generateToken())
1166
+ // result.token → "nvk_abc123..." (shown only once!)
1167
+
1168
+ // Use it immediately or store in secure location
1169
+ console.log('Save this token:', result.token)
1170
+ nivaro.setToken(result.token)
1171
+
1172
+ // Later: revoke it
1173
+ await nivaro.request(revokeToken())
1174
+ ```
1175
+
1176
+ #### Admin: Generate token for another user
1177
+
1178
+ ```typescript
1179
+ import { generateUserToken, revokeUserToken } from '@nivaro/sdk'
1180
+
1181
+ // Generate token for a user (admin only)
1182
+ const { data } = await nivaro.request(generateUserToken('user-uuid'))
1183
+ console.log('Token for user:', data.token)
1184
+
1185
+ // Revoke it
1186
+ await nivaro.request(revokeUserToken('user-uuid'))
1187
+ ```
1188
+
1189
+ #### Token configuration (API keys admin page)
1190
+
1191
+ When creating a token in the admin UI, you can configure:
1192
+
1193
+ | Setting | Description | Example |
1194
+ | --- | --- | --- |
1195
+ | Scopes | What the token can do (read, write, admin) | ["read:all", "write:articles"] |
1196
+ | Expires at | Expiry datetime (optional) | 2025-12-31T23:59:59Z |
1197
+ | IP allowlist | Restrict to specific IPs (optional) | ["203.0.113.0", "203.0.113.1"] |
1198
+ | Rate limit | Requests per minute (optional) | 100 |
1199
+
1200
+ > **Warning:** Token values are only returned in the API response once. After you leave the page or close the dialog, the value cannot be retrieved. Copy it to a password manager or secure store immediately.
1201
+
1202
+ > **Note:** All tokens are stored as sha256 hashes in the database — the plaintext is never persisted. Use `setToken()` to set the SDK token at runtime.
1203
+
1204
+ ---
1205
+
1206
+ ## SDK — Files & Upload
1207
+
1208
+ Upload files to the Nivaro file manager and get URLs for serving them. Files are stored locally by default; configure S3 or other providers in settings.
1209
+
1210
+ #### Upload a file
1211
+
1212
+ ```typescript
1213
+ import { createNivaro } from '@nivaro/sdk'
1214
+
1215
+ const nivaro = createNivaro('https://nivaro.example.com', { token: '...' })
1216
+
1217
+ // From a file input
1218
+ const fileInput = document.querySelector<HTMLInputElement>('#file-input')!
1219
+ const file = fileInput.files![0]
1220
+
1221
+ const result = await nivaro.upload(file, {
1222
+ title: 'Q2 Report', // optional: display name
1223
+ folder: 'folder-uuid-here', // optional: folder ID
1224
+ })
1225
+
1226
+ // result → FileUploadResult
1227
+ console.log('Uploaded:', result.id, result.filesize, 'bytes')
1228
+ ```
1229
+
1230
+ #### Get file URL
1231
+
1232
+ ```typescript
1233
+ // Generate a download URL
1234
+ const url = nivaro.fileUrl(fileId)
1235
+ // url → https://nivaro.example.com/api/files/<id>/content
1236
+
1237
+ // Display in an img tag
1238
+ <img src={url} alt="Report" />
1239
+
1240
+ // Or link for download
1241
+ <a href={url} download>Download Report</a>
1242
+ ```
1243
+
1244
+ #### FileUploadResult shape
1245
+
1246
+ | Field | Type | Description |
1247
+ | --- | --- | --- |
1248
+ | id | string | UUID primary key (use for fileUrl). |
1249
+ | filename_disk | string | Hashed filename on disk (for deduplication). |
1250
+ | filename_download | string | Original filename from upload. |
1251
+ | title | string | null | Custom display title. |
1252
+ | type | string | MIME type, e.g. "image/png", "application/pdf". |
1253
+ | filesize | number | File size in bytes. |
1254
+ | width | number | null | Image width in pixels (images only). |
1255
+ | height | number | null | Image height in pixels (images only). |
1256
+ | folder | string | null | Folder ID, or null if in root. |
1257
+ | uploaded_on | string | ISO 8601 upload timestamp. |
1258
+
1259
+ #### Usage in forms
1260
+
1261
+ ```typescript
1262
+ // Upload and store file ID in a field
1263
+ async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
1264
+ const file = e.target.files?.[0]
1265
+ if (!file) return
1266
+
1267
+ const uploaded = await nivaro.upload(file)
1268
+ form.setValue('attachment_id', uploaded.id) // store the ID
1269
+ }
1270
+
1271
+ // On render, show the file
1272
+ const fileId = form.values.attachment_id
1273
+ if (fileId) {
1274
+ const url = nivaro.fileUrl(fileId)
1275
+ return <a href={url}>{fileId}</a>
1276
+ }
1277
+ ```
1278
+
1279
+ ---
1280
+
1281
+ ## SDK — Realtime (Socket.io)
1282
+
1283
+ Subscribe to live events (item updates, notifications, presence) via Socket.io. The SDK wraps Socket.io with a simple subscribe/unsubscribe API.
1284
+
1285
+ #### Basic setup
1286
+
1287
+ ```typescript
1288
+ import { createRealtime } from '@nivaro/sdk'
1289
+
1290
+ // Create and connect
1291
+ const rt = createRealtime()
1292
+ await rt.connect('https://nivaro.example.com', { token: 'nvk_...' })
1293
+ ```
1294
+
1295
+ #### Subscribe to events
1296
+
1297
+ ```typescript
1298
+ // Subscribe to all updates on a collection
1299
+ const unsubscribe = rt.subscribe(
1300
+ 'articles',
1301
+ { event: 'update' },
1302
+ (article) => {
1303
+ console.log('Article updated:', article)
1304
+ // article → full updated item snapshot
1305
+ }
1306
+ )
1307
+
1308
+ // Subscribe to updates on a specific item
1309
+ rt.subscribe(
1310
+ 'articles:123',
1311
+ { event: 'update' },
1312
+ (article) => console.log('This article changed')
1313
+ )
1314
+
1315
+ // Subscribe to your notifications
1316
+ rt.subscribe(
1317
+ `user:${userId}`,
1318
+ { event: 'notification:new' },
1319
+ (notif) => console.log('New notification:', notif.subject)
1320
+ )
1321
+
1322
+ // Subscribe to presence changes on an item
1323
+ rt.subscribe(
1324
+ 'articles:123',
1325
+ { event: 'presence' },
1326
+ (users) => console.log(`${users.length} users viewing`)
1327
+ )
1328
+ ```
1329
+
1330
+ #### Event types
1331
+
1332
+ | Event | Fires On | Data |
1333
+ | --- | --- | --- |
1334
+ | create | New item added | Full item snapshot |
1335
+ | update | Item field changed | Full item snapshot (with updated values) |
1336
+ | delete | Item deleted | Item ID and deletion timestamp |
1337
+ | notification:new | Notification created | Notification object |
1338
+ | presence | User joins/leaves/edits | Array of currently viewing users |
1339
+
1340
+ #### Unsubscribe and disconnect
1341
+
1342
+ ```typescript
1343
+ // Unsubscribe from a room
1344
+ unsubscribe()
1345
+
1346
+ // Disconnect from server (closes all subscriptions)
1347
+ rt.disconnect()
1348
+ ```
1349
+
1350
+ > **Note:** Socket.io is multiplexed over the same URL as the API (e.g., https://nivaro.example.com). No separate server configuration needed. The Redis pub/sub adapter means events propagate across all server replicas.
1351
+
1352
+ ---
1353
+
1354
+ ## SDK — Filter Helpers
1355
+
1356
+ The SDK exports operator helper functions that make filters type-safe and readable.
1357
+
1358
+ ```typescript
1359
+ import {
1360
+ _eq, _neq, _gt, _gte, _lt, _lte,
1361
+ _in, _nin, _null, _nnull,
1362
+ _contains, _ncontains, _starts_with, _ends_with,
1363
+ _and, _or, _some, _none,
1364
+ asc, desc
1365
+ } from '@nivaro/sdk'
1366
+
1367
+ // Scalar field conditions
1368
+ const filter = {
1369
+ status: _in(['active', 'draft']),
1370
+ amount: _gt(1000),
1371
+ deleted_at: _null(),
1372
+ name: _contains('fiber'),
1373
+ email: _ends_with('@nivaro.dev'),
1374
+ title: _ncontains('archived'),
1375
+ }
1376
+
1377
+ // Logical combinators
1378
+ const combined = _and(
1379
+ { status: _eq('active') },
1380
+ _or({ region: _eq('East') }, { region: _eq('West') })
1381
+ )
1382
+
1383
+ // Relation filters — O2M / M2M
1384
+ const withTags = {
1385
+ tags: _some({ name: _eq('featured') }), // at least one tag named "featured"
1386
+ approvals: _none({ status: _eq('rejected') }), // no rejected approvals
1387
+ }
1388
+
1389
+ // Sort helpers
1390
+ const items = await nivaro.request(
1391
+ readItems('projects', {
1392
+ filter: combined,
1393
+ sort: [asc('region.short_name'), desc('created_at')],
1394
+ })
1395
+ )
1396
+ ```
1397
+
1398
+ #### Scalar operators
1399
+
1400
+ | Helper | SQL | Notes |
1401
+ | --- | --- | --- |
1402
+ | _eq(v) | = v | Exact equality. |
1403
+ | _neq(v) | != v | Not equal. |
1404
+ | _gt(v) | > v | |
1405
+ | _gte(v) | >= v | |
1406
+ | _lt(v) | < v | |
1407
+ | _lte(v) | <= v | |
1408
+ | _in(arr) | IN (...) | Array of values. |
1409
+ | _nin(arr) | NOT IN (...) | |
1410
+ | _null() | IS NULL | |
1411
+ | _nnull() | IS NOT NULL | |
1412
+ | _contains(s) | LIKE %s% | Substring match. |
1413
+ | _ncontains(s) | NOT LIKE %s% | Substring exclusion. |
1414
+ | _starts_with(s) | LIKE s% | Prefix match. |
1415
+ | _ends_with(s) | LIKE %s | Suffix match. |
1416
+
1417
+ #### Logical & relation operators
1418
+
1419
+ | Helper | Type | Notes |
1420
+ | --- | --- | --- |
1421
+ | _and(...clauses) | Logical | All clauses must match. |
1422
+ | _or(...clauses) | Logical | At least one clause must match. |
1423
+ | _some(filter) | Relation | At least one related record matches filter. |
1424
+ | _none(filter) | Relation | No related records match filter. |
1425
+
1426
+ #### Sort helpers
1427
+
1428
+ | Helper | Example | Notes |
1429
+ | --- | --- | --- |
1430
+ | asc(field) | asc('created_at') | Ascending. Dotted paths for M2O: asc('region.short_name'). |
1431
+ | desc(field) | desc('amount') | Descending. |
1432
+
1433
+ ---
1434
+
1435
+ ## SDK — Comments & Mentions
1436
+
1437
+ Threaded comments on any record. Users can mention other users via @username syntax — mentioned users receive in-app notifications automatically.
1438
+
1439
+ ```typescript
1440
+ import { readComments, createComment, updateComment, deleteComment } from '@nivaro/sdk'
1441
+
1442
+ // All comments on an item (oldest first)
1443
+ const { data: comments } = await nivaro.request(
1444
+ readComments('projects', itemId)
1445
+ )
1446
+ // comments → Comment[] — { id, text, user_id, first_name, last_name, created_at, mentions: [{ user_id, first_name }] }
1447
+
1448
+ // Create a comment with @mentions
1449
+ const { data: comment } = await nivaro.request(
1450
+ createComment({
1451
+ collection: 'projects',
1452
+ item: itemId,
1453
+ text: '@jane Please review when you get a chance. Thanks @bob!',
1454
+ })
1455
+ )
1456
+ // @mentions are parsed from text; mentioned users get notifications
1457
+
1458
+ // Update own comment
1459
+ await nivaro.request(
1460
+ updateComment(comment.id, { text: 'Updated — needs urgent review' })
1461
+ )
1462
+
1463
+ // Delete own comment (or admin delete any)
1464
+ await nivaro.request(deleteComment(comment.id))
1465
+ ```
1466
+
1467
+ | Function | Route | Auth |
1468
+ | --- | --- | --- |
1469
+ | readComments(collection, item) | GET /comments | Authenticated |
1470
+ | createComment(body) | POST /comments | Authenticated |
1471
+ | updateComment(id, body) | PATCH /comments/:id | Authenticated (own) |
1472
+ | deleteComment(id) | DELETE /comments/:id | Authenticated (own or admin) |
1473
+
1474
+ > **Note:** Mention syntax: `@firstname-lastname` (from the user directory). The server parses mentions from the text and sends notifications to all mentioned users. Mention list is also returned in the response for UI highlighting.
1475
+
1476
+ ---
1477
+
1478
+ ## SDK — Webhooks
1479
+
1480
+ HTTP webhooks fire when collection items are created, updated, or deleted. Payloads include the full item snapshot + delta for updates. Deliveries are retried automatically with exponential backoff.
1481
+
1482
+ ```typescript
1483
+ import {
1484
+ readWebhooks, readWebhook,
1485
+ createWebhook, updateWebhook, deleteWebhook, testWebhook,
1486
+ } from '@nivaro/sdk'
1487
+
1488
+ // List all webhooks
1489
+ const { data: webhooks } = await nivaro.request(readWebhooks())
1490
+
1491
+ // Create a webhook
1492
+ const { data: wh } = await nivaro.request(
1493
+ createWebhook({
1494
+ name: 'Slack notifications',
1495
+ url: 'https://hooks.slack.com/services/xxx/yyy/zzz',
1496
+ collection: 'projects',
1497
+ events: ['create', 'update'], // or 'delete'
1498
+ headers: { 'X-Custom-Header': 'value' },
1499
+ enabled: true,
1500
+ })
1501
+ )
1502
+
1503
+ // Update
1504
+ await nivaro.request(updateWebhook(wh.id, { enabled: false }))
1505
+
1506
+ // Test delivery (fire one immediately to verify endpoint is working)
1507
+ const { data: testResult } = await nivaro.request(testWebhook(wh.id))
1508
+ console.log(testResult.status, testResult.response_time)
1509
+
1510
+ // Delete
1511
+ await nivaro.request(deleteWebhook(wh.id))
1512
+ ```
1513
+
1514
+ #### Payload shape
1515
+
1516
+ ```typescript
1517
+ {
1518
+ "event": "item.create",
1519
+ "collection": "projects",
1520
+ "item": {
1521
+ "id": "123",
1522
+ "name": "New Project",
1523
+ "status": "draft",
1524
+ ...
1525
+ },
1526
+ "delta": {
1527
+ // For updates only — fields that changed
1528
+ "status": "draft"
1529
+ },
1530
+ "timestamp": "2024-06-14T10:30:00Z",
1531
+ "user_id": "user-uuid",
1532
+ "delivery_id": "delivery-uuid"
1533
+ }
1534
+ ```
1535
+
1536
+ | Function | Route | Auth |
1537
+ | --- | --- | --- |
1538
+ | readWebhooks() | GET /webhooks | Admin |
1539
+ | readWebhook(id) | GET /webhooks/:id | Admin |
1540
+ | createWebhook(data) | POST /webhooks | Admin |
1541
+ | updateWebhook(id, data) | PATCH /webhooks/:id | Admin |
1542
+ | deleteWebhook(id) | DELETE /webhooks/:id | Admin |
1543
+ | testWebhook(id) | POST /webhooks/:id/test | Admin |
1544
+
1545
+ > **Note:** Webhooks are fired asynchronously after the item is committed to the database. Failures are retried up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s). See the Webhooks admin page to view delivery history and retry failed deliveries.
1546
+
1547
+ ---
1548
+
1549
+ ## SDK — Rules & Automation
1550
+
1551
+ Automations (rules) run server-side on create/update/delete. Define conditions (field values that must match) and actions (notify, set field, reject, trigger external system). All actions run transactionally — if any action fails, the entire operation is rolled back.
1552
+
1553
+ ```typescript
1554
+ import { readRules, createRule, updateRule, deleteRule } from '@nivaro/sdk'
1555
+
1556
+ // List rules for a collection
1557
+ const { data: rules } = await nivaro.request(readRules('orders'))
1558
+
1559
+ // Create a rule
1560
+ const { data: rule } = await nivaro.request(
1561
+ createRule({
1562
+ name: 'Auto-escalate urgent orders',
1563
+ collection: 'orders',
1564
+ trigger: 'create', // or 'update' or 'delete'
1565
+ conditions: [
1566
+ { field: 'priority', operator: '_eq', value: 'urgent' }
1567
+ ],
1568
+ actions: [
1569
+ { type: 'notify', recipient_user_id: 'manager-uuid', subject: 'Urgent order' },
1570
+ { type: 'set_field', field: 'escalated', value: true },
1571
+ ],
1572
+ enabled: true,
1573
+ })
1574
+ )
1575
+
1576
+ // Disable a rule temporarily
1577
+ await nivaro.request(updateRule(rule.id, { enabled: false }))
1578
+
1579
+ // Delete
1580
+ await nivaro.request(deleteRule(rule.id))
1581
+ ```
1582
+
1583
+ #### Condition operators
1584
+
1585
+ | Operator | Meaning | Example |
1586
+ | --- | --- | --- |
1587
+ | _eq | Equal | status _eq "urgent" |
1588
+ | _neq | Not equal | status _neq "draft" |
1589
+ | _gt | Greater than | amount _gt 1000 |
1590
+ | _gte | Greater or equal | amount _gte 500 |
1591
+ | _lt | Less than | age _lt 18 |
1592
+ | _lte | Less or equal | age _lte 65 |
1593
+ | _in | In set | status _in ["urgent", "high"] |
1594
+ | _empty | Is null/empty | description _empty null |
1595
+ | _contains | Substring | name _contains "test" |
1596
+ | _starts_with | Prefix | email _starts_with "admin" |
1597
+
1598
+ #### Action types
1599
+
1600
+ | Action | Fields | Description |
1601
+ | --- | --- | --- |
1602
+ | notify | recipient_user_id, subject, body | Send in-app notification. |
1603
+ | set_field | field, value | Auto-set a field (no auth bypass — must be writable). |
1604
+ | reject | error_message | Block the save and return an error to the user. |
1605
+ | trigger_external | external_api_id, path, method, body | Call an external API (credentials stay server-side). |
1606
+
1607
+ | Function | Route | Auth |
1608
+ | --- | --- | --- |
1609
+ | readRules(collection?) | GET /rules | Admin |
1610
+ | createRule(data) | POST /rules | Admin |
1611
+ | updateRule(id, data) | PATCH /rules/:id | Admin |
1612
+ | deleteRule(id) | DELETE /rules/:id | Admin |
1613
+
1614
+ > **Note:** All conditions in a rule must be true (AND logic) for actions to fire. Actions execute in order. If a "reject" action runs, no subsequent actions run and the item is not saved.
1615
+
1616
+ ---
1617
+
1618
+ ## SDK — Flow Runs
1619
+
1620
+ Flows are Inngest-backed automations (schedules, webhooks, manual triggers). View execution history, logs, and errors via the SDK.
1621
+
1622
+ ```typescript
1623
+ import { readFlowRuns, readFlowRun, triggerFlowRun } from '@nivaro/sdk'
1624
+
1625
+ // List all runs for a flow (newest first)
1626
+ const { data: runs } = await nivaro.request(
1627
+ readFlowRuns(flowId, { limit: 50, status: 'error' })
1628
+ )
1629
+ // runs → Run[] — { id, status, started_at, completed_at, duration_ms, output, error_message }
1630
+
1631
+ // Single run detail with step logs
1632
+ const { data: run } = await nivaro.request(readFlowRun(runId))
1633
+ console.log(run.status) // 'running' | 'success' | 'error'
1634
+ console.log(run.error_message) // if status === 'error'
1635
+
1636
+ // Trigger a flow immediately (bypasses schedule)
1637
+ const { data: run } = await nivaro.request(
1638
+ triggerFlowRun(flowId, { payload: { action: 'review', user_id: '123' } })
1639
+ )
1640
+ ```
1641
+
1642
+ | Function | Route | Auth |
1643
+ | --- | --- | --- |
1644
+ | readFlowRuns(flowId, opts?) | GET /flows/:id/runs | Admin |
1645
+ | readFlowRun(runId) | GET /flows/runs/:id | Admin |
1646
+ | triggerFlowRun(flowId, payload?) | POST /flows/:id/trigger | Admin |
1647
+
1648
+ > **Note:** Flows are created and configured in the admin UI at `/flows`. They support cron schedules, manual triggers, webhook triggers, and event-driven execution. Each flow run produces logs for every step — view them in the admin UI or via the GraphQL API.
1649
+
1650
+ ---
1651
+
1652
+ ## SDK — Custom Queries
1653
+
1654
+ Custom queries are parameterized SQL endpoints defined by admins. Use them for complex analytics, cross-collection joins, and aggregations that the standard filter DSL cannot express.
1655
+
1656
+ ```typescript
1657
+ import { readCustomQueries, executeCustomQuery } from '@nivaro/sdk'
1658
+
1659
+ // List all queries visible to the current user
1660
+ const { data: queries } = await nivaro.request(readCustomQueries())
1661
+ // queries → Query[] — { id, name, slug, description, access, cache_ttl, params }
1662
+
1663
+ // Execute a query by slug with parameters
1664
+ const { data: rows, cached, executed_at } = await nivaro.request(
1665
+ executeCustomQuery('high-value-deals', { region: 'West', min_amount: 50000 })
1666
+ )
1667
+ // rows → Record[] — raw query results (any shape, depends on the query)
1668
+ ```
1669
+
1670
+ #### Example: Defining a custom query
1671
+
1672
+ In the admin UI at `/custom-queries`, create a query:
1673
+
1674
+ ```typescript
1675
+ -- Name: High-value deals
1676
+ -- Access: authenticated
1677
+
1678
+ SELECT
1679
+ d.id,
1680
+ d.name,
1681
+ d.amount,
1682
+ d.region,
1683
+ u.first_name,
1684
+ u.last_name,
1685
+ COUNT(i.id) as item_count
1686
+ FROM deals d
1687
+ LEFT JOIN users u ON u.id = d.owner_id
1688
+ LEFT JOIN items i ON i.deal_id = d.id
1689
+ WHERE d.region = @region
1690
+ AND d.amount >= @min_amount
1691
+ GROUP BY d.id, d.name, d.amount, d.region, u.first_name, u.last_name
1692
+ ORDER BY d.amount DESC
1693
+ ```
1694
+
1695
+ #### Parameters
1696
+
1697
+ | Name | Type | Required | Default |
1698
+ | --- | --- | --- | --- |
1699
+ | region | string | true | N/A |
1700
+ | min_amount | number | false | 0 |
1701
+
1702
+ | Function | Route | Auth |
1703
+ | --- | --- | --- |
1704
+ | readCustomQueries() | GET /custom-queries | Authenticated |
1705
+ | executeCustomQuery(slug, params?) | POST /custom-queries/:slug/execute | Per-query setting |
1706
+
1707
+ > **Note:** All parameters are bound safely via MSSQL parameterized queries (@name syntax). Never string-interpolate user input into custom queries. Results can be cached via the TTL setting.
1708
+
1709
+ ---
1710
+
1711
+ ## SDK — Collections & Schema
1712
+
1713
+ Read collection metadata and field definitions from the registry. Useful for building dynamic UIs, form generators, and schema explorers.
1714
+
1715
+ ```typescript
1716
+ import { readCollections, readCollection, readFields } from '@nivaro/sdk'
1717
+
1718
+ // List all collections visible to the current user
1719
+ const { data: collections } = await nivaro.request(readCollections())
1720
+ // collections → Collection[] — { id, collection, label, icon, sort_field, display_template }
1721
+
1722
+ // Single collection metadata
1723
+ const { data: col } = await nivaro.request(readCollection('orders'))
1724
+ // col → { id, collection: "orders", label: "Orders", primary_key_field: "id", ... }
1725
+
1726
+ // All fields in a collection
1727
+ const { data: fields } = await nivaro.request(readFields('orders'))
1728
+ // fields → Field[] — { key, type, interface, required, sort, hidden, ... }
1729
+ ```
1730
+
1731
+ #### Collection metadata
1732
+
1733
+ | Property | Type | Description |
1734
+ | --- | --- | --- |
1735
+ | id | string | UUID. |
1736
+ | collection | string | Slug used in API paths. |
1737
+ | label | string | Display name. |
1738
+ | icon | string | Icon class name. |
1739
+ | primary_key_field | string | Usually "id". |
1740
+ | sort_field | string | null | Default sort column. |
1741
+ | display_template | string | null | Handlebars for rendering rows (e.g., "{{ name }} ({{ status }})"). |
1742
+
1743
+ #### Field metadata
1744
+
1745
+ | Property | Type | Description |
1746
+ | --- | --- | --- |
1747
+ | key | string | Field name. |
1748
+ | label | string | Display label. |
1749
+ | type | string | text | number | boolean | date | select | etc. |
1750
+ | interface | string | UI hint: text-input | textarea | toggle | date-picker | etc. |
1751
+ | required | boolean | If true, must have a value. |
1752
+ | sort | number | null | Display order. |
1753
+ | hidden | boolean | If true, hidden from UI (but readable via API). |
1754
+ | computed_formula | string | null | If set, field is auto-calculated (read-only). |
1755
+
1756
+ | Function | Route | Auth |
1757
+ | --- | --- | --- |
1758
+ | readCollections() | GET /collections | Authenticated |
1759
+ | readCollection(collection) | GET /collections/:collection | Authenticated |
1760
+ | readFields(collection) | GET /fields/:collection | Authenticated |
1761
+
1762
+ ---
1763
+
1764
+ ## SDK — Blackout Dates
1765
+
1766
+ Blackout date ranges (e.g., holidays, maintenance windows) pause scheduled flows, SLA timers, and other time-based automations. Mark days as blackout and time is not counted.
1767
+
1768
+ ```typescript
1769
+ import {
1770
+ readBlackoutDates, checkBlackoutDate,
1771
+ createBlackoutDate, deleteBlackoutDate
1772
+ } from '@nivaro/sdk'
1773
+
1774
+ // List all blackout dates
1775
+ const { data: dates } = await nivaro.request(readBlackoutDates())
1776
+ // dates → Blackout[] — { id, name, start_date, end_date, is_active }
1777
+
1778
+ // Check if a specific date is blacked out
1779
+ const { isBlackout, reason } = await nivaro.request(
1780
+ checkBlackoutDate('2025-12-25')
1781
+ )
1782
+ console.log(isBlackout) // true if in any active blackout range
1783
+
1784
+ // Create a blackout range
1785
+ const { data: bd } = await nivaro.request(
1786
+ createBlackoutDate({
1787
+ name: 'Winter Shutdown',
1788
+ description: 'No deployments',
1789
+ start_date: '2024-12-20',
1790
+ end_date: '2025-01-02',
1791
+ is_active: true,
1792
+ })
1793
+ )
1794
+
1795
+ // Delete
1796
+ await nivaro.request(deleteBlackoutDate(bd.id))
1797
+ ```
1798
+
1799
+ #### Usage in time-based systems
1800
+
1801
+ When SLA rules, scheduled flows, or other time-tracking systems are active:
1802
+
1803
+ - Time during blackout windows is not counted.
1804
+ - Timers pause on the first second of a blackout and resume on the first second after.
1805
+ - Business hours SLA settings (e.g., "Mon-Fri 09:00-17:00") are multiplied by the business hours factor within a blackout window.
1806
+
1807
+ | Function | Route | Auth |
1808
+ | --- | --- | --- |
1809
+ | readBlackoutDates() | GET /blackout-dates | Authenticated |
1810
+ | checkBlackoutDate(date) | GET /blackout-dates/check | Authenticated |
1811
+ | createBlackoutDate(body) | POST /blackout-dates | Admin |
1812
+ | deleteBlackoutDate(id) | DELETE /blackout-dates/:id | Admin |
1813
+
1814
+ ---
1815
+
1816
+ ## SDK — Schema Snapshots
1817
+
1818
+ Capture point-in-time snapshots of your entire metadata registry (collections, fields, relations). Useful for version control, environment promotion, and disaster recovery.
1819
+
1820
+ ```typescript
1821
+ import { readSchemaSnapshots, createSchemaSnapshot, restoreSchemaSnapshot } from '@nivaro/sdk'
1822
+
1823
+ // List all snapshots
1824
+ const { data: snapshots } = await nivaro.request(readSchemaSnapshots())
1825
+ // snapshots → Snapshot[] — { id, name, created_at, collection_count, field_count }
1826
+
1827
+ // Capture a snapshot
1828
+ const { data: snap } = await nivaro.request(
1829
+ createSchemaSnapshot({
1830
+ name: 'Before v2.0 migration',
1831
+ description: 'Backup of schema before major refactoring',
1832
+ })
1833
+ )
1834
+
1835
+ // Restore a snapshot (rolls back collections/fields to that point in time)
1836
+ await nivaro.request(restoreSchemaSnapshot(snap.id))
1837
+ ```
1838
+
1839
+ #### What gets snapshotted
1840
+
1841
+ - All collections (metadata, settings, display templates)
1842
+ - All fields (types, interfaces, validation rules, computed formulas)
1843
+ - All relations (M2O, O2M, M2M, M2A)
1844
+ - Field groups, layouts, and assignments
1845
+ - Collection-level settings (draft/publish, item locking, etc.)
1846
+
1847
+ #### What does NOT get snapshotted
1848
+
1849
+ - Item data (rows) — only schema
1850
+ - Workflows, pipelines, rules, or other business logic
1851
+ - User permissions or role assignments
1852
+ - Custom queries, external APIs, or webhooks
1853
+
1854
+ | Function | Route | Auth |
1855
+ | --- | --- | --- |
1856
+ | readSchemaSnapshots() | GET /schema-snapshot | Admin |
1857
+ | createSchemaSnapshot(body) | POST /schema-snapshot | Admin |
1858
+ | restoreSchemaSnapshot(id) | POST /schema-snapshot/:id/restore | Admin |
1859
+
1860
+ > **Warning:** Restoring a snapshot overwrites the current schema. It does NOT roll back data. Always back up your database before restoring.
1861
+
1862
+ ---
1863
+
1864
+ ## SDK — Alerts & Monitoring
1865
+
1866
+ Define threshold-based or anomaly-detection alerts on collection fields. Monitor values in real-time and notify users when conditions are met. All users can subscribe to alerts relevant to their work.
1867
+
1868
+ #### Admin: Create alert definitions
1869
+
1870
+ ```typescript
1871
+ import { readAlertDefinitions, createAlertDefinition, updateAlertDefinition, deleteAlertDefinition } from '@nivaro/sdk'
1872
+
1873
+ // List all alert definitions
1874
+ const { data: defs } = await nivaro.request(readAlertDefinitions())
1875
+
1876
+ // Create a threshold alert
1877
+ const { data: alert } = await nivaro.request(
1878
+ createAlertDefinition({
1879
+ name: 'High-value order',
1880
+ category: 'threshold', // or 'anomaly'
1881
+ collection: 'orders',
1882
+ field: 'total_amount',
1883
+ operator: 'gt', // _eq, _neq, _gt, _gte, _lt, _lte, _in, _contains
1884
+ threshold: 100000,
1885
+ unit: 'USD',
1886
+ cooldown_minutes: 60, // prevent alert spam
1887
+ is_active: true,
1888
+ })
1889
+ )
1890
+
1891
+ // Update alert
1892
+ await nivaro.request(updateAlertDefinition(alert.id, { is_active: false }))
1893
+
1894
+ // Delete
1895
+ await nivaro.request(deleteAlertDefinition(alert.id))
1896
+ ```
1897
+
1898
+ #### User: Subscribe to alerts
1899
+
1900
+ ```typescript
1901
+ import { readAlertSubscriptions, createAlertSubscription, deleteAlertSubscription } from '@nivaro/sdk'
1902
+
1903
+ // List alerts you are subscribed to
1904
+ const { data: subs } = await nivaro.request(readAlertSubscriptions())
1905
+ // subs → Subscription[] — { id, alert_id, notify_inapp, notify_email }
1906
+
1907
+ // Subscribe to an alert
1908
+ const { data: sub } = await nivaro.request(
1909
+ createAlertSubscription({
1910
+ alert_definition_id: alertId,
1911
+ notify_inapp: true, // in-app bell notification
1912
+ notify_email: false, // email digest
1913
+ })
1914
+ )
1915
+
1916
+ // Unsubscribe
1917
+ await nivaro.request(deleteAlertSubscription(sub.id))
1918
+ ```
1919
+
1920
+ #### Admin: View alert log and manually evaluate
1921
+
1922
+ ```typescript
1923
+ import { readAlertLog, evaluateAlerts } from '@nivaro/sdk'
1924
+
1925
+ // Last 100 alert firings for a definition
1926
+ const { data: log } = await nivaro.request(readAlertLog(alertId))
1927
+ // log → Firing[] — { id, triggered_at, collection, item_id, field_value }
1928
+
1929
+ // Trigger immediate evaluation (normally runs every 5 minutes)
1930
+ await nivaro.request(evaluateAlerts())
1931
+ ```
1932
+
1933
+ #### Alert operators
1934
+
1935
+ | Operator | Meaning | Example |
1936
+ | --- | --- | --- |
1937
+ | _eq | Equal | status == "overdue" |
1938
+ | _neq | Not equal | status != "active" |
1939
+ | _gt | Greater than | amount > 100000 |
1940
+ | _gte | Greater or equal | age >= 65 |
1941
+ | _lt | Less than | days_remaining < 0 |
1942
+ | _lte | Less or equal | days_remaining <= 7 |
1943
+ | _in | In set | status in ("failed", "cancelled") |
1944
+ | _contains | Contains substring | email contains "@spam" |
1945
+
1946
+ | Command | Route | Auth |
1947
+ | --- | --- | --- |
1948
+ | readAlertDefinitions(collection?) | GET /alerts/definitions | Admin |
1949
+ | createAlertDefinition(body) | POST /alerts/definitions | Admin |
1950
+ | updateAlertDefinition(id, body) | PATCH /alerts/definitions/:id | Admin |
1951
+ | deleteAlertDefinition(id) | DELETE /alerts/definitions/:id | Admin |
1952
+ | readAlertSubscriptions() | GET /alerts/subscriptions | Authenticated |
1953
+ | createAlertSubscription(body) | POST /alerts/subscriptions | Authenticated |
1954
+ | deleteAlertSubscription(id) | DELETE /alerts/subscriptions/:id | Authenticated |
1955
+ | readAlertLog(alertId?) | GET /alerts/log | Admin |
1956
+ | evaluateAlerts() | POST /alerts/evaluate | Admin |
1957
+
1958
+ > **Note:** Alerts are evaluated every 5 minutes by an Inngest cron job. Anomaly detection uses statistical analysis (standard deviation multipliers) to identify unusual values. Cooldown prevents the same alert from firing multiple times within a time window.
1959
+
1960
+ ---
1961
+
1962
+ ## SDK — Dynamic Attributes
1963
+
1964
+ Dynamic attributes (EAV — Entity-Attribute-Value) let you attach arbitrary custom fields to any collection without modifying the database schema. Useful for extensibility, multi-tenant customization, and handling one-off custom fields.
1965
+
1966
+ #### Admin: Define attribute types
1967
+
1968
+ ```typescript
1969
+ import {
1970
+ readAttributeDefinitions, createAttributeDefinition,
1971
+ updateAttributeDefinition, deleteAttributeDefinition,
1972
+ } from '@nivaro/sdk'
1973
+
1974
+ // List all attribute definitions for a collection
1975
+ const { data: defs } = await nivaro.request(readAttributeDefinitions('projects'))
1976
+
1977
+ // Create a new attribute type
1978
+ const { data: def } = await nivaro.request(
1979
+ createAttributeDefinition('projects', {
1980
+ key: 'risk_level', // slug used in API calls
1981
+ label: 'Risk Level', // display name
1982
+ type: 'select', // text | number | boolean | date | select
1983
+ options: ['low', 'medium', 'high'], // required for type='select'
1984
+ required: false,
1985
+ sort: 1,
1986
+ })
1987
+ )
1988
+
1989
+ // Update definition
1990
+ await nivaro.request(updateAttributeDefinition(def.id, { required: true }))
1991
+
1992
+ // Delete (also cleans up orphaned values)
1993
+ await nivaro.request(deleteAttributeDefinition(def.id))
1994
+ ```
1995
+
1996
+ #### User: Read and write values
1997
+
1998
+ ```typescript
1999
+ import { readAttributes, updateAttributes } from '@nivaro/sdk'
2000
+
2001
+ // Read all attribute values for an item
2002
+ const { data: attrs } = await nivaro.request(readAttributes('projects', '42'))
2003
+ // attrs → { risk_level: 'medium', budget_code: 'IT-2024-003' }
2004
+
2005
+ // Update attribute values (partial patch)
2006
+ await nivaro.request(
2007
+ updateAttributes('projects', '42', {
2008
+ risk_level: 'high',
2009
+ budget_code: 'IT-2024-099',
2010
+ })
2011
+ )
2012
+ // Omitted keys are left unchanged
2013
+ ```
2014
+
2015
+ #### Attribute types
2016
+
2017
+ | Type | Stored As | Parsed As | Example |
2018
+ | --- | --- | --- | --- |
2019
+ | text | string | string | "extended warranty" |
2020
+ | number | string | number | "42" |
2021
+ | boolean | string | boolean | "true" or "false" |
2022
+ | date | string | ISO 8601 date | "2024-12-31" |
2023
+ | select | string | option key | "gold" |
2024
+
2025
+ #### Admin UI
2026
+
2027
+ Attribute definitions are managed in `/data-model` → Table Editor → Attributes tab (admin only). Once definitions exist for a collection, an Attributes card appears on the item editor for users to fill in values.
2028
+
2029
+ | Command | Route | Auth |
2030
+ | --- | --- | --- |
2031
+ | readAttributeDefinitions(collection) | GET /attributes/definitions/:collection | Authenticated |
2032
+ | createAttributeDefinition(collection, body) | POST /attributes/definitions/:collection | Admin |
2033
+ | updateAttributeDefinition(id, body) | PATCH /attributes/definitions/:id | Admin |
2034
+ | deleteAttributeDefinition(id) | DELETE /attributes/definitions/:id | Admin |
2035
+ | readAttributes(collection, itemId) | GET /attributes/:collection/:itemId | Authenticated |
2036
+ | updateAttributes(collection, itemId, body) | PATCH /attributes/:collection/:itemId | Authenticated |
2037
+
2038
+ > **Note:** All attribute values are stored as strings in `nivaro_attribute_values` regardless of the definition type. SDKs and the admin UI handle type conversion on read/write. Deleting a definition cleans up its orphaned values.
2039
+
2040
+ ---
2041
+
2042
+ ## SDK — Notification Subscriptions
2043
+
2044
+ Users subscribe to collection events (create/update/delete) with optional field filters. Notifications are delivered instantly or as daily/weekly digests. Self-serve — no admin permission needed.
2045
+
2046
+ ```typescript
2047
+ import {
2048
+ readNotificationSubscriptions,
2049
+ createNotificationSubscription,
2050
+ updateNotificationSubscription,
2051
+ deleteNotificationSubscription,
2052
+ } from '@nivaro/sdk'
2053
+
2054
+ // List my subscriptions
2055
+ const { data: subs } = await nivaro.request(readNotificationSubscriptions())
2056
+ // subs → Subscription[] — { id, collection, event_type, filter_field, filter_value, label, digest_frequency, is_active }
2057
+
2058
+ // Subscribe to all new "urgent" orders
2059
+ const { data: sub } = await nivaro.request(
2060
+ createNotificationSubscription({
2061
+ collection: 'orders',
2062
+ event_type: 'create', // 'create' | 'update' | 'delete'
2063
+ filter_field: 'priority', // optional: filter by field value
2064
+ filter_value: 'urgent', // only notify if priority == 'urgent'
2065
+ label: 'Urgent orders', // custom label
2066
+ is_active: true,
2067
+ })
2068
+ )
2069
+
2070
+ // Switch to daily digest instead of instant notifications
2071
+ await nivaro.request(
2072
+ updateNotificationSubscription(sub.id, {
2073
+ digest_frequency: 'instant', // or 'daily' or 'weekly'
2074
+ })
2075
+ )
2076
+
2077
+ // Unsubscribe
2078
+ await nivaro.request(deleteNotificationSubscription(sub.id))
2079
+ ```
2080
+
2081
+ #### Subscription types
2082
+
2083
+ | Event Type | Triggers | Example |
2084
+ | --- | --- | --- |
2085
+ | create | New item is added to the collection | Notify on new leads |
2086
+ | update | An existing item is modified | Notify when status changes |
2087
+ | delete | An item is removed from the collection | Notify on deleted orders |
2088
+
2089
+ #### Filters (optional)
2090
+
2091
+ If you only care about certain items (e.g., high-priority orders), specify a filter_field and filter_value. Notifications only fire when the field matches the value.
2092
+
2093
+ | No Filter | With Filter | Result |
2094
+ | --- | --- | --- |
2095
+ | collection: "orders" | filter_field: "priority", filter_value: "urgent" | Only notify on urgent orders |
2096
+ | collection: "deals" | filter_field: "amount", filter_value: "100000" | Only notify on deals >= 100k |
2097
+ | collection: "projects" | (no filter) | Notify on ALL project changes |
2098
+
2099
+ #### Digest modes
2100
+
2101
+ | Mode | Delivery | Best For |
2102
+ | --- | --- | --- |
2103
+ | instant | Real-time in-app bell notification | Critical events that need immediate action |
2104
+ | daily | Email digest at 08:00 daily | Summary of events from the last 24 hours |
2105
+ | weekly | Email digest on Monday 08:00 | Lower-priority notifications, trending events |
2106
+
2107
+ | Command | Route | Auth |
2108
+ | --- | --- | --- |
2109
+ | readNotificationSubscriptions() | GET /notification-subscriptions | Authenticated |
2110
+ | createNotificationSubscription(body) | POST /notification-subscriptions | Authenticated |
2111
+ | updateNotificationSubscription(id, body) | PATCH /notification-subscriptions/:id | Authenticated |
2112
+ | deleteNotificationSubscription(id) | DELETE /notification-subscriptions/:id | Authenticated |
2113
+
2114
+ > **Note:** Digest emails are sent daily at 08:00 and weekly on Monday at 08:00 (UTC). Each user has a single watermark (`last_digest_at`) shared across both daily and weekly digests — whichever sends first advances the watermark, so events are never delivered twice.
2115
+
2116
+ ---
2117
+
2118
+ ## SDK — SLA Rules
2119
+
2120
+ Attach time-based SLA targets (e.g., "resolve within 24 hours") to workflow states. Track elapsed time, warn on approaching breach, and escalate to managers when targets are missed.
2121
+
2122
+ #### Admin: Define SLA rules
2123
+
2124
+ ```typescript
2125
+ import {
2126
+ readSlaRules, readSlaRule,
2127
+ createSlaRule, updateSlaRule, deleteSlaRule,
2128
+ } from '@nivaro/sdk'
2129
+
2130
+ // List all SLA rules for a workflow template
2131
+ const { data: rules } = await nivaro.request(readSlaRules(workflowTemplateId))
2132
+
2133
+ // Create an SLA rule
2134
+ const { data: rule } = await nivaro.request(
2135
+ createSlaRule({
2136
+ name: 'Critical bug resolution',
2137
+ workflow_template_id: 'wf-uuid',
2138
+ state_key: 'in_progress', // applies to this workflow state
2139
+ duration_hours: 8, // must resolve within 8 hours
2140
+ warning_threshold_pct: 75, // warn at 75% (6 hours)
2141
+ business_hours_only: true, // count only Mon-Fri 09:00-17:00
2142
+ notify_on_warning: true, // notify owner at 75%
2143
+ notify_on_breach: true, // notify owner at 100%
2144
+ escalation_user_id: 'manager-uuid', // escalate to manager on breach
2145
+ is_active: true,
2146
+ })
2147
+ )
2148
+
2149
+ // Update
2150
+ await nivaro.request(updateSlaRule(rule.id, { duration_hours: 16 }))
2151
+
2152
+ // Delete
2153
+ await nivaro.request(deleteSlaRule(rule.id))
2154
+ ```
2155
+
2156
+ #### User: Check SLA status
2157
+
2158
+ ```typescript
2159
+ import { readSlaStatus, readSlaStatusBatch } from '@nivaro/sdk'
2160
+
2161
+ // Check SLA for a single item
2162
+ const { data: status } = await nivaro.request(readSlaStatus('orders', '42'))
2163
+ // status → SlaStatus[] — one entry per active SLA rule for the workflow state
2164
+ // {
2165
+ // rule_id,
2166
+ // rule_name,
2167
+ // state_key,
2168
+ // elapsed_hours: 4.5,
2169
+ // remaining_hours: 3.5,
2170
+ // warning_threshold_pct: 75,
2171
+ // is_warning: false, // 75% reached?
2172
+ // is_breached: false, // 100% exceeded?
2173
+ // breached_at: null,
2174
+ // }
2175
+
2176
+ // Batch check multiple items
2177
+ const { data: batch } = await nivaro.request(
2178
+ readSlaStatusBatch('orders', ['42', '43', '44'])
2179
+ )
2180
+ // batch → { [itemId]: SlaStatus[] }
2181
+ ```
2182
+
2183
+ #### Business hours calculation
2184
+
2185
+ When `business_hours_only: true`, only Mon-Fri 09:00-17:00 is counted. Blackout dates (holidays, maintenance windows) pause the timer entirely.
2186
+
2187
+ | Scenario | Time Counting | Example |
2188
+ | --- | --- | --- |
2189
+ | Mon 10:00 - Mon 18:00 | 8 business hours counted | 8h of an 8h SLA |
2190
+ | Fri 16:00 - Fri 17:00 + Mon 09:00 - 10:00 | 2 business hours counted | 1h Friday + 1h Monday |
2191
+ | During blackout date | Timer paused | Winter shutdown 12/20-1/2 → no time counted |
2192
+ | 24/7 mode (business_hours_only: false) | All hours counted | Calendar hours only |
2193
+
2194
+ | Command | Route | Auth |
2195
+ | --- | --- | --- |
2196
+ | readSlaRules(workflowTemplateId?) | GET /sla/rules | Admin |
2197
+ | readSlaRule(id) | GET /sla/rules/:id | Admin |
2198
+ | createSlaRule(body) | POST /sla/rules | Admin |
2199
+ | updateSlaRule(id, body) | PATCH /sla/rules/:id | Admin |
2200
+ | deleteSlaRule(id) | DELETE /sla/rules/:id | Admin |
2201
+ | readSlaStatus(collection, itemId) | GET /sla/status/:collection/:item | Authenticated |
2202
+ | readSlaStatusBatch(collection, ids) | POST /sla/status/batch | Authenticated |
2203
+
2204
+ > **Note:** SLA times are calculated from workflow history: elapsed time is how long the item has been in the current state. Transitions reset the clock for the new state (which may have its own SLA rule).
2205
+
2206
+ ---
2207
+
2208
+ ## SDK — Queues
2209
+
2210
+ Full command coverage for cross-collection worklists: queue CRUD and source configuration, item resolution with scopes/filters/sorting/pagination, claims, saved views (including column snapshots), per-viewer default views, stat trends, per-owner workload, and materialized-cache rebuilds.
2211
+
2212
+ ```typescript
2213
+ import {
2214
+ createNivaro, listQueues, readQueueItems, claimQueueItem,
2215
+ listQueueViews, createQueueView, setQueueDefaultView, readQueueTrends
2216
+ } from '@nivaro/sdk'
2217
+
2218
+ const nivaro = createNivaro('https://nivaro.example.com', { token: 'nvk_…' })
2219
+
2220
+ const { data: queues } = await nivaro.request(listQueues())
2221
+
2222
+ // Table-style page 1 with per-column filters (omit page/limit for the full set)
2223
+ const result = await nivaro.request(
2224
+ readQueueItems(queues[0].id, {
2225
+ scope: 'mine',
2226
+ sort: '-priority',
2227
+ filters: { state: 'Waiting on Manager Approval' },
2228
+ page: 1,
2229
+ limit: 25
2230
+ })
2231
+ )
2232
+ console.log(result.stats.total, result.data.length)
2233
+
2234
+ // Claim the first unclaimed item
2235
+ const next = result.data.find((i) => !i.claimed_by)
2236
+ if (next) await nivaro.request(claimQueueItem(queues[0].id, next))
2237
+
2238
+ // Save the current table state as a shared view and star it as my default
2239
+ const view = await nivaro.request(
2240
+ createQueueView(queues[0].id, {
2241
+ name: 'My triage',
2242
+ is_shared: true,
2243
+ state: { scope: 'mine', sort: '-priority', view: 'table', columns: null }
2244
+ })
2245
+ )
2246
+ await nivaro.request(setQueueDefaultView(queues[0].id, view.data.id))
2247
+ ```
2248
+
2249
+ | Command | Purpose |
2250
+ | --- | --- |
2251
+ | listQueues / readQueue | Queues visible to the caller; one queue with sources + extra-field metadata |
2252
+ | createQueue / updateQueue / deleteQueue | Queue CRUD incl. display_config (views, row_click, default_columns…) |
2253
+ | updateQueueSources | Replace sources wholesale (max 10); cache-affecting edits rebuild materialized queues |
2254
+ | readQueueItems | Resolve items: scope mine/unowned/all/claimed, column filters, sort, optional pagination |
2255
+ | readQueueWorkload | Items grouped per owner with WIP limits |
2256
+ | readQueueTrends | Daily stat snapshots for sparklines (scope "mine" = caller series) |
2257
+ | claimQueueItem / releaseQueueItem | Claims with pipeline instance-owner write-through |
2258
+ | listQueueViews / createQueueView / updateQueueView / deleteQueueView | Saved views — state snapshots incl. visible columns |
2259
+ | readQueueColumnPrefs / setQueueDefaultView | Per-viewer starred default view |
2260
+ | rematerializeQueue | Force a materialized-cache rebuild |
2261
+ | readQueueCollectionStates / suggestQueueLabels | Builder helpers: state pickers, label-template previews |
2262
+
2263
+ > **Note:** Queue reads respect queue-level visibility (owner, shared, role-scoped). Claims return 403 when the queue has claims disabled.
2264
+
2265
+ ---
2266
+
2267
+ ## SDK — Presence & Awareness
2268
+
2269
+ Real-time presence tracking via Socket.io. See who is currently viewing/editing an item and take coordination actions (lock, merge, notify).
2270
+
2271
+ #### REST: Query presence
2272
+
2273
+ ```typescript
2274
+ import { readPresence, readAllPresence } from '@nivaro/sdk'
2275
+
2276
+ // Who is currently viewing/editing a specific item?
2277
+ const { data: viewers } = await nivaro.request(readPresence('contracts', '99'))
2278
+ // viewers → Presence[] — { user_id, first_name, last_name, email, is_editing, last_heartbeat }
2279
+
2280
+ // All active sessions across the instance (admin)
2281
+ const { data: sessions, total } = await nivaro.request(readAllPresence())
2282
+ // sessions → Presence[] (paginated; default 100 per page)
2283
+ ```
2284
+
2285
+ #### Socket.io: Real-time subscription
2286
+
2287
+ ```typescript
2288
+ import { createRealtime } from '@nivaro/sdk'
2289
+
2290
+ const rt = createRealtime(token)
2291
+ await rt.connect('https://nivaro.example.com')
2292
+
2293
+ // Subscribe to presence updates for an item
2294
+ rt.presence.subscribe('contracts:99', (users) => {
2295
+ console.log(`${users.length} users viewing`)
2296
+ users.forEach(u => {
2297
+ if (u.is_editing) console.log(`${u.first_name} is editing`)
2298
+ })
2299
+ })
2300
+
2301
+ // Announce that you are editing
2302
+ rt.presence.setEditing('contracts:99', true)
2303
+
2304
+ // Stop editing
2305
+ rt.presence.setEditing('contracts:99', false)
2306
+
2307
+ // Leave the presence room
2308
+ rt.presence.leave('contracts:99')
2309
+ ```
2310
+
2311
+ #### Soft edit locks (item locking)
2312
+
2313
+ Pair presence with item locking to prevent conflicting edits:
2314
+
2315
+ ```typescript
2316
+ // Admin UI example
2317
+ const canEdit = async (collection, itemId, userId) => {
2318
+ // Check if item is locked by another user
2319
+ const { data: locked } = await nivaro.request(
2320
+ isItemLocked(collection, itemId, userId)
2321
+ )
2322
+
2323
+ if (locked && locked.lock.user_id !== userId) {
2324
+ // Item is locked by someone else
2325
+ return false
2326
+ }
2327
+
2328
+ // Item is free to edit — acquire a lock
2329
+ const { data: lock } = await nivaro.request(
2330
+ acquireItemLock(collection, itemId)
2331
+ )
2332
+ return true
2333
+ }
2334
+
2335
+ // Release lock when done editing
2336
+ await nivaro.request(releaseItemLock(collection, itemId))
2337
+ ```
2338
+
2339
+ #### Presence events via Socket.io
2340
+
2341
+ | Event | When Fired | Data |
2342
+ | --- | --- | --- |
2343
+ | presence:join | User enters a room | { user_id, first_name, last_name } |
2344
+ | presence:leave | User leaves a room | { user_id } |
2345
+ | presence:editing | User starts/stops editing | { user_id, is_editing } |
2346
+ | presence:heartbeat | Periodic keep-alive (30s) | { user_id, last_seen } |
2347
+
2348
+ | Command | Route | Auth |
2349
+ | --- | --- | --- |
2350
+ | readPresence(collection, itemId) | GET /presence/:collection/:itemId | Authenticated |
2351
+ | readAllPresence(limit?, offset?) | GET /presence | Admin |
2352
+
2353
+ > **Note:** Presence data is ephemeral — it lives only in Redis and is lost on server restart. Perfect for collaboration cues but not for audit/compliance tracking. The admin UI emits heartbeats automatically; custom clients should emit `presence:heartbeat` every 30 seconds to stay visible.
2354
+
2355
+ ---
2356
+
2357
+ ## Tree & Hierarchy
2358
+
2359
+ The Nivaro SDK provides typed commands for both same-collection trees and multi-collection hierarchies.
2360
+
2361
+ #### Tree commands
2362
+
2363
+ ```typescript
2364
+ import { createNivaro, readTreeConfig, readTreeNodes, readTreeNested, readTreeAncestors, readTreeDescendants, readTreeChildren, moveTreeNode, reorderTreeSiblings, rebuildTreePaths } from '@nivaro/sdk'
2365
+
2366
+ const nivaro = createNivaro('https://nivaro.example.com', { token: 'my-token' })
2367
+
2368
+ // Check if a collection has a tree config
2369
+ const config = await nivaro.request(readTreeConfig('org_units'))
2370
+ // → { data: { id, collection, parent_field, label_field, order_field } | null }
2371
+
2372
+ // Flat node list (for custom rendering)
2373
+ const nodes = await nivaro.request(readTreeNodes('org_units'))
2374
+
2375
+ // Fully nested tree (recursive children arrays)
2376
+ const tree = await nivaro.request(readTreeNested('org_units'))
2377
+
2378
+ // Ancestors of a node (root-first breadcrumb)
2379
+ const path = await nivaro.request(readTreeAncestors('org_units', 42))
2380
+
2381
+ // Direct children of a node
2382
+ const kids = await nivaro.request(readTreeChildren('org_units', 42))
2383
+
2384
+ // All descendants (any depth)
2385
+ const all = await nivaro.request(readTreeDescendants('org_units', 42))
2386
+
2387
+ // Move a node (null = make root)
2388
+ await nivaro.request(moveTreeNode('org_units', 42, 7))
2389
+
2390
+ // Reorder siblings (requires order_field on the tree config)
2391
+ await nivaro.request(reorderTreeSiblings('org_units', 42, [
2392
+ { id: 42, sort: 0 },
2393
+ { id: 43, sort: 1 },
2394
+ ]))
2395
+
2396
+ // Rebuild materialized path/depth columns (admin; maintain_path configs)
2397
+ await nivaro.request(rebuildTreePaths(3))
2398
+ ```
2399
+
2400
+ #### Tree permission commands (admin)
2401
+
2402
+ ```typescript
2403
+ import { listTreePermissions, createTreePermission, updateTreePermission, deleteTreePermission } from '@nivaro/sdk'
2404
+
2405
+ // List rules (optionally for one collection)
2406
+ const rules = await nivaro.request(listTreePermissions('org_units'))
2407
+
2408
+ // Deny the "Contractors" role updates inside node 42's subtree
2409
+ await nivaro.request(createTreePermission({
2410
+ collection: 'org_units',
2411
+ node_id: 42,
2412
+ role: '0a1b2c3d-…', // role UUID
2413
+ action: 'update',
2414
+ allow: false,
2415
+ }))
2416
+
2417
+ await nivaro.request(updateTreePermission(7, { action: '*' }))
2418
+ await nivaro.request(deleteTreePermission(7))
2419
+ ```
2420
+
2421
+ > **Note:** Item reads on tree collections may include an `_inherited` sidecar (`{ field: ancestorId }`) when inheritable fields resolved values from an ancestor — see Inherited Field Values.
2422
+
2423
+ #### Hierarchy commands
2424
+
2425
+ ```typescript
2426
+ import { createNivaro, listHierarchyConfigs, readHierarchyConfig, readHierarchyTree, readHierarchyNodes, readHierarchyNodeChildren, readHierarchyNodeAncestors, createHierarchyConfig, updateHierarchyConfig, deleteHierarchyConfig } from '@nivaro/sdk'
2427
+
2428
+ // List all hierarchy configs
2429
+ const configs = await nivaro.request(listHierarchyConfigs())
2430
+
2431
+ // Full nested tree for hierarchy #1
2432
+ const tree = await nivaro.request(readHierarchyTree(1))
2433
+
2434
+ // Flat nodes for hierarchy #1
2435
+ const nodes = await nivaro.request(readHierarchyNodes(1))
2436
+
2437
+ // Children of a specific node
2438
+ const children = await nivaro.request(readHierarchyNodeChildren(1, 'divisions', 5))
2439
+
2440
+ // Ancestors (breadcrumb) of a node
2441
+ const ancestors = await nivaro.request(readHierarchyNodeAncestors(1, 'regions', 22))
2442
+
2443
+ // Create a new hierarchy config
2444
+ await nivaro.request(createHierarchyConfig({
2445
+ name: 'Org Structure',
2446
+ levels: [
2447
+ { collection: 'divisions', label_field: 'name', parent_fk: null },
2448
+ { collection: 'regions', label_field: 'name', parent_fk: 'division_id' },
2449
+ ],
2450
+ }))
2451
+ ```
2452
+
2453
+ ---
2454
+
2455
+ ## SDK Coverage: 300+ Typed Commands
2456
+
2457
+ The @nivaro/sdk command surface now covers every feature area — roughly 175 typed `Command<T>` factories spanning items, files, workflows, pipelines, flows, comments, webhooks, rules, custom queries, trees and hierarchies, submission forms, field watches, notification subscriptions, imports, SLA, alerts, AI endpoints (generate, summarize, validate, check-duplicates), translations, drafts, scheduled changes, record templates, saved views, API keys, widget feeds, sync jobs, ERP submissions, PDF templates, pages, queues (worklists, items, claims, saved views, trends, workload), roles & policies (RBAC/RLS), user management + out-of-office delegation, file management (list/meta/presign/transform URLs), dashboard widgets, extension item/bulk actions, throughput reporting, and more. If a REST route exists, there is a typed command for it.
2458
+
2459
+ #### Discovering commands
2460
+
2461
+ - Everything is exported from the package root — editor autocomplete on `import { … } from "@nivaro/sdk"` is the fastest index.
2462
+ - The Playground at /playground runs snippets against the live instance with your session's permissions, with collection and field comboboxes to scaffold calls.
2463
+ - All commands flow through `nivaro.request(command)`, so auth, workspace headers, and error handling are uniform.
2464
+
2465
+ ```typescript
2466
+ import { createNivaro, readItems, aiValidate, listWidgetFeeds } from '@nivaro/sdk';
2467
+
2468
+ const nivaro = createNivaro('https://nivaro.example.com').withToken('nvk_...');
2469
+
2470
+ const articles = await nivaro.request(readItems('articles', { limit: 5 }));
2471
+ const check = await nivaro.request(aiValidate('articles', { title: 'Draft post' }));
2472
+ const feeds = await nivaro.request(listWidgetFeeds());
2473
+ ```
2474
+
2475
+
2476
+ ---
2477
+
2478
+ ## TypeScript
2479
+
2480
+ All commands are fully typed. Pass your collection interface as a generic to get typed responses:
2481
+
2482
+ ```typescript
2483
+ interface Project {
2484
+ id: string
2485
+ name: string
2486
+ status: 'active' | 'done' | 'archived'
2487
+ owner: string
2488
+ created_at: string
2489
+ }
2490
+
2491
+ const list = await nivaro.request(readItems<Project>('projects', {
2492
+ filter: { status: _eq('active') },
2493
+ sort: [desc('created_at')],
2494
+ }))
2495
+ // list.data is Project[]
2496
+
2497
+ const { data: project } = await nivaro.request(readItem<Project>('projects', id))
2498
+ // project is Project
2499
+ ```
2500
+
2501
+ ---
2502
+
2503
+ ## License
2504
+
2505
+ MIT — see [LICENSE](https://github.com/nodeworks/nivaro/blob/main/LICENSE).