next-evolve 0.0.0-stage → 0.3.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 +21 -0
- package/README.md +136 -2
- package/dist/_virtual/_rolldown/runtime.js +12 -0
- package/dist/aggregate/attribution.d.ts +40 -0
- package/dist/aggregate/attribution.js +115 -0
- package/dist/aggregate/run.d.ts +21 -0
- package/dist/aggregate/run.js +139 -0
- package/dist/cli/aggregate.d.ts +9 -0
- package/dist/cli/aggregate.js +104 -0
- package/dist/cli/args.d.ts +12 -0
- package/dist/cli/args.js +23 -0
- package/dist/cli/cloud.d.ts +41 -0
- package/dist/cli/cloud.js +74 -0
- package/dist/cli/connect.d.ts +8 -0
- package/dist/cli/connect.js +46 -0
- package/dist/cli/detect.d.ts +26 -0
- package/dist/cli/detect.js +97 -0
- package/dist/cli/doctor.d.ts +34 -0
- package/dist/cli/doctor.js +230 -0
- package/dist/cli/env-file.d.ts +4 -0
- package/dist/cli/env-file.js +15 -0
- package/dist/cli/files.d.ts +25 -0
- package/dist/cli/files.js +173 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +81 -0
- package/dist/cli/init.d.ts +26 -0
- package/dist/cli/init.js +240 -0
- package/dist/cli/load-app.d.ts +19 -0
- package/dist/cli/load-app.js +67 -0
- package/dist/cli/migrate.d.ts +7 -0
- package/dist/cli/migrate.js +30 -0
- package/dist/cli/pages.d.ts +17 -0
- package/dist/cli/pages.js +304 -0
- package/dist/cli/plan.d.ts +27 -0
- package/dist/cli/plan.js +129 -0
- package/dist/cli/pull.d.ts +13 -0
- package/dist/cli/pull.js +62 -0
- package/dist/cli/push.d.ts +14 -0
- package/dist/cli/push.js +33 -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 +14 -0
- package/dist/cloud/manifest.js +46 -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 +35 -0
- package/dist/core/assign.js +104 -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 +53 -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 +24 -0
- package/dist/instrumentation/index.js +54 -0
- package/dist/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.d.ts +11169 -0
- package/dist/proxy/index.d.ts +2 -0
- package/dist/proxy/index.js +1 -0
- package/dist/proxy/proxy.d.ts +24 -0
- package/dist/proxy/proxy.js +120 -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 +15 -0
- package/dist/react/render-bundle.js +43 -0
- package/dist/react/with-evolve.d.ts +29 -0
- package/dist/react/with-evolve.js +57 -0
- package/dist/server/aggregate-route.d.ts +16 -0
- package/dist/server/aggregate-route.js +68 -0
- package/dist/server/collect.d.ts +62 -0
- package/dist/server/collect.js +157 -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 +17 -0
- package/dist/server/handler.js +105 -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/sync-route.d.ts +28 -0
- package/dist/server/sync-route.js +100 -0
- package/dist/server/track-route.d.ts +25 -0
- package/dist/server/track-route.js +49 -0
- package/dist/store/disk.d.ts +6 -0
- package/dist/store/disk.js +23 -0
- package/dist/store/fetch.d.ts +26 -0
- package/dist/store/fetch.js +74 -0
- package/dist/store/global-config.d.ts +39 -0
- package/dist/store/global-config.js +122 -0
- package/dist/store/memory.d.ts +39 -0
- package/dist/store/memory.js +72 -0
- package/dist/store/runtime.d.ts +44 -0
- package/dist/store/runtime.js +101 -0
- package/migrations/0001_init.sql +56 -0
- package/package.json +73 -3
|
@@ -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,53 @@
|
|
|
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
|
+
/** When the beacon arrived (same clock as event `ts`), not when the batch is written. */
|
|
9
|
+
first_at: Date;
|
|
10
|
+
};
|
|
11
|
+
export type EventRow = {
|
|
12
|
+
ts: Date;
|
|
13
|
+
name: string;
|
|
14
|
+
anon_id: string | null;
|
|
15
|
+
user_id: string | null;
|
|
16
|
+
value: number | null;
|
|
17
|
+
goal_values: Json | null;
|
|
18
|
+
props: Json | null;
|
|
19
|
+
source: string;
|
|
20
|
+
path: string | null;
|
|
21
|
+
dedupe_key: string | null;
|
|
22
|
+
};
|
|
23
|
+
export type IdentityRow = {
|
|
24
|
+
anon_id: string;
|
|
25
|
+
user_id: string;
|
|
26
|
+
};
|
|
27
|
+
export type WriterOptions = {
|
|
28
|
+
sql: () => Sql | null;
|
|
29
|
+
/** node: flush on a timer / row count and keep rows while the DB is down. serverless: flush per request. */
|
|
30
|
+
mode?: "node" | "serverless";
|
|
31
|
+
flushMs?: number;
|
|
32
|
+
flushRows?: number;
|
|
33
|
+
maxBuffer?: number;
|
|
34
|
+
};
|
|
35
|
+
export type Writer = {
|
|
36
|
+
readonly mode: "node" | "serverless";
|
|
37
|
+
exposures(rows: ExposureRow[]): void;
|
|
38
|
+
events(rows: EventRow[]): void;
|
|
39
|
+
identities(rows: IdentityRow[]): void;
|
|
40
|
+
/** Adds tested page views to a UTC day (`YYYY-MM-DD`). */
|
|
41
|
+
usage(day: string, pageviews: number): void;
|
|
42
|
+
/** Rows waiting to be written. */
|
|
43
|
+
pending(): number;
|
|
44
|
+
/** Writes everything queued so far: one insert per table (chunked at 1,000 rows). */
|
|
45
|
+
flush(): Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
export declare function createWriter(opts: WriterOptions): Writer;
|
|
48
|
+
/**
|
|
49
|
+
* The process-wide writer for a DB URL variable. On long-running Node it also
|
|
50
|
+
* flushes on SIGTERM so a graceful shutdown doesn't lose buffered rows.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getWriter(sql: () => Sql | null, key?: string): Writer;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { logRateLimited } from "../core/log.js";
|
|
2
|
+
import { isServerless } from "./client.js";
|
|
3
|
+
// src/db/writer.ts
|
|
4
|
+
const CHUNK = 1e3;
|
|
5
|
+
const MAX_BACKOFF_MS = 3e4;
|
|
6
|
+
function createWriter(opts) {
|
|
7
|
+
const mode = opts.mode ?? (isServerless() ? "serverless" : "node");
|
|
8
|
+
const flushMs = opts.flushMs ?? 1e3;
|
|
9
|
+
const flushRows = opts.flushRows ?? 100;
|
|
10
|
+
const maxBuffer = opts.maxBuffer ?? 1e4;
|
|
11
|
+
let exposures = [];
|
|
12
|
+
let events = [];
|
|
13
|
+
let identities = [];
|
|
14
|
+
let usage = /* @__PURE__ */ new Map();
|
|
15
|
+
let timer;
|
|
16
|
+
let backoffMs = 0;
|
|
17
|
+
let running = Promise.resolve();
|
|
18
|
+
const pending = () => exposures.length + events.length + identities.length + usage.size;
|
|
19
|
+
function schedule(delay) {
|
|
20
|
+
if (mode !== "node" || timer) return;
|
|
21
|
+
timer = setTimeout(() => {
|
|
22
|
+
timer = void 0;
|
|
23
|
+
flush();
|
|
24
|
+
}, delay);
|
|
25
|
+
timer.unref?.();
|
|
26
|
+
}
|
|
27
|
+
function added() {
|
|
28
|
+
if (mode !== "node") return;
|
|
29
|
+
trim();
|
|
30
|
+
if (pending() >= flushRows && backoffMs === 0) flush();
|
|
31
|
+
else schedule(backoffMs || flushMs);
|
|
32
|
+
}
|
|
33
|
+
/** Keeps the buffer bounded while the DB is down: the oldest rows go first. */
|
|
34
|
+
function trim() {
|
|
35
|
+
let over = pending() - maxBuffer;
|
|
36
|
+
if (over <= 0) return;
|
|
37
|
+
logRateLimited("writer:drop", `customer DB unavailable; dropping ${over} buffered rows`);
|
|
38
|
+
for (const list of [
|
|
39
|
+
events,
|
|
40
|
+
exposures,
|
|
41
|
+
identities
|
|
42
|
+
]) {
|
|
43
|
+
const n = Math.min(over, list.length);
|
|
44
|
+
list.splice(0, n);
|
|
45
|
+
over -= n;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function write(sql, batch) {
|
|
49
|
+
for (const rows of chunks(batch.exposures)) await sql`insert into evolve.exposures ${sql(rows, "experiment_id", "anon_id", "variant_id", "path", "first_at")}
|
|
50
|
+
on conflict do nothing`;
|
|
51
|
+
for (const rows of chunks(batch.events)) await sql`insert into evolve.events ${sql(rows.map((r) => ({
|
|
52
|
+
...r,
|
|
53
|
+
goal_values: r.goal_values && sql.json(r.goal_values),
|
|
54
|
+
props: r.props && sql.json(r.props)
|
|
55
|
+
})), EVENT_COLS)}
|
|
56
|
+
on conflict (dedupe_key) where dedupe_key is not null do nothing`;
|
|
57
|
+
for (const rows of chunks(batch.identities)) await sql`insert into evolve.identities ${sql(rows, "anon_id", "user_id")} on conflict do nothing`;
|
|
58
|
+
const days = [...batch.usage].map(([day, n]) => ({
|
|
59
|
+
day,
|
|
60
|
+
tested_pageviews: n
|
|
61
|
+
}));
|
|
62
|
+
if (days.length) await sql`insert into evolve.usage_daily ${sql(days, "day", "tested_pageviews")}
|
|
63
|
+
on conflict (day) do update
|
|
64
|
+
set tested_pageviews = evolve.usage_daily.tested_pageviews + excluded.tested_pageviews`;
|
|
65
|
+
}
|
|
66
|
+
function take() {
|
|
67
|
+
const b = {
|
|
68
|
+
exposures,
|
|
69
|
+
events,
|
|
70
|
+
identities,
|
|
71
|
+
usage
|
|
72
|
+
};
|
|
73
|
+
exposures = [];
|
|
74
|
+
events = [];
|
|
75
|
+
identities = [];
|
|
76
|
+
usage = /* @__PURE__ */ new Map();
|
|
77
|
+
return b;
|
|
78
|
+
}
|
|
79
|
+
function restore(b) {
|
|
80
|
+
exposures = [...b.exposures, ...exposures];
|
|
81
|
+
events = [...b.events, ...events];
|
|
82
|
+
identities = [...b.identities, ...identities];
|
|
83
|
+
for (const [d, n] of b.usage) usage.set(d, (usage.get(d) ?? 0) + n);
|
|
84
|
+
}
|
|
85
|
+
async function flushOnce() {
|
|
86
|
+
if (pending() === 0) return;
|
|
87
|
+
const sql = opts.sql();
|
|
88
|
+
if (!sql) {
|
|
89
|
+
logRateLimited("writer:nodb", "EVOLVE_DATABASE_URL is not set; events are not stored");
|
|
90
|
+
take();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const batch = take();
|
|
94
|
+
try {
|
|
95
|
+
await write(sql, batch);
|
|
96
|
+
backoffMs = 0;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (mode === "serverless") try {
|
|
99
|
+
await write(sql, batch);
|
|
100
|
+
return;
|
|
101
|
+
} catch (e2) {
|
|
102
|
+
logRateLimited("writer:dropped", "customer DB write failed; batch dropped", e2);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
logRateLimited("writer:retry", "customer DB write failed; will retry", e);
|
|
106
|
+
restore(batch);
|
|
107
|
+
trim();
|
|
108
|
+
backoffMs = Math.min(MAX_BACKOFF_MS, backoffMs ? backoffMs * 2 : flushMs);
|
|
109
|
+
schedule(backoffMs);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function flush() {
|
|
113
|
+
if (timer) {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
timer = void 0;
|
|
116
|
+
}
|
|
117
|
+
running = running.then(flushOnce, flushOnce);
|
|
118
|
+
return running;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
mode,
|
|
122
|
+
exposures(rows) {
|
|
123
|
+
if (!rows.length) return;
|
|
124
|
+
exposures.push(...rows);
|
|
125
|
+
added();
|
|
126
|
+
},
|
|
127
|
+
events(rows) {
|
|
128
|
+
if (!rows.length) return;
|
|
129
|
+
events.push(...rows);
|
|
130
|
+
added();
|
|
131
|
+
},
|
|
132
|
+
identities(rows) {
|
|
133
|
+
if (!rows.length) return;
|
|
134
|
+
identities.push(...rows);
|
|
135
|
+
added();
|
|
136
|
+
},
|
|
137
|
+
usage(day, n) {
|
|
138
|
+
if (n <= 0) return;
|
|
139
|
+
usage.set(day, (usage.get(day) ?? 0) + n);
|
|
140
|
+
added();
|
|
141
|
+
},
|
|
142
|
+
pending,
|
|
143
|
+
flush
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const EVENT_COLS = [
|
|
147
|
+
"ts",
|
|
148
|
+
"name",
|
|
149
|
+
"anon_id",
|
|
150
|
+
"user_id",
|
|
151
|
+
"value",
|
|
152
|
+
"goal_values",
|
|
153
|
+
"props",
|
|
154
|
+
"source",
|
|
155
|
+
"path",
|
|
156
|
+
"dedupe_key"
|
|
157
|
+
];
|
|
158
|
+
function* chunks(rows) {
|
|
159
|
+
for (let i = 0; i < rows.length; i += CHUNK) yield rows.slice(i, i + CHUNK);
|
|
160
|
+
}
|
|
161
|
+
const WRITERS = Symbol.for("next-evolve.writers");
|
|
162
|
+
/**
|
|
163
|
+
* The process-wide writer for a DB URL variable. On long-running Node it also
|
|
164
|
+
* flushes on SIGTERM so a graceful shutdown doesn't lose buffered rows.
|
|
165
|
+
*/
|
|
166
|
+
function getWriter(sql, key = "EVOLVE_DATABASE_URL") {
|
|
167
|
+
const g = globalThis;
|
|
168
|
+
g[WRITERS] ??= /* @__PURE__ */ new Map();
|
|
169
|
+
let w = g[WRITERS].get(key);
|
|
170
|
+
if (!w) {
|
|
171
|
+
const writer = createWriter({ sql });
|
|
172
|
+
w = writer;
|
|
173
|
+
g[WRITERS].set(key, writer);
|
|
174
|
+
if (!isServerless() && typeof process !== "undefined" && process.once) process.once("SIGTERM", () => {
|
|
175
|
+
writer.flush().finally(() => {
|
|
176
|
+
if (process.listenerCount("SIGTERM") === 0) process.exit(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return w;
|
|
181
|
+
}
|
|
182
|
+
export { createWriter, getWriter };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { OptimizableComponent, OptimizableMeta, getOptimizableMeta, optimizable } from "./react/optimizable.js";
|
|
2
|
+
import { Optimizable, OptimizableProps } from "./react/region.js";
|
|
3
|
+
import { EvolvePageProps, evolveMetadata, evolveStaticParams, withEvolve } from "./react/with-evolve.js";
|
|
4
|
+
import "server-only";
|
|
5
|
+
export { type EvolvePageProps, Optimizable, type OptimizableComponent, type OptimizableMeta, type OptimizableProps, evolveMetadata, evolveStaticParams, getOptimizableMeta, optimizable, withEvolve };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { getOptimizableMeta, optimizable } from "./react/optimizable.js";
|
|
2
|
+
import { Optimizable } from "./react/region.js";
|
|
3
|
+
import { evolveMetadata, evolveStaticParams, withEvolve } from "./react/with-evolve.js";
|
|
4
|
+
import "server-only";
|
|
5
|
+
export { Optimizable, evolveMetadata, evolveStaticParams, getOptimizableMeta, optimizable, withEvolve };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { EvolveConfig } from "../config/config.js";
|
|
2
|
+
import { Goals } from "../config/goals.js";
|
|
3
|
+
export type RegisterEvolveOptions = {
|
|
4
|
+
config: EvolveConfig;
|
|
5
|
+
goals?: Goals;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Call from `instrumentation.ts` `register()`:
|
|
9
|
+
*
|
|
10
|
+
* export async function register() {
|
|
11
|
+
* if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
12
|
+
* const { registerEvolve } = await import("next-evolve/instrumentation");
|
|
13
|
+
* await registerEvolve({ config, goals });
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Registers the goals for this process and pushes the manifest to the cloud
|
|
18
|
+
* (goals, routes, versions, app URL; spec §8.4), once per process. On
|
|
19
|
+
* long-running Node it also polls the bundle in the background (spec §7.5),
|
|
20
|
+
* so the disk cache stays fresh between requests. Never throws.
|
|
21
|
+
*/
|
|
22
|
+
export declare function registerEvolve(opts: RegisterEvolveOptions): Promise<void>;
|
|
23
|
+
/** Test helper. */
|
|
24
|
+
export declare function resetRegisterEvolve(): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { buildManifest, detectAppUrl } from "../cloud/manifest.js";
|
|
2
|
+
import { pushToCloud } from "../cloud/push.js";
|
|
3
|
+
import { resolveConfig } from "../config/config.js";
|
|
4
|
+
import { setRuntime } from "../server/runtime.js";
|
|
5
|
+
import { getBundleStore, isLongRunningNode } from "../store/runtime.js";
|
|
6
|
+
// src/instrumentation/index.ts
|
|
7
|
+
let registered = false;
|
|
8
|
+
let poll = null;
|
|
9
|
+
/**
|
|
10
|
+
* Call from `instrumentation.ts` `register()`:
|
|
11
|
+
*
|
|
12
|
+
* export async function register() {
|
|
13
|
+
* if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
14
|
+
* const { registerEvolve } = await import("next-evolve/instrumentation");
|
|
15
|
+
* await registerEvolve({ config, goals });
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Registers the goals for this process and pushes the manifest to the cloud
|
|
20
|
+
* (goals, routes, versions, app URL; spec §8.4), once per process. On
|
|
21
|
+
* long-running Node it also polls the bundle in the background (spec §7.5),
|
|
22
|
+
* so the disk cache stays fresh between requests. Never throws.
|
|
23
|
+
*/
|
|
24
|
+
async function registerEvolve(opts) {
|
|
25
|
+
if (registered) return;
|
|
26
|
+
registered = true;
|
|
27
|
+
try {
|
|
28
|
+
setRuntime({
|
|
29
|
+
config: opts.config,
|
|
30
|
+
goals: opts.goals
|
|
31
|
+
});
|
|
32
|
+
if (isLongRunningNode()) {
|
|
33
|
+
const cfg = resolveConfig(opts.config);
|
|
34
|
+
const store = getBundleStore(cfg);
|
|
35
|
+
if (store) {
|
|
36
|
+
store.refresh();
|
|
37
|
+
poll = setInterval(() => void store.refresh(), cfg.pollSeconds * 1e3);
|
|
38
|
+
poll.unref();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const manifest = buildManifest(opts.config, opts.goals, { appUrl: detectAppUrl() });
|
|
42
|
+
const res = await pushToCloud("/v1/manifest", manifest);
|
|
43
|
+
if (!res.ok && res.status !== "no_key") console.error(`[evolve] manifest push failed (${res.status})`);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error("[evolve] registerEvolve failed", e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Test helper. */
|
|
49
|
+
function resetRegisterEvolve() {
|
|
50
|
+
registered = false;
|
|
51
|
+
if (poll) clearInterval(poll);
|
|
52
|
+
poll = null;
|
|
53
|
+
}
|
|
54
|
+
export { registerEvolve, resetRegisterEvolve };
|