luchy 0.1.0 → 1.0.1

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
113
127
 
114
- `luchy/server` carries no browser globals, so it runs in Node, Bun,
115
- Deno and Cloudflare Workers:
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.
133
+
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.
137
166
 
138
- Fire it after the response so it costs no latency. On Cloudflare Workers:
167
+ ## Server-Side Tracking (`luchy/server`)
168
+
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,21 @@ 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). `luchy.schema()` documents the tables.
258
+ const { rows } = await luchy.sql({
259
+ query:
260
+ 'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
261
+ params: ['project_id']
173
262
  });
174
263
  ```
175
264
 
@@ -184,7 +273,8 @@ const { data, error } = await luchy.api.GET('/health');
184
273
 
185
274
  The request and response types come from the document too, so they are worth
186
275
  importing rather than restating: `EventInput`, `PageviewInput`,
187
- `IngestSuccess`, `QueryRequest`, `QueryResponse`, plus the raw `paths` and
276
+ `IngestSuccess`, `QueryRequest`, `QueryResponse`, `SqlRequest`, `SqlResponse`,
277
+ plus the raw `paths` and
188
278
  `components`.
189
279
 
190
280
  ### Where the types come from
@@ -203,6 +293,125 @@ client can be regenerated without a dashboard running anywhere. `schema.d.ts`
203
293
  is generated too — never edit either by hand. Change the zod schemas in
204
294
  `apps/dash`, re-run both steps, commit the result.
205
295
 
296
+ ## React Router (`luchy/react-router`)
297
+
298
+ For React Router v7.9+ apps with middleware. Three pieces:
299
+
300
+ - `luchyMiddleware` — a root route middleware. Every mutation React Router
301
+ serves becomes a server event, attributed to the request's identity.
302
+ - `getLuchy(context)` — for the root loader: the browser script's config, with
303
+ the identity already signed.
304
+ - `<LuchyScript {...luchy} />` — renders the browser script with
305
+ `data-identity` / `data-props`, so server events and browser pageviews land
306
+ in the same session.
307
+
308
+ A React Router app already tells you what it did — in the request. Every
309
+ console mutation is a form POST whose `intent` field names it (`rotate`,
310
+ `invite-member`, `create-api-key`, …), every JSON API mutation is
311
+ discriminated by its method, and every auth verb has a path that names the
312
+ operation. So the event is _derived_ from the request instead of emitted by
313
+ hand from route modules: zero per-route code, and a new intent is tracked the
314
+ day it is written.
315
+
316
+ ```tsx
317
+ // app/root.tsx
318
+ import { getLuchy, luchyMiddleware, LuchyScript } from 'luchy/react-router';
319
+
320
+ export const middleware: Route.MiddlewareFunction[] = [
321
+ luchyMiddleware({
322
+ apiKey: env.LUCHY_API_KEY,
323
+ secret: env.LUCHY_SECRET,
324
+ enabled: env.APP_ENV === 'production',
325
+ // How to keep the event alive after the response. Nothing here imports
326
+ // `cloudflare:workers`; hand it whatever your runtime has.
327
+ waitUntil:
328
+ ({ context }) =>
329
+ (promise) =>
330
+ context.get(cfContext).ctx.waitUntil(promise),
331
+ identity: async ({ context }) => {
332
+ const s = await context.get(appContext).getMaybeSessionContext();
333
+ if (!s) {
334
+ return undefined;
335
+ }
336
+
337
+ return {
338
+ user: s.user.id,
339
+ tenant: String(s.workspaceId),
340
+ props: { role: s.role },
341
+ traits: { name: s.user.name, email: s.user.email }
342
+ };
343
+ }
344
+ })
345
+ ];
346
+
347
+ export async function loader({ context }: Route.LoaderArgs) {
348
+ return { luchy: await getLuchy(context) };
349
+ }
350
+
351
+ export function Layout({ children }: { children: React.ReactNode }) {
352
+ const data = useRouteLoaderData<typeof loader>('root');
353
+
354
+ return (
355
+ <html lang="en">
356
+ <head>
357
+ {/* … */}
358
+ {data ? <LuchyScript {...data.luchy} /> : null}
359
+ </head>
360
+ <body>{children}</body>
361
+ </html>
362
+ );
363
+ }
364
+ ```
365
+
366
+ How it behaves:
367
+
368
+ - Only mutations (`POST`/`PUT`/`PATCH`/`DELETE`) produce events. Reads are the
369
+ browser script's job.
370
+ - Events are named `route:intent` — `team/:id:invite-member`,
371
+ `applications:create` — with ids collapsed to `:id`, React Router's
372
+ single-fetch `.data` suffix stripped (so a hydrated and a non-hydrated
373
+ submission report the same name), and `/` mapped to `home`.
374
+ - The body is cloned before `next()` only when it can carry an `intent`
375
+ (urlencoded); the action still reads the original.
376
+ - Redirects and responses thrown by actions are tracked with their real status
377
+ and re-thrown untouched.
378
+ - The payload is the identity's `props` plus `status` (always the response
379
+ status — props cannot override it). The end user's `cf-connecting-ip`,
380
+ country (`request.cf.country` or `cf-ipcountry`), first `accept-language` tag
381
+ and user agent are forwarded.
382
+ - `identity` is resolved lazily and at most once per request: only when an
383
+ event ships or `getLuchy` asks. A rejection is reported to `onError` and the
384
+ request is treated as anonymous.
385
+ - `traits`, when the resolver returns them, are sent via `identify` through
386
+ the same `waitUntil` — at most once per isolate per hour for the same user
387
+ and traits (a small in-memory map, bounded at 1000 entries). Changed traits
388
+ go out on the next request. Needs `secret`; traits never reach the page.
389
+ - `<LuchyScript>` must be server-rendered — React does not execute `<script>`
390
+ elements it creates on the client. Re-renders after login/logout update its
391
+ attributes, and the script picks them up on the next send.
392
+ - Only requests React Router routes are seen. Anything your Worker answers
393
+ before handing over to React Router is invisible to the middleware.
394
+
395
+ | Option | Default | What it does |
396
+ | --------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
397
+ | `apiKey` | — | Luchy API key. The public ingest key is fine. |
398
+ | `secret` | — | Secret key. Without it no identity or traits are sent (props still are). |
399
+ | `endpoint` | hosted ingest | Ingest base, without a trailing slash. Also what `getLuchy` hands the script. |
400
+ | `enabled` | `true` | When false no events are produced — no clone, no network. `getLuchy` keeps working. |
401
+ | `waitUntil` | — | `(args) => (promise) => void`. Called only for requests that produce an event. |
402
+ | `identity` | — | `(args) => ({ user, actor?, tenant?, props?, traits? }) \| undefined`, sync or async. |
403
+ | `ignorePrefixes` | `['/__manifest']` | Raw-pathname prefixes to drop. Yours are added to the default. |
404
+ | `ignoreRouteSuffixes` | `[]` | Normalized-route suffixes to drop, e.g. `/user-keys/validate`. |
405
+ | `ignoreEvents` | `[]` | Fully-formed event names to drop, e.g. `notifications:markRead`. |
406
+ | `trackFailures` | `false` | When true, 4xx/5xx are tracked too (tell them apart by `status`). |
407
+ | `methodSuffix` | `false` | When true, an intent-less mutation is `route:post` / `route:delete` instead of bare `route`. |
408
+ | `onError` | — | The only way to see failures. |
409
+
410
+ `<LuchyScript>` also takes `src` (defaults to the CDN build) and `nonce`.
411
+
412
+ The naming pieces are exported on their own too — `isMutatingMethod`,
413
+ `carriesIntent`, `normalizeRoute`, `serverEventName`.
414
+
206
415
  ## Development
207
416
 
208
417
  ### Build Scripts
@@ -18,12 +18,18 @@ 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
+ /** The body accepted by `POST /analytics/query`. */
26
+ export type QueryRequest = components['schemas']['AnalyticsQueryRequest'];
27
+ /** The result of a successful `POST /analytics/query`. */
28
+ export type QueryResponse = components['schemas']['AnalyticsQueryResponse'];
29
+ /** The body accepted by `POST /analytics/sql`. */
30
+ export type SqlRequest = components['schemas']['SqlRequest'];
31
+ /** The result of a successful `POST /analytics/sql`. */
32
+ export type SqlResponse = components['schemas']['SqlResponse'];
27
33
  /** The `GET /health` body. */
28
34
  export type HealthResponse = components['schemas']['HealthResponse'];
29
35
  /** A 400 from any endpoint. */
@@ -53,21 +59,25 @@ export type LuchyClient = {
53
59
  /**
54
60
  * The underlying `openapi-fetch` client, pre-authenticated. Use it for
55
61
  * 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.
62
+ * the full document, so `api.POST('/analytics/query', { body })` is checked
63
+ * end to end.
57
64
  */
58
65
  api: LuchyApi;
59
66
  trackEvent(event: EventInput): Promise<IngestSuccess | null>;
60
67
  trackPageview(pageview: PageviewInput): Promise<IngestSuccess | null>;
61
68
  query(request: QueryRequest): Promise<QueryResponse>;
69
+ sql(request: SqlRequest): Promise<SqlResponse>;
70
+ schema(): Promise<string>;
62
71
  health(): Promise<HealthResponse>;
63
72
  };
64
73
  /**
65
74
  * Thrown by the endpoints where failing loudly is the right answer — reads
66
- * (`query`, `health`), as opposed to the fire-and-forget tracking calls.
75
+ * (`query`, `sql`, `schema`, `health`), as opposed to the fire-and-forget
76
+ * tracking calls.
67
77
  *
68
78
  * ```ts
69
79
  * try {
70
- * await luchy.query({ date_range: '7d', metrics: ['pageviews'] });
80
+ * await luchy.sql({ query: 'SELECT COUNT(*) AS n FROM events' });
71
81
  * } catch (error) {
72
82
  * if (error instanceof LuchyApiError) {
73
83
  * console.error(error.status, error.body);
@@ -103,8 +113,10 @@ export declare class LuchyApiError extends Error {
103
113
  * });
104
114
  *
105
115
  * const stats = await luchy.query({
106
- * date_range: '7d',
107
- * metrics: ['pageviews', 'visitors']
116
+ * from: '2025-01-01T00:00:00Z',
117
+ * to: '2025-02-01T00:00:00Z',
118
+ * metrics: ['pageviews', 'visitors'],
119
+ * compare: 'previous_period'
108
120
  * });
109
121
  * ```
110
122
  */
@@ -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":"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,sEAAsE;AACtE,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,oDAAoD;AACpD,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,uBAAuB,CAAC,CAAC;AAC1E,0DAA0D;AAC1D,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,wBAAwB,CAAC,CAAC;AAC5E,kDAAkD;AAClD,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC;AAC7D,wDAAwD;AACxD,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,aAAa,CAAC,CAAC;AAC/D,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;;;;;OAKG;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,GAAG,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IAC/C,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1B,MAAM,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;CACnC,CAAC;AAYF;;;;;;;;;;;;;;GAcG;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;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAgL1E"}
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,42 @@ 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`). Throws
108
+ * `LuchyApiError` on a non-2xx, including rejected statements (400).
109
+ *
110
+ * ```ts
111
+ * const { rows } = await luchy.sql({
112
+ * query: 'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
113
+ * params: ['project_1']
114
+ * });
115
+ * ```
116
+ */
117
+ async sql(request) {
118
+ const { data, error, response } = await api.POST("/analytics/sql", {
119
+ body: request
120
+ });
121
+ if (error || !data) {
122
+ throw new LuchyApiError(
123
+ `Luchy sql failed with ${response.status}`,
124
+ response.status,
125
+ error
126
+ );
127
+ }
128
+ return data;
129
+ },
130
+ /** The tables, columns and conventions to write `sql` queries against. */
131
+ async schema() {
132
+ const { data, response } = await api.GET("/analytics/schema");
133
+ if (!data) {
134
+ throw new LuchyApiError(
135
+ `Luchy schema failed with ${response.status}`,
136
+ response.status
137
+ );
138
+ }
139
+ return data.doc;
140
+ },
103
141
  /**
104
142
  * Pings the API. Throws `LuchyApiError` if it is not healthy.
105
143
  *