@databob/sdk 0.0.0-stage → 0.0.2
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 +202 -0
- package/README.md +180 -2
- package/dist/api.d.ts +17 -0
- package/dist/api.js +13 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +302 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/mcp.d.ts +163 -0
- package/dist/mcp.js +307 -0
- package/dist/ops.d.ts +33 -0
- package/dist/ops.js +102 -0
- package/dist/project.d.ts +31 -0
- package/dist/project.js +283 -0
- package/dist/react.d.ts +27 -0
- package/dist/react.js +133 -0
- package/dist/server.d.ts +63 -0
- package/dist/server.js +130 -0
- package/package.json +52 -4
package/dist/project.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Finds, scaffolds, generates, and loads a databob project directory.
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { isMetric, projectProblems, randomToken, schemaToJson, } from "@databob/engine";
|
|
6
|
+
export const SOURCE_DIR = "databob";
|
|
7
|
+
export const STATE_DIR = ".databob";
|
|
8
|
+
export function paths(root) {
|
|
9
|
+
const state = join(root, STATE_DIR);
|
|
10
|
+
return {
|
|
11
|
+
root,
|
|
12
|
+
source: join(root, SOURCE_DIR),
|
|
13
|
+
generated: join(root, SOURCE_DIR, "_generated"),
|
|
14
|
+
state,
|
|
15
|
+
data: join(state, "data"),
|
|
16
|
+
build: join(state, "build"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function findEntry(dir, base) {
|
|
20
|
+
for (const extension of [".ts", ".mts", ".js", ".mjs"]) {
|
|
21
|
+
const file = join(dir, base + extension);
|
|
22
|
+
if (existsSync(file))
|
|
23
|
+
return file;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const SCHEMA_TEMPLATE = `import { defineSchema, event, v } from "@databob/sdk";
|
|
28
|
+
|
|
29
|
+
export default defineSchema({
|
|
30
|
+
// Every event belongs to one of your customers (a workspace, a store, an org).
|
|
31
|
+
// Each tenant is stored on its own and can be shown its own analytics.
|
|
32
|
+
tenants: true,
|
|
33
|
+
events: {
|
|
34
|
+
pageview: event({ path: v.string() }),
|
|
35
|
+
signup: event({ plan: v.enum(["free", "pro"]) }),
|
|
36
|
+
purchase: event({ amount: v.number({ min: 0 }), plan: v.enum(["free", "pro"]) }),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
`;
|
|
40
|
+
const METRICS_TEMPLATE = `import { v } from "@databob/sdk";
|
|
41
|
+
import { metric } from "./_generated/server";
|
|
42
|
+
|
|
43
|
+
export const pageviews = metric({
|
|
44
|
+
description: "Pageviews and users per day",
|
|
45
|
+
args: { range: v.optional(v.range()) },
|
|
46
|
+
query: (q, { range }) => q.events("pageview").range(range ?? "30d").by("$day").count("views").users(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const topPages = metric({
|
|
50
|
+
description: "Most viewed pages",
|
|
51
|
+
args: { range: v.optional(v.range()) },
|
|
52
|
+
query: (q, { range }) => q.events("pageview").range(range ?? "7d").by("path").count("views").limit(10),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const revenue = metric({
|
|
56
|
+
description: "Revenue by plan",
|
|
57
|
+
args: { range: v.optional(v.range()) },
|
|
58
|
+
query: (q, { range }) => q.events("purchase").range(range ?? "30d").by("plan").sum("amount", "revenue").count("orders"),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const activation = metric({
|
|
62
|
+
description: "Signed up, then purchased within 7 days",
|
|
63
|
+
query: (q) => q.funnel("signup", "purchase").within("7d").range("30d"),
|
|
64
|
+
});
|
|
65
|
+
`;
|
|
66
|
+
const README_TEMPLATE = `# databob
|
|
67
|
+
|
|
68
|
+
This folder defines your analytics. \`npx databob dev\` watches it.
|
|
69
|
+
|
|
70
|
+
- \`schema.ts\` lists every event and its properties. An event that is not here is refused.
|
|
71
|
+
- \`metrics.ts\` holds named queries. Your server and your users' browsers read them by name.
|
|
72
|
+
- \`_generated/\` is written by databob. Commit it, do not edit it.
|
|
73
|
+
|
|
74
|
+
Track on the server:
|
|
75
|
+
|
|
76
|
+
\`\`\`ts
|
|
77
|
+
import { Databob } from "@databob/sdk/server";
|
|
78
|
+
import type { Schema } from "./databob/_generated/server";
|
|
79
|
+
|
|
80
|
+
const databob = new Databob<Schema>();
|
|
81
|
+
await databob.track("signup", { plan: "pro" }, { tenant: org.id, user: user.id });
|
|
82
|
+
\`\`\`
|
|
83
|
+
|
|
84
|
+
Show a tenant its own numbers:
|
|
85
|
+
|
|
86
|
+
\`\`\`ts
|
|
87
|
+
// server: mint a token for one tenant
|
|
88
|
+
const { token } = await databob.createToken({ tenant: org.id });
|
|
89
|
+
\`\`\`
|
|
90
|
+
|
|
91
|
+
\`\`\`tsx
|
|
92
|
+
// browser
|
|
93
|
+
import { DatabobProvider, useMetric } from "@databob/sdk/react";
|
|
94
|
+
import { api } from "./databob/_generated/api";
|
|
95
|
+
|
|
96
|
+
const { data } = useMetric(api.topPages, { range: "7d" }, { live: true });
|
|
97
|
+
\`\`\`
|
|
98
|
+
|
|
99
|
+
Rules databob enforces:
|
|
100
|
+
|
|
101
|
+
- No personal data. Property names like \`email\` or \`ip\` are refused, and values that look like an email, IP address, phone number, or card number are refused.
|
|
102
|
+
- User ids are stored as a keyed hash. \`databob.deleteUser(id)\` erases that user everywhere.
|
|
103
|
+
- Queries are JSON compiled to SQL with bound values. Your query code never runs on the server.
|
|
104
|
+
|
|
105
|
+
Agents: add the MCP server (\`claude mcp add databob -- npx databob mcp\`), or run \`npx databob describe\` for the schema and metrics as JSON, \`npx databob check\` to validate,
|
|
106
|
+
and \`npx databob query <metric> --args '{"range":"7d"}' --tenant <id>\` to read data.
|
|
107
|
+
`;
|
|
108
|
+
const SERVER_GENERATED = `/* Generated by databob. Do not edit. */
|
|
109
|
+
import schema from "../schema";
|
|
110
|
+
import { metricFactory, queryRoot } from "@databob/sdk";
|
|
111
|
+
|
|
112
|
+
export type Schema = typeof schema;
|
|
113
|
+
|
|
114
|
+
// Define metrics with this so event names, properties, and rows are typed.
|
|
115
|
+
export const metric = metricFactory(schema);
|
|
116
|
+
|
|
117
|
+
// Build an ad hoc query with the same types.
|
|
118
|
+
export const q = queryRoot(schema);
|
|
119
|
+
`;
|
|
120
|
+
const API_GENERATED = `/* Generated by databob. Do not edit. */
|
|
121
|
+
import { makeApi } from "@databob/sdk";
|
|
122
|
+
import type * as metrics from "../metrics";
|
|
123
|
+
|
|
124
|
+
// Typed references to your metrics. Safe to import in the browser: it holds
|
|
125
|
+
// names only, not query definitions.
|
|
126
|
+
export const api = makeApi<typeof metrics>();
|
|
127
|
+
`;
|
|
128
|
+
// Ignores line endings, so a CRLF checkout (git autocrlf on Windows) does not
|
|
129
|
+
// get its generated files rewritten on every run.
|
|
130
|
+
function writeIfChanged(file, content) {
|
|
131
|
+
if (existsSync(file) && readFileSync(file, "utf8").replace(/\r\n/g, "\n") === content)
|
|
132
|
+
return false;
|
|
133
|
+
writeFileSync(file, content);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
export function codegen(p) {
|
|
137
|
+
mkdirSync(p.generated, { recursive: true });
|
|
138
|
+
const written = [];
|
|
139
|
+
if (writeIfChanged(join(p.generated, "server.ts"), SERVER_GENERATED))
|
|
140
|
+
written.push("server.ts");
|
|
141
|
+
if (writeIfChanged(join(p.generated, "api.ts"), API_GENERATED))
|
|
142
|
+
written.push("api.ts");
|
|
143
|
+
return written;
|
|
144
|
+
}
|
|
145
|
+
export function scaffold(p) {
|
|
146
|
+
mkdirSync(p.source, { recursive: true });
|
|
147
|
+
const created = [];
|
|
148
|
+
const files = [
|
|
149
|
+
["schema.ts", SCHEMA_TEMPLATE],
|
|
150
|
+
["metrics.ts", METRICS_TEMPLATE],
|
|
151
|
+
["README.md", README_TEMPLATE],
|
|
152
|
+
];
|
|
153
|
+
for (const [name, content] of files) {
|
|
154
|
+
const file = join(p.source, name);
|
|
155
|
+
if (name !== "README.md" && findEntry(p.source, name.replace(/\.ts$/, "")))
|
|
156
|
+
continue;
|
|
157
|
+
if (existsSync(file))
|
|
158
|
+
continue;
|
|
159
|
+
writeFileSync(file, content);
|
|
160
|
+
created.push(relative(p.root, file));
|
|
161
|
+
}
|
|
162
|
+
codegen(p);
|
|
163
|
+
ignoreState(p);
|
|
164
|
+
return created;
|
|
165
|
+
}
|
|
166
|
+
function ignoreState(p) {
|
|
167
|
+
const file = join(p.root, ".gitignore");
|
|
168
|
+
const line = `${STATE_DIR}/`;
|
|
169
|
+
if (existsSync(file)) {
|
|
170
|
+
const content = readFileSync(file, "utf8");
|
|
171
|
+
if (content.split(/\r?\n/).some((entry) => entry.trim() === line || entry.trim() === STATE_DIR))
|
|
172
|
+
return;
|
|
173
|
+
appendFileSync(file, `${content.endsWith("\n") || content === "" ? "" : "\n"}${line}\n`);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
writeFileSync(file, `${line}\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export class ProjectLoadError extends Error {
|
|
180
|
+
}
|
|
181
|
+
export async function loadProject(p) {
|
|
182
|
+
const schemaFile = findEntry(p.source, "schema");
|
|
183
|
+
if (!schemaFile)
|
|
184
|
+
throw new ProjectLoadError(`No ${SOURCE_DIR}/schema.ts. Run \`npx databob init\`.`);
|
|
185
|
+
const metricsFile = findEntry(p.source, "metrics");
|
|
186
|
+
codegen(p);
|
|
187
|
+
const { build } = await import("esbuild");
|
|
188
|
+
const entryPoints = { schema: schemaFile };
|
|
189
|
+
if (metricsFile)
|
|
190
|
+
entryPoints.metrics = metricsFile;
|
|
191
|
+
mkdirSync(p.build, { recursive: true });
|
|
192
|
+
try {
|
|
193
|
+
await build({
|
|
194
|
+
entryPoints,
|
|
195
|
+
outdir: p.build,
|
|
196
|
+
outExtension: { ".js": ".mjs" },
|
|
197
|
+
bundle: true,
|
|
198
|
+
platform: "node",
|
|
199
|
+
format: "esm",
|
|
200
|
+
packages: "external",
|
|
201
|
+
logLevel: "silent",
|
|
202
|
+
write: true,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const errors = error.errors ?? [];
|
|
207
|
+
const details = errors.map((item) => (item.location ? `${item.location.file}:${item.location.line} ${item.text}` : item.text));
|
|
208
|
+
throw new ProjectLoadError(details.length > 0 ? details.join("\n") : String(error));
|
|
209
|
+
}
|
|
210
|
+
const stamp = `?v=${Date.now()}`;
|
|
211
|
+
let schemaModule;
|
|
212
|
+
try {
|
|
213
|
+
schemaModule = (await import(pathToFileURL(join(p.build, "schema.mjs")).href + stamp));
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
throw new ProjectLoadError(`${SOURCE_DIR}/schema.ts: ${error instanceof Error ? error.message : error}`);
|
|
217
|
+
}
|
|
218
|
+
const schema = schemaModule.default;
|
|
219
|
+
if (!schema || schema.kind !== "schema") {
|
|
220
|
+
throw new ProjectLoadError(`${SOURCE_DIR}/schema.ts must \`export default defineSchema({ ... })\``);
|
|
221
|
+
}
|
|
222
|
+
const metrics = {};
|
|
223
|
+
if (metricsFile) {
|
|
224
|
+
let metricsModule;
|
|
225
|
+
try {
|
|
226
|
+
metricsModule = (await import(pathToFileURL(join(p.build, "metrics.mjs")).href + stamp));
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
throw new ProjectLoadError(`${SOURCE_DIR}/metrics.ts: ${error instanceof Error ? error.message : error}`);
|
|
230
|
+
}
|
|
231
|
+
for (const [name, value] of Object.entries(metricsModule)) {
|
|
232
|
+
if (!isMetric(value))
|
|
233
|
+
continue;
|
|
234
|
+
const entry = { args: value.args, query: value.query };
|
|
235
|
+
if (value.description !== undefined)
|
|
236
|
+
entry.description = value.description;
|
|
237
|
+
metrics[name] = JSON.parse(JSON.stringify(entry));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const json = schemaToJson(schema);
|
|
241
|
+
return { schema: json, metrics, problems: projectProblems(json, metrics) };
|
|
242
|
+
}
|
|
243
|
+
export function devState(p) {
|
|
244
|
+
const file = join(p.state, "dev.json");
|
|
245
|
+
if (existsSync(file))
|
|
246
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
247
|
+
mkdirSync(p.state, { recursive: true });
|
|
248
|
+
const state = {
|
|
249
|
+
projectId: "local",
|
|
250
|
+
secretKey: `dbsk_dev_${randomToken(24)}`,
|
|
251
|
+
signingKey: randomToken(32),
|
|
252
|
+
userKey: randomToken(32),
|
|
253
|
+
};
|
|
254
|
+
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
255
|
+
return state;
|
|
256
|
+
}
|
|
257
|
+
export function projectConfig(state, loaded) {
|
|
258
|
+
return {
|
|
259
|
+
id: state.projectId,
|
|
260
|
+
schema: loaded.schema,
|
|
261
|
+
metrics: loaded.metrics,
|
|
262
|
+
signingKey: state.signingKey,
|
|
263
|
+
userKey: state.userKey,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// Adds DATABOB_URL and DATABOB_SECRET_KEY to .env.local when they are missing.
|
|
267
|
+
export function writeEnv(p, url, secretKey) {
|
|
268
|
+
const file = join(p.root, ".env.local");
|
|
269
|
+
const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
270
|
+
const has = (name) => new RegExp(`^\\s*${name}\\s*=`, "m").test(existing);
|
|
271
|
+
const lines = [];
|
|
272
|
+
if (!has("DATABOB_URL"))
|
|
273
|
+
lines.push(`DATABOB_URL=${url}`);
|
|
274
|
+
if (!has("DATABOB_SECRET_KEY"))
|
|
275
|
+
lines.push(`DATABOB_SECRET_KEY=${secretKey}`);
|
|
276
|
+
if (!has("NEXT_PUBLIC_DATABOB_URL"))
|
|
277
|
+
lines.push(`NEXT_PUBLIC_DATABOB_URL=${url}`);
|
|
278
|
+
if (lines.length === 0)
|
|
279
|
+
return [];
|
|
280
|
+
const prefix = existing === "" || existing.endsWith("\n") ? "" : "\n";
|
|
281
|
+
appendFileSync(file, `${prefix}${lines.join("\n")}\n`);
|
|
282
|
+
return lines.map((line) => line.split("=")[0]);
|
|
283
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type AnySchema, type Built, type EventNames, type EventProps, type QueryAst, type QueryResult, 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
|
+
queryJson(query: QueryAst, options?: ReadOptions): Promise<QueryResult>;
|
|
46
|
+
createToken(options?: {
|
|
47
|
+
tenant?: string;
|
|
48
|
+
expiresIn?: number;
|
|
49
|
+
metrics?: MetricRef[];
|
|
50
|
+
}): Promise<{
|
|
51
|
+
token: string;
|
|
52
|
+
expiresAt: number;
|
|
53
|
+
tenant: string;
|
|
54
|
+
}>;
|
|
55
|
+
deleteUser(userId: string | number): Promise<{
|
|
56
|
+
erased: number;
|
|
57
|
+
}>;
|
|
58
|
+
deleteTenant(tenant: string): Promise<{
|
|
59
|
+
dropped: boolean;
|
|
60
|
+
}>;
|
|
61
|
+
describe(): Promise<unknown>;
|
|
62
|
+
}
|
|
63
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
// The same, from a JSON query (for example one an agent wrote).
|
|
106
|
+
queryJson(query, options = {}) {
|
|
107
|
+
return this.request("POST", "/v1/query", { query, ...options });
|
|
108
|
+
}
|
|
109
|
+
// A short-lived token your frontend uses to read one tenant's metrics.
|
|
110
|
+
createToken(options = {}) {
|
|
111
|
+
const body = {};
|
|
112
|
+
if (options.tenant !== undefined)
|
|
113
|
+
body.tenant = options.tenant;
|
|
114
|
+
if (options.expiresIn !== undefined)
|
|
115
|
+
body.expiresIn = options.expiresIn;
|
|
116
|
+
if (options.metrics)
|
|
117
|
+
body.metrics = options.metrics.map((metric) => metric.name);
|
|
118
|
+
return this.request("POST", "/v1/tokens", body);
|
|
119
|
+
}
|
|
120
|
+
// Deletes every event for this user id, in every tenant.
|
|
121
|
+
deleteUser(userId) {
|
|
122
|
+
return this.request("DELETE", `/v1/users/${encodeURIComponent(String(userId))}`);
|
|
123
|
+
}
|
|
124
|
+
deleteTenant(tenant) {
|
|
125
|
+
return this.request("DELETE", `/v1/tenants/${encodeURIComponent(tenant)}`);
|
|
126
|
+
}
|
|
127
|
+
describe() {
|
|
128
|
+
return this.request("GET", "/v1/describe");
|
|
129
|
+
}
|
|
130
|
+
}
|