@createcms/core 0.1.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/README.md +169 -0
- package/dist/ab-edge/index.cjs +214 -0
- package/dist/ab-edge/index.d.cts +121 -0
- package/dist/ab-edge/index.d.ts +121 -0
- package/dist/ab-edge/index.js +205 -0
- package/dist/bin/createcms.js +3082 -0
- package/dist/db.cjs +496 -0
- package/dist/db.d.cts +128 -0
- package/dist/db.d.ts +128 -0
- package/dist/db.js +488 -0
- package/dist/index.cjs +13789 -0
- package/dist/index.d.cts +10277 -0
- package/dist/index.d.ts +10277 -0
- package/dist/index.js +13737 -0
- package/dist/nanoid.cjs +50 -0
- package/dist/nanoid.d.cts +29 -0
- package/dist/nanoid.d.ts +29 -0
- package/dist/nanoid.js +47 -0
- package/dist/next/index.cjs +60 -0
- package/dist/next/index.d.cts +141 -0
- package/dist/next/index.d.ts +141 -0
- package/dist/next/index.js +58 -0
- package/dist/next/middleware.cjs +113 -0
- package/dist/next/middleware.d.cts +77 -0
- package/dist/next/middleware.d.ts +77 -0
- package/dist/next/middleware.js +111 -0
- package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
- package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.js +343 -0
- package/dist/plugins/ab-test/client.cjs +686 -0
- package/dist/plugins/ab-test/client.d.cts +233 -0
- package/dist/plugins/ab-test/client.d.ts +233 -0
- package/dist/plugins/ab-test/client.js +684 -0
- package/dist/plugins/ab-test/index.cjs +3400 -0
- package/dist/plugins/ab-test/index.d.cts +1131 -0
- package/dist/plugins/ab-test/index.d.ts +1131 -0
- package/dist/plugins/ab-test/index.js +3367 -0
- package/dist/plugins/client.cjs +20 -0
- package/dist/plugins/client.d.cts +3 -0
- package/dist/plugins/client.d.ts +3 -0
- package/dist/plugins/client.js +3 -0
- package/dist/plugins/consent/client.cjs +315 -0
- package/dist/plugins/consent/client.d.cts +145 -0
- package/dist/plugins/consent/client.d.ts +145 -0
- package/dist/plugins/consent/client.js +313 -0
- package/dist/plugins/consent/index.cjs +267 -0
- package/dist/plugins/consent/index.d.cts +618 -0
- package/dist/plugins/consent/index.d.ts +618 -0
- package/dist/plugins/consent/index.js +258 -0
- package/dist/plugins/i18n/index.cjs +2177 -0
- package/dist/plugins/i18n/index.d.cts +562 -0
- package/dist/plugins/i18n/index.d.ts +562 -0
- package/dist/plugins/i18n/index.js +2150 -0
- package/dist/plugins/media-optimize/index.cjs +315 -0
- package/dist/plugins/media-optimize/index.d.cts +144 -0
- package/dist/plugins/media-optimize/index.d.ts +144 -0
- package/dist/plugins/media-optimize/index.js +311 -0
- package/dist/plugins/multi-tenant/index.cjs +210 -0
- package/dist/plugins/multi-tenant/index.d.cts +431 -0
- package/dist/plugins/multi-tenant/index.d.ts +431 -0
- package/dist/plugins/multi-tenant/index.js +207 -0
- package/dist/plugins/server.cjs +24 -0
- package/dist/plugins/server.d.cts +3 -0
- package/dist/plugins/server.d.ts +3 -0
- package/dist/plugins/server.js +3 -0
- package/dist/react/blocks.cjs +233 -0
- package/dist/react/blocks.d.cts +320 -0
- package/dist/react/blocks.d.ts +320 -0
- package/dist/react/blocks.js +226 -0
- package/dist/react/index.cjs +901 -0
- package/dist/react/index.d.cts +992 -0
- package/dist/react/index.d.ts +992 -0
- package/dist/react/index.js +872 -0
- package/dist/react/tracking.cjs +243 -0
- package/dist/react/tracking.d.cts +364 -0
- package/dist/react/tracking.d.ts +364 -0
- package/dist/react/tracking.js +216 -0
- package/dist/react/variant.cjs +59 -0
- package/dist/react/variant.d.cts +26 -0
- package/dist/react/variant.d.ts +26 -0
- package/dist/react/variant.js +57 -0
- package/package.json +303 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
/** One variant branch of the resolved test (enough for edge bucketing). */
|
|
4
|
+
type ResolvedAbVariant = {
|
|
5
|
+
/** ab_test_variants.id — the `variantId` resolveVariant buckets to. */
|
|
6
|
+
variantId: string;
|
|
7
|
+
/** The published branch this variant renders. */
|
|
8
|
+
branchId: string;
|
|
9
|
+
weight: number;
|
|
10
|
+
isControl: boolean;
|
|
11
|
+
};
|
|
12
|
+
type AbResolveResult = {
|
|
13
|
+
test: {
|
|
14
|
+
testId: string;
|
|
15
|
+
/** The root the test attaches to (page root or an embedded block root). */
|
|
16
|
+
rootId: string;
|
|
17
|
+
trafficPercentage: number;
|
|
18
|
+
variants: ResolvedAbVariant[];
|
|
19
|
+
} | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type AbTestMiddlewareOptions = {
|
|
23
|
+
/** The slug-routed collection these public paths belong to (e.g. 'pages'). */
|
|
24
|
+
collection: string;
|
|
25
|
+
/** Where the CMS router is mounted. Default '/api/cms'. */
|
|
26
|
+
cmsBaseUrl?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The control sentinel code used as the variant-code segment when there is no
|
|
29
|
+
* test / the visitor is outside traffic. Default 'control'. Must match the
|
|
30
|
+
* value your `[abVariant]` route treats as "render control".
|
|
31
|
+
*/
|
|
32
|
+
controlCode?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The static path prefix the variant render route lives under
|
|
35
|
+
* (`app/<prefix>/[abVariant]/[[...rest]]`). Default '/ab'. Keeps the
|
|
36
|
+
* variant-coded route off the URL root so it does not shadow sibling app
|
|
37
|
+
* routes (e.g. a `/app/*` dashboard). Must match your route folder.
|
|
38
|
+
*/
|
|
39
|
+
variantPrefix?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Name prefix for the per-test variant cookie (`<prefix><testId>`). Default
|
|
42
|
+
* 'ab_'. The cookie stores ONLY the assigned variant code — no identifier.
|
|
43
|
+
*/
|
|
44
|
+
variantCookiePrefix?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Lifetime of the variant cookie in seconds. Default 30 days. Keep it close to
|
|
47
|
+
* your test duration (ePrivacy discourages a long-lived cookie "without a
|
|
48
|
+
* technical reason").
|
|
49
|
+
*/
|
|
50
|
+
variantCookieMaxAge?: number;
|
|
51
|
+
/**
|
|
52
|
+
* How to fetch the resolve seam for a path. Default: GET
|
|
53
|
+
* `<cmsBaseUrl>/<collection>/resolveAbVariant?path=`, forwarding the request
|
|
54
|
+
* cookies so the CMS resolves the same tenant/language scope. This default
|
|
55
|
+
* does the (cheap) resolve lookup PER REQUEST — a middleware fetch is not
|
|
56
|
+
* served by the Next.js Data Cache. For high traffic, override this with a
|
|
57
|
+
* reader backed by Vercel Edge Config / KV precomputed on test start/stop.
|
|
58
|
+
*/
|
|
59
|
+
resolve?: (request: NextRequest, path: string) => Promise<AbResolveResult>;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Next.js Pattern A A/B fan-out — ALWAYS rewrites the request to the variant-
|
|
63
|
+
* coded `[abVariant]` route. Compose it inside your `proxy.ts` (Next 16) AFTER
|
|
64
|
+
* the auth gate, and only for PUBLIC CMS paths (it has no passthrough):
|
|
65
|
+
* ```ts
|
|
66
|
+
* // proxy.ts
|
|
67
|
+
* const abTest = abTestMiddleware({ collection: 'pages' });
|
|
68
|
+
* export default async function proxy(request) {
|
|
69
|
+
* if (isProtected(request)) return authGate(request); // not rewritten
|
|
70
|
+
* return abTest(request); // public CMS content → /ab/<code>/<path>
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
declare function abTestMiddleware(options: AbTestMiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
75
|
+
|
|
76
|
+
export { abTestMiddleware };
|
|
77
|
+
export type { AbTestMiddlewareOptions };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
/** One variant branch of the resolved test (enough for edge bucketing). */
|
|
4
|
+
type ResolvedAbVariant = {
|
|
5
|
+
/** ab_test_variants.id — the `variantId` resolveVariant buckets to. */
|
|
6
|
+
variantId: string;
|
|
7
|
+
/** The published branch this variant renders. */
|
|
8
|
+
branchId: string;
|
|
9
|
+
weight: number;
|
|
10
|
+
isControl: boolean;
|
|
11
|
+
};
|
|
12
|
+
type AbResolveResult = {
|
|
13
|
+
test: {
|
|
14
|
+
testId: string;
|
|
15
|
+
/** The root the test attaches to (page root or an embedded block root). */
|
|
16
|
+
rootId: string;
|
|
17
|
+
trafficPercentage: number;
|
|
18
|
+
variants: ResolvedAbVariant[];
|
|
19
|
+
} | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type AbTestMiddlewareOptions = {
|
|
23
|
+
/** The slug-routed collection these public paths belong to (e.g. 'pages'). */
|
|
24
|
+
collection: string;
|
|
25
|
+
/** Where the CMS router is mounted. Default '/api/cms'. */
|
|
26
|
+
cmsBaseUrl?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The control sentinel code used as the variant-code segment when there is no
|
|
29
|
+
* test / the visitor is outside traffic. Default 'control'. Must match the
|
|
30
|
+
* value your `[abVariant]` route treats as "render control".
|
|
31
|
+
*/
|
|
32
|
+
controlCode?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The static path prefix the variant render route lives under
|
|
35
|
+
* (`app/<prefix>/[abVariant]/[[...rest]]`). Default '/ab'. Keeps the
|
|
36
|
+
* variant-coded route off the URL root so it does not shadow sibling app
|
|
37
|
+
* routes (e.g. a `/app/*` dashboard). Must match your route folder.
|
|
38
|
+
*/
|
|
39
|
+
variantPrefix?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Name prefix for the per-test variant cookie (`<prefix><testId>`). Default
|
|
42
|
+
* 'ab_'. The cookie stores ONLY the assigned variant code — no identifier.
|
|
43
|
+
*/
|
|
44
|
+
variantCookiePrefix?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Lifetime of the variant cookie in seconds. Default 30 days. Keep it close to
|
|
47
|
+
* your test duration (ePrivacy discourages a long-lived cookie "without a
|
|
48
|
+
* technical reason").
|
|
49
|
+
*/
|
|
50
|
+
variantCookieMaxAge?: number;
|
|
51
|
+
/**
|
|
52
|
+
* How to fetch the resolve seam for a path. Default: GET
|
|
53
|
+
* `<cmsBaseUrl>/<collection>/resolveAbVariant?path=`, forwarding the request
|
|
54
|
+
* cookies so the CMS resolves the same tenant/language scope. This default
|
|
55
|
+
* does the (cheap) resolve lookup PER REQUEST — a middleware fetch is not
|
|
56
|
+
* served by the Next.js Data Cache. For high traffic, override this with a
|
|
57
|
+
* reader backed by Vercel Edge Config / KV precomputed on test start/stop.
|
|
58
|
+
*/
|
|
59
|
+
resolve?: (request: NextRequest, path: string) => Promise<AbResolveResult>;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Next.js Pattern A A/B fan-out — ALWAYS rewrites the request to the variant-
|
|
63
|
+
* coded `[abVariant]` route. Compose it inside your `proxy.ts` (Next 16) AFTER
|
|
64
|
+
* the auth gate, and only for PUBLIC CMS paths (it has no passthrough):
|
|
65
|
+
* ```ts
|
|
66
|
+
* // proxy.ts
|
|
67
|
+
* const abTest = abTestMiddleware({ collection: 'pages' });
|
|
68
|
+
* export default async function proxy(request) {
|
|
69
|
+
* if (isProtected(request)) return authGate(request); // not rewritten
|
|
70
|
+
* return abTest(request); // public CMS content → /ab/<code>/<path>
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
declare function abTestMiddleware(options: AbTestMiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
75
|
+
|
|
76
|
+
export { abTestMiddleware };
|
|
77
|
+
export type { AbTestMiddlewareOptions };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { CONTROL_CODE, DEFAULT_VARIANT_PREFIX, decideEdgeVariant, variantRewritePath } from '../ab-edge/index.js';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// AB_FANOUT — Next.js edge middleware (Pattern A: cache-per-variant)
|
|
6
|
+
// ============================================================================
|
|
7
|
+
//
|
|
8
|
+
// A THIN adapter over the framework-agnostic core in `@createcms/core/ab-edge`:
|
|
9
|
+
// supplies the Next primitives (NextRequest cookies, NextResponse.rewrite, the
|
|
10
|
+
// resolve fetch). The bucketing + rewrite decision lives in `decideEdgeVariant`.
|
|
11
|
+
// EDGE-SAFE (only `next/server` + the core; no Node built-ins).
|
|
12
|
+
//
|
|
13
|
+
// ALWAYS-REWRITE + CONSENT-FREE: every request is rewritten to
|
|
14
|
+
// `<prefix>/<code><pathname>` (the assigned branch, or the control sentinel) so
|
|
15
|
+
// it lands on the single `[abVariant]` route. The variant is kept consistent via
|
|
16
|
+
// a VARIANT-ONLY cookie `ab_<testId>=<code>` — no visitor id, no behavioural
|
|
17
|
+
// data, no third-party transmission → ePrivacy "strictly necessary" exemption,
|
|
18
|
+
// so fresh ad traffic gets real variants (not always control). The consent-gated
|
|
19
|
+
// pieces (persistent visitor id, GA4/dataLayer forwarding) live in the client
|
|
20
|
+
// pipeline, NOT here. There is NO passthrough, so scope this to PUBLIC CMS paths
|
|
21
|
+
// only (compose it in your proxy after the auth gate).
|
|
22
|
+
const DEFAULT_CMS_BASE = '/api/cms';
|
|
23
|
+
const DEFAULT_VARIANT_COOKIE_PREFIX = 'ab_';
|
|
24
|
+
const THIRTY_DAYS_SEC = 2_592_000;
|
|
25
|
+
async function defaultResolve(request, options, path) {
|
|
26
|
+
const base = options.cmsBaseUrl ?? DEFAULT_CMS_BASE;
|
|
27
|
+
const url = new URL(`${base}/${options.collection}/resolveAbVariant`, request.nextUrl.origin);
|
|
28
|
+
url.searchParams.set('path', path);
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
headers: {
|
|
32
|
+
cookie: request.headers.get('cookie') ?? ''
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) return {
|
|
36
|
+
test: null
|
|
37
|
+
};
|
|
38
|
+
return await res.json();
|
|
39
|
+
} catch {
|
|
40
|
+
return {
|
|
41
|
+
test: null
|
|
42
|
+
}; // fail open to control — render/paint never blocks
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Next.js Pattern A A/B fan-out — ALWAYS rewrites the request to the variant-
|
|
47
|
+
* coded `[abVariant]` route. Compose it inside your `proxy.ts` (Next 16) AFTER
|
|
48
|
+
* the auth gate, and only for PUBLIC CMS paths (it has no passthrough):
|
|
49
|
+
* ```ts
|
|
50
|
+
* // proxy.ts
|
|
51
|
+
* const abTest = abTestMiddleware({ collection: 'pages' });
|
|
52
|
+
* export default async function proxy(request) {
|
|
53
|
+
* if (isProtected(request)) return authGate(request); // not rewritten
|
|
54
|
+
* return abTest(request); // public CMS content → /ab/<code>/<path>
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/ function abTestMiddleware(options) {
|
|
58
|
+
const controlCode = options.controlCode ?? CONTROL_CODE;
|
|
59
|
+
const variantPrefix = options.variantPrefix ?? DEFAULT_VARIANT_PREFIX;
|
|
60
|
+
const cookiePrefix = options.variantCookiePrefix ?? DEFAULT_VARIANT_COOKIE_PREFIX;
|
|
61
|
+
const cookieMaxAge = options.variantCookieMaxAge ?? THIRTY_DAYS_SEC;
|
|
62
|
+
const resolve = options.resolve ?? ((req, path)=>defaultResolve(req, options, path));
|
|
63
|
+
return async (request)=>{
|
|
64
|
+
const { pathname } = request.nextUrl;
|
|
65
|
+
// Resolve, reuse-or-assign the variant, then ALWAYS rewrite to
|
|
66
|
+
// `<prefix>/<code><pathname>`. Any failure fails closed to the control code,
|
|
67
|
+
// so the request still lands on the `[abVariant]` route (never a 404).
|
|
68
|
+
let rewritePath;
|
|
69
|
+
let setCookie = null;
|
|
70
|
+
try {
|
|
71
|
+
const resolved = await resolve(request, pathname);
|
|
72
|
+
const testId = resolved.test?.testId ?? null;
|
|
73
|
+
const assignedCode = testId ? request.cookies.get(`${cookiePrefix}${testId}`)?.value ?? null : null;
|
|
74
|
+
const decision = decideEdgeVariant({
|
|
75
|
+
pathname,
|
|
76
|
+
resolve: resolved,
|
|
77
|
+
assignedCode,
|
|
78
|
+
controlCode,
|
|
79
|
+
variantPrefix
|
|
80
|
+
});
|
|
81
|
+
rewritePath = decision.rewritePath;
|
|
82
|
+
// A first assignment → persist the chosen variant code (variant-only).
|
|
83
|
+
if (decision.assignCode && decision.testId) {
|
|
84
|
+
setCookie = {
|
|
85
|
+
name: `${cookiePrefix}${decision.testId}`,
|
|
86
|
+
value: decision.assignCode
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
rewritePath = variantRewritePath(variantPrefix, controlCode, pathname);
|
|
91
|
+
}
|
|
92
|
+
const rewriteUrl = request.nextUrl.clone();
|
|
93
|
+
rewriteUrl.pathname = rewritePath; // transparent — browser URL unchanged
|
|
94
|
+
const response = NextResponse.rewrite(rewriteUrl);
|
|
95
|
+
if (setCookie) {
|
|
96
|
+
response.cookies.set(setCookie.name, setCookie.value, {
|
|
97
|
+
path: '/',
|
|
98
|
+
maxAge: cookieMaxAge,
|
|
99
|
+
sameSite: 'lax',
|
|
100
|
+
secure: true,
|
|
101
|
+
// httpOnly: the cookie is sent with every request, so cross-page
|
|
102
|
+
// conversion attribution reads it SERVER-side (no client read needed) —
|
|
103
|
+
// keep it httpOnly to harden against XSS. It holds only the variant code.
|
|
104
|
+
httpOnly: true
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return response;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { abTestMiddleware };
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
var drizzleOrm = require('drizzle-orm');
|
|
4
|
+
var nanoid$1 = require('nanoid');
|
|
5
|
+
|
|
6
|
+
const nanoid = nanoid$1.customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 20);
|
|
7
|
+
const prefixes = {
|
|
8
|
+
root: 'rot',
|
|
9
|
+
commit: 'cmt',
|
|
10
|
+
branch: 'brn',
|
|
11
|
+
blockVersion: 'blv',
|
|
12
|
+
block: 'blk',
|
|
13
|
+
mergeRequest: 'mrq',
|
|
14
|
+
mergeConflict: 'mcf',
|
|
15
|
+
approval: 'apr',
|
|
16
|
+
assetFolder: 'afl',
|
|
17
|
+
asset: 'ast',
|
|
18
|
+
contentUsage: 'cus',
|
|
19
|
+
commentThread: 'cth',
|
|
20
|
+
commentMessage: 'cmg',
|
|
21
|
+
commentMention: 'cmn',
|
|
22
|
+
variable: 'var',
|
|
23
|
+
template: 'tpl',
|
|
24
|
+
tplVarUsage: 'tvu',
|
|
25
|
+
notification: 'ntf',
|
|
26
|
+
si: 'sid',
|
|
27
|
+
redirect: 'rdr'
|
|
28
|
+
};
|
|
29
|
+
const customPrefixes = new Map();
|
|
30
|
+
function newId(prefix) {
|
|
31
|
+
const resolved = prefixes[prefix] ?? customPrefixes.get(prefix);
|
|
32
|
+
if (!resolved) {
|
|
33
|
+
throw new Error(`Unknown ID prefix "${prefix}". Register it with registerIdPrefix() first.`);
|
|
34
|
+
}
|
|
35
|
+
return `${resolved}_${nanoid()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Prevent Turbopack/webpack from statically analyzing these imports.
|
|
39
|
+
// The bundler rewrites bare `import('...')` calls into require/resolve
|
|
40
|
+
// that fail when the package lives in a different workspace. By
|
|
41
|
+
// constructing the specifier at runtime the import stays a true
|
|
42
|
+
// dynamic import that Node resolves at execution time.
|
|
43
|
+
const _upstashRedisId = [
|
|
44
|
+
'@upstash',
|
|
45
|
+
'redis'
|
|
46
|
+
].join('/');
|
|
47
|
+
const _upstashRealtimeId = [
|
|
48
|
+
'@upstash',
|
|
49
|
+
'realtime'
|
|
50
|
+
].join('/');
|
|
51
|
+
const _importUpstashRedis = ()=>new Function('id', 'return import(id)')(_upstashRedisId);
|
|
52
|
+
const _importUpstashRealtime = ()=>new Function('id', 'return import(id)')(_upstashRealtimeId);
|
|
53
|
+
const aggregationsTable = {
|
|
54
|
+
tableName: 'ab_test_aggregations',
|
|
55
|
+
indexPrefix: 'aba',
|
|
56
|
+
columns: {
|
|
57
|
+
id: {
|
|
58
|
+
type: 'text',
|
|
59
|
+
primaryKey: true,
|
|
60
|
+
defaultId: true,
|
|
61
|
+
defaultIdPrefix: 'abTestAgg'
|
|
62
|
+
},
|
|
63
|
+
testId: {
|
|
64
|
+
type: 'text',
|
|
65
|
+
notNull: true,
|
|
66
|
+
references: {
|
|
67
|
+
table: 'abTests',
|
|
68
|
+
column: 'id',
|
|
69
|
+
onDelete: 'cascade'
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
variantId: {
|
|
73
|
+
type: 'text',
|
|
74
|
+
notNull: true,
|
|
75
|
+
references: {
|
|
76
|
+
table: 'abTestVariants',
|
|
77
|
+
column: 'id',
|
|
78
|
+
onDelete: 'cascade'
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
eventType: {
|
|
82
|
+
type: 'text',
|
|
83
|
+
notNull: true
|
|
84
|
+
},
|
|
85
|
+
count: {
|
|
86
|
+
type: 'integer',
|
|
87
|
+
notNull: true,
|
|
88
|
+
default: {
|
|
89
|
+
kind: 'literal',
|
|
90
|
+
value: 0
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
uniqueVisitors: {
|
|
94
|
+
type: 'integer',
|
|
95
|
+
notNull: true,
|
|
96
|
+
default: {
|
|
97
|
+
kind: 'literal',
|
|
98
|
+
value: 0
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
periodStart: {
|
|
102
|
+
type: 'timestamp',
|
|
103
|
+
notNull: true
|
|
104
|
+
},
|
|
105
|
+
periodEnd: {
|
|
106
|
+
type: 'timestamp',
|
|
107
|
+
notNull: true
|
|
108
|
+
},
|
|
109
|
+
updatedAt: {
|
|
110
|
+
type: 'timestamp',
|
|
111
|
+
notNull: true,
|
|
112
|
+
defaultNow: true
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
indexes: {
|
|
116
|
+
testPeriodIdx: {
|
|
117
|
+
columns: [
|
|
118
|
+
'testId',
|
|
119
|
+
'periodStart'
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
uniqueBucketIdx: {
|
|
123
|
+
columns: [
|
|
124
|
+
'testId',
|
|
125
|
+
'variantId',
|
|
126
|
+
'eventType',
|
|
127
|
+
'periodStart'
|
|
128
|
+
],
|
|
129
|
+
unique: true
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Upstash Redis Stream adapter with batch flush and realtime delta publishing.
|
|
135
|
+
*
|
|
136
|
+
* Requires `@upstash/redis` and `@upstash/realtime` as peer dependencies.
|
|
137
|
+
* Events are stored in Redis Streams and flushed to Postgres on demand.
|
|
138
|
+
* Live deltas are published to `ab:live:{testId}` channels for realtime dashboards.
|
|
139
|
+
*/ function upstashAnalytics(options) {
|
|
140
|
+
let db;
|
|
141
|
+
let redis;
|
|
142
|
+
const adapter = {
|
|
143
|
+
tables: {
|
|
144
|
+
abTestAggregations: aggregationsTable
|
|
145
|
+
},
|
|
146
|
+
realtimeInstance: null,
|
|
147
|
+
async init (instance) {
|
|
148
|
+
db = instance;
|
|
149
|
+
const upstashRedis = await _importUpstashRedis();
|
|
150
|
+
redis = new upstashRedis.Redis({
|
|
151
|
+
url: options.url,
|
|
152
|
+
token: options.token
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const upstashRealtime = await _importUpstashRealtime();
|
|
156
|
+
adapter.realtimeInstance = new upstashRealtime.Realtime({
|
|
157
|
+
url: options.url,
|
|
158
|
+
token: options.token
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
// @upstash/realtime not installed -- realtime disabled
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
async track (event) {
|
|
165
|
+
// The Upstash adapter is the A/B-dashboard sink: it streams per-test
|
|
166
|
+
// events (keyed by testId) for live deltas + flush-to-aggregations, and
|
|
167
|
+
// it does NOT provision an ab_test_events table. A non-A/B analytics
|
|
168
|
+
// event (no `ab`) therefore has no durable home in this adapter and is
|
|
169
|
+
// DROPPED — there is no other sink to catch it until the M3 event-bus
|
|
170
|
+
// ships (see AB_MEASUREMENT_DESIGN §9 carry-forward). Make the drop loud
|
|
171
|
+
// rather than silent so a single-sink upstash deployment can see it.
|
|
172
|
+
if (!event.ab) {
|
|
173
|
+
console.warn(`[cms] upstashAnalytics dropped a non-A/B event ("${event.name}"): this A/B-realtime adapter has no durable store for non-A/B events. Use the postgres adapter (or wait for the M3 event-bus) to persist page_view / form_submit events.`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const { testId, variantId } = event.ab;
|
|
177
|
+
const streamKey = `ab:events:${testId}`;
|
|
178
|
+
const entry = {
|
|
179
|
+
testId,
|
|
180
|
+
variantId,
|
|
181
|
+
visitorId: event.visitorId ?? '',
|
|
182
|
+
anonymous: String(event.anonymous),
|
|
183
|
+
eventType: event.name,
|
|
184
|
+
timestamp: event.timestamp.toISOString()
|
|
185
|
+
};
|
|
186
|
+
if (event.metadata) {
|
|
187
|
+
entry.metadata = JSON.stringify(event.metadata);
|
|
188
|
+
}
|
|
189
|
+
await redis.xadd(streamKey, '*', entry);
|
|
190
|
+
if (adapter.realtimeInstance) {
|
|
191
|
+
const delta = {
|
|
192
|
+
variantId,
|
|
193
|
+
eventType: event.name,
|
|
194
|
+
count: 1,
|
|
195
|
+
timestamp: Date.now()
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
await redis.publish(`ab:live:${testId}`, JSON.stringify(delta));
|
|
199
|
+
} catch {
|
|
200
|
+
// Non-critical
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
async query (testId, options) {
|
|
205
|
+
const fromClause = options?.from ? drizzleOrm.sql` AND a.period_start >= ${options.from}` : drizzleOrm.sql``;
|
|
206
|
+
const toClause = options?.to ? drizzleOrm.sql` AND a.period_end <= ${options.to}` : drizzleOrm.sql``;
|
|
207
|
+
const rows = await db.execute(drizzleOrm.sql`
|
|
208
|
+
SELECT
|
|
209
|
+
a.variant_id,
|
|
210
|
+
v.name AS variant_name,
|
|
211
|
+
a.event_type,
|
|
212
|
+
SUM(a.count)::int AS count,
|
|
213
|
+
SUM(a.unique_visitors)::int AS unique_visitors
|
|
214
|
+
FROM cms.ab_test_aggregations a
|
|
215
|
+
INNER JOIN cms.ab_test_variants v ON v.id = a.variant_id
|
|
216
|
+
WHERE a.test_id = ${testId}
|
|
217
|
+
${fromClause}
|
|
218
|
+
${toClause}
|
|
219
|
+
GROUP BY a.variant_id, v.name, a.event_type
|
|
220
|
+
ORDER BY a.variant_id, a.event_type
|
|
221
|
+
`);
|
|
222
|
+
const variantMap = new Map();
|
|
223
|
+
for (const row of rows.rows){
|
|
224
|
+
let v = variantMap.get(row.variant_id);
|
|
225
|
+
if (!v) {
|
|
226
|
+
v = {
|
|
227
|
+
variantId: row.variant_id,
|
|
228
|
+
variantName: row.variant_name,
|
|
229
|
+
impressions: 0,
|
|
230
|
+
conversions: 0,
|
|
231
|
+
uniqueVisitors: 0,
|
|
232
|
+
conversionRate: 0,
|
|
233
|
+
// The upstash pre-aggregate flush does not track interaction ids, so
|
|
234
|
+
// the funnel (attempts/completionRate) is the postgres path only.
|
|
235
|
+
attempts: 0,
|
|
236
|
+
completionRate: 0,
|
|
237
|
+
eventBreakdown: {}
|
|
238
|
+
};
|
|
239
|
+
variantMap.set(row.variant_id, v);
|
|
240
|
+
}
|
|
241
|
+
v.eventBreakdown[row.event_type] = {
|
|
242
|
+
count: row.count,
|
|
243
|
+
uniqueVisitors: row.unique_visitors,
|
|
244
|
+
distinctInteractions: 0
|
|
245
|
+
};
|
|
246
|
+
if (row.event_type === 'impression') {
|
|
247
|
+
v.impressions = row.count;
|
|
248
|
+
v.uniqueVisitors = row.unique_visitors;
|
|
249
|
+
} else if (row.event_type === 'conversion') {
|
|
250
|
+
v.conversions = row.count;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const variants = [
|
|
254
|
+
...variantMap.values()
|
|
255
|
+
];
|
|
256
|
+
for (const v of variants){
|
|
257
|
+
v.conversionRate = v.impressions > 0 ? Math.round(v.conversions / v.impressions * 10000) / 100 : 0;
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
testId,
|
|
261
|
+
variants,
|
|
262
|
+
totalImpressions: variants.reduce((s, v)=>s + v.impressions, 0),
|
|
263
|
+
totalConversions: variants.reduce((s, v)=>s + v.conversions, 0)
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
async flush (testId) {
|
|
267
|
+
const streamKeys = [];
|
|
268
|
+
if (testId) {
|
|
269
|
+
streamKeys.push(`ab:events:${testId}`);
|
|
270
|
+
} else {
|
|
271
|
+
let cursor = '0';
|
|
272
|
+
do {
|
|
273
|
+
const [nextCursor, keys] = await redis.scan(cursor, {
|
|
274
|
+
match: 'ab:events:*',
|
|
275
|
+
count: 100
|
|
276
|
+
});
|
|
277
|
+
cursor = nextCursor;
|
|
278
|
+
streamKeys.push(...keys);
|
|
279
|
+
}while (cursor !== '0')
|
|
280
|
+
}
|
|
281
|
+
let totalFlushed = 0;
|
|
282
|
+
for (const streamKey of streamKeys){
|
|
283
|
+
const cursorKey = `ab:cursor:${streamKey}`;
|
|
284
|
+
const lastId = await redis.get(cursorKey) ?? '0-0';
|
|
285
|
+
const results = await redis.xread([
|
|
286
|
+
{
|
|
287
|
+
key: streamKey,
|
|
288
|
+
id: lastId
|
|
289
|
+
}
|
|
290
|
+
], {
|
|
291
|
+
count: 10000
|
|
292
|
+
});
|
|
293
|
+
if (!results || results.length === 0) continue;
|
|
294
|
+
const stream = results[0];
|
|
295
|
+
if (!stream || !stream.messages || stream.messages.length === 0) continue;
|
|
296
|
+
const agg = new Map();
|
|
297
|
+
let maxId = lastId;
|
|
298
|
+
for (const msg of stream.messages){
|
|
299
|
+
maxId = msg.id;
|
|
300
|
+
const d = msg.message;
|
|
301
|
+
const key = `${d.testId}:${d.variantId}:${d.eventType}`;
|
|
302
|
+
let bucket = agg.get(key);
|
|
303
|
+
if (!bucket) {
|
|
304
|
+
bucket = {
|
|
305
|
+
count: 0,
|
|
306
|
+
visitors: new Set()
|
|
307
|
+
};
|
|
308
|
+
agg.set(key, bucket);
|
|
309
|
+
}
|
|
310
|
+
bucket.count++;
|
|
311
|
+
bucket.visitors.add(d.visitorId);
|
|
312
|
+
}
|
|
313
|
+
const now = new Date();
|
|
314
|
+
const periodStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
315
|
+
const periodEnd = new Date(periodStart.getTime() + 86400000);
|
|
316
|
+
for (const [key, bucket] of agg){
|
|
317
|
+
const [tId, variantId, eventType] = key.split(':');
|
|
318
|
+
const id = newId('abTestAgg');
|
|
319
|
+
await db.execute(drizzleOrm.sql`
|
|
320
|
+
INSERT INTO cms.ab_test_aggregations
|
|
321
|
+
(id, test_id, variant_id, event_type, count, unique_visitors, period_start, period_end, updated_at)
|
|
322
|
+
VALUES
|
|
323
|
+
(${id}, ${tId}, ${variantId}, ${eventType}, ${bucket.count}, ${bucket.visitors.size}, ${periodStart}, ${periodEnd}, NOW())
|
|
324
|
+
ON CONFLICT (test_id, variant_id, event_type, period_start) DO UPDATE SET
|
|
325
|
+
count = cms.ab_test_aggregations.count + EXCLUDED.count,
|
|
326
|
+
unique_visitors = GREATEST(cms.ab_test_aggregations.unique_visitors, EXCLUDED.unique_visitors),
|
|
327
|
+
updated_at = NOW()
|
|
328
|
+
`);
|
|
329
|
+
totalFlushed += bucket.count;
|
|
330
|
+
}
|
|
331
|
+
await redis.set(cursorKey, maxId);
|
|
332
|
+
await redis.xtrim(streamKey, {
|
|
333
|
+
strategy: 'MINID',
|
|
334
|
+
threshold: maxId
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
flushed: totalFlushed
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
return adapter;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
exports.upstashAnalytics = upstashAnalytics;
|