@nivaro/sdk 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +286 -195
  2. package/dist/index.d.ts +1501 -95
  3. package/dist/index.js +787 -120
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -48,12 +48,32 @@ const nivaro = createNivaro('https://nivaro.example.com', {
48
48
  // Full options
49
49
  const nivaro = createNivaro('https://nivaro.example.com', {
50
50
  token: 'nvk_abc123...',
51
- workspace: 'workspace-uuid', // optional: workspace to target
52
- headers: { 'X-Custom': 'value' }, // optional: extra headers
53
- timeoutMs: 90_000, // optional: abandon a GET read that has not answered (off by default)
51
+ headers: { 'x-workspace': 'workspace-uuid', 'x-nivaro-app': 'my-app' }, // extra headers (or a function)
52
+ timeoutMs: 90_000, // abandon a GET read that has not answered (off by default)
53
+ retry: 2, // retry GETs on 408/429/502/503/504 + network errors, honouring Retry-After (off by default)
54
+ debug: true, // one console.debug line per request: method, path, status, ms, request id
55
+ onRequest: (req) => { req.headers['traceparent'] = currentTrace() },
56
+ onResponse: (res) => metrics.observe(res.path, res.status, res.durationMs, res.requestId),
57
+ onError: (err, where) => logger.warn({ where, err }),
54
58
  })
55
59
  ```
56
60
 
61
+ #### Errors
62
+
63
+ Every non-2xx answer throws a `NivaroError` (an `Error`): `status`, the API machine `code` (e.g. `QUOTA_EXCEEDED`, `SERVER_BUSY`), `requestId` (the `x-nivaro-request-id` the server stamped — quote it when reporting a problem), the parsed body on `response`, `method`, `path` and `retryAfterMs`. A GET past `timeoutMs` throws a `TimeoutError`; a caller abort (`withSignal`) throws the platform `AbortError`.
64
+
65
+ ```typescript
66
+ import { isNivaroError, readItem } from '@nivaro/sdk'
67
+
68
+ try {
69
+ await nivaro.request(readItem('orders', 42))
70
+ } catch (err) {
71
+ if (isNivaroError(err) && err.status === 404) return null
72
+ if (isNivaroError(err)) console.error(err.code, err.message, 'request', err.requestId)
73
+ throw err
74
+ }
75
+ ```
76
+
57
77
  #### Client methods
58
78
 
59
79
  | Method | Purpose | Example |
@@ -89,7 +109,15 @@ const items = await nivaro.request(
89
109
 
90
110
  ## SDK — Authentication
91
111
 
92
- The SDK supports two authentication methods: static tokens (for backends/scripts) and session cookies (for browser SPAs).
112
+ The SDK sends whatever `token` you give it as `Authorization: Bearer`; without one it rides the browser session cookie.
113
+
114
+ | Credential | Where it comes from | Notes |
115
+ | --- | --- | --- |
116
+ | Named API key `nvk_…` | API Keys page / createApiKey() | Scopes, expiry, IP allowlist, rate limit; sandbox keys never write. Sockets: use a ws-token. |
117
+ | Static user token | generateToken() / generateUserToken() | Acts as that user; also signs a socket in directly. |
118
+ | Session cookie | OIDC / SAML / password sign-in in the browser | No token; `credentials: include`. Sockets: use a ws-token. |
119
+ | View-as token `nvm_…` | An admin’s View-as | Acts as the viewed person; ends with the View-as. |
120
+ | OAuth access token `nvo_…` | Remote MCP clients (OAuth 2.1) | Accepted on /api/mcp and its tool calls only — never on other routes. |
93
121
 
94
122
  #### Static tokens (server-side / CLI)
95
123
 
@@ -160,6 +188,18 @@ nivaro.setToken(null)
160
188
  await nivaro.request(revokeUserToken('user-uuid'))
161
189
  ```
162
190
 
191
+ #### Session helpers
192
+
193
+ ```typescript
194
+ import { readMe, logout, logoutAll, readWsToken, readAuthProviders } from '@nivaro/sdk'
195
+
196
+ const { data: me } = await nivaro.request(readMe())
197
+ const { data: providers } = await nivaro.request(readAuthProviders()) // public: which sign-ins exist
198
+ const { token } = await nivaro.request(readWsToken()) // one-time socket token (2 min)
199
+ await nivaro.request(logout()) // end this session (cookie sign-in)
200
+ await nivaro.request(logoutAll()) // end every session of mine
201
+ ```
202
+
163
203
  ---
164
204
 
165
205
  ## SDK — REST Commands
@@ -185,8 +225,23 @@ const { data, total, limit, offset } = await nivaro.request(
185
225
  )
186
226
  // data → T[]
187
227
 
188
- // Single item
189
- const { data: article } = await nivaro.request(readItem('articles', '123'))
228
+ // Search a few fields by relevance, a cached total, relation labels flattened in
229
+ const found = await nivaro.request(
230
+ readItems('articles', {
231
+ search: 'pump',
232
+ search_fields: ['title', 'code'],
233
+ search_rank: true,
234
+ count: 'approx', // or false to skip the count (total → null)
235
+ labels: true,
236
+ })
237
+ )
238
+
239
+ // Keyset paging for walking a large collection
240
+ let res = await nivaro.request(readItems('articles', { after: 'start', limit: 500 }))
241
+ while (res.next_cursor) res = await nivaro.request(readItems('articles', { after: res.next_cursor, limit: 500 }))
242
+
243
+ // Single item (optionally projected)
244
+ const { data: article } = await nivaro.request(readItem('articles', '123', { fields: ['id', 'title'] }))
190
245
  // data → T
191
246
  ```
192
247
 
@@ -229,29 +284,59 @@ import { deleteItem } from '@nivaro/sdk'
229
284
  await nivaro.request(deleteItem('articles', '123'))
230
285
  ```
231
286
 
232
- #### Bulk operations
287
+ #### Bulk, batch and rehearsed writes
288
+
289
+ Every row goes through the items service as the caller — permissions, row filters, User Scopes, validation, hooks and revisions apply row by row; one refused row never fails the others unless you ask for all-or-nothing.
233
290
 
234
291
  ```typescript
235
- import { bulkCreateItems, bulkUpdateItems, bulkDeleteItems } from '@nivaro/sdk'
292
+ import {
293
+ bulkWriteItems, queueBulkWriteItems, readBulkRun, batchItems,
294
+ bulkUpdateItems, bulkDeleteItems, bulkTransitionItems,
295
+ rehearseCreateItem, rehearseUpdateItem, checkItemFilter
296
+ } from '@nivaro/sdk'
236
297
 
237
- // Bulk create
238
- const { data: created } = await nivaro.request(
239
- bulkCreateItems('articles', [
240
- { title: 'Article 1', status: 'draft' },
241
- { title: 'Article 2', status: 'draft' },
242
- ])
298
+ // Up to 500 rows: a row with an id is an update, without one a create (207, per-row status)
299
+ const { data: out } = await nivaro.request(
300
+ bulkWriteItems('articles', [{ title: 'A' }, { id: '124', status: 'published' }])
243
301
  )
302
+ // out.results[i] → { index, op, status, id?, error?, code? }; atomic: true = creates, all or nothing
244
303
 
245
- // Bulk update
246
- const { data: updated } = await nivaro.request(
247
- bulkUpdateItems('articles', [
248
- { id: '123', status: 'published' },
249
- { id: '124', status: 'published' },
250
- ])
251
- )
304
+ // Bigger runs in the background, results kept 24 h
305
+ const { data: queued } = await nivaro.request(queueBulkWriteItems('articles', rows))
306
+ const { data: results } = await nivaro.request(readBulkRun('articles', queued.run_id))
307
+
308
+ // Up to 100 creates + updates, one result per row
309
+ await nivaro.request(batchItems('articles', { create: [{ title: 'B' }], update: [{ id: '9', title: 'C' }] }))
252
310
 
253
- // Bulk delete
311
+ // The same values on several records; a transition on several records; delete several
312
+ await nivaro.request(bulkUpdateItems('articles', ['123', '124'], { status: 'published' }))
313
+ await nivaro.request(bulkTransitionItems('articles', ['123'], 'transition-uuid', 'Approved'))
254
314
  await nivaro.request(bulkDeleteItems('articles', ['123', '124']))
315
+
316
+ // Rehearse: everything runs, nothing is saved
317
+ const plan = await nivaro.request(rehearseUpdateItem('articles', '123', { status: 'published' }))
318
+ // plan.changes → [{ field, from, to }]; plan.ok / plan.error / plan.code when it would be refused
319
+ const check = await nivaro.request(checkItemFilter('articles', { status: { _eq: 'live' } }))
320
+ ```
321
+
322
+ #### Lookups, trash and history
323
+
324
+ ```typescript
325
+ import {
326
+ readItem, readItemBySlug, readItemDistinct, listTrash, restoreTrashItem,
327
+ readOwnerHistory, readCommentCounts
328
+ } from '@nivaro/sdk'
329
+
330
+ const { data: a } = await nivaro.request(readItem('articles', '123', { fields: ['id', 'author.email'] }))
331
+ const { data: page } = await nivaro.request(readItemBySlug('pages', 'about-us'))
332
+ const { data: statuses } = await nivaro.request(readItemDistinct('articles', 'status'))
333
+
334
+ // Deleted records (admins: all; everyone else: their own deletions)
335
+ const { data: trash } = await nivaro.request(listTrash({ collection: 'articles', days: 30 }))
336
+ await nivaro.request(restoreTrashItem(trash[0].id))
337
+
338
+ const { data: owners } = await nivaro.request(readOwnerHistory('articles', '123')) // per pipeline state
339
+ const { data: counts } = await nivaro.request(readCommentCounts('articles', ['123', '124']))
255
340
  ```
256
341
 
257
342
  #### Singletons
@@ -290,12 +375,12 @@ const { data: updated } = await nivaro.request(
290
375
  ```typescript
291
376
  import { readRevisions, readRevision } from '@nivaro/sdk'
292
377
 
293
- // All changes to an item (newest first)
378
+ // All changes to an item (newest first, up to 100; no paging)
294
379
  const { data: revisions } = await nivaro.request(readRevisions('articles', '123'))
295
- // Each revision: { id, action ('create'|'update'|'delete'), data, delta, timestamp, user_id, first_name, last_name }
380
+ // Each revision: { id, action, data, delta, timestamp, user_id, first_name, last_name, comment, event? }
296
381
 
297
382
  // Single revision detail
298
- const { data: rev } = await nivaro.request(readRevision('rev-uuid'))
383
+ const { data: rev } = await nivaro.request(readRevision(revisions[0].id!))
299
384
  // rev.data → full snapshot at that point in time
300
385
  // rev.delta → only the fields that changed (for updates)
301
386
  ```
@@ -902,131 +987,174 @@ Query keys are `liveRecordKey(collection, id, fields)` and `liveListKey(collecti
902
987
 
903
988
  ## SDK — Notifications & Inbox
904
989
 
905
- 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.
990
+ Read and work the caller's own inbox. Notifications are created by rules, pipelines, comments (@mentions), alerts and people. Every command acts on the caller's own rows only.
906
991
 
907
992
  ```typescript
908
993
  import {
909
- readNotifications, readNotificationCount,
910
- markNotificationRead, markAllNotificationsRead, deleteNotification
994
+ listNotifications, readUnreadNotificationCount,
995
+ markNotificationRead, markNotificationsRead, markAllNotificationsRead, deleteNotification
911
996
  } from '@nivaro/sdk'
912
997
 
913
- // List your inbox notifications (paginated)
914
- const { data: notifs, total, offset } = await nivaro.request(
915
- readNotifications({ limit: 50, offset: 0 })
998
+ // One page of the inbox (default 50, max 200). Filters match the notification center's own.
999
+ const page = await nivaro.request(
1000
+ listNotifications({ page: 1, limit: 50, lane: 'attention', search: 'invoice', unread: true })
916
1001
  )
917
- // notifs → Notification[] — { id, subject, message, status, timestamp, collection, item }
1002
+ // page.data → NotificationItem[]; page.pinned → acknowledgement-required rows (page 1)
1003
+ // status: 'inbox' | 'read' | 'archived' | 'all'; snoozed: true lists only sleeping rows
918
1004
 
919
- // Get unread count (lightweight, for badge)
920
- const { data: counts } = await nivaro.request(readNotificationCount())
921
- // counts → { unread: 5, total: 42 }
1005
+ // Badge figures, split by lane
1006
+ const counts = await nivaro.request(readUnreadNotificationCount())
1007
+ // → { unread: 5, attention: 3, lanes: { critical, needs_you, fyi } }
922
1008
 
923
- // Mark one as read
924
- await nivaro.request(markNotificationRead(notif.id))
925
-
926
- // Mark all as read
1009
+ await nivaro.request(markNotificationRead(id))
1010
+ await nivaro.request(markNotificationsRead([id1, id2]))
927
1011
  await nivaro.request(markAllNotificationsRead())
1012
+ await nivaro.request(deleteNotification(id))
1013
+ ```
928
1014
 
929
- // Delete one
930
- await nivaro.request(deleteNotification(notif.id))
1015
+ #### Snooze, acknowledge, bulk actions, mutes
1016
+
1017
+ ```typescript
1018
+ import {
1019
+ snoozeNotification, acknowledgeNotification, bulkNotificationAction,
1020
+ archiveReadNotifications, listSentNotifications, readRecordMute, toggleRecordMute
1021
+ } from '@nivaro/sdk'
1022
+
1023
+ await nivaro.request(snoozeNotification(id, { until: '2026-11-02T09:00:00Z' }))
1024
+ await nivaro.request(snoozeNotification(id, { untilChange: true })) // wake when the record moves
1025
+ await nivaro.request(acknowledgeNotification(id)) // when the sender asked for an acknowledgement
1026
+
1027
+ // One action over explicit ids (≤1000) or everything matching a filter (≤2000)
1028
+ await nivaro.request(bulkNotificationAction({ action: 'archive', filter: { status: 'read', lane: 'fyi' } }))
1029
+ await nivaro.request(archiveReadNotifications(30)) // read rows older than 30 days
1030
+
1031
+ // What I sent, with each recipient's read / acknowledgement state
1032
+ const { data: sent } = await nivaro.request(listSentNotifications({ lane: 'all' }))
1033
+
1034
+ // Mute one record's notifications for me
1035
+ const { data: mute } = await nivaro.request(toggleRecordMute('orders', 42)) // { muted: true }
931
1036
  ```
932
1037
 
933
- #### Real-time notifications via Socket.io
1038
+ #### Delivery history and reminders
1039
+
1040
+ ```typescript
1041
+ import { readNotificationHistory, listReminders, createReminder, deleteReminder } from '@nivaro/sdk'
1042
+
1043
+ // What actually reached me: in-app rows, alert firings, report alerts, digest-queued emails
1044
+ const { data: history } = await nivaro.request(readNotificationHistory(80))
1045
+ // history[i] → { kind, at, title, detail, collection?, item?, status?, channels: ['in-app', 'push'] }
1046
+
1047
+ const { data: r } = await nivaro.request(createReminder({ note: 'Call vendor', remind_at: '2026-11-03T14:00:00Z' }))
1048
+ const { data: mine } = await nivaro.request(listReminders())
1049
+ await nivaro.request(deleteReminder(r.id))
1050
+ ```
934
1051
 
935
- For live notifications as they arrive, subscribe to the Socket.io event:
1052
+ #### Live notifications
936
1053
 
937
1054
  ```typescript
938
- import { createRealtime } from '@nivaro/sdk'
1055
+ import { createRealtime, NOTIFICATIONS_CHANNEL, readWsToken } from '@nivaro/sdk'
939
1056
 
940
1057
  const rt = createRealtime()
941
- await rt.connect('https://nivaro.example.com')
1058
+ rt.connect('https://nivaro.example.com', () => nivaro.request(readWsToken()).then((r) => r.token))
942
1059
 
943
- // Subscribe to your notifications room
944
- rt.subscribe(`user:${userId}`, { event: 'notification:new' }, (notif) => {
945
- console.log('New notification:', notif.subject)
946
- // Update badge, toast, etc.
947
- })
1060
+ // notification:new for the signed-in person
1061
+ rt.subscribe(NOTIFICATIONS_CHANNEL, {}, ([notif]) => console.log('New notification', notif))
948
1062
  ```
949
1063
 
950
- | Field | Type | Description |
951
- | --- | --- | --- |
952
- | id | string | UUID. |
953
- | subject | string | Notification title. |
954
- | message | string | Full message body. |
955
- | status | string | "inbox" | "read" |
956
- | timestamp | string | ISO 8601 when created. |
957
- | collection | string | null | Related collection (if from an item event). |
958
- | item | string | null | Related item ID. |
959
- | sender | string | null | User who triggered it (if applicable). |
960
-
961
1064
  ---
962
1065
 
963
- ## SDK — Activity & Revisions
1066
+ ## SDK — Activity, Revisions & Logs
964
1067
 
965
- The activity log records all changes and actions in your CMS. Revisions give you full audit trail and rollback capability.
1068
+ The audit log records every change and notable action; revisions are a record's own history; the change feed, request log, job runs and mail log answer "what happened" from the API itself. Reads of the system-wide logs are for administrators.
966
1069
 
967
- #### Activity log
1070
+ #### Audit log (administrators)
968
1071
 
969
1072
  ```typescript
970
- import { readActivity } from '@nivaro/sdk'
1073
+ import { readActivity, readActivityEntry, listActivityActions, logExport } from '@nivaro/sdk'
971
1074
 
972
- // All activity, newest first (system-wide audit log; administrators only — 403 otherwise)
1075
+ // Newest first; limit default 25, max 200
973
1076
  const { data: entries, total } = await nivaro.request(
974
- readActivity({ limit: 50, offset: 0 })
975
- )
976
- // entries → Activity[] — { id, action, collection, item, user_id, timestamp, ... }
977
-
978
- // Filter by collection, action, or user
979
- const { data: creates } = await nivaro.request(
980
- readActivity({
981
- collection: 'inventory_requests',
982
- action: 'create', // 'create' | 'update' | 'delete' | 'schema-*'
983
- user_id: 'user-uuid', // optional
984
- limit: 25,
985
- })
1077
+ readActivity({ collection: 'orders', action: 'update', user: 'user-uuid', limit: 50 })
986
1078
  )
1079
+ // entries[i] → { id, action, collection, item, user, timestamp, ip, comment, origin,
1080
+ // first_name, last_name, user_email, via_agent }
1081
+
1082
+ const { data: entry } = await nivaro.request(readActivityEntry(entries[0].id)) // + user_agent
1083
+ const { data: actions } = await nivaro.request(listActivityActions()) // every action in the log
1084
+
1085
+ // Anyone signed in: report an export made in the browser (an `export` audit row)
1086
+ await nivaro.request(logExport({ collection: 'orders', row_count: 120, format: 'csv' }))
987
1087
  ```
988
1088
 
989
- #### Revisions (item-level history)
1089
+ #### Revisions (a record’s history)
990
1090
 
991
1091
  ```typescript
992
- import { readRevisions, readRevision } from '@nivaro/sdk'
1092
+ import { readRevisions, readRevision, readLatestRevisionId, rollbackRevision } from '@nivaro/sdk'
993
1093
 
994
- // All revisions for a specific item (newest first)
995
- const { data: revisions } = await nivaro.request(
996
- readRevisions('inventory_requests', itemId, { limit: 50 })
997
- )
998
- // revisions → Revision[] — each revision includes action, full snapshot, and delta
1094
+ // Newest first: up to 100 revisions plus folded pipeline moves and O2M child events.
1095
+ // Same gate as opening the record; snapshots are cut to the fields you may read. No paging.
1096
+ const { data: revisions } = await nivaro.request(readRevisions('orders', 42))
1097
+ for (const rev of revisions) {
1098
+ if (rev.event) console.log('moved', rev.event.from_label, '→', rev.event.to_label)
1099
+ else console.log(rev.action, 'by', rev.first_name, 'changed', Object.keys(rev.delta ?? {}))
1100
+ }
999
1101
 
1000
- revisions.forEach(rev => {
1001
- console.log(
1002
- `${rev.action.toUpperCase()} by ${rev.first_name} at ${rev.timestamp}`
1003
- )
1004
- if (rev.delta) {
1005
- console.log('Changed:', Object.keys(rev.delta))
1006
- }
1007
- })
1102
+ const { data: rev } = await nivaro.request(readRevision(revisions[0].id!))
1103
+ const { data: latest } = await nivaro.request(readLatestRevisionId('orders', 42)) // { latest }
1104
+ await nivaro.request(rollbackRevision(rev.id!)) // through the items service, as you
1105
+ ```
1106
+
1107
+ #### Change feed
1008
1108
 
1009
- // Single revision detail with full snapshot and delta
1010
- const { data: rev } = await nivaro.request(readRevision(revisionId))
1011
- console.log('Full snapshot:', rev.data)
1012
- console.log('Changed fields:', rev.delta)
1109
+ ```typescript
1110
+ import { readChanges } from '@nivaro/sdk'
1111
+
1112
+ // Ids, actions and changed field NAMES only, for records you can see now
1113
+ let page = (await nivaro.request(readChanges({ since: '2026-10-29T12:00:00Z', collections: ['orders'] }))).data
1114
+ while (page.has_more) page = (await nivaro.request(readChanges({ cursor: page.cursor }))).data
1115
+ // keep page.cursor and call again later for what changed since
1013
1116
  ```
1014
1117
 
1015
- #### Revision shape
1118
+ #### API request log, key usage, jobs and mail (administrators)
1016
1119
 
1017
- | Field | Type | Description |
1018
- | --- | --- | --- |
1019
- | id | string | Revision UUID. |
1020
- | action | string | "create" | "update" | "delete" |
1021
- | collection | string | Collection name. |
1022
- | item_id | string | Item ID (null for create). |
1023
- | data | Record | Full snapshot of the record at that revision. |
1024
- | delta | Record | null | Only changed fields (for updates). Null for create/delete. |
1025
- | timestamp | string | ISO 8601 datetime. |
1026
- | user_id | string | User who made the change. |
1027
- | first_name / last_name / user_email | string | Display info from nivaro_users. |
1028
-
1029
- > **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.
1120
+ ```typescript
1121
+ import {
1122
+ listApiRequests, readApiAnalyticsByKey, readApiKeyUsage, readApiKeyMonthlyUsage,
1123
+ listJobRuns, readJobRunLog, cancelJobRun, readBusyJobs,
1124
+ listMailLog, readMailLogEntry, resendMail, listRecordMail
1125
+ } from '@nivaro/sdk'
1126
+
1127
+ const { data: failures } = await nivaro.request(listApiRequests({ status: '5xx', hours: 6 }))
1128
+ const { data: byKey } = await nivaro.request(readApiAnalyticsByKey(24))
1129
+ const { data: usage } = await nivaro.request(readApiKeyUsage(keyId)) // last 30 days
1130
+ const csv = await nivaro.request(readApiKeyMonthlyUsage(keyId, '2026-10', 'csv'))
1131
+
1132
+ const { data: runs } = await nivaro.request(listJobRuns({ status: 'error' }))
1133
+ const { data: log } = await nivaro.request(readJobRunLog(runs[0].id)) // { run, lines }
1134
+ const { data: busy } = await nivaro.request(readBusyJobs())
1135
+
1136
+ const { data: mails } = await nivaro.request(listMailLog({ status: 'failed' }))
1137
+ await nivaro.request(resendMail(mails[0].id))
1138
+ // Anyone who can read the record: the mail sent about it (headers only)
1139
+ const { data: recordMail } = await nivaro.request(listRecordMail('orders', 42))
1140
+ ```
1141
+
1142
+ #### Your own jobs and background reads
1143
+
1144
+ ```typescript
1145
+ import { listMyJobs, startBackgroundRun, readBackgroundRun, readBackgroundRunResult } from '@nivaro/sdk'
1146
+
1147
+ const { data: jobs } = await nivaro.request(listMyJobs()) // running or finished in the last 30 min
1148
+
1149
+ // Hand a slow read to the server; it is replayed as you
1150
+ const { data: run } = await nivaro.request(
1151
+ startBackgroundRun({ method: 'POST', url: '/api/custom-queries/q/execute', body: { params: {} } })
1152
+ )
1153
+ const { data: now } = await nivaro.request(readBackgroundRun(run.id))
1154
+ if (now.status === 'completed') console.log(await nivaro.request(readBackgroundRunResult(run.id)))
1155
+ ```
1156
+
1157
+ > **Note:** Every API answer carries an `x-nivaro-request-id` header — the id the slow-trace buffer, the access log and the request log share. The client puts it on `NivaroError.requestId` and on the `onResponse` hook (see Setup). A cross-origin browser call can only read it when the API exposes the header through CORS.
1030
1158
 
1031
1159
  ---
1032
1160
 
@@ -1216,13 +1344,13 @@ console.log('Created:', result.createArticle.id)
1216
1344
  // GraphQL subscriptions require a separate WebSocket transport
1217
1345
  // Use the Socket.io realtime client instead — it's simpler and handles reconnection
1218
1346
 
1219
- import { createRealtime } from '@nivaro/sdk'
1347
+ import { createRealtime, readWsToken } from '@nivaro/sdk'
1220
1348
 
1221
1349
  const rt = createRealtime()
1222
- await rt.connect('https://nivaro.example.com')
1350
+ rt.connect('https://nivaro.example.com', () => nivaro.request(readWsToken()).then((r) => r.token))
1223
1351
 
1224
- rt.subscribe('articles', { event: 'update' }, (article) => {
1225
- console.log('Article updated:', article)
1352
+ rt.subscribe('articles', { event: 'update' }, (changes) => {
1353
+ console.log('Articles changed:', changes.map((c) => c.item))
1226
1354
  })
1227
1355
  ```
1228
1356
 
@@ -1363,74 +1491,49 @@ if (fileId) {
1363
1491
 
1364
1492
  ## SDK — Realtime (Socket.io)
1365
1493
 
1366
- Subscribe to live events (item updates, notifications, presence) via Socket.io. The SDK wraps Socket.io with a simple subscribe/unsubscribe API.
1494
+ Hear record changes and notifications as they happen. `createRealtime()` signs the socket in, joins one read-gated room per subscribed collection, and re-joins after every reconnect. Events carry ids, the action and changed field NAMES only — never values; read the record through the REST API when you need it.
1367
1495
 
1368
- #### Basic setup
1496
+ #### Connect and sign in
1369
1497
 
1370
1498
  ```typescript
1371
- import { createRealtime } from '@nivaro/sdk'
1499
+ import { createNivaro, createRealtime, readWsToken } from '@nivaro/sdk'
1372
1500
 
1373
- // Create and connect
1501
+ const nivaro = createNivaro('https://nivaro.example.com', { token: process.env.NIVARO_TOKEN })
1374
1502
  const rt = createRealtime()
1375
- await rt.connect('https://nivaro.example.com', { token: 'nvk_...' })
1503
+
1504
+ // A ws-token works for every sign-in (session cookie, API key, static token). It is single-use,
1505
+ // so pass a getter: it is asked again on every reconnect.
1506
+ rt.connect('https://nivaro.example.com', () => nivaro.request(readWsToken()).then((r) => r.token))
1507
+
1508
+ // A user's static token or a View-as token may be passed as a plain string instead.
1509
+ rt.onAuthenticated((userId) => console.log('signed in as', userId))
1376
1510
  ```
1377
1511
 
1378
- #### Subscribe to events
1512
+ #### Subscribe to a collection
1379
1513
 
1380
1514
  ```typescript
1381
- // Subscribe to all updates on a collection
1382
- const unsubscribe = rt.subscribe(
1383
- 'articles',
1384
- { event: 'update' },
1385
- (article) => {
1386
- console.log('Article updated:', article)
1387
- // article → full updated item snapshot
1515
+ const unsubscribe = rt.subscribe('orders', { event: 'update' }, (changes) => {
1516
+ for (const c of changes) {
1517
+ // c → { collection, item, action, changed_fields?, source?, coalesced?, many? }
1518
+ if (c.many) refetchList() // a burst too large to list ids
1519
+ else refetchRow(c.item)
1388
1520
  }
1389
- )
1390
-
1391
- // Subscribe to updates on a specific item
1392
- rt.subscribe(
1393
- 'articles:123',
1394
- { event: 'update' },
1395
- (article) => console.log('This article changed')
1396
- )
1521
+ })
1397
1522
 
1398
- // Subscribe to your notifications
1399
- rt.subscribe(
1400
- `user:${userId}`,
1401
- { event: 'notification:new' },
1402
- (notif) => console.log('New notification:', notif.subject)
1403
- )
1523
+ rt.subscribe('orders', { event: '*' }, (changes) => {}) // create + update + delete
1404
1524
 
1405
- // Subscribe to presence changes on an item
1406
- rt.subscribe(
1407
- 'articles:123',
1408
- { event: 'presence' },
1409
- (users) => console.log(`${users.length} users viewing`)
1410
- )
1525
+ unsubscribe() // leaves the room when no subscription on it is left
1526
+ rt.disconnect()
1411
1527
  ```
1412
1528
 
1413
- #### Event types
1414
-
1415
- | Event | Fires On | Data |
1529
+ | Event | Fires on | Payload (one RealtimeChange per record) |
1416
1530
  | --- | --- | --- |
1417
- | create | New item added | Full item snapshot |
1418
- | update | Item field changed | Full item snapshot (with updated values) |
1419
- | delete | Item deleted | Item ID and deletion timestamp |
1420
- | notification:new | Notification created | Notification object |
1421
- | presence | User joins/leaves/edits | Array of currently viewing users |
1531
+ | create | A record was created | { collection, item, action: "create" } |
1532
+ | update | A record changed | { collection, item, action: "update", changed_fields? } |
1533
+ | delete | A record was deleted | { collection, item, action: "delete" } |
1534
+ | NOTIFICATIONS_CHANNEL | notification:new for the signed-in person | The notification object |
1422
1535
 
1423
- #### Unsubscribe and disconnect
1424
-
1425
- ```typescript
1426
- // Unsubscribe from a room
1427
- unsubscribe()
1428
-
1429
- // Disconnect from server (closes all subscriptions)
1430
- rt.disconnect()
1431
- ```
1432
-
1433
- > **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.
1536
+ > **Note:** Wire protocol: the client emits `auth {token}` after connecting (the server answers `auth:ok`), then `collection:join {collection}` per room; the server sends `collection:update` (bursts coalesced as `{ items, many, count, coalesced: true }`) and `notification:new`. A collection whose realtime mode is `off` sends nothing — poll it. SDK 0.2.x and earlier emitted `subscribe` and listened for `items:*`, which the server never handled.
1434
1537
 
1435
1538
  ---
1436
1539
 
@@ -2423,43 +2526,31 @@ Real-time presence tracking via Socket.io. See who is currently viewing/editing
2423
2526
  #### REST: Query presence
2424
2527
 
2425
2528
  ```typescript
2426
- import { readPresence, readAllPresence } from '@nivaro/sdk'
2529
+ import { getPresence, listActivePresence, readOnlinePresence } from '@nivaro/sdk'
2530
+
2531
+ // Who is viewing a specific record right now (de-duplicated by user/session)
2532
+ const { data: viewers } = await nivaro.request(getPresence('contracts', '99'))
2427
2533
 
2428
- // Who is currently viewing/editing a specific item?
2429
- const { data: viewers } = await nivaro.request(readPresence('contracts', '99'))
2430
- // viewers → Presence[] — { user_id, first_name, last_name, email, is_editing, last_heartbeat }
2534
+ // Who is online (the Online list, filtered to what you may see)
2535
+ const online = await nivaro.request(readOnlinePresence())
2431
2536
 
2432
- // All active sessions across the instance (admin)
2433
- const { data: sessions, total } = await nivaro.request(readAllPresence())
2434
- // sessions → Presence[] (paginated; default 100 per page)
2537
+ // Every active page session across the instance (admin)
2538
+ const { data: sessions, total } = await nivaro.request(listActivePresence())
2435
2539
  ```
2436
2540
 
2437
- #### Socket.io: Real-time subscription
2541
+ #### Report your own presence
2438
2542
 
2439
2543
  ```typescript
2440
- import { createRealtime } from '@nivaro/sdk'
2544
+ import { createPresence } from '@nivaro/sdk'
2441
2545
 
2442
- const rt = createRealtime(token)
2443
- await rt.connect('https://nivaro.example.com')
2444
-
2445
- // Subscribe to presence updates for an item
2446
- rt.presence.subscribe('contracts:99', (users) => {
2447
- console.log(`${users.length} users viewing`)
2448
- users.forEach(u => {
2449
- if (u.is_editing) console.log(`${u.first_name} is editing`)
2450
- })
2451
- })
2452
-
2453
- // Announce that you are editing
2454
- rt.presence.setEditing('contracts:99', true)
2455
-
2456
- // Stop editing
2457
- rt.presence.setEditing('contracts:99', false)
2458
-
2459
- // Leave the presence room
2460
- rt.presence.leave('contracts:99')
2546
+ // Starts at once: pings POST /api/presence/ping every 30 s (and when the tab is shown again)
2547
+ const presence = createPresence({ apiUrl: 'https://nivaro.example.com', userId, userName })
2548
+ presence.setUser(newUserId) // after a sign-in change
2549
+ presence.destroy() // stop pinging and send the disconnect beacon
2461
2550
  ```
2462
2551
 
2552
+ > **Note:** Record changes and notifications arrive over the realtime client (`createRealtime`, see Realtime). Presence on records is read over REST; poll `getPresence` while a record is open.
2553
+
2463
2554
  #### Soft edit locks (item locking)
2464
2555
 
2465
2556
  Pair presence with item locking to prevent conflicting edits: