next-evolve 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +40 -0
- package/dist/client/beacon.d.ts +11 -0
- package/dist/client/beacon.js +51 -0
- package/dist/client/evolve-script.d.ts +6 -0
- package/dist/client/evolve-script.js +60 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +3 -0
- package/dist/cloud/manifest.d.ts +12 -0
- package/dist/cloud/manifest.js +42 -0
- package/dist/cloud/push.d.ts +21 -0
- package/dist/cloud/push.js +56 -0
- package/dist/cloud/regions.d.ts +8 -0
- package/dist/cloud/regions.js +25 -0
- package/dist/config/config.d.ts +51 -0
- package/dist/config/config.js +47 -0
- package/dist/config/goals.d.ts +50 -0
- package/dist/config/goals.js +85 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.js +3 -0
- package/dist/core/assign.d.ts +23 -0
- package/dist/core/assign.js +82 -0
- package/dist/core/bots.d.ts +5 -0
- package/dist/core/bots.js +25 -0
- package/dist/core/constants.d.ts +3 -0
- package/dist/core/constants.js +5 -0
- package/dist/core/cookies.d.ts +12 -0
- package/dist/core/cookies.js +25 -0
- package/dist/core/keys.d.ts +14 -0
- package/dist/core/keys.js +38 -0
- package/dist/core/log.d.ts +2 -0
- package/dist/core/log.js +10 -0
- package/dist/db/client.d.ts +22 -0
- package/dist/db/client.js +50 -0
- package/dist/db/migrate.d.ts +18 -0
- package/dist/db/migrate.js +43 -0
- package/dist/db/writer.d.ts +51 -0
- package/dist/db/writer.js +182 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/instrumentation/index.d.ts +23 -0
- package/dist/instrumentation/index.js +39 -0
- package/dist/proxy/index.d.ts +2 -0
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +103 -0
- package/dist/react/context.d.ts +6 -0
- package/dist/react/context.js +8 -0
- package/dist/react/optimizable.d.ts +37 -0
- package/dist/react/optimizable.js +38 -0
- package/dist/react/region.d.ts +17 -0
- package/dist/react/region.js +127 -0
- package/dist/react/render-bundle.d.ts +9 -0
- package/dist/react/render-bundle.js +14 -0
- package/dist/react/with-evolve.d.ts +29 -0
- package/dist/react/with-evolve.js +57 -0
- package/dist/server/collect.d.ts +46 -0
- package/dist/server/collect.js +131 -0
- package/dist/server/events.d.ts +18 -0
- package/dist/server/events.js +50 -0
- package/dist/server/evolve.d.ts +20 -0
- package/dist/server/evolve.js +57 -0
- package/dist/server/handler.d.ts +16 -0
- package/dist/server/handler.js +72 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/runtime.d.ts +26 -0
- package/dist/server/runtime.js +45 -0
- package/dist/server/track-route.d.ts +25 -0
- package/dist/server/track-route.js +49 -0
- package/dist/store/fetch.d.ts +22 -0
- package/dist/store/fetch.js +39 -0
- package/dist/store/memory.d.ts +30 -0
- package/dist/store/memory.js +68 -0
- package/dist/store/runtime.d.ts +30 -0
- package/dist/store/runtime.js +69 -0
- package/migrations/0001_init.sql +56 -0
- package/package.json +72 -3
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { logRateLimited } from "../core/log.js";
|
|
2
|
+
import { GOAL_KEY_RE, serializedGoalSchema } from "@next-evolve/protocol";
|
|
3
|
+
// src/config/goals.ts
|
|
4
|
+
/** Identity helper for `evolve.goals.ts`: types plus validation (throws on mistakes). */
|
|
5
|
+
function defineGoals(goals) {
|
|
6
|
+
serializeGoals(goals);
|
|
7
|
+
return goals;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Goal definitions as sent to the cloud. Function values become
|
|
11
|
+
* `{ kind: "local-fn" }`; the function itself never leaves the app.
|
|
12
|
+
*/
|
|
13
|
+
function serializeGoals(goals) {
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [key, def] of Object.entries(goals)) {
|
|
16
|
+
if (!GOAL_KEY_RE.test(key)) throw new Error(`[evolve] goal key "${key}" must match ${GOAL_KEY_RE}`);
|
|
17
|
+
if ("sql" in def) throw new Error(`[evolve] goal "${key}": SQL goals aren't supported yet`);
|
|
18
|
+
const common = {
|
|
19
|
+
window: def.window ?? "7d",
|
|
20
|
+
unit: def.unit ?? "visitor",
|
|
21
|
+
direction: def.direction ?? "increase"
|
|
22
|
+
};
|
|
23
|
+
let wire;
|
|
24
|
+
if ("event" in def && "pageview" in def) throw new Error(`[evolve] goal "${key}": use either event or pageview, not both`);
|
|
25
|
+
else if ("event" in def) wire = {
|
|
26
|
+
kind: "event",
|
|
27
|
+
event: def.event,
|
|
28
|
+
source: def.source ?? null,
|
|
29
|
+
where: def.where ?? null,
|
|
30
|
+
value: typeof def.value === "function" ? { kind: "local-fn" } : typeof def.value === "string" ? {
|
|
31
|
+
kind: "prop",
|
|
32
|
+
path: def.value
|
|
33
|
+
} : null,
|
|
34
|
+
...common
|
|
35
|
+
};
|
|
36
|
+
else if ("pageview" in def) wire = {
|
|
37
|
+
kind: "pageview",
|
|
38
|
+
path: def.pageview,
|
|
39
|
+
value: null,
|
|
40
|
+
...common
|
|
41
|
+
};
|
|
42
|
+
else throw new Error(`[evolve] goal "${key}" needs event or pageview`);
|
|
43
|
+
const r = serializedGoalSchema.safeParse(wire);
|
|
44
|
+
if (!r.success) {
|
|
45
|
+
const what = r.error.issues.map((i) => `${i.path.join(".") || "goal"}: ${i.message}`);
|
|
46
|
+
throw new Error(`[evolve] goal "${key}" is invalid (${what.join("; ")})`);
|
|
47
|
+
}
|
|
48
|
+
out[key] = r.data;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** True if a stored `$pageview` at `path` would count for some pageview goal. */
|
|
53
|
+
function matchesPageviewGoal(goals, path) {
|
|
54
|
+
if (!goals) return false;
|
|
55
|
+
for (const def of Object.values(goals)) {
|
|
56
|
+
if (!("pageview" in def)) continue;
|
|
57
|
+
const p = def.pageview;
|
|
58
|
+
if (p.endsWith("*") ? path.startsWith(p.slice(0, -1)) : path === p) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Runs the `value` functions of matching event goals. Returns `{ goalKey: n }`
|
|
64
|
+
* or null. A function that throws or returns a non-finite number is skipped.
|
|
65
|
+
*/
|
|
66
|
+
function computeGoalValues(goals, e) {
|
|
67
|
+
if (!goals) return null;
|
|
68
|
+
let out = null;
|
|
69
|
+
for (const [key, def] of Object.entries(goals)) {
|
|
70
|
+
if (!("event" in def) || typeof def.value !== "function" || def.event !== e.name) continue;
|
|
71
|
+
if (def.source !== void 0 && def.source !== e.source) continue;
|
|
72
|
+
if (def.where && !Object.entries(def.where).every(([k, v]) => e.props[k] === v)) continue;
|
|
73
|
+
try {
|
|
74
|
+
const v = def.value(e);
|
|
75
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
76
|
+
out ??= {};
|
|
77
|
+
out[key] = v;
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logRateLimited(`goal:${key}`, `goal "${key}": value function threw`, err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export { computeGoalValues, defineGoals, matchesPageviewGoal, serializeGoals };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { EvolveConfig, ResolvedConfig, RouteConfig, defineConfig, resolveConfig } from "./config.js";
|
|
2
|
+
import { EventGoal, GoalDefinition, GoalEvent, GoalWindow, Goals, PageviewGoal, defineGoals, serializeGoals } from "./goals.js";
|
|
3
|
+
export { type EventGoal, type EvolveConfig, type GoalDefinition, type GoalEvent, type GoalWindow, type Goals, type PageviewGoal, type ResolvedConfig, type RouteConfig, defineConfig, defineGoals, resolveConfig, serializeGoals };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { BundlePayload, CodePair } from "@next-evolve/protocol";
|
|
2
|
+
type Variant = {
|
|
3
|
+
id: string;
|
|
4
|
+
w: number;
|
|
5
|
+
};
|
|
6
|
+
/** Picks a variant id for r ∈ [0, 1) proportionally to weight (w > 0 only). */
|
|
7
|
+
export declare function weightedPick(variants: readonly Variant[], r: number): string;
|
|
8
|
+
export type Assignment = {
|
|
9
|
+
/** Pairs for the variant code (unsorted; `encodeCode` sorts). */
|
|
10
|
+
pairs: CodePair[];
|
|
11
|
+
/** Assignments to write back to `evolve_a`. */
|
|
12
|
+
assigned: Map<string, string>;
|
|
13
|
+
/** Whether `assigned` differs from the cookie that came in. */
|
|
14
|
+
changed: boolean;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Assigns a visitor to the experiments on `path` (spec §6.3). Pure: no I/O.
|
|
18
|
+
* Sticky: an active assignment is kept. Completed experiments render their
|
|
19
|
+
* winner and aren't counted. Cookie entries for experiments no longer in the
|
|
20
|
+
* bundle are pruned.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assign(bundle: BundlePayload, path: string, cookie: ReadonlyMap<string, string>, rand: () => number): Assignment;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import "./cookies.js";
|
|
2
|
+
// src/core/assign.ts
|
|
3
|
+
/** Picks a variant id for r ∈ [0, 1) proportionally to weight (w > 0 only). */
|
|
4
|
+
function weightedPick(variants, r) {
|
|
5
|
+
let total = 0;
|
|
6
|
+
for (const v of variants) if (v.w > 0) total += v.w;
|
|
7
|
+
let acc = 0;
|
|
8
|
+
let last = variants[0].id;
|
|
9
|
+
for (const v of variants) {
|
|
10
|
+
if (v.w <= 0) continue;
|
|
11
|
+
acc += v.w / total;
|
|
12
|
+
last = v.id;
|
|
13
|
+
if (r < acc) return v.id;
|
|
14
|
+
}
|
|
15
|
+
return last;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Assigns a visitor to the experiments on `path` (spec §6.3). Pure: no I/O.
|
|
19
|
+
* Sticky: an active assignment is kept. Completed experiments render their
|
|
20
|
+
* winner and aren't counted. Cookie entries for experiments no longer in the
|
|
21
|
+
* bundle are pruned.
|
|
22
|
+
*/
|
|
23
|
+
function assign(bundle, path, cookie, rand) {
|
|
24
|
+
const { experiments, routes } = bundle.routing;
|
|
25
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
26
|
+
let changed = false;
|
|
27
|
+
for (const [exp, v] of cookie) if (experiments[exp]) assigned.set(exp, v);
|
|
28
|
+
else changed = true;
|
|
29
|
+
const pairs = [];
|
|
30
|
+
for (const exp of routes[path] ?? []) {
|
|
31
|
+
const e = experiments[exp];
|
|
32
|
+
if (!e) continue;
|
|
33
|
+
if (e.status === "completed") {
|
|
34
|
+
pairs.push({
|
|
35
|
+
exp,
|
|
36
|
+
var: e.winner,
|
|
37
|
+
counted: false
|
|
38
|
+
});
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const a = assigned.get(exp);
|
|
42
|
+
if (a === "x") {
|
|
43
|
+
pairs.push({
|
|
44
|
+
exp,
|
|
45
|
+
var: e.control,
|
|
46
|
+
counted: false
|
|
47
|
+
});
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (a !== void 0 && e.variants.some((v) => v.id === a && v.w > 0)) {
|
|
51
|
+
pairs.push({
|
|
52
|
+
exp,
|
|
53
|
+
var: a,
|
|
54
|
+
counted: true
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
changed = true;
|
|
59
|
+
if (rand() >= e.traffic) {
|
|
60
|
+
assigned.set(exp, "x");
|
|
61
|
+
pairs.push({
|
|
62
|
+
exp,
|
|
63
|
+
var: e.control,
|
|
64
|
+
counted: false
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const v = weightedPick(e.variants, rand());
|
|
69
|
+
assigned.set(exp, v);
|
|
70
|
+
pairs.push({
|
|
71
|
+
exp,
|
|
72
|
+
var: v,
|
|
73
|
+
counted: true
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
pairs,
|
|
78
|
+
assigned,
|
|
79
|
+
changed
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export { assign, weightedPick };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a detector: `isbot` + empty user agent + configured `extraPatterns`
|
|
3
|
+
* (regex sources, case-insensitive). Bots get `ev_0` and no cookies.
|
|
4
|
+
*/
|
|
5
|
+
export declare function createBotDetector(extraPatterns?: readonly string[]): (ua: string | null | undefined) => boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isbot } from "isbot";
|
|
2
|
+
// src/core/bots.ts
|
|
3
|
+
/** Unfurlers and tools `isbot` may not flag; all served control and never counted. */
|
|
4
|
+
const EXTRA = [
|
|
5
|
+
/whatsapp/i,
|
|
6
|
+
/telegrambot/i,
|
|
7
|
+
/chrome-lighthouse/i,
|
|
8
|
+
/headlesschrome/i,
|
|
9
|
+
/vercelbot/i,
|
|
10
|
+
/claude-user/i
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* Returns a detector: `isbot` + empty user agent + configured `extraPatterns`
|
|
14
|
+
* (regex sources, case-insensitive). Bots get `ev_0` and no cookies.
|
|
15
|
+
*/
|
|
16
|
+
function createBotDetector(extraPatterns = []) {
|
|
17
|
+
const extra = [...EXTRA, ...extraPatterns.map((p) => new RegExp(p, "i"))];
|
|
18
|
+
return (ua) => {
|
|
19
|
+
if (!ua?.trim()) return true;
|
|
20
|
+
if (isbot(ua)) return true;
|
|
21
|
+
for (const re of extra) if (re.test(ua)) return true;
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export { createBotDetector };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Cookie names (spec §6.2). */
|
|
2
|
+
export declare const ANON_COOKIE = "evolve_id";
|
|
3
|
+
export declare const ASSIGN_COOKIE = "evolve_a";
|
|
4
|
+
export declare const PREVIEW_COOKIE = "evolve_pv";
|
|
5
|
+
/** Max experiments remembered in `evolve_a`. */
|
|
6
|
+
export declare const MAX_ASSIGNMENTS = 20;
|
|
7
|
+
/** Assignment value meaning "outside the experiment's traffic share". */
|
|
8
|
+
export declare const EXCLUDED = "x";
|
|
9
|
+
/** Parses `evolve_a` (`exp:var` pairs joined by `.`). Malformed entries are dropped. */
|
|
10
|
+
export declare function parseAssignments(raw: string | undefined): Map<string, string>;
|
|
11
|
+
/** Serializes assignments sorted by experiment id, keeping at most {@link MAX_ASSIGNMENTS}. */
|
|
12
|
+
export declare function serializeAssignments(assigned: ReadonlyMap<string, string>): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/core/cookies.ts
|
|
2
|
+
/** Cookie names (spec §6.2). */
|
|
3
|
+
const ANON_COOKIE = "evolve_id";
|
|
4
|
+
const ASSIGN_COOKIE = "evolve_a";
|
|
5
|
+
const PREVIEW_COOKIE = "evolve_pv";
|
|
6
|
+
/** Max experiments remembered in `evolve_a`. */
|
|
7
|
+
const MAX_ASSIGNMENTS = 20;
|
|
8
|
+
/** Assignment value meaning "outside the experiment's traffic share". */
|
|
9
|
+
const EXCLUDED = "x";
|
|
10
|
+
const ENTRY_RE = /^([a-z0-9]{6}):([a-z0-9]{6}|x)$/;
|
|
11
|
+
/** Parses `evolve_a` (`exp:var` pairs joined by `.`). Malformed entries are dropped. */
|
|
12
|
+
function parseAssignments(raw) {
|
|
13
|
+
const out = /* @__PURE__ */ new Map();
|
|
14
|
+
if (!raw) return out;
|
|
15
|
+
for (const entry of raw.split(".")) {
|
|
16
|
+
const m = ENTRY_RE.exec(entry);
|
|
17
|
+
if (m && !out.has(m[1]) && out.size < 20) out.set(m[1], m[2]);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Serializes assignments sorted by experiment id, keeping at most {@link MAX_ASSIGNMENTS}. */
|
|
22
|
+
function serializeAssignments(assigned) {
|
|
23
|
+
return [...assigned].sort(([a], [b]) => a < b ? -1 : 1).slice(0, 20).map(([e, v]) => `${e}:${v}`).join(".");
|
|
24
|
+
}
|
|
25
|
+
export { ANON_COOKIE, ASSIGN_COOKIE, EXCLUDED, MAX_ASSIGNMENTS, PREVIEW_COOKIE, parseAssignments, serializeAssignments };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { KeyRing } from "@next-evolve/protocol";
|
|
2
|
+
type Env = Record<string, string | undefined>;
|
|
3
|
+
/**
|
|
4
|
+
* Public keys this app trusts for bundles and preview tokens: Evolve Cloud's
|
|
5
|
+
* production keys, plus an optional test key for local development and the
|
|
6
|
+
* fake cloud.
|
|
7
|
+
*
|
|
8
|
+
* `EVOLVE_UNSAFE_TEST_PUBLIC_KEY=<kid>:<x>` is ignored when
|
|
9
|
+
* NODE_ENV=production, unless `EVOLVE_UNSAFE_ALLOW_TEST_KEY=1` is also set
|
|
10
|
+
* (needed only to run SDK e2e tests against a production build).
|
|
11
|
+
*/
|
|
12
|
+
export declare function trustedKeySet(env?: Env): Record<string, string>;
|
|
13
|
+
export declare function trustedKeys(env?: Env): KeyRing;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { EVOLVE_PUBLIC_KEYS, createKeyRing } from "@next-evolve/protocol";
|
|
2
|
+
// src/core/keys.ts
|
|
3
|
+
let warned = false;
|
|
4
|
+
/**
|
|
5
|
+
* Public keys this app trusts for bundles and preview tokens: Evolve Cloud's
|
|
6
|
+
* production keys, plus an optional test key for local development and the
|
|
7
|
+
* fake cloud.
|
|
8
|
+
*
|
|
9
|
+
* `EVOLVE_UNSAFE_TEST_PUBLIC_KEY=<kid>:<x>` is ignored when
|
|
10
|
+
* NODE_ENV=production, unless `EVOLVE_UNSAFE_ALLOW_TEST_KEY=1` is also set
|
|
11
|
+
* (needed only to run SDK e2e tests against a production build).
|
|
12
|
+
*/
|
|
13
|
+
function trustedKeySet(env = process.env) {
|
|
14
|
+
const keys = { ...EVOLVE_PUBLIC_KEYS };
|
|
15
|
+
const test = env.EVOLVE_UNSAFE_TEST_PUBLIC_KEY;
|
|
16
|
+
if (!test) return keys;
|
|
17
|
+
const prod = env.NODE_ENV === "production";
|
|
18
|
+
if (prod && env.EVOLVE_UNSAFE_ALLOW_TEST_KEY !== "1") {
|
|
19
|
+
if (!warned) {
|
|
20
|
+
warned = true;
|
|
21
|
+
console.error("[evolve] EVOLVE_UNSAFE_TEST_PUBLIC_KEY is ignored in production.");
|
|
22
|
+
}
|
|
23
|
+
return keys;
|
|
24
|
+
}
|
|
25
|
+
const m = /^([A-Za-z0-9_.-]{1,64}):([A-Za-z0-9_-]{43})$/.exec(test);
|
|
26
|
+
if (!m) {
|
|
27
|
+
console.error("[evolve] EVOLVE_UNSAFE_TEST_PUBLIC_KEY must be <kid>:<base64url x>; ignored.");
|
|
28
|
+
return keys;
|
|
29
|
+
}
|
|
30
|
+
if (m[1] in keys) return keys;
|
|
31
|
+
if (prod) console.error("[evolve] UNSAFE: trusting a test signing key in a production build.");
|
|
32
|
+
keys[m[1]] = m[2];
|
|
33
|
+
return keys;
|
|
34
|
+
}
|
|
35
|
+
function trustedKeys(env = process.env) {
|
|
36
|
+
return createKeyRing(trustedKeySet(env));
|
|
37
|
+
}
|
|
38
|
+
export { trustedKeySet, trustedKeys };
|
package/dist/core/log.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/core/log.ts
|
|
2
|
+
const last = /* @__PURE__ */ new Map();
|
|
3
|
+
/** Logs at most once per `intervalMs` per key (default 60 s), so a hot path can't flood logs. */
|
|
4
|
+
function logRateLimited(key, message, err, intervalMs = 6e4) {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
if ((last.get(key) ?? 0) + intervalMs > now) return;
|
|
7
|
+
last.set(key, now);
|
|
8
|
+
console.error(`[evolve] ${message}`, ...err === void 0 ? [] : [err]);
|
|
9
|
+
}
|
|
10
|
+
export { logRateLimited };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
export type Sql = postgres.Sql;
|
|
3
|
+
type Env = Record<string, string | undefined>;
|
|
4
|
+
/** True on hosts where each request may run in a fresh, short-lived instance. */
|
|
5
|
+
export declare function isServerless(env?: Env): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Opens a connection pool. Prepared statements are off so pooled endpoints
|
|
8
|
+
* (Neon's `-pooler` host, PgBouncer in transaction mode) work.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createSql(url: string, opts?: {
|
|
11
|
+
env?: Env;
|
|
12
|
+
max?: number;
|
|
13
|
+
debug?: postgres.Options<Record<string, never>>["debug"];
|
|
14
|
+
}): Sql;
|
|
15
|
+
/**
|
|
16
|
+
* The process-wide pool for the customer DB, created on first use. Null when
|
|
17
|
+
* the URL variable isn't set. The proxy never imports this module.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getSql(urlEnv?: string, env?: Env): Sql | null;
|
|
20
|
+
/** Closes every pool (tests, CLI). */
|
|
21
|
+
export declare function closeAllSql(): Promise<void>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
// src/db/client.ts
|
|
3
|
+
/** True on hosts where each request may run in a fresh, short-lived instance. */
|
|
4
|
+
function isServerless(env = process.env) {
|
|
5
|
+
return Boolean(env.VERCEL || env.AWS_LAMBDA_FUNCTION_NAME || env.NETLIFY);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Opens a connection pool. Prepared statements are off so pooled endpoints
|
|
9
|
+
* (Neon's `-pooler` host, PgBouncer in transaction mode) work.
|
|
10
|
+
*/
|
|
11
|
+
function createSql(url, opts = {}) {
|
|
12
|
+
return postgres(url, {
|
|
13
|
+
max: opts.max ?? (isServerless(opts.env) ? 1 : 10),
|
|
14
|
+
prepare: false,
|
|
15
|
+
idle_timeout: 20,
|
|
16
|
+
connect_timeout: 10,
|
|
17
|
+
onnotice: () => {},
|
|
18
|
+
connection: { application_name: "next-evolve" },
|
|
19
|
+
...opts.debug ? { debug: opts.debug } : {}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const POOLS = Symbol.for("next-evolve.sql");
|
|
23
|
+
const pools = () => {
|
|
24
|
+
const g = globalThis;
|
|
25
|
+
g[POOLS] ??= /* @__PURE__ */ new Map();
|
|
26
|
+
return g[POOLS];
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The process-wide pool for the customer DB, created on first use. Null when
|
|
30
|
+
* the URL variable isn't set. The proxy never imports this module.
|
|
31
|
+
*/
|
|
32
|
+
function getSql(urlEnv = "EVOLVE_DATABASE_URL", env = process.env) {
|
|
33
|
+
const url = env[urlEnv];
|
|
34
|
+
if (!url) return null;
|
|
35
|
+
const all = pools();
|
|
36
|
+
let sql = all.get(url);
|
|
37
|
+
if (!sql) {
|
|
38
|
+
sql = createSql(url, { env });
|
|
39
|
+
all.set(url, sql);
|
|
40
|
+
}
|
|
41
|
+
return sql;
|
|
42
|
+
}
|
|
43
|
+
/** Closes every pool (tests, CLI). */
|
|
44
|
+
async function closeAllSql() {
|
|
45
|
+
const all = pools();
|
|
46
|
+
const list = [...all.values()];
|
|
47
|
+
all.clear();
|
|
48
|
+
await Promise.all(list.map((s) => s.end({ timeout: 5 })));
|
|
49
|
+
}
|
|
50
|
+
export { closeAllSql, createSql, getSql, isServerless };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Sql } from "./client.js";
|
|
2
|
+
/** Shipped with the package: `next-evolve/migrations/*.sql`. */
|
|
3
|
+
export declare const MIGRATIONS_DIR: URL;
|
|
4
|
+
export type Migration = {
|
|
5
|
+
id: string;
|
|
6
|
+
sql: string;
|
|
7
|
+
};
|
|
8
|
+
export type MigrateResult = {
|
|
9
|
+
applied: string[];
|
|
10
|
+
skipped: string[];
|
|
11
|
+
};
|
|
12
|
+
export declare function loadMigrations(dir?: URL): Promise<Migration[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Applies pending migrations in order, all in one transaction under an
|
|
15
|
+
* advisory lock, so concurrent runs (several instances booting) are safe and
|
|
16
|
+
* a failure leaves nothing half-applied. Idempotent.
|
|
17
|
+
*/
|
|
18
|
+
export declare function migrate(sql: Sql, migrations?: Migration[]): Promise<MigrateResult>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
// src/db/migrate.ts
|
|
3
|
+
/** Shipped with the package: `next-evolve/migrations/*.sql`. */
|
|
4
|
+
const MIGRATIONS_DIR = new URL("../../migrations/", import.meta.url);
|
|
5
|
+
const LOCK_KEY = 1702260588;
|
|
6
|
+
async function loadMigrations(dir = MIGRATIONS_DIR) {
|
|
7
|
+
const files = (await readdir(dir)).filter((f) => /^\d{4}_[a-z0-9_]+\.sql$/.test(f)).sort();
|
|
8
|
+
return Promise.all(files.map(async (f) => ({
|
|
9
|
+
id: f.replace(/\.sql$/, ""),
|
|
10
|
+
sql: await readFile(new URL(f, dir), "utf8")
|
|
11
|
+
})));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Applies pending migrations in order, all in one transaction under an
|
|
15
|
+
* advisory lock, so concurrent runs (several instances booting) are safe and
|
|
16
|
+
* a failure leaves nothing half-applied. Idempotent.
|
|
17
|
+
*/
|
|
18
|
+
async function migrate(sql, migrations) {
|
|
19
|
+
const list = migrations ?? await loadMigrations();
|
|
20
|
+
return sql.begin(async (tx) => {
|
|
21
|
+
await tx`select pg_advisory_xact_lock(${LOCK_KEY})`;
|
|
22
|
+
await tx`create schema if not exists evolve`;
|
|
23
|
+
await tx`create table if not exists evolve.migrations (
|
|
24
|
+
id text primary key, applied_at timestamptz not null default now())`;
|
|
25
|
+
const done = new Set((await tx`select id from evolve.migrations`).map((r) => r.id));
|
|
26
|
+
const applied = [];
|
|
27
|
+
const skipped = [];
|
|
28
|
+
for (const m of list) {
|
|
29
|
+
if (done.has(m.id)) {
|
|
30
|
+
skipped.push(m.id);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
await tx.unsafe(m.sql);
|
|
34
|
+
await tx`insert into evolve.migrations (id) values (${m.id})`;
|
|
35
|
+
applied.push(m.id);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
applied,
|
|
39
|
+
skipped
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export { MIGRATIONS_DIR, loadMigrations, migrate };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Sql } from "./client.js";
|
|
2
|
+
type Json = Record<string, unknown>;
|
|
3
|
+
export type ExposureRow = {
|
|
4
|
+
experiment_id: string;
|
|
5
|
+
anon_id: string;
|
|
6
|
+
variant_id: string;
|
|
7
|
+
path: string;
|
|
8
|
+
};
|
|
9
|
+
export type EventRow = {
|
|
10
|
+
ts: Date;
|
|
11
|
+
name: string;
|
|
12
|
+
anon_id: string | null;
|
|
13
|
+
user_id: string | null;
|
|
14
|
+
value: number | null;
|
|
15
|
+
goal_values: Json | null;
|
|
16
|
+
props: Json | null;
|
|
17
|
+
source: string;
|
|
18
|
+
path: string | null;
|
|
19
|
+
dedupe_key: string | null;
|
|
20
|
+
};
|
|
21
|
+
export type IdentityRow = {
|
|
22
|
+
anon_id: string;
|
|
23
|
+
user_id: string;
|
|
24
|
+
};
|
|
25
|
+
export type WriterOptions = {
|
|
26
|
+
sql: () => Sql | null;
|
|
27
|
+
/** node: flush on a timer / row count and keep rows while the DB is down. serverless: flush per request. */
|
|
28
|
+
mode?: "node" | "serverless";
|
|
29
|
+
flushMs?: number;
|
|
30
|
+
flushRows?: number;
|
|
31
|
+
maxBuffer?: number;
|
|
32
|
+
};
|
|
33
|
+
export type Writer = {
|
|
34
|
+
readonly mode: "node" | "serverless";
|
|
35
|
+
exposures(rows: ExposureRow[]): void;
|
|
36
|
+
events(rows: EventRow[]): void;
|
|
37
|
+
identities(rows: IdentityRow[]): void;
|
|
38
|
+
/** Adds tested page views to a UTC day (`YYYY-MM-DD`). */
|
|
39
|
+
usage(day: string, pageviews: number): void;
|
|
40
|
+
/** Rows waiting to be written. */
|
|
41
|
+
pending(): number;
|
|
42
|
+
/** Writes everything queued so far: one insert per table (chunked at 1,000 rows). */
|
|
43
|
+
flush(): Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
export declare function createWriter(opts: WriterOptions): Writer;
|
|
46
|
+
/**
|
|
47
|
+
* The process-wide writer for a DB URL variable. On long-running Node it also
|
|
48
|
+
* flushes on SIGTERM so a graceful shutdown doesn't lose buffered rows.
|
|
49
|
+
*/
|
|
50
|
+
export declare function getWriter(sql: () => Sql | null, key?: string): Writer;
|
|
51
|
+
export {};
|