luchy 0.2.0 → 1.1.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/README.md CHANGED
@@ -45,15 +45,21 @@ npm install luchy
45
45
 
46
46
  ## Data Attributes
47
47
 
48
- | Attribute | Description | Required |
49
- | ---------------------- | --------------------------------------- | -------- |
50
- | `data-api-key` | Your Luchy API key | ✅ Yes |
51
- | `data-endpoint` | Custom API endpoint | ❌ No |
52
- | `data-auto-pageviews` | Enable automatic pageview tracking | ❌ No |
53
- | `data-auto-outbound` | Enable automatic outbound link tracking | ❌ No |
54
- | `data-auto-events` | Enable automatic data attribute event tracking | ❌ No |
55
- | `data-hash-routing` | Enable hash routing support | ❌ No |
56
- | `data-track-localhost` | Track localhost traffic | ❌ No |
48
+ | Attribute | Description | Required |
49
+ | ---------------------- | --------------------------------------------------------------- | -------- |
50
+ | `data-api-key` | Your Luchy API key | ✅ Yes |
51
+ | `data-endpoint` | Custom ingest endpoint | ❌ No |
52
+ | `data-identity` | Signed identity token (see [Identity](#identity)) | ❌ No |
53
+ | `data-props` | JSON object merged into the payload of every event and pageview | ❌ No |
54
+ | `data-auto-pageviews` | Enable automatic pageview tracking | ❌ No |
55
+ | `data-auto-outbound` | Enable automatic outbound link tracking | ❌ No |
56
+ | `data-auto-events` | Enable automatic data attribute event tracking | ❌ No |
57
+ | `data-hash-routing` | Enable hash routing support | ❌ No |
58
+ | `data-track-localhost` | Track localhost traffic | ❌ No |
59
+
60
+ `data-identity` and `data-props` are read from the script tag on **every**
61
+ send, not once at load, so a server-rendered tag that is re-rendered after
62
+ login or logout is picked up without a reload.
57
63
 
58
64
  ## Data Attribute Events
59
65
 
@@ -64,7 +70,11 @@ Track custom events without JavaScript by adding `data-luchy-event` to any HTML
64
70
  <button data-luchy-event="cta-click">Sign Up</button>
65
71
 
66
72
  <!-- With a payload (data-luchy-payload-*) -->
67
- <a data-luchy-event="post-click" data-luchy-payload-slug="hello-world" href="/blog/hello-world">
73
+ <a
74
+ data-luchy-event="post-click"
75
+ data-luchy-payload-slug="hello-world"
76
+ href="/blog/hello-world"
77
+ >
68
78
  Read Post
69
79
  </a>
70
80
 
@@ -99,53 +109,121 @@ window.luchy.trackEvent('Button Click', {
99
109
  page: 'home'
100
110
  });
101
111
 
102
- // Enable auto-tracking features
103
- window.luchy.enableAutoPageviews();
104
- window.luchy.enableAutoOutboundTracking();
105
- window.luchy.enableHashRouting();
112
+ // Toggle auto-tracking features
113
+ window.luchy.setOptions({ autoPageviews: true, hashRouting: false });
114
+
115
+ // SPAs that learn who the user is after load: attribute everything from now
116
+ // on to a signed token (rendered by your server), with optional props.
117
+ window.luchy.identify(token, { role: 'admin' });
118
+
119
+ // Back to anonymous, e.g. on logout.
120
+ window.luchy.reset();
106
121
  ```
107
122
 
108
- ## Server-Side Tracking
123
+ `identify`/`reset` write through to the script tag's `data-identity` /
124
+ `data-props`, so the tag stays the single source of truth.
109
125
 
110
- The browser script can only see what happens in a page. A server sees what the
111
- application actually *did* — an order placed, a payment recorded, a webhook
112
- processed — and those are usually the events worth having.
126
+ ## Identity
127
+
128
+ Luchy ties hits to your users only when your server vouches for them. An
129
+ identity is `{ user, actor?, tenant? }` — `user` is the account being viewed
130
+ as, `actor` the person actually acting when they differ (impersonation),
131
+ `tenant` your own workspace/client id. Everything else (role, plan, …) goes in
132
+ props.
113
133
 
114
- `luchy/server` carries no browser globals, so it runs in Node, Bun,
115
- Deno and Cloudflare Workers:
134
+ The server signs it with the project's secret key (`lsk_…`, from the
135
+ dashboard settings):
116
136
 
117
137
  ```ts
118
- import { createServerTracker } from 'luchy/server';
138
+ import { signIdentity } from 'luchy/server';
119
139
 
120
- const luchy = createServerTracker({
121
- apiKey: process.env.LUCHY_API_KEY!,
122
- // optional: point at a self-hosted dashboard
123
- endpoint: 'https://dash.luchy.app/api/ingest',
124
- onError: (error) => logger.warn('[luchy] event dropped', error)
125
- });
140
+ const token = await signIdentity(
141
+ { user: user.id, tenant: String(workspace.id) },
142
+ env.LUCHY_SECRET
143
+ );
144
+ ```
126
145
 
127
- await luchy.trackEvent({
128
- name: 'order:placed',
129
- pathname: '/checkout',
130
- type: 'server',
131
- payload: { plan: 'pro', amount: 29 }
132
- });
146
+ The token is `base64url(JSON({ u, a?, t? })).base64url(HMAC-SHA256)`, signed
147
+ with WebCrypto (Workers, Node ≥ 18, Bun, Deno). Ingest verifies it; a missing,
148
+ unsigned or tampered token stores the hit anonymous — it is never rejected. The
149
+ secret never leaves your server: only the token is rendered into the page.
150
+
151
+ ## Person profiles
152
+
153
+ `user` is a stable id (`usr_7Hq2mXk9`, `42`), not something to read. Profile
154
+ traits — a name, an email, a plan — are stored once per person with
155
+ `identify`, server-side, authenticated with the secret key:
156
+
157
+ ```ts
158
+ await luchy.identify(user.id, { name: user.name, email: user.email });
133
159
  ```
134
160
 
135
- Nothing here ever rejects — analytics must not break the request it is
136
- describing. Pass `onError` if you want failures in your logs.
161
+ Traits merge into what is stored; `null` deletes one. The dashboard labels a
162
+ person (and a support agent helping them) by `traits.name`, else
163
+ `traits.email`, else the id. The wire call is
164
+ `POST /api/ingest/identify` with `Authorization: Bearer <lsk_…>` and body
165
+ `{ user, traits }`; the public ingest key is rejected there.
166
+
167
+ ## Server-Side Tracking (`luchy/server`)
137
168
 
138
- Fire it after the response so it costs no latency. On Cloudflare Workers:
169
+ The browser script can only see what happens in a page. A server sees what the
170
+ application actually _did_ — an order placed, a payment recorded, a webhook
171
+ processed — and those are usually the events worth having. `luchy/server`
172
+ carries no browser globals, so it runs in Node, Bun, Deno and Cloudflare
173
+ Workers:
139
174
 
140
175
  ```ts
141
- ctx.waitUntil(luchy.trackEvent({ name: 'order:placed', pathname: '/checkout' }));
176
+ import { createLuchy } from 'luchy/server';
177
+
178
+ const luchy = createLuchy({
179
+ apiKey: env.LUCHY_API_KEY,
180
+ // optional: signs identities; without it identities are not sent
181
+ secret: env.LUCHY_SECRET,
182
+ // optional: the ingest base of a self-hosted dashboard
183
+ endpoint: 'https://dash.luchy.app/api/ingest',
184
+ // optional: false turns every call into a no-op
185
+ enabled: env.APP_ENV === 'production',
186
+ onError: (error) => logger.warn('[luchy] event dropped', error)
187
+ });
188
+
189
+ ctx.waitUntil(
190
+ luchy.track(
191
+ {
192
+ name: 'order:placed',
193
+ pathname: '/checkout',
194
+ type: 'server',
195
+ payload: { plan: 'pro', amount: 29 },
196
+ // forward the end user's request so the hit joins their session
197
+ userAgent: request.headers.get('user-agent') ?? undefined,
198
+ ip: request.headers.get('cf-connecting-ip') ?? undefined,
199
+ country: request.cf?.country,
200
+ language: 'es-DO'
201
+ },
202
+ { user: user.id, tenant: String(workspace.id) }
203
+ )
204
+ );
205
+
206
+ await luchy.pageview({ pathname: '/pricing', payload: { plan: 'pro' } });
142
207
  ```
143
208
 
209
+ - `identify(user, traits)` stores profile traits (see
210
+ [Person profiles](#person-profiles)). It needs `secret`; without it the call
211
+ is a no-op and `onError` hears about it once.
212
+ - `track(event, identity?)` and `pageview(pageview, identity?)` take an
213
+ identity object (signed with `secret`) or an already-signed token string.
214
+ - **Without `secret`, an identity object is not sent at all** — ingest would
215
+ drop it unsigned anyway, so the hit is plainly anonymous.
216
+ - `ip` is only used to compute the visitor hash and is never stored. Without
217
+ it, ingest uses the sender's own `cf-connecting-ip` — which, for a server,
218
+ is the server.
219
+ - Nothing ever rejects — analytics must not break the request it is
220
+ describing. Pass `onError` if you want failures in your logs.
221
+
144
222
  ## API client (`luchy/api`)
145
223
 
146
- `createServerTracker` covers the two calls most apps make. `luchy/api`
224
+ `createLuchy` covers the two calls most apps make. `luchy/api`
147
225
  is the whole API: a typed client generated from the dashboard's own OpenAPI
148
- document, with tracking *and* reading on it. It has no DOM globals and no node
226
+ document, with tracking _and_ reading on it. It has no DOM globals and no node
149
227
  built-ins, so the same import works on a server and in a browser.
150
228
 
151
229
  ```ts
@@ -166,10 +244,22 @@ await luchy.trackEvent({
166
244
  });
167
245
 
168
246
  // Reads do — a chart with silently missing numbers is worse than an error.
169
- const { results } = await luchy.query({
170
- date_range: '30d',
171
- metrics: ['pageviews', 'visitors'],
172
- dimensions: ['event:page']
247
+ const { current, delta } = await luchy.query({
248
+ from: '2025-01-01T00:00:00Z',
249
+ to: '2025-02-01T00:00:00Z',
250
+ metrics: ['events', 'users'],
251
+ breakdown: 'tenant', // or page, user, props.<key>, hour, weekday, …
252
+ filters: [['props.role', 'eq', 'admin']],
253
+ compare: 'previous_period'
254
+ });
255
+
256
+ // Anything the engine can't express: one read-only SELECT/WITH over the whole
257
+ // database (not scoped to a project). The tables are documented in the
258
+ // method's description: https://dash.luchy.app/llms.txt
259
+ const { rows } = await luchy.sql({
260
+ query:
261
+ 'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
262
+ params: ['project_id']
173
263
  });
174
264
  ```
175
265
 
@@ -184,13 +274,18 @@ const { data, error } = await luchy.api.GET('/health');
184
274
 
185
275
  The request and response types come from the document too, so they are worth
186
276
  importing rather than restating: `EventInput`, `PageviewInput`,
187
- `IngestSuccess`, `QueryRequest`, `QueryResponse`, plus the raw `paths` and
188
- `components`.
277
+ `IngestSuccess`, `QueryRequest`, `QueryResponse`, `SqlRequest`, `SqlResponse`,
278
+ `MethodError`, plus the raw `paths` and `components`.
279
+
280
+ Reads use a query key (`wak_…`) minted in the dashboard under Keys. They call
281
+ the dashboard's analytics methods (`POST /api/analytics.query`,
282
+ `/api/analytics.sql`, …), the same ones its MCP server at
283
+ `https://dash.luchy.app/mcp` exposes to agents.
189
284
 
190
285
  ### Where the types come from
191
286
 
192
287
  ```
193
- apps/dash zod route schemas
288
+ apps/dash ingest routes (zod) + kit methods (app/kit)
194
289
  → bun run openapi:emit (in apps/dash)
195
290
  → packages/tracker/openapi.json
196
291
  → bun run generate (in packages/tracker)
@@ -200,67 +295,127 @@ apps/dash zod route schemas
200
295
  `openapi.json` is checked in: it is the wire contract, so a change to the API's
201
296
  shape shows up as a reviewable diff in the commit that caused it, and the
202
297
  client can be regenerated without a dashboard running anywhere. `schema.d.ts`
203
- is generated too — never edit either by hand. Change the zod schemas in
298
+ is generated too — never edit either by hand. Change the schemas in
204
299
  `apps/dash`, re-run both steps, commit the result.
205
300
 
206
- ## React Router on Cloudflare (`luchy/react-router`)
301
+ ## React Router (`luchy/react-router`)
302
+
303
+ For React Router v7.9+ apps with middleware. Three pieces:
304
+
305
+ - `luchyMiddleware` — a root route middleware. Every mutation React Router
306
+ serves becomes a server event, attributed to the request's identity.
307
+ - `getLuchy(context)` — for the root loader: the browser script's config, with
308
+ the identity already signed.
309
+ - `<LuchyScript {...luchy} />` — renders the browser script with
310
+ `data-identity` / `data-props`, so server events and browser pageviews land
311
+ in the same session.
207
312
 
208
313
  A React Router app already tells you what it did — in the request. Every
209
314
  console mutation is a form POST whose `intent` field names it (`rotate`,
210
315
  `invite-member`, `create-api-key`, …), every JSON API mutation is
211
316
  discriminated by its method, and every auth verb has a path that names the
212
- operation. So the event does not need to be emitted by hand from a route
213
- module, where it is only tracked once somebody remembers to instrument it: the
214
- Worker *derives* it from the request it is already holding. One hook, zero
215
- per-route code, and a new intent is tracked the day it is written.
216
-
217
- Two lines in the Worker's `fetch`:
218
-
219
- ```ts
220
- import { createRequestTracker } from 'luchy/react-router';
221
-
222
- const tracker = createRequestTracker({
223
- apiKey: LUCHY_API_KEY,
224
- enabled: env.APP_ENV === 'production'
225
- });
226
-
227
- export default {
228
- async fetch(request, env, ctx) {
229
- const finish = tracker.begin(request);
230
- const response = await requestHandler(request, loadContext);
231
- finish(response, ctx);
232
- return response;
233
- }
234
- } satisfies ExportedHandler<Env>;
317
+ operation. So the event is _derived_ from the request instead of emitted by
318
+ hand from route modules: zero per-route code, and a new intent is tracked the
319
+ day it is written.
320
+
321
+ ```tsx
322
+ // app/root.tsx
323
+ import { getLuchy, luchyMiddleware, LuchyScript } from 'luchy/react-router';
324
+
325
+ export const middleware: Route.MiddlewareFunction[] = [
326
+ luchyMiddleware({
327
+ apiKey: env.LUCHY_API_KEY,
328
+ secret: env.LUCHY_SECRET,
329
+ enabled: env.APP_ENV === 'production',
330
+ // How to keep the event alive after the response. Nothing here imports
331
+ // `cloudflare:workers`; hand it whatever your runtime has.
332
+ waitUntil:
333
+ ({ context }) =>
334
+ (promise) =>
335
+ context.get(cfContext).ctx.waitUntil(promise),
336
+ identity: async ({ context }) => {
337
+ const s = await context.get(appContext).getMaybeSessionContext();
338
+ if (!s) {
339
+ return undefined;
340
+ }
341
+
342
+ return {
343
+ user: s.user.id,
344
+ tenant: String(s.workspaceId),
345
+ props: { role: s.role },
346
+ traits: { name: s.user.name, email: s.user.email }
347
+ };
348
+ }
349
+ })
350
+ ];
351
+
352
+ export async function loader({ context }: Route.LoaderArgs) {
353
+ return { luchy: await getLuchy(context) };
354
+ }
355
+
356
+ export function Layout({ children }: { children: React.ReactNode }) {
357
+ const data = useRouteLoaderData<typeof loader>('root');
358
+
359
+ return (
360
+ <html lang="en">
361
+ <head>
362
+ {/* … */}
363
+ {data ? <LuchyScript {...data.luchy} /> : null}
364
+ </head>
365
+ <body>{children}</body>
366
+ </html>
367
+ );
368
+ }
235
369
  ```
236
370
 
237
- `begin` has to run *before* React Router gets the request: it is what clones
238
- the body, and once the handler has read it, it is gone. `finish` schedules
239
- everything else on `ctx.waitUntil`, so nothing about analytics is on the
240
- response's critical path, and nothing it does can throw into your handler.
241
-
242
- Events come out named `route:intent` — `team/:id:invite-member`,
243
- `applications:create` — with ids collapsed to `:id`, React Router's
244
- single-fetch `.data` suffix stripped (so a hydrated and a non-hydrated
245
- submission report the same name), and `/` mapped to `home`. The payload always
246
- carries `status`.
247
-
248
- | Option | Default | What it does |
249
- | --- | --- | --- |
250
- | `apiKey` | — | Luchy API key. The public ingest key is fine. |
251
- | `endpoint` | hosted API | API root, without a trailing slash. |
252
- | `enabled` | `true` | When false, everything is a no-op — no clone, no network. |
253
- | `ignorePrefixes` | `['/__manifest']` | Raw-pathname prefixes to drop. Yours are added to the default, not swapped for it. |
254
- | `ignoreRouteSuffixes` | `[]` | Normalized-route suffixes to drop, e.g. `/user-keys/validate`. |
255
- | `ignoreEvents` | `[]` | Fully-formed event names to drop, e.g. `notifications:markRead`. |
256
- | `trackFailures` | `false` | When true, 4xx/5xx are tracked too (tell them apart by `status`). |
257
- | `methodSuffix` | `false` | When true, an intent-less mutation is `route:post` / `route:delete` instead of bare `route`. |
258
- | `payload` | — | `(request, response) => payload`, merged over `status`. Runs inside `waitUntil`; a rejection costs the payload, not the event. |
259
- | `onError` | — | The only way to see failures. |
260
-
261
- The pieces are exported on their own too — `isMutatingMethod`,
262
- `carriesIntent`, `normalizeRoute`, `serverEventName` — if you want the naming
263
- without the hook.
371
+ How it behaves:
372
+
373
+ - Only mutations (`POST`/`PUT`/`PATCH`/`DELETE`) produce events. Reads are the
374
+ browser script's job.
375
+ - Events are named `route:intent` — `team/:id:invite-member`,
376
+ `applications:create` — with ids collapsed to `:id`, React Router's
377
+ single-fetch `.data` suffix stripped (so a hydrated and a non-hydrated
378
+ submission report the same name), and `/` mapped to `home`.
379
+ - The body is cloned before `next()` only when it can carry an `intent`
380
+ (urlencoded); the action still reads the original.
381
+ - Redirects and responses thrown by actions are tracked with their real status
382
+ and re-thrown untouched.
383
+ - The payload is the identity's `props` plus `status` (always the response
384
+ status — props cannot override it). The end user's `cf-connecting-ip`,
385
+ country (`request.cf.country` or `cf-ipcountry`), first `accept-language` tag
386
+ and user agent are forwarded.
387
+ - `identity` is resolved lazily and at most once per request: only when an
388
+ event ships or `getLuchy` asks. A rejection is reported to `onError` and the
389
+ request is treated as anonymous.
390
+ - `traits`, when the resolver returns them, are sent via `identify` through
391
+ the same `waitUntil` — at most once per isolate per hour for the same user
392
+ and traits (a small in-memory map, bounded at 1000 entries). Changed traits
393
+ go out on the next request. Needs `secret`; traits never reach the page.
394
+ - `<LuchyScript>` must be server-rendered — React does not execute `<script>`
395
+ elements it creates on the client. Re-renders after login/logout update its
396
+ attributes, and the script picks them up on the next send.
397
+ - Only requests React Router routes are seen. Anything your Worker answers
398
+ before handing over to React Router is invisible to the middleware.
399
+
400
+ | Option | Default | What it does |
401
+ | --------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
402
+ | `apiKey` | — | Luchy API key. The public ingest key is fine. |
403
+ | `secret` | — | Secret key. Without it no identity or traits are sent (props still are). |
404
+ | `endpoint` | hosted ingest | Ingest base, without a trailing slash. Also what `getLuchy` hands the script. |
405
+ | `enabled` | `true` | When false no events are produced — no clone, no network. `getLuchy` keeps working. |
406
+ | `waitUntil` | — | `(args) => (promise) => void`. Called only for requests that produce an event. |
407
+ | `identity` | — | `(args) => ({ user, actor?, tenant?, props?, traits? }) \| undefined`, sync or async. |
408
+ | `ignorePrefixes` | `['/__manifest']` | Raw-pathname prefixes to drop. Yours are added to the default. |
409
+ | `ignoreRouteSuffixes` | `[]` | Normalized-route suffixes to drop, e.g. `/user-keys/validate`. |
410
+ | `ignoreEvents` | `[]` | Fully-formed event names to drop, e.g. `notifications:markRead`. |
411
+ | `trackFailures` | `false` | When true, 4xx/5xx are tracked too (tell them apart by `status`). |
412
+ | `methodSuffix` | `false` | When true, an intent-less mutation is `route:post` / `route:delete` instead of bare `route`. |
413
+ | `onError` | — | The only way to see failures. |
414
+
415
+ `<LuchyScript>` also takes `src` (defaults to the CDN build) and `nonce`.
416
+
417
+ The naming pieces are exported on their own too — `isMutatingMethod`,
418
+ `carriesIntent`, `normalizeRoute`, `serverEventName`.
264
419
 
265
420
  ## Development
266
421
 
@@ -18,12 +18,40 @@ export type { components, paths };
18
18
  export type EventInput = components['schemas']['EventInput'];
19
19
  /** The body accepted by `POST /ingest/pageview`. */
20
20
  export type PageviewInput = components['schemas']['PageviewInput'];
21
+ /** The body accepted by `POST /ingest/identify` (secret key only). */
22
+ export type IdentifyInput = components['schemas']['IdentifyInput'];
21
23
  /** What both ingest endpoints answer with on success. */
22
24
  export type IngestSuccess = components['schemas']['SuccessResponse'];
23
- /** The body accepted by `POST /query`. */
24
- export type QueryRequest = components['schemas']['QueryRequest'];
25
- /** The result of a successful `POST /query`. */
26
- export type QueryResponse = components['schemas']['QueryResponse'];
25
+ type Body<P extends keyof paths> = paths[P] extends {
26
+ post: {
27
+ requestBody?: {
28
+ content: {
29
+ 'application/json': infer B;
30
+ };
31
+ };
32
+ };
33
+ } ? B : never;
34
+ type Result<P extends keyof paths> = paths[P] extends {
35
+ post: {
36
+ responses: {
37
+ 200: {
38
+ content: {
39
+ 'application/json': infer R;
40
+ };
41
+ };
42
+ };
43
+ };
44
+ } ? R : never;
45
+ /** The body accepted by `POST /analytics.query`. */
46
+ export type QueryRequest = Body<'/analytics.query'>;
47
+ /** The result of a successful `POST /analytics.query`. */
48
+ export type QueryResponse = Result<'/analytics.query'>;
49
+ /** The body accepted by `POST /analytics.sql`. */
50
+ export type SqlRequest = Body<'/analytics.sql'>;
51
+ /** The result of a successful `POST /analytics.sql`. */
52
+ export type SqlResponse = Result<'/analytics.sql'>;
53
+ /** A kit method's error body (`{ error }`, plus `fields` for invalid input). */
54
+ export type MethodError = components['schemas']['InvalidInput'];
27
55
  /** The `GET /health` body. */
28
56
  export type HealthResponse = components['schemas']['HealthResponse'];
29
57
  /** A 400 from any endpoint. */
@@ -53,21 +81,24 @@ export type LuchyClient = {
53
81
  /**
54
82
  * The underlying `openapi-fetch` client, pre-authenticated. Use it for
55
83
  * anything the convenience methods below do not cover; it is typed against
56
- * the full document, so `api.POST('/query', { body })` is checked end to end.
84
+ * the full document, so `api.POST('/analytics.query', { body })` is checked
85
+ * end to end.
57
86
  */
58
87
  api: LuchyApi;
59
88
  trackEvent(event: EventInput): Promise<IngestSuccess | null>;
60
89
  trackPageview(pageview: PageviewInput): Promise<IngestSuccess | null>;
61
90
  query(request: QueryRequest): Promise<QueryResponse>;
91
+ sql(request: SqlRequest): Promise<SqlResponse>;
62
92
  health(): Promise<HealthResponse>;
63
93
  };
64
94
  /**
65
95
  * Thrown by the endpoints where failing loudly is the right answer — reads
66
- * (`query`, `health`), as opposed to the fire-and-forget tracking calls.
96
+ * (`query`, `sql`, `health`), as opposed to the fire-and-forget
97
+ * tracking calls.
67
98
  *
68
99
  * ```ts
69
100
  * try {
70
- * await luchy.query({ date_range: '7d', metrics: ['pageviews'] });
101
+ * await luchy.sql({ query: 'SELECT COUNT(*) AS n FROM events' });
71
102
  * } catch (error) {
72
103
  * if (error instanceof LuchyApiError) {
73
104
  * console.error(error.status, error.body);
@@ -79,8 +110,8 @@ export declare class LuchyApiError extends Error {
79
110
  /** The HTTP status. Transport failures propagate as the runtime's own error, not this one. */
80
111
  readonly status: number;
81
112
  /** The parsed error body, when the API sent one. */
82
- readonly body: ValidationError | ServerError | undefined;
83
- constructor(message: string, status: number, body?: ValidationError | ServerError);
113
+ readonly body: ValidationError | ServerError | MethodError | undefined;
114
+ constructor(message: string, status: number, body?: ValidationError | ServerError | MethodError);
84
115
  }
85
116
  /**
86
117
  * Creates a Luchy API client.
@@ -103,8 +134,10 @@ export declare class LuchyApiError extends Error {
103
134
  * });
104
135
  *
105
136
  * const stats = await luchy.query({
106
- * date_range: '7d',
107
- * metrics: ['pageviews', 'visitors']
137
+ * from: '2025-01-01T00:00:00Z',
138
+ * to: '2025-02-01T00:00:00Z',
139
+ * metrics: ['pageviews', 'visitors'],
140
+ * compare: 'previous_period'
108
141
  * });
109
142
  * ```
110
143
  */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAqB,EAAE,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAElD;;;;;;;;;;;;GAYG;AAEH,YAAY,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;AAElC,iDAAiD;AACjD,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC;AAC7D,oDAAoD;AACpD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,CAAC;AACnE,yDAAyD;AACzD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,iBAAiB,CAAC,CAAC;AACrE,0CAA0C;AAC1C,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,cAAc,CAAC,CAAC;AACjE,gDAAgD;AAChD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,CAAC;AACnE,8BAA8B;AAC9B,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,gBAAgB,CAAC,CAAC;AACrE,+BAA+B;AAC/B,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,yBAAyB,CAAC,CAAC;AAC/E,+BAA+B;AAC/B,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,qBAAqB,CAAC,CAAC;AAEvE,kEAAkE;AAClE,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;AAErC,MAAM,MAAM,kBAAkB,GAAG;IAC/B,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB;;;;OAIG;IACH,GAAG,EAAE,QAAQ,CAAC;IACd,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IAC7D,aAAa,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IACtE,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACrD,MAAM,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;CACnC,CAAC;AAYF;;;;;;;;;;;;;GAaG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,GAAG,SAAS,CAAC;gBAGvD,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,eAAe,GAAG,WAAW;CAOvC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAoI1E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
package/dist/api/index.js CHANGED
@@ -75,20 +75,22 @@ function createLuchyClient(options) {
75
75
  );
76
76
  },
77
77
  /**
78
- * Runs an analytics query. Unlike the tracking calls this throws
79
- * `LuchyApiError` on a non-2xx, because a caller rendering a chart needs to
80
- * know the numbers are missing.
78
+ * Runs an analytics query against the key's project. Unlike the tracking
79
+ * calls this throws `LuchyApiError` on a non-2xx, because a caller
80
+ * rendering a chart needs to know the numbers are missing.
81
81
  *
82
82
  * ```ts
83
- * const { results } = await luchy.query({
84
- * date_range: '30d',
85
- * metrics: ['pageviews', 'visitors'],
86
- * dimensions: ['event:page']
83
+ * const { current } = await luchy.query({
84
+ * from: '2025-01-01T00:00:00Z',
85
+ * to: '2025-02-01T00:00:00Z',
86
+ * metrics: ['events', 'users'],
87
+ * breakdown: 'tenant',
88
+ * filters: [['props.role', 'eq', 'admin']]
87
89
  * });
88
90
  * ```
89
91
  */
90
92
  async query(request) {
91
- const { data, error, response } = await api.POST("/query", {
93
+ const { data, error, response } = await api.POST("/analytics.query", {
92
94
  body: request
93
95
  });
94
96
  if (error || !data) {
@@ -100,6 +102,32 @@ function createLuchyClient(options) {
100
102
  }
101
103
  return data;
102
104
  },
105
+ /**
106
+ * Runs one read-only `SELECT`/`WITH` statement over the whole database
107
+ * (not scoped to a project — filter on `project_id`). The schema is in
108
+ * the method's description: `GET /llms.txt` or `/openapi.json` on the dash.
109
+ * Throws `LuchyApiError` on a non-2xx, including rejected statements (400).
110
+ *
111
+ * ```ts
112
+ * const { rows } = await luchy.sql({
113
+ * query: 'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
114
+ * params: ['project_1']
115
+ * });
116
+ * ```
117
+ */
118
+ async sql(request) {
119
+ const { data, error, response } = await api.POST("/analytics.sql", {
120
+ body: request
121
+ });
122
+ if (error || !data) {
123
+ throw new LuchyApiError(
124
+ `Luchy sql failed with ${response.status}`,
125
+ response.status,
126
+ error
127
+ );
128
+ }
129
+ return data;
130
+ },
103
131
  /**
104
132
  * Pings the API. Throws `LuchyApiError` if it is not healthy.
105
133
  *