@sovaria/sdk 0.0.0-stage → 0.5.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.
- package/LICENSE +21 -0
- package/README.md +248 -2
- package/dist/analytics.d.ts +92 -0
- package/dist/analytics.js +236 -0
- package/dist/client.d.ts +146 -0
- package/dist/client.js +583 -0
- package/dist/collections.d.ts +255 -0
- package/dist/collections.js +569 -0
- package/dist/complete-api.d.ts +54 -0
- package/dist/complete-api.js +71 -0
- package/dist/crashes.d.ts +111 -0
- package/dist/crashes.js +244 -0
- package/dist/crypto.d.ts +37 -0
- package/dist/crypto.js +189 -0
- package/dist/errors.d.ts +40 -0
- package/dist/errors.js +258 -0
- package/dist/firestore.d.ts +239 -0
- package/dist/firestore.js +431 -0
- package/dist/generated.d.ts +2462 -0
- package/dist/generated.js +1962 -0
- package/dist/inapp.d.ts +52 -0
- package/dist/inapp.js +48 -0
- package/dist/index.d.ts +3562 -0
- package/dist/index.js +3018 -0
- package/dist/offline.d.ts +92 -0
- package/dist/offline.js +575 -0
- package/dist/passkeys.d.ts +17 -0
- package/dist/passkeys.js +76 -0
- package/dist/perf.d.ts +76 -0
- package/dist/perf.js +212 -0
- package/dist/remote-config.d.ts +87 -0
- package/dist/remote-config.js +182 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.js +1 -0
- package/dist/webpush.d.ts +46 -0
- package/dist/webpush.js +99 -0
- package/package.json +74 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sovaria
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,249 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Sovaria TypeScript SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TypeScript SDK for Sovaria gateway APIs.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @sovaria/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Works in browsers, React Native and Node 18+ (realtime in Node needs Node 22+ or a global `WebSocket`).
|
|
12
|
+
Docs: <https://sovaria.in/docs/javascript-setup> · Reference: <https://sovaria.in/docs/javascript-reference>
|
|
13
|
+
|
|
14
|
+
For local workspace development:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install
|
|
18
|
+
npm run --workspace @sovaria/sdk build
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start (an app)
|
|
22
|
+
|
|
23
|
+
Copy the project URL and anon key from the console's **Connect** panel:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createClient } from '@sovaria/sdk'
|
|
27
|
+
|
|
28
|
+
const sov = createClient('https://<ref>.sovarias.in', '<anon-key>')
|
|
29
|
+
|
|
30
|
+
await sov.auth.signIn('reader@example.com', 'password') // session is saved in localStorage
|
|
31
|
+
const { rows } = await sov.data.sql('select id, title from books order by title')
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or link the folder with the CLI (`sov link`) and build the client from `sovaria.config.json`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import config from './sovaria.config.json'
|
|
38
|
+
import { createClientFromProjectConfig } from '@sovaria/sdk'
|
|
39
|
+
|
|
40
|
+
const sov = createClientFromProjectConfig(config, { publishableKey: import.meta.env.VITE_SOVARIA_ANON_KEY })
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Your Postgres functions
|
|
44
|
+
|
|
45
|
+
`data.rpc` calls a database function over `POST /rest/v1/rpc/{name}` with the arguments by name. A function
|
|
46
|
+
that returns one value resolves to it; one that returns rows resolves to an array. App users need an access
|
|
47
|
+
policy that allows `fn:<name>`.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const count = await sov.data.rpc<number>('post_count', { u: userId })
|
|
51
|
+
await sov.data.rpc('transfer_points', { from_user: a, to_user: b, amount: 50 })
|
|
52
|
+
|
|
53
|
+
// Read-only functions over GET, with PostgREST parameters for functions that return rows
|
|
54
|
+
const top = await sov.data.rpc('top_posts', { days: 7 }, { get: true, query: { select: 'id,title', limit: 10 } })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Sessions
|
|
58
|
+
|
|
59
|
+
In a browser the signed-in session is kept across reloads (`persistSession: false` turns it off;
|
|
60
|
+
`{ storage, key }` stores it elsewhere). Expired access tokens are refreshed automatically; when the
|
|
61
|
+
refresh token is revoked the user is signed out.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const stop = sov.auth.onAuthStateChange((event, session) => {
|
|
65
|
+
// INITIAL_SESSION | SIGNED_IN | TOKEN_REFRESHED | SIGNED_OUT
|
|
66
|
+
})
|
|
67
|
+
sov.auth.getSession() // { access_token, refresh_token?, expires_at } | null
|
|
68
|
+
await sov.auth.signOut()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Passkeys
|
|
72
|
+
|
|
73
|
+
Users add a passkey after signing in, then sign in with it (Face ID, Touch ID, Windows Hello, a security
|
|
74
|
+
key). Set the project up under **Users › Passkeys** first.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
if (sov.auth.passkeys.isSupported()) {
|
|
78
|
+
await sov.auth.passkeys.register('MacBook') // signed in: the browser creates the passkey
|
|
79
|
+
}
|
|
80
|
+
const tokens = await sov.auth.passkeys.signIn() // later: no email or password needed
|
|
81
|
+
tokens.aal // 'aal2' when the device checked the user
|
|
82
|
+
await sov.auth.passkeys.list() // and .remove(id)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`startRegistration` / `finishRegistration` and `startSignIn` / `finishSignIn` take and return WebAuthn JSON
|
|
86
|
+
for environments with their own WebAuthn API (React Native, an embedded browser).
|
|
87
|
+
|
|
88
|
+
### Errors you can show to users
|
|
89
|
+
|
|
90
|
+
Every failed call throws `SovariaError`. Its `message` is written for end users ("You don't have
|
|
91
|
+
permission to do that.") and never contains policy names, SQL or table names. Branch on `code`;
|
|
92
|
+
the server's own text is in `technicalMessage` / `detail` for logs.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { SovariaError, userMessageFor } from '@sovaria/sdk'
|
|
96
|
+
|
|
97
|
+
try { await sov.data.sql('delete from reviews where id = $1', [id]) }
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (e instanceof SovariaError && e.code === 'UNAUTHENTICATED') goToSignIn()
|
|
100
|
+
toast(userMessageFor(e))
|
|
101
|
+
console.debug(e instanceof SovariaError ? e.technicalMessage : e)
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Codes: `NETWORK_ERROR`, `UNAUTHENTICATED`, `PERMISSION_DENIED`, `NOT_FOUND`, `CONFLICT`,
|
|
106
|
+
`INVALID_INPUT`, `PAYLOAD_TOO_LARGE`, `RATE_LIMITED`, `PLAN_LIMIT_REACHED`, `PLAN_FEATURE_REQUIRED`,
|
|
107
|
+
`PLAN_QUOTA_EXHAUSTED`, `SERVICE_UNAVAILABLE`, `SERVER_ERROR`, `BAD_REQUEST`, `CONFIG_ERROR`.
|
|
108
|
+
|
|
109
|
+
### Realtime
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const ch = sov.realtime.channel('book:42', {
|
|
113
|
+
onMessage: (payload) => render(payload),
|
|
114
|
+
onStatus: (s) => setStatus(s), // connecting | live | reconnecting | closed
|
|
115
|
+
onGap: () => reload(), // missed messages that couldn't be recovered
|
|
116
|
+
})
|
|
117
|
+
await ch.send({ type: 'review', rating: 5 })
|
|
118
|
+
ch.close()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
After a reconnect the channel first delivers what was published while it was offline (from the channel's
|
|
122
|
+
replay history, in order, each message once), then live messages. `catchUp: false` turns that off.
|
|
123
|
+
|
|
124
|
+
## Local development (`sov start`)
|
|
125
|
+
|
|
126
|
+
`sov start` runs the Sovaria stack on your machine (Docker) and writes `.env.local` with the URL and the well-known local keys, which work only against that stack. Guide: <https://sovaria.in/docs/local-development>.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// .env.local: VITE_SOVARIA_URL=http://localhost:54321, VITE_SOVARIA_ANON_KEY=svr_local_anon_5a0f1c8e2b7d4e6f9a3c1b5d7e9f0a2c
|
|
130
|
+
const sov = createClient(import.meta.env.VITE_SOVARIA_URL, import.meta.env.VITE_SOVARIA_ANON_KEY)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Plain `http://localhost:54321` is the local project; `http://<ref>.localhost:54321` names it explicitly. Only the two variables change between the local stack and the cloud.
|
|
134
|
+
|
|
135
|
+
## Quick Start (operators and scripts)
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { SovariaSDK } from '@sovaria/sdk'
|
|
139
|
+
|
|
140
|
+
const sdk = new SovariaSDK({ baseUrl: 'https://api.sovaria.in' })
|
|
141
|
+
await sdk.auth.consoleLogin('you@example.com', 'password')
|
|
142
|
+
sdk.setScope({ orgId: '<org-uuid>', projectId: '<project-uuid>' })
|
|
143
|
+
console.log(await sdk.system.enginesHealth())
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Supabase-Style Bootstrap (Portal Config)
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { createClient } from '@sovaria/sdk'
|
|
150
|
+
|
|
151
|
+
const sdk = createClient({
|
|
152
|
+
url: 'https://api.sovaria.in',
|
|
153
|
+
orgId: '11111111-1111-1111-1111-111111111111',
|
|
154
|
+
projectId: '22222222-2222-2222-2222-222222222222',
|
|
155
|
+
envId: '33333333-3333-3333-3333-333333333333',
|
|
156
|
+
publishableKey: '<token>',
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Or bootstrap from environment variables:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const sdk = SovariaSDK.fromEnv(process.env)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Supported environment variables:
|
|
167
|
+
- `SOVARIA_URL` (also `NEXT_PUBLIC_SOVARIA_URL`, `VITE_SOVARIA_URL`)
|
|
168
|
+
- `SOVARIA_ORG_ID`
|
|
169
|
+
- `SOVARIA_PROJECT_ID`
|
|
170
|
+
- `SOVARIA_ENV_ID`
|
|
171
|
+
- `SOVARIA_ACCESS_TOKEN` or `SOVARIA_PUBLISHABLE_KEY` or `SOVARIA_ANON_KEY`
|
|
172
|
+
|
|
173
|
+
## AI: knowledge bases (RAG) and agents
|
|
174
|
+
|
|
175
|
+
Put documents in a knowledge base on your project's own Postgres and search them with citations; only
|
|
176
|
+
passages the signed-in user may read come back.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
await sov.knowledge.addFile('support_docs', file) // PDF, DOCX, HTML, Markdown, TXT, CSV, JSON
|
|
180
|
+
await sov.knowledge.addUrl('support_docs', 'https://example.com/faq')
|
|
181
|
+
const { results, citations } = await sov.knowledge.search('support_docs', 'refund window', { k: 5 })
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Talk to your project's agents from the app (an agent the team opened to app users). The answer streams;
|
|
185
|
+
pass `threadId` to continue the conversation.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const reply = await sov.agents.run(agentId, { message: 'Where is my order?', onEvent: (e) => render(e) })
|
|
189
|
+
await sov.agents.run(agentId, { message: 'And the invoice?', threadId: reply.threadId })
|
|
190
|
+
for await (const event of sov.agents.stream(agentId, { message: 'Summarise my plan' })) { /* text_delta, tool_call, done */ }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Managing knowledge bases and the embedding model (your own OpenAI/Gemini/compatible key, or Sovaria's):
|
|
194
|
+
`sov.api.knowledge.*` and `sov.api.embeddings.*` (`getSettings`, `setSettings`, `usage` in tokens and dollars).
|
|
195
|
+
|
|
196
|
+
## Auth lanes
|
|
197
|
+
|
|
198
|
+
| Method | Path | Lane | Session KEK |
|
|
199
|
+
| --- | --- | --- | --- |
|
|
200
|
+
| `auth.login` (**deprecated**) | `/auth/console/login` unscoped, `/auth/project/login` when scoped | compatibility | derived when `argon2_salt` + password |
|
|
201
|
+
| `auth.consoleLogin` / `auth.loginConsole` | `/auth/console/login` | console operator | derived when salt present |
|
|
202
|
+
| `auth.projectLogin` / `auth.loginProduct` | `/auth/project/login` | product user | derived when salt present |
|
|
203
|
+
| `auth.register` | `/auth/register` | depends on scope | derived when tokens + salt returned |
|
|
204
|
+
| `auth.verifyOtp` / `auth.verifyMagicLink` / `auth.oauthHandoff` | passwordless | product/console | **not** derived unless you pass `password` |
|
|
205
|
+
| `auth.listUsers` (**deprecated**) | warns; product-users paths | n/a | n/a |
|
|
206
|
+
| `auth.consoleMembers.list` | `/org/{orgId}/members` | console member admin | n/a |
|
|
207
|
+
| `auth.listConsoleProductUsers` | `/auth/product-users` | console operator | n/a |
|
|
208
|
+
| `auth.productUsers.list` | `/projects/{id}/auth/users` | project admin + `setScope` | n/a |
|
|
209
|
+
|
|
210
|
+
`auth.logout(refreshToken?)` defaults to the stored refresh token, sends Bearer,
|
|
211
|
+
and always clears tokens/KEK (even when HTTP logout fails). Default
|
|
212
|
+
`onTokenRefresh` omits `refresh_token`; use `onTokenRefreshSensitive` only if you
|
|
213
|
+
must persist it.
|
|
214
|
+
|
|
215
|
+
Refresh has two supported modes. Do not mix them:
|
|
216
|
+
|
|
217
|
+
- **SDK / CLI:** JSON body `{"refresh_token": "..."}`.
|
|
218
|
+
- **Console / browser:** empty JSON body plus the HttpOnly refresh cookie.
|
|
219
|
+
|
|
220
|
+
E2EE (`hash-wasm` 4.12.0, pinned) needs CSP `wasm-unsafe-eval` in browsers.
|
|
221
|
+
|
|
222
|
+
## Coverage
|
|
223
|
+
|
|
224
|
+
- Axis: auth, context, graph
|
|
225
|
+
- Atlas-Doc: doc put/get/delete/scan
|
|
226
|
+
- Atlas-Rel: SQL + table lifecycle
|
|
227
|
+
- Pulse: stream + e2ee + websocket URL helper
|
|
228
|
+
- Guard: policies
|
|
229
|
+
- Echo: audit + simulation
|
|
230
|
+
- Chrono: timeline/snapshot/variance APIs
|
|
231
|
+
- Gateway: engines health
|
|
232
|
+
|
|
233
|
+
## Escape Hatch
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
await sdk.request('GET', '/api/v1/system/engines/health', { requiresAuth: false })
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Functions + Storage Pass-Through
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// Forge
|
|
243
|
+
const deployStatus = await sdk.functions.status('deploy-123')
|
|
244
|
+
|
|
245
|
+
// Vault
|
|
246
|
+
await sdk.storage.putJson('kv/app/settings', { theme: 'light' })
|
|
247
|
+
const settings = await sdk.storage.get('kv/app/settings')
|
|
248
|
+
await sdk.storage.delete('kv/app/settings')
|
|
249
|
+
```
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product analytics: events from your app, stored next to your project and queryable in the console
|
|
3
|
+
* (Analytics), the CLI (`sov analytics`) and Sovariq.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* sov.analytics.track('purchase', { amount: 20, plan: 'pro' })
|
|
7
|
+
* sov.analytics.screen('Checkout')
|
|
8
|
+
* sov.analytics.identify({ plan: 'pro', team_size: 4 }) // user properties
|
|
9
|
+
* await sov.analytics.setEnabled(false) // the user opted out (stops collection everywhere)
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Events are queued (persisted when you pass a storage), sent in batches every 10 seconds or 20 events, and
|
|
13
|
+
* kept while offline. The signed-in user comes from the request's token on the server — never from the event.
|
|
14
|
+
* A new session (and `$session_start`) begins after 30 minutes without events; the first event of a run sends
|
|
15
|
+
* `$app_open`. Properties are flat scalars; emails, secret-looking keys and long digit runs are dropped server-side.
|
|
16
|
+
*/
|
|
17
|
+
import type { OfflineStorage } from './offline.js';
|
|
18
|
+
export type AnalyticsProps = Record<string, string | number | boolean | null>;
|
|
19
|
+
export interface AnalyticsEvent {
|
|
20
|
+
name: string;
|
|
21
|
+
ts: string;
|
|
22
|
+
props?: AnalyticsProps;
|
|
23
|
+
user_props?: AnalyticsProps;
|
|
24
|
+
session_id?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface AnalyticsOptions {
|
|
27
|
+
/** Where the queue, device id and opt-out survive restarts (indexedDbStorage(), keyValueStorage(AsyncStorage)…). */
|
|
28
|
+
storage?: OfflineStorage;
|
|
29
|
+
flushIntervalMs?: number;
|
|
30
|
+
batchSize?: number;
|
|
31
|
+
/** `$session_start` / `$app_open` events (default true). */
|
|
32
|
+
autoSessions?: boolean;
|
|
33
|
+
appVersion?: string;
|
|
34
|
+
platform?: string;
|
|
35
|
+
/** Start disabled until the user agrees (for consent-first apps). */
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
maxQueue?: number;
|
|
38
|
+
}
|
|
39
|
+
type Send = (body: {
|
|
40
|
+
events: AnalyticsEvent[];
|
|
41
|
+
context: Record<string, unknown>;
|
|
42
|
+
}) => Promise<unknown>;
|
|
43
|
+
type Consent = (granted: boolean, anonId: string, erase: boolean) => Promise<unknown>;
|
|
44
|
+
export declare class Analytics {
|
|
45
|
+
private readonly send;
|
|
46
|
+
private readonly consent;
|
|
47
|
+
private options;
|
|
48
|
+
private queue;
|
|
49
|
+
private timer?;
|
|
50
|
+
private sessionId;
|
|
51
|
+
private lastEventAt;
|
|
52
|
+
private opened;
|
|
53
|
+
private enabled;
|
|
54
|
+
private deviceId;
|
|
55
|
+
private loaded?;
|
|
56
|
+
private minute;
|
|
57
|
+
private sentThisMinute;
|
|
58
|
+
private sending;
|
|
59
|
+
constructor(send: Send, consent: Consent);
|
|
60
|
+
/** Optional: persistence, intervals, app version. Call before the first `track` to restore a saved queue. */
|
|
61
|
+
configure(options: AnalyticsOptions): void;
|
|
62
|
+
/** The device id sent as `anon_id` (stable per install when a storage is configured). */
|
|
63
|
+
get anonId(): string;
|
|
64
|
+
private restore;
|
|
65
|
+
private persist;
|
|
66
|
+
/** Record an event. Returns false when it was dropped (disabled, bad name, or over 300 a minute). */
|
|
67
|
+
track(name: string, props?: AnalyticsProps): boolean;
|
|
68
|
+
/** Record a screen or page view (`$screen`). */
|
|
69
|
+
screen(name: string, props?: AnalyticsProps): boolean;
|
|
70
|
+
/** Set user properties (`$identify`); merged with what the user already has. */
|
|
71
|
+
identify(userProps: AnalyticsProps): boolean;
|
|
72
|
+
/** One `$exposure` per experiment and session (the remote config client calls this). */
|
|
73
|
+
private exposed;
|
|
74
|
+
trackExposure(experiment: string, variant: string): void;
|
|
75
|
+
private push;
|
|
76
|
+
private schedule;
|
|
77
|
+
/** Send everything queued now (also on page hide / app background). Network errors keep the events. */
|
|
78
|
+
flush(): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Turn collection on or off for this user (DPDP / GDPR consent). Off also tells the server, so the user's
|
|
81
|
+
* events are dropped on every device; `erase` deletes what was already stored.
|
|
82
|
+
*/
|
|
83
|
+
setEnabled(enabled: boolean, opts?: {
|
|
84
|
+
erase?: boolean;
|
|
85
|
+
}): Promise<void>;
|
|
86
|
+
isEnabled(): boolean;
|
|
87
|
+
/** Forget the device id and session (on sign-out); the next event starts a new anonymous identity. */
|
|
88
|
+
reset(): Promise<void>;
|
|
89
|
+
/** Events waiting to be sent. */
|
|
90
|
+
pending(): number;
|
|
91
|
+
}
|
|
92
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product analytics: events from your app, stored next to your project and queryable in the console
|
|
3
|
+
* (Analytics), the CLI (`sov analytics`) and Sovariq.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* sov.analytics.track('purchase', { amount: 20, plan: 'pro' })
|
|
7
|
+
* sov.analytics.screen('Checkout')
|
|
8
|
+
* sov.analytics.identify({ plan: 'pro', team_size: 4 }) // user properties
|
|
9
|
+
* await sov.analytics.setEnabled(false) // the user opted out (stops collection everywhere)
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Events are queued (persisted when you pass a storage), sent in batches every 10 seconds or 20 events, and
|
|
13
|
+
* kept while offline. The signed-in user comes from the request's token on the server — never from the event.
|
|
14
|
+
* A new session (and `$session_start`) begins after 30 minutes without events; the first event of a run sends
|
|
15
|
+
* `$app_open`. Properties are flat scalars; emails, secret-looking keys and long digit runs are dropped server-side.
|
|
16
|
+
*/
|
|
17
|
+
const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
18
|
+
const KEY_QUEUE = 'sovaria.analytics.queue';
|
|
19
|
+
const KEY_DEVICE = 'sovaria.analytics.device';
|
|
20
|
+
const KEY_OPTOUT = 'sovaria.analytics.optout';
|
|
21
|
+
const MAX_PER_MINUTE = 300;
|
|
22
|
+
function randomId() {
|
|
23
|
+
const c = globalThis.crypto;
|
|
24
|
+
if (c?.randomUUID)
|
|
25
|
+
return c.randomUUID();
|
|
26
|
+
return 'd' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
27
|
+
}
|
|
28
|
+
function detectPlatform() {
|
|
29
|
+
const g = globalThis;
|
|
30
|
+
if (g.navigator?.product === 'ReactNative')
|
|
31
|
+
return 'react-native';
|
|
32
|
+
if (typeof g.window !== 'undefined' && typeof g.document !== 'undefined')
|
|
33
|
+
return 'web';
|
|
34
|
+
if (g.process?.versions?.node)
|
|
35
|
+
return 'server';
|
|
36
|
+
return 'other';
|
|
37
|
+
}
|
|
38
|
+
export class Analytics {
|
|
39
|
+
send;
|
|
40
|
+
consent;
|
|
41
|
+
options = {};
|
|
42
|
+
queue = [];
|
|
43
|
+
timer;
|
|
44
|
+
sessionId = '';
|
|
45
|
+
lastEventAt = 0;
|
|
46
|
+
opened = false;
|
|
47
|
+
enabled = true;
|
|
48
|
+
deviceId = '';
|
|
49
|
+
loaded;
|
|
50
|
+
minute = 0;
|
|
51
|
+
sentThisMinute = 0;
|
|
52
|
+
sending = false;
|
|
53
|
+
constructor(send, consent) {
|
|
54
|
+
this.send = send;
|
|
55
|
+
this.consent = consent;
|
|
56
|
+
}
|
|
57
|
+
/** Optional: persistence, intervals, app version. Call before the first `track` to restore a saved queue. */
|
|
58
|
+
configure(options) {
|
|
59
|
+
this.options = { ...this.options, ...options };
|
|
60
|
+
if (options.enabled !== undefined)
|
|
61
|
+
this.enabled = options.enabled;
|
|
62
|
+
this.loaded = this.restore();
|
|
63
|
+
}
|
|
64
|
+
/** The device id sent as `anon_id` (stable per install when a storage is configured). */
|
|
65
|
+
get anonId() {
|
|
66
|
+
if (!this.deviceId)
|
|
67
|
+
this.deviceId = randomId();
|
|
68
|
+
return this.deviceId;
|
|
69
|
+
}
|
|
70
|
+
async restore() {
|
|
71
|
+
const s = this.options.storage;
|
|
72
|
+
if (!s)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
const [q, d, o] = await Promise.all([s.get(KEY_QUEUE), s.get(KEY_DEVICE), s.get(KEY_OPTOUT)]);
|
|
76
|
+
if (d)
|
|
77
|
+
this.deviceId = d;
|
|
78
|
+
else
|
|
79
|
+
await s.set(KEY_DEVICE, this.anonId);
|
|
80
|
+
if (o === '1')
|
|
81
|
+
this.enabled = false;
|
|
82
|
+
if (q)
|
|
83
|
+
this.queue = [...JSON.parse(q), ...this.queue].slice(-(this.options.maxQueue ?? 1000));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* storage is best effort */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async persist() {
|
|
90
|
+
try {
|
|
91
|
+
await this.options.storage?.set(KEY_QUEUE, JSON.stringify(this.queue));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* best effort */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Record an event. Returns false when it was dropped (disabled, bad name, or over 300 a minute). */
|
|
98
|
+
track(name, props) {
|
|
99
|
+
if (!this.enabled || !name || name.length > 64)
|
|
100
|
+
return false;
|
|
101
|
+
return this.push({ name, props });
|
|
102
|
+
}
|
|
103
|
+
/** Record a screen or page view (`$screen`). */
|
|
104
|
+
screen(name, props) {
|
|
105
|
+
if (!this.enabled)
|
|
106
|
+
return false;
|
|
107
|
+
return this.push({ name: '$screen', props: { ...(props ?? {}), screen: String(name).slice(0, 200) } });
|
|
108
|
+
}
|
|
109
|
+
/** Set user properties (`$identify`); merged with what the user already has. */
|
|
110
|
+
identify(userProps) {
|
|
111
|
+
if (!this.enabled)
|
|
112
|
+
return false;
|
|
113
|
+
return this.push({ name: '$identify', user_props: userProps });
|
|
114
|
+
}
|
|
115
|
+
/** One `$exposure` per experiment and session (the remote config client calls this). */
|
|
116
|
+
exposed = new Set();
|
|
117
|
+
trackExposure(experiment, variant) {
|
|
118
|
+
const key = `${experiment}:${variant}`;
|
|
119
|
+
if (this.exposed.has(key) && Date.now() - this.lastEventAt <= SESSION_GAP_MS)
|
|
120
|
+
return;
|
|
121
|
+
this.exposed.add(key);
|
|
122
|
+
this.push({ name: '$exposure', props: { experiment, variant } });
|
|
123
|
+
}
|
|
124
|
+
push(event) {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const minute = Math.floor(now / 60_000);
|
|
127
|
+
if (minute !== this.minute) {
|
|
128
|
+
this.minute = minute;
|
|
129
|
+
this.sentThisMinute = 0;
|
|
130
|
+
}
|
|
131
|
+
if (this.sentThisMinute >= MAX_PER_MINUTE)
|
|
132
|
+
return false;
|
|
133
|
+
this.sentThisMinute++;
|
|
134
|
+
if (!this.sessionId || now - this.lastEventAt > SESSION_GAP_MS) {
|
|
135
|
+
this.sessionId = randomId();
|
|
136
|
+
if (event.name !== '$exposure')
|
|
137
|
+
this.exposed.clear();
|
|
138
|
+
if (this.options.autoSessions !== false && !event.name.startsWith('$session')) {
|
|
139
|
+
this.queue.push({ name: '$session_start', ts: new Date(now).toISOString(), session_id: this.sessionId });
|
|
140
|
+
if (!this.opened) {
|
|
141
|
+
this.opened = true;
|
|
142
|
+
this.queue.push({ name: '$app_open', ts: new Date(now).toISOString(), session_id: this.sessionId });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.lastEventAt = now;
|
|
147
|
+
this.queue.push({ ...event, ts: new Date(now).toISOString(), session_id: this.sessionId });
|
|
148
|
+
const max = this.options.maxQueue ?? 1000;
|
|
149
|
+
if (this.queue.length > max)
|
|
150
|
+
this.queue.splice(0, this.queue.length - max);
|
|
151
|
+
void this.persist();
|
|
152
|
+
if (this.queue.length >= (this.options.batchSize ?? 20))
|
|
153
|
+
void this.flush();
|
|
154
|
+
else
|
|
155
|
+
this.schedule();
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
schedule() {
|
|
159
|
+
if (this.timer)
|
|
160
|
+
return;
|
|
161
|
+
this.timer = setTimeout(() => {
|
|
162
|
+
this.timer = undefined;
|
|
163
|
+
void this.flush();
|
|
164
|
+
}, this.options.flushIntervalMs ?? 10_000);
|
|
165
|
+
}
|
|
166
|
+
/** Send everything queued now (also on page hide / app background). Network errors keep the events. */
|
|
167
|
+
async flush() {
|
|
168
|
+
await this.loaded;
|
|
169
|
+
if (this.timer) {
|
|
170
|
+
clearTimeout(this.timer);
|
|
171
|
+
this.timer = undefined;
|
|
172
|
+
}
|
|
173
|
+
if (this.sending || !this.enabled)
|
|
174
|
+
return;
|
|
175
|
+
this.sending = true;
|
|
176
|
+
try {
|
|
177
|
+
while (this.queue.length) {
|
|
178
|
+
const batch = this.queue.slice(0, 100);
|
|
179
|
+
try {
|
|
180
|
+
await this.send({ events: batch, context: { anon_id: this.anonId, platform: this.options.platform ?? detectPlatform(),
|
|
181
|
+
app_version: this.options.appVersion } });
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const status = error.statusCode ?? 0;
|
|
185
|
+
if (status === 0 || status === 429 || status >= 500) {
|
|
186
|
+
this.schedule(); // offline or throttled: keep them and try later
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// 4xx: the server refused this batch (bad data, disabled): drop it, never loop
|
|
190
|
+
}
|
|
191
|
+
this.queue.splice(0, batch.length);
|
|
192
|
+
await this.persist();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
this.sending = false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Turn collection on or off for this user (DPDP / GDPR consent). Off also tells the server, so the user's
|
|
201
|
+
* events are dropped on every device; `erase` deletes what was already stored.
|
|
202
|
+
*/
|
|
203
|
+
async setEnabled(enabled, opts = {}) {
|
|
204
|
+
this.enabled = enabled;
|
|
205
|
+
if (!enabled) {
|
|
206
|
+
this.queue = [];
|
|
207
|
+
await this.persist();
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await this.options.storage?.set(KEY_OPTOUT, enabled ? '0' : '1');
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* best effort */
|
|
214
|
+
}
|
|
215
|
+
await this.consent(enabled, this.anonId, Boolean(opts.erase));
|
|
216
|
+
}
|
|
217
|
+
isEnabled() {
|
|
218
|
+
return this.enabled;
|
|
219
|
+
}
|
|
220
|
+
/** Forget the device id and session (on sign-out); the next event starts a new anonymous identity. */
|
|
221
|
+
async reset() {
|
|
222
|
+
this.deviceId = randomId();
|
|
223
|
+
this.sessionId = '';
|
|
224
|
+
this.exposed.clear();
|
|
225
|
+
try {
|
|
226
|
+
await this.options.storage?.set(KEY_DEVICE, this.deviceId);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* best effort */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Events waiting to be sent. */
|
|
233
|
+
pending() {
|
|
234
|
+
return this.queue.length;
|
|
235
|
+
}
|
|
236
|
+
}
|