@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.
- package/LICENSE +202 -0
- package/README.md +158 -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 +355 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -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 +62 -0
- package/dist/server.js +126 -0
- package/package.json +52 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// databob CLI. Every command prints JSON or plain lines, so agents can drive it.
|
|
3
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
import { parseEnv } from "node:util";
|
|
7
|
+
import { Engine, EngineError, createHandler } from "@databob/engine";
|
|
8
|
+
import { LocalTenants, StaticProjects, toNodeListener } from "@databob/engine/node";
|
|
9
|
+
import { ProjectLoadError, codegen, devState, loadProject, paths, projectConfig, scaffold, writeEnv, } from "./project.js";
|
|
10
|
+
const HELP = `databob: typed analytics you define in code
|
|
11
|
+
|
|
12
|
+
Usage: databob <command> [options]
|
|
13
|
+
|
|
14
|
+
init Create databob/schema.ts and databob/metrics.ts
|
|
15
|
+
dev [--port 4100] Run the local API, watch databob/, regenerate types
|
|
16
|
+
start [--port 4100] Self-host in production. Needs DATABOB_SECRET_KEY,
|
|
17
|
+
DATABOB_SIGNING_KEY, DATABOB_USER_KEY (32+ chars each)
|
|
18
|
+
check Validate the schema and every metric (exit 1 on problems)
|
|
19
|
+
deploy [--url URL] Ship databob/ to databob cloud. Needs DATABOB_URL and
|
|
20
|
+
DATABOB_DEPLOY_KEY (environment or .env.local)
|
|
21
|
+
codegen Write databob/_generated
|
|
22
|
+
describe Print the schema and metrics as JSON
|
|
23
|
+
query <metric> [options] Run a metric on local data and print JSON
|
|
24
|
+
--args '{"range":"7d"}' --tenant <id> --tz <IANA zone> --compare
|
|
25
|
+
track <event> [options] Record one event locally
|
|
26
|
+
--props '{"plan":"pro"}' --tenant <id> --user <id>
|
|
27
|
+
|
|
28
|
+
Data lives in .databob/ (git-ignored). Requires Node 22.13+.`;
|
|
29
|
+
function parse(argv) {
|
|
30
|
+
const flags = { _: [] };
|
|
31
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
32
|
+
const arg = argv[index];
|
|
33
|
+
if (!arg.startsWith("--")) {
|
|
34
|
+
flags._.push(arg);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const [name, inline] = arg.slice(2).split("=", 2);
|
|
38
|
+
if (inline !== undefined)
|
|
39
|
+
flags[name] = inline;
|
|
40
|
+
else if (argv[index + 1] !== undefined && !argv[index + 1].startsWith("--"))
|
|
41
|
+
flags[name] = argv[++index];
|
|
42
|
+
else
|
|
43
|
+
flags[name] = true;
|
|
44
|
+
}
|
|
45
|
+
return flags;
|
|
46
|
+
}
|
|
47
|
+
function str(flags, name) {
|
|
48
|
+
const value = flags[name];
|
|
49
|
+
return typeof value === "string" ? value : undefined;
|
|
50
|
+
}
|
|
51
|
+
function jsonFlag(flags, name) {
|
|
52
|
+
const value = str(flags, name);
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(value);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`--${name} must be JSON, for example --${name} '{"range":"7d"}'`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function print(value) {
|
|
63
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
64
|
+
}
|
|
65
|
+
async function loadOrExit(p) {
|
|
66
|
+
const loaded = await loadProject(p);
|
|
67
|
+
if (loaded.problems.length > 0) {
|
|
68
|
+
for (const problem of loaded.problems)
|
|
69
|
+
console.error(`✗ ${problem}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
return loaded;
|
|
73
|
+
}
|
|
74
|
+
async function local(p) {
|
|
75
|
+
const loaded = await loadOrExit(p);
|
|
76
|
+
const tenants = new LocalTenants({ dir: p.data });
|
|
77
|
+
return { engine: new Engine(tenants), project: projectConfig(devState(p), loaded), tenants };
|
|
78
|
+
}
|
|
79
|
+
const MIN_KEY_LENGTH = 32;
|
|
80
|
+
function requiredSecret(name) {
|
|
81
|
+
const value = process.env[name] ?? "";
|
|
82
|
+
if (value.length < MIN_KEY_LENGTH) {
|
|
83
|
+
console.error(`✗ Set ${name} to a random value of at least ${MIN_KEY_LENGTH} characters.`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
// Production self-hosting: no file watching, no generated keys, no .env writes.
|
|
89
|
+
async function start(p, flags) {
|
|
90
|
+
const port = Number(str(flags, "port") ?? process.env.PORT ?? process.env.DATABOB_PORT ?? 4100);
|
|
91
|
+
const host = str(flags, "host") ?? process.env.DATABOB_HOST ?? "0.0.0.0";
|
|
92
|
+
const secretKey = requiredSecret("DATABOB_SECRET_KEY");
|
|
93
|
+
if (!secretKey.startsWith("dbsk_")) {
|
|
94
|
+
console.error("✗ DATABOB_SECRET_KEY must start with dbsk_");
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const loaded = await loadOrExit(p);
|
|
98
|
+
const config = {
|
|
99
|
+
id: process.env.DATABOB_PROJECT_ID ?? "default",
|
|
100
|
+
schema: loaded.schema,
|
|
101
|
+
metrics: loaded.metrics,
|
|
102
|
+
signingKey: requiredSecret("DATABOB_SIGNING_KEY"),
|
|
103
|
+
userKey: requiredSecret("DATABOB_USER_KEY"),
|
|
104
|
+
};
|
|
105
|
+
const tenants = new LocalTenants({ dir: process.env.DATABOB_DATA_DIR ?? p.data });
|
|
106
|
+
const handler = createHandler({ projects: new StaticProjects([{ secretKey, config }]), engine: new Engine(tenants) });
|
|
107
|
+
const server = createServer(toNodeListener(handler));
|
|
108
|
+
server.listen(port, host, () => {
|
|
109
|
+
console.log(`databob listening on http://${host}:${port} (${Object.keys(config.metrics).length} metrics)`);
|
|
110
|
+
});
|
|
111
|
+
const stop = () => {
|
|
112
|
+
server.close(() => {
|
|
113
|
+
tenants.close();
|
|
114
|
+
process.exit(0);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
process.on("SIGINT", stop);
|
|
118
|
+
process.on("SIGTERM", stop);
|
|
119
|
+
}
|
|
120
|
+
// Reads .env.local and .env without overriding variables already set.
|
|
121
|
+
function loadEnvFiles(root) {
|
|
122
|
+
for (const name of [".env.local", ".env"]) {
|
|
123
|
+
const file = join(root, name);
|
|
124
|
+
if (!existsSync(file))
|
|
125
|
+
continue;
|
|
126
|
+
const parsed = parseEnv(readFileSync(file, "utf8"));
|
|
127
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
128
|
+
if (process.env[key] === undefined && value !== undefined)
|
|
129
|
+
process.env[key] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function deploy(p, flags) {
|
|
134
|
+
loadEnvFiles(p.root);
|
|
135
|
+
const url = (str(flags, "url") ?? process.env.DATABOB_URL ?? "").replace(/\/+$/, "");
|
|
136
|
+
const key = process.env.DATABOB_DEPLOY_KEY ?? "";
|
|
137
|
+
if (url === "" || /^https?:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/.test(url)) {
|
|
138
|
+
console.error("✗ Set DATABOB_URL to your databob cloud URL. `databob dev` reloads by itself and needs no deploy.");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (!key.startsWith("dbdk_")) {
|
|
142
|
+
console.error("✗ Set DATABOB_DEPLOY_KEY to the app's deploy key (dbdk_...). The dashboard shows it once.");
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
const loaded = await loadOrExit(p);
|
|
146
|
+
let response;
|
|
147
|
+
try {
|
|
148
|
+
response = await fetch(`${url}/v1/deploy`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ schema: loaded.schema, metrics: loaded.metrics }),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error(`✗ Could not reach ${url}: ${error instanceof Error ? error.message : error}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const body = (await response.json().catch(() => ({})));
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
console.error(`✗ Deploy failed (${response.status}): ${body.message ?? response.statusText}`);
|
|
161
|
+
for (const problem of body.problems ?? [])
|
|
162
|
+
console.error(` ✗ ${problem}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
console.log(`✓ Deployed version ${body.version}: ${body.events} event(s), ${body.metrics} metric(s) to ${url}`);
|
|
166
|
+
}
|
|
167
|
+
async function dev(p, flags) {
|
|
168
|
+
const port = Number(str(flags, "port") ?? process.env.DATABOB_PORT ?? 4100);
|
|
169
|
+
const host = str(flags, "host") ?? "127.0.0.1";
|
|
170
|
+
if (scaffold(p).length > 0)
|
|
171
|
+
console.log(`Created ${relative(process.cwd(), p.source)}/ with a starter schema and metrics.`);
|
|
172
|
+
const state = devState(p);
|
|
173
|
+
let loaded;
|
|
174
|
+
try {
|
|
175
|
+
loaded = await loadProject(p);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
console.error(error instanceof Error ? error.message : error);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
for (const problem of loaded.problems)
|
|
182
|
+
console.error(`✗ ${problem}`);
|
|
183
|
+
const projects = new StaticProjects([{ secretKey: state.secretKey, config: projectConfig(state, loaded) }]);
|
|
184
|
+
const tenants = new LocalTenants({ dir: p.data });
|
|
185
|
+
const handler = createHandler({ projects, engine: new Engine(tenants) });
|
|
186
|
+
const server = createServer(toNodeListener(async (request) => {
|
|
187
|
+
const url = new URL(request.url);
|
|
188
|
+
if (url.pathname === "/") {
|
|
189
|
+
const config = await projects.byId(state.projectId);
|
|
190
|
+
return Response.json({
|
|
191
|
+
name: "databob dev",
|
|
192
|
+
project: state.projectId,
|
|
193
|
+
events: Object.keys(config?.schema.events ?? {}),
|
|
194
|
+
metrics: Object.keys(config?.metrics ?? {}),
|
|
195
|
+
routes: ["POST /v1/track", "POST /v1/metrics/:name", "GET /v1/metrics/:name/live", "POST /v1/query", "POST /v1/tokens", "GET /v1/describe"],
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return handler(request);
|
|
199
|
+
}));
|
|
200
|
+
server.on("error", (error) => {
|
|
201
|
+
console.error(error.code === "EADDRINUSE" ? `Port ${port} is in use. Try --port ${port + 1}.` : error.message);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
});
|
|
204
|
+
server.listen(port, host, () => {
|
|
205
|
+
const url = `http://${host}:${port}`;
|
|
206
|
+
const added = writeEnv(p, url, state.secretKey);
|
|
207
|
+
const events = Object.keys(loaded.schema.events).length;
|
|
208
|
+
const metrics = Object.keys(loaded.metrics).length;
|
|
209
|
+
console.log(`databob dev ${url}`);
|
|
210
|
+
console.log(` project ${state.projectId}: ${events} event(s), ${metrics} metric(s)${loaded.schema.tenants ? ", tenants on" : ""}`);
|
|
211
|
+
console.log(` data ${relative(process.cwd(), p.data) || "."}`);
|
|
212
|
+
console.log(` secret ${state.secretKey}`);
|
|
213
|
+
if (added.length > 0)
|
|
214
|
+
console.log(` env added ${added.join(", ")} to .env.local`);
|
|
215
|
+
console.log(`Watching ${relative(process.cwd(), p.source)}/ for changes.`);
|
|
216
|
+
});
|
|
217
|
+
let timer = null;
|
|
218
|
+
const reload = async () => {
|
|
219
|
+
try {
|
|
220
|
+
const next = await loadProject(p);
|
|
221
|
+
if (next.problems.length > 0) {
|
|
222
|
+
for (const problem of next.problems)
|
|
223
|
+
console.error(`✗ ${problem}`);
|
|
224
|
+
console.error("Keeping the previous schema and metrics until these are fixed.");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
projects.replace([{ secretKey: state.secretKey, config: projectConfig(state, next) }]);
|
|
228
|
+
loaded = next;
|
|
229
|
+
console.log(`✓ Reloaded: ${Object.keys(next.schema.events).length} event(s), ${Object.keys(next.metrics).length} metric(s)`);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
console.error(`✗ ${error instanceof Error ? error.message : error}`);
|
|
233
|
+
console.error("Keeping the previous schema and metrics until this is fixed.");
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
watch(p.source, { recursive: true }, (_event, file) => {
|
|
237
|
+
if (!file || String(file).includes("_generated"))
|
|
238
|
+
return;
|
|
239
|
+
if (timer)
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
timer = setTimeout(() => void reload(), 100);
|
|
242
|
+
});
|
|
243
|
+
const stop = () => {
|
|
244
|
+
tenants.close();
|
|
245
|
+
server.close();
|
|
246
|
+
process.exit(0);
|
|
247
|
+
};
|
|
248
|
+
process.on("SIGINT", stop);
|
|
249
|
+
process.on("SIGTERM", stop);
|
|
250
|
+
}
|
|
251
|
+
async function main() {
|
|
252
|
+
const flags = parse(process.argv.slice(2));
|
|
253
|
+
const command = flags._[0] ?? "help";
|
|
254
|
+
const p = paths(process.cwd());
|
|
255
|
+
switch (command) {
|
|
256
|
+
case "init": {
|
|
257
|
+
const created = scaffold(p);
|
|
258
|
+
console.log(created.length > 0 ? `Created ${created.join(", ")}` : "databob/ already exists. Nothing to create.");
|
|
259
|
+
console.log("Next: npx databob dev");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case "dev":
|
|
263
|
+
return dev(p, flags);
|
|
264
|
+
case "start":
|
|
265
|
+
return start(p, flags);
|
|
266
|
+
case "deploy":
|
|
267
|
+
return deploy(p, flags);
|
|
268
|
+
case "codegen": {
|
|
269
|
+
const written = codegen(p);
|
|
270
|
+
console.log(written.length > 0 ? `Wrote ${written.join(", ")}` : "Generated files are up to date.");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
case "check": {
|
|
274
|
+
const loaded = await loadOrExit(p);
|
|
275
|
+
console.log(`✓ ${Object.keys(loaded.schema.events).length} event(s) and ${Object.keys(loaded.metrics).length} metric(s) are valid`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
case "describe": {
|
|
279
|
+
const loaded = await loadOrExit(p);
|
|
280
|
+
print({ schema: loaded.schema, metrics: loaded.metrics });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
case "query": {
|
|
284
|
+
const name = flags._[1];
|
|
285
|
+
if (!name)
|
|
286
|
+
throw new Error("Usage: databob query <metric> [--args JSON] [--tenant id]");
|
|
287
|
+
const { engine, project, tenants } = await local(p);
|
|
288
|
+
const options = {};
|
|
289
|
+
const tenant = str(flags, "tenant");
|
|
290
|
+
if (tenant)
|
|
291
|
+
options.tenant = tenant;
|
|
292
|
+
const tz = str(flags, "tz");
|
|
293
|
+
if (tz)
|
|
294
|
+
options.timeZone = tz;
|
|
295
|
+
if (flags.compare)
|
|
296
|
+
options.compare = "previous";
|
|
297
|
+
print(await engine.metric(project, name, jsonFlag(flags, "args") ?? {}, options));
|
|
298
|
+
tenants.close();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
case "track": {
|
|
302
|
+
const name = flags._[1];
|
|
303
|
+
if (!name)
|
|
304
|
+
throw new Error("Usage: databob track <event> [--props JSON] [--tenant id] [--user id]");
|
|
305
|
+
const event = { name, props: jsonFlag(flags, "props") ?? {} };
|
|
306
|
+
const tenant = str(flags, "tenant");
|
|
307
|
+
if (tenant)
|
|
308
|
+
event.tenant = tenant;
|
|
309
|
+
const user = str(flags, "user");
|
|
310
|
+
if (user)
|
|
311
|
+
event.user = user;
|
|
312
|
+
// Prefer a running dev server, so live views see the event.
|
|
313
|
+
const state = devState(p);
|
|
314
|
+
const port = Number(str(flags, "port") ?? process.env.DATABOB_PORT ?? 4100);
|
|
315
|
+
try {
|
|
316
|
+
const response = await fetch(`http://127.0.0.1:${port}/v1/track`, {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: { authorization: `Bearer ${state.secretKey}`, "content-type": "application/json" },
|
|
319
|
+
body: JSON.stringify(event),
|
|
320
|
+
});
|
|
321
|
+
const result = (await response.json());
|
|
322
|
+
print(result);
|
|
323
|
+
if (!response.ok || (result.rejected?.length ?? 0) > 0)
|
|
324
|
+
process.exit(1);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// No dev server. Write to the local data directly.
|
|
329
|
+
}
|
|
330
|
+
const { engine, project, tenants } = await local(p);
|
|
331
|
+
const result = await engine.track(project, [event]);
|
|
332
|
+
print(result);
|
|
333
|
+
tenants.close();
|
|
334
|
+
if (result.rejected.length > 0)
|
|
335
|
+
process.exit(1);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
case "help":
|
|
339
|
+
case "--help":
|
|
340
|
+
case "-h":
|
|
341
|
+
console.log(HELP);
|
|
342
|
+
return;
|
|
343
|
+
default:
|
|
344
|
+
console.error(`Unknown command "${command}".\n`);
|
|
345
|
+
console.log(HELP);
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
main().catch((error) => {
|
|
350
|
+
if (error instanceof EngineError || error instanceof ProjectLoadError)
|
|
351
|
+
console.error(`✗ ${error.message}`);
|
|
352
|
+
else
|
|
353
|
+
console.error(error instanceof Error ? error.message : error);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { v, defineSchema, event, metricFactory, queryRoot, type AggregateResult, type AnySchema, type Cell, type EventNames, type EventProps, type FunnelResult, type FunnelStep, type Infer, type MetricDef, type QueryAst, type QueryResult, type RangeValue, type Schema, } from "@databob/engine";
|
|
2
|
+
export { makeApi, ref, type ApiFromModule, type MetricRef, type RefArgs, type RefResult } from "./api.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// databob: typed product analytics you define in code.
|
|
2
|
+
//
|
|
3
|
+
// databob/schema.ts events and their properties
|
|
4
|
+
// databob/metrics.ts named queries your app and your users read
|
|
5
|
+
// databob/_generated written by `databob dev`; commit it
|
|
6
|
+
export { v, defineSchema, event, metricFactory, queryRoot, } from "@databob/engine";
|
|
7
|
+
export { makeApi, ref } from "./api.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type MetricJson, type ProjectConfig, type SchemaJson } from "@databob/engine";
|
|
2
|
+
export declare const SOURCE_DIR = "databob";
|
|
3
|
+
export declare const STATE_DIR = ".databob";
|
|
4
|
+
export type Paths = {
|
|
5
|
+
root: string;
|
|
6
|
+
source: string;
|
|
7
|
+
generated: string;
|
|
8
|
+
state: string;
|
|
9
|
+
data: string;
|
|
10
|
+
build: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function paths(root: string): Paths;
|
|
13
|
+
export declare function codegen(p: Paths): string[];
|
|
14
|
+
export declare function scaffold(p: Paths): string[];
|
|
15
|
+
export type LoadedProject = {
|
|
16
|
+
schema: SchemaJson;
|
|
17
|
+
metrics: Record<string, MetricJson>;
|
|
18
|
+
problems: string[];
|
|
19
|
+
};
|
|
20
|
+
export declare class ProjectLoadError extends Error {
|
|
21
|
+
}
|
|
22
|
+
export declare function loadProject(p: Paths): Promise<LoadedProject>;
|
|
23
|
+
export type DevState = {
|
|
24
|
+
projectId: string;
|
|
25
|
+
secretKey: string;
|
|
26
|
+
signingKey: string;
|
|
27
|
+
userKey: string;
|
|
28
|
+
};
|
|
29
|
+
export declare function devState(p: Paths): DevState;
|
|
30
|
+
export declare function projectConfig(state: DevState, loaded: LoadedProject): ProjectConfig;
|
|
31
|
+
export declare function writeEnv(p: Paths, url: string, secretKey: string): string[];
|
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: 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
|
+
}
|