@databob/sdk 0.0.0-stage → 0.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.
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from "react";
2
+ import type { MetricRef, RefArgs, RefResult } from "./api.js";
3
+ type TokenSource = () => Promise<string>;
4
+ export type DatabobProviderProps = {
5
+ url?: string;
6
+ token?: string;
7
+ fetchToken?: TokenSource;
8
+ children: ReactNode;
9
+ };
10
+ export declare function DatabobProvider({ url, token, fetchToken, children }: DatabobProviderProps): import("react").JSX.Element;
11
+ export type MetricOptions = {
12
+ live?: boolean;
13
+ timeZone?: string;
14
+ compare?: "previous";
15
+ skip?: boolean;
16
+ };
17
+ export type MetricState<T> = {
18
+ data: T | undefined;
19
+ error: Error | undefined;
20
+ isLoading: boolean;
21
+ };
22
+ export declare class MetricError extends Error {
23
+ readonly code: string;
24
+ constructor(code: string, message: string);
25
+ }
26
+ export declare function useMetric<R extends MetricRef>(ref: R, args?: RefArgs<R>, options?: MetricOptions): MetricState<RefResult<R>>;
27
+ export {};
package/dist/react.js ADDED
@@ -0,0 +1,133 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // Read metrics in the browser with an embed token your backend mints.
4
+ //
5
+ // <DatabobProvider url={process.env.NEXT_PUBLIC_DATABOB_URL} fetchToken={getToken}>
6
+ // <Dashboard />
7
+ // </DatabobProvider>
8
+ //
9
+ // const { data } = useMetric(api.topPages, { range: "7d" }, { live: true });
10
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
11
+ const DatabobContext = createContext(null);
12
+ export function DatabobProvider({ url, token, fetchToken, children }) {
13
+ const cached = useRef({ token: token ?? "", pending: null });
14
+ useEffect(() => {
15
+ if (token)
16
+ cached.current.token = token;
17
+ }, [token]);
18
+ const getToken = useCallback(async (refresh) => {
19
+ const state = cached.current;
20
+ if (!refresh && state.token)
21
+ return state.token;
22
+ if (!fetchToken) {
23
+ if (state.token)
24
+ return state.token;
25
+ throw new Error("DatabobProvider needs token or fetchToken");
26
+ }
27
+ if (!state.pending) {
28
+ state.pending = fetchToken().finally(() => {
29
+ state.pending = null;
30
+ });
31
+ }
32
+ state.token = await state.pending;
33
+ return state.token;
34
+ }, [fetchToken]);
35
+ const value = useMemo(() => ({ url: (url ?? "http://127.0.0.1:4100").replace(/\/+$/, ""), getToken }), [url, getToken]);
36
+ return _jsx(DatabobContext.Provider, { value: value, children: children });
37
+ }
38
+ export class MetricError extends Error {
39
+ code;
40
+ constructor(code, message) {
41
+ super(message);
42
+ this.code = code;
43
+ this.name = "MetricError";
44
+ }
45
+ }
46
+ function browserTimeZone() {
47
+ try {
48
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
49
+ }
50
+ catch {
51
+ return "UTC";
52
+ }
53
+ }
54
+ export function useMetric(ref, args, options = {}) {
55
+ const context = useContext(DatabobContext);
56
+ if (!context)
57
+ throw new Error("useMetric needs a <DatabobProvider> above it");
58
+ const [state, setState] = useState({ data: undefined, error: undefined, isLoading: !options.skip });
59
+ const timeZone = options.timeZone ?? browserTimeZone();
60
+ const key = JSON.stringify([ref.name, args ?? {}, timeZone, options.compare ?? null, options.live === true]);
61
+ useEffect(() => {
62
+ if (options.skip)
63
+ return;
64
+ let cancelled = false;
65
+ let source = null;
66
+ let retry = null;
67
+ const { url, getToken } = context;
68
+ const body = { args: args ?? {}, timeZone, ...(options.compare ? { compare: options.compare } : {}) };
69
+ setState((previous) => ({ ...previous, isLoading: previous.data === undefined }));
70
+ const fail = (error) => {
71
+ if (cancelled)
72
+ return;
73
+ setState((previous) => ({
74
+ ...previous,
75
+ error: error instanceof Error ? error : new Error(String(error)),
76
+ isLoading: false,
77
+ }));
78
+ };
79
+ const once = async (refresh = false) => {
80
+ const token = await getToken(refresh);
81
+ const response = await fetch(`${url}/v1/metrics/${ref.name}`, {
82
+ method: "POST",
83
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
84
+ body: JSON.stringify(body),
85
+ });
86
+ if (response.status === 401 && !refresh)
87
+ return once(true);
88
+ const data = (await response.json());
89
+ if (!response.ok)
90
+ throw new MetricError(data.error ?? "error", data.message ?? response.statusText);
91
+ if (!cancelled)
92
+ setState({ data: data, error: undefined, isLoading: false });
93
+ };
94
+ const subscribe = async (refresh = false) => {
95
+ const token = await getToken(refresh);
96
+ if (cancelled)
97
+ return;
98
+ const params = new URLSearchParams({ token, args: JSON.stringify(body.args), timeZone });
99
+ if (options.compare)
100
+ params.set("compare", options.compare);
101
+ source = new EventSource(`${url}/v1/metrics/${ref.name}/live?${params}`);
102
+ source.addEventListener("result", (event) => {
103
+ if (!cancelled)
104
+ setState({ data: JSON.parse(event.data), error: undefined, isLoading: false });
105
+ });
106
+ source.addEventListener("failure", (event) => {
107
+ const parsed = JSON.parse(event.data);
108
+ fail(new MetricError(parsed.error ?? "error", parsed.message ?? "Live metric failed"));
109
+ });
110
+ // The browser retries a dropped stream by itself. A refused one (an
111
+ // expired token, a restart) closes, so reconnect with a fresh token.
112
+ source.addEventListener("error", () => {
113
+ if (cancelled || source?.readyState !== EventSource.CLOSED)
114
+ return;
115
+ retry = setTimeout(() => void subscribe(true).catch(fail), 2000);
116
+ });
117
+ source.addEventListener("expired", () => {
118
+ source?.close();
119
+ void subscribe(true).catch(fail);
120
+ });
121
+ };
122
+ (options.live ? subscribe() : once()).catch(fail);
123
+ return () => {
124
+ cancelled = true;
125
+ if (retry)
126
+ clearTimeout(retry);
127
+ source?.close();
128
+ };
129
+ // `key` covers ref, args, and options.
130
+ // eslint-disable-next-line react-hooks/exhaustive-deps
131
+ }, [key, context, options.skip]);
132
+ return state;
133
+ }
@@ -0,0 +1,62 @@
1
+ import { type AnySchema, type Built, type EventNames, type EventProps, type QueryRoot, type ResultOf, type TrackResult } from "@databob/engine";
2
+ import type { MetricRef, RefArgs, RefResult } from "./api.js";
3
+ export declare const DEFAULT_URL = "http://127.0.0.1:4100";
4
+ export type TrackContext = {
5
+ tenant?: string;
6
+ user?: string | number;
7
+ ts?: Date | number;
8
+ key?: string;
9
+ };
10
+ export type ReadOptions = {
11
+ tenant?: string;
12
+ timeZone?: string;
13
+ compare?: "previous";
14
+ };
15
+ export type DatabobOptions = {
16
+ url?: string;
17
+ secretKey?: string;
18
+ fetch?: typeof fetch;
19
+ strict?: boolean;
20
+ };
21
+ export declare class DatabobError extends Error {
22
+ readonly code: string;
23
+ readonly status: number;
24
+ readonly details?: unknown | undefined;
25
+ constructor(code: string, message: string, status: number, details?: unknown | undefined);
26
+ }
27
+ type PropsArgs<S extends AnySchema, E extends EventNames<S>> = {} extends EventProps<S, E> ? [props?: EventProps<S, E>, context?: TrackContext] : [props: EventProps<S, E>, context?: TrackContext];
28
+ export type TrackInput<S extends AnySchema> = {
29
+ [E in EventNames<S>]: {
30
+ name: E;
31
+ props?: EventProps<S, E>;
32
+ } & TrackContext;
33
+ }[EventNames<S>];
34
+ export declare class Databob<S extends AnySchema = AnySchema> {
35
+ private readonly url;
36
+ private readonly secretKey;
37
+ private readonly fetchImpl;
38
+ private readonly strict;
39
+ constructor(options?: DatabobOptions);
40
+ private request;
41
+ track<E extends EventNames<S>>(name: E, ...[props, context]: PropsArgs<S, E>): Promise<TrackResult>;
42
+ trackMany(events: TrackInput<S>[]): Promise<TrackResult>;
43
+ metric<R extends MetricRef>(ref: R, args?: RefArgs<R>, options?: ReadOptions): Promise<RefResult<R>>;
44
+ query<B extends Built<unknown>>(build: (q: QueryRoot<S>) => B, options?: ReadOptions): Promise<ResultOf<B>>;
45
+ createToken(options?: {
46
+ tenant?: string;
47
+ expiresIn?: number;
48
+ metrics?: MetricRef[];
49
+ }): Promise<{
50
+ token: string;
51
+ expiresAt: number;
52
+ tenant: string;
53
+ }>;
54
+ deleteUser(userId: string | number): Promise<{
55
+ erased: number;
56
+ }>;
57
+ deleteTenant(tenant: string): Promise<{
58
+ dropped: boolean;
59
+ }>;
60
+ describe(): Promise<unknown>;
61
+ }
62
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,126 @@
1
+ // Server client. It holds the secret key, so never import it in the browser.
2
+ //
3
+ // import { Databob } from "@databob/sdk/server";
4
+ // import type { Schema } from "./databob/_generated/server";
5
+ // const databob = new Databob<Schema>();
6
+ // await databob.track("signup", { plan: "pro" }, { tenant: org.id, user: user.id });
7
+ import { queryRoot, } from "@databob/engine";
8
+ export const DEFAULT_URL = "http://127.0.0.1:4100";
9
+ export class DatabobError extends Error {
10
+ code;
11
+ status;
12
+ details;
13
+ constructor(code, message, status, details) {
14
+ super(message);
15
+ this.code = code;
16
+ this.status = status;
17
+ this.details = details;
18
+ this.name = "DatabobError";
19
+ }
20
+ }
21
+ function env(name) {
22
+ const value = globalThis.process?.env?.[name];
23
+ return value === "" ? undefined : value;
24
+ }
25
+ export class Databob {
26
+ url;
27
+ secretKey;
28
+ fetchImpl;
29
+ strict;
30
+ constructor(options = {}) {
31
+ this.url = (options.url ?? env("DATABOB_URL") ?? DEFAULT_URL).replace(/\/+$/, "");
32
+ this.secretKey = options.secretKey ?? env("DATABOB_SECRET_KEY");
33
+ this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
34
+ this.strict = options.strict ?? env("NODE_ENV") !== "production";
35
+ }
36
+ async request(method, path, body, allowRejected = false) {
37
+ if (!this.secretKey) {
38
+ throw new DatabobError("unauthorized", "Set DATABOB_SECRET_KEY or pass secretKey. `npx databob dev` prints a local one.", 401);
39
+ }
40
+ const headers = { authorization: `Bearer ${this.secretKey}` };
41
+ const init = { method, headers };
42
+ if (body !== undefined) {
43
+ init.body = JSON.stringify(body);
44
+ headers["content-type"] = "application/json";
45
+ }
46
+ let response;
47
+ try {
48
+ response = await this.fetchImpl(`${this.url}${path}`, init);
49
+ }
50
+ catch (error) {
51
+ const reason = error instanceof Error ? error.message : String(error);
52
+ throw new DatabobError("network", `Could not reach databob at ${this.url} (${reason}). Is \`databob dev\` running?`, 0);
53
+ }
54
+ const text = await response.text();
55
+ let data = {};
56
+ try {
57
+ data = text ? JSON.parse(text) : {};
58
+ }
59
+ catch {
60
+ throw new DatabobError("bad_response", `databob answered ${response.status} with a body that is not JSON`, response.status);
61
+ }
62
+ const rejectedBatch = allowRejected && response.status === 400 && Array.isArray(data.rejected);
63
+ if (!response.ok && !rejectedBatch) {
64
+ const failure = data;
65
+ throw new DatabobError(failure.error ?? "error", failure.message ?? response.statusText, response.status, data);
66
+ }
67
+ return data;
68
+ }
69
+ async track(name, ...[props, context]) {
70
+ return this.trackMany([{ name, props, ...context }]);
71
+ }
72
+ async trackMany(events) {
73
+ const body = {
74
+ events: events.map(({ ts, props, ...rest }) => ({
75
+ ...rest,
76
+ ...(props === undefined ? {} : { props }),
77
+ ...(ts === undefined ? {} : { ts: ts instanceof Date ? ts.getTime() : ts }),
78
+ })),
79
+ };
80
+ try {
81
+ const result = await this.request("POST", "/v1/track", body, true);
82
+ if (result.rejected.length > 0) {
83
+ const first = result.rejected[0];
84
+ const message = `databob refused ${result.rejected.length} of ${events.length} event(s). First: ${first.message}`;
85
+ if (this.strict)
86
+ throw new DatabobError(first.error, message, 400, result);
87
+ console.warn(message);
88
+ }
89
+ return result;
90
+ }
91
+ catch (error) {
92
+ if (this.strict)
93
+ throw error;
94
+ console.warn(error instanceof Error ? error.message : error);
95
+ return { accepted: 0, duplicates: 0, rejected: [] };
96
+ }
97
+ }
98
+ metric(ref, args, options = {}) {
99
+ return this.request("POST", `/v1/metrics/${ref.name}`, { args: args ?? {}, ...options });
100
+ }
101
+ // An ad hoc query, built with the same typed builder as metrics.
102
+ query(build, options = {}) {
103
+ return this.request("POST", "/v1/query", { query: build(queryRoot()).toJSON(), ...options });
104
+ }
105
+ // A short-lived token your frontend uses to read one tenant's metrics.
106
+ createToken(options = {}) {
107
+ const body = {};
108
+ if (options.tenant !== undefined)
109
+ body.tenant = options.tenant;
110
+ if (options.expiresIn !== undefined)
111
+ body.expiresIn = options.expiresIn;
112
+ if (options.metrics)
113
+ body.metrics = options.metrics.map((metric) => metric.name);
114
+ return this.request("POST", "/v1/tokens", body);
115
+ }
116
+ // Deletes every event for this user id, in every tenant.
117
+ deleteUser(userId) {
118
+ return this.request("DELETE", `/v1/users/${encodeURIComponent(String(userId))}`);
119
+ }
120
+ deleteTenant(tenant) {
121
+ return this.request("DELETE", `/v1/tenants/${encodeURIComponent(tenant)}`);
122
+ }
123
+ describe() {
124
+ return this.request("GET", "/v1/describe");
125
+ }
126
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@databob/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Privacy-first product analytics you define in code. Typed events, named metrics, per-tenant data, and live React hooks.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "bin": {
16
+ "databob": "./dist/cli.js"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./server": {
24
+ "types": "./dist/server.d.ts",
25
+ "import": "./dist/server.js"
26
+ },
27
+ "./react": {
28
+ "types": "./dist/react.d.ts",
29
+ "import": "./dist/react.js"
30
+ }
31
+ },
32
+ "engines": {
33
+ "node": ">=22.13"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@databob/engine": "^0.0.1",
40
+ "esbuild": "^0.27.0"
41
+ },
42
+ "peerDependencies": {
43
+ "react": ">=18"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "react": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.json",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit"
53
+ }
54
+ }