@delmaredigital/payload-experiments 0.2.0-rc.1 → 0.2.0-rc.3
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 +1 -1
- package/dist/admin/paths.d.ts +3 -3
- package/dist/admin/paths.js +3 -3
- package/dist/endpoints/preflight.js +3 -2
- package/dist/endpoints/previewToken.js +3 -2
- package/dist/endpoints/recompute.js +3 -2
- package/dist/endpoints/results.js +3 -2
- package/dist/endpoints/routeId.d.ts +5 -0
- package/dist/endpoints/routeId.js +13 -0
- package/dist/endpoints/summary.js +32 -21
- package/dist/endpoints/transitions.js +3 -2
- package/dist/exports/adminRsc.d.ts +3 -0
- package/dist/exports/adminRsc.js +3 -0
- package/dist/exports/rsc.d.ts +0 -3
- package/dist/exports/rsc.js +0 -3
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @delmaredigital/payload-experiments
|
|
2
2
|
|
|
3
|
-
> ⚠️ Upgrading to 0.2? After upgrading, run `payload generate:importmap`: the Tests surface is registered through admin.dependencies, and without it the Tests routes show a 'not installed' message instead of the Tests screen. The old experiments edit-form components are gone from `/admin/client`, and the `xp-experiments` admin list and edit views now redirect to the Tests surface.
|
|
3
|
+
> ⚠️ Upgrading to 0.2? After upgrading, run `payload generate:importmap`: the Tests surface is registered through admin.dependencies, and without it the Tests routes show a 'not installed' message instead of the Tests screen. In 0.2.0-rc.2, the Tests server views moved from `/rsc` to `/admin/rsc`, so the import map must be regenerated when upgrading from rc.1 too. The old experiments edit-form components are gone from `/admin/client`, and the `xp-experiments` admin list and edit views now redirect to the Tests surface.
|
|
4
4
|
|
|
5
5
|
A/B testing for Payload CMS and Next.js, with results inside the admin and data
|
|
6
6
|
in your own Postgres database. Editors can test blocks, whole pages and
|
package/dist/admin/paths.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export declare const XP_METRIC_SOURCE_SELECT = "@delmaredigital/payload-experiments/admin/client#MetricSourceSelect";
|
|
2
2
|
export declare const XP_LAYER_USAGE = "@delmaredigital/payload-experiments/admin/client#LayerUsage";
|
|
3
3
|
export declare const XP_ARM_SELECT = "@delmaredigital/payload-experiments/admin/client#ArmSelectField";
|
|
4
|
-
export declare const XP_TESTS_VIEW = "@delmaredigital/payload-experiments/rsc#ExperimentsRootView";
|
|
5
|
-
export declare const XP_TESTS_LIST_REDIRECT = "@delmaredigital/payload-experiments/rsc#CollectionListRedirect";
|
|
6
|
-
export declare const XP_TEST_EDIT_REDIRECT = "@delmaredigital/payload-experiments/rsc#CollectionEditRedirect";
|
|
4
|
+
export declare const XP_TESTS_VIEW = "@delmaredigital/payload-experiments/admin/rsc#ExperimentsRootView";
|
|
5
|
+
export declare const XP_TESTS_LIST_REDIRECT = "@delmaredigital/payload-experiments/admin/rsc#CollectionListRedirect";
|
|
6
|
+
export declare const XP_TEST_EDIT_REDIRECT = "@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect";
|
|
7
7
|
export declare const XP_TESTS_NAV_LINK = "@delmaredigital/payload-experiments/admin/client#TestsNavLink";
|
|
8
8
|
export declare const XP_TESTS_SURFACE = "@delmaredigital/payload-experiments/admin/client#TestsSurface";
|
package/dist/admin/paths.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export const XP_METRIC_SOURCE_SELECT = '@delmaredigital/payload-experiments/admin/client#MetricSourceSelect';
|
|
2
2
|
export const XP_LAYER_USAGE = '@delmaredigital/payload-experiments/admin/client#LayerUsage';
|
|
3
3
|
export const XP_ARM_SELECT = '@delmaredigital/payload-experiments/admin/client#ArmSelectField';
|
|
4
|
-
export const XP_TESTS_VIEW = '@delmaredigital/payload-experiments/rsc#ExperimentsRootView';
|
|
5
|
-
export const XP_TESTS_LIST_REDIRECT = '@delmaredigital/payload-experiments/rsc#CollectionListRedirect';
|
|
6
|
-
export const XP_TEST_EDIT_REDIRECT = '@delmaredigital/payload-experiments/rsc#CollectionEditRedirect';
|
|
4
|
+
export const XP_TESTS_VIEW = '@delmaredigital/payload-experiments/admin/rsc#ExperimentsRootView';
|
|
5
|
+
export const XP_TESTS_LIST_REDIRECT = '@delmaredigital/payload-experiments/admin/rsc#CollectionListRedirect';
|
|
6
|
+
export const XP_TEST_EDIT_REDIRECT = '@delmaredigital/payload-experiments/admin/rsc#CollectionEditRedirect';
|
|
7
7
|
export const XP_TESTS_NAV_LINK = '@delmaredigital/payload-experiments/admin/client#TestsNavLink';
|
|
8
8
|
export const XP_TESTS_SURFACE = '@delmaredigital/payload-experiments/admin/client#TestsSurface';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { preflightData } from '../transitions/preflightData.js';
|
|
2
|
+
import { routeId } from './routeId.js';
|
|
2
3
|
export async function handlePreflight(req, ctx, dependencies) {
|
|
3
|
-
const id = req
|
|
4
|
-
const result =
|
|
4
|
+
const id = routeId(req);
|
|
5
|
+
const result = id !== undefined ? await preflightData(req, {
|
|
5
6
|
experimentId: id,
|
|
6
7
|
options: ctx.options
|
|
7
8
|
}, dependencies) : null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { signPreviewToken } from '../runtime/previewToken.js';
|
|
2
|
+
import { routeId } from './routeId.js';
|
|
2
3
|
export async function handlePreviewToken(req, { options }) {
|
|
3
4
|
const headers = {
|
|
4
5
|
'Cache-Control': 'no-store'
|
|
@@ -14,8 +15,8 @@ export async function handlePreviewToken(req, { options }) {
|
|
|
14
15
|
status,
|
|
15
16
|
headers
|
|
16
17
|
});
|
|
17
|
-
const id = req
|
|
18
|
-
if (
|
|
18
|
+
const id = routeId(req);
|
|
19
|
+
if (id === undefined) return refuse(404, 'not-found', 'Experiment not found');
|
|
19
20
|
const result = await req.payload.find({
|
|
20
21
|
collection: 'xp-experiments',
|
|
21
22
|
req,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { runResults } from '../server/runResults.js';
|
|
2
2
|
import { readSnapshots, resultsError } from './results.js';
|
|
3
|
+
import { routeId } from './routeId.js';
|
|
3
4
|
export async function handleRecompute(req, _ctx) {
|
|
4
|
-
const id = req
|
|
5
|
-
if (
|
|
5
|
+
const id = routeId(req);
|
|
6
|
+
if (id === undefined) return resultsError(404, 'not-found', 'Experiment not found');
|
|
6
7
|
const { docs } = await req.payload.find({
|
|
7
8
|
collection: 'xp-experiments',
|
|
8
9
|
req,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resultsMetadata, newestSnapshotIds } from '../db/snapshots.js';
|
|
2
2
|
import { pooledExecutor } from '../db/executor.js';
|
|
3
|
+
import { routeId } from './routeId.js';
|
|
3
4
|
const headers = {
|
|
4
5
|
'Cache-Control': 'no-store'
|
|
5
6
|
};
|
|
@@ -52,8 +53,8 @@ export async function readSnapshots(req, ids, { per = 'phase', decisionId, serie
|
|
|
52
53
|
}));
|
|
53
54
|
}
|
|
54
55
|
export async function handleResults(req, _ctx) {
|
|
55
|
-
const id = req
|
|
56
|
-
if (
|
|
56
|
+
const id = routeId(req);
|
|
57
|
+
if (id === undefined) return resultsError(404, 'not-found', 'Experiment not found');
|
|
57
58
|
const params = new URL(req.url ?? 'http://localhost').searchParams;
|
|
58
59
|
const selected = params.get('phase');
|
|
59
60
|
if (selected !== null && (!/^\d+$/.test(selected) || !Number.isSafeInteger(Number(selected)) || Number(selected) < 1)) return resultsError(400, 'invalid-body', 'Choose a valid round');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PayloadRequest } from 'payload';
|
|
2
|
+
import type { DocId } from '../types/contracts.js';
|
|
3
|
+
/** Match Payload's relationship ID type before passing an HTTP ID to Local API or SQL. */
|
|
4
|
+
export declare function experimentId(req: PayloadRequest, value: unknown): DocId | undefined;
|
|
5
|
+
export declare function routeId(req: PayloadRequest): DocId | undefined;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Match Payload's relationship ID type before passing an HTTP ID to Local API or SQL. */ export function experimentId(req, value) {
|
|
2
|
+
if (typeof value !== 'string' && typeof value !== 'number') return undefined;
|
|
3
|
+
const id = String(value);
|
|
4
|
+
const idType = req.payload.collections?.['xp-experiments']?.customIDType ?? req.payload.db.defaultIDType;
|
|
5
|
+
if (idType !== 'number') return id;
|
|
6
|
+
if (!/^\d+$/.test(id)) return undefined;
|
|
7
|
+
const number = Number(id);
|
|
8
|
+
// Payload's Postgres serial ids are int4; larger values would reach SQL and fail with 22003.
|
|
9
|
+
return Number.isSafeInteger(number) && number <= 2_147_483_647 ? number : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function routeId(req) {
|
|
12
|
+
return experimentId(req, req.routeParams?.id);
|
|
13
|
+
}
|
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
import { checkStart } from '../transitions/checkStart.js';
|
|
2
2
|
import { loadStartInputsBatch } from '../transitions/preflightData.js';
|
|
3
3
|
import { readSnapshots, resultsError } from './results.js';
|
|
4
|
+
import { experimentId } from './routeId.js';
|
|
4
5
|
export async function handleSummary(req, { options }) {
|
|
5
6
|
let ids;
|
|
6
7
|
try {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const url = new URL(req.url ?? 'http://localhost');
|
|
9
|
+
// URLSearchParams tolerates malformed escapes; preserve the existing parse refusal.
|
|
10
|
+
decodeURIComponent(url.search.replaceAll('+', ' '));
|
|
11
|
+
const values = url.searchParams.getAll('ids');
|
|
12
|
+
if (values.length === 0) return resultsError(400, 'invalid-body', 'Provide experiment ids');
|
|
13
|
+
ids = values.flatMap((value)=>value.split(',')).map((id)=>id.trim()).filter(Boolean);
|
|
11
14
|
} catch {
|
|
12
15
|
return resultsError(400, 'invalid-body', 'Could not parse experiment ids');
|
|
13
16
|
}
|
|
14
17
|
if (ids.length > 100) return resultsError(422, 'too-many-ids', 'Request at most 100 experiments');
|
|
15
|
-
if (ids.
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
if (ids.length === 0) return resultsError(422, 'invalid-ids', 'Experiment ids must be nonempty');
|
|
19
|
+
const requested = ids.flatMap((key)=>{
|
|
20
|
+
const id = experimentId(req, key);
|
|
21
|
+
return id === undefined ? [] : [
|
|
22
|
+
{
|
|
23
|
+
key,
|
|
24
|
+
id
|
|
25
|
+
}
|
|
26
|
+
];
|
|
20
27
|
});
|
|
28
|
+
const uniqueIds = [
|
|
29
|
+
...new Set(requested.map(({ id })=>id))
|
|
30
|
+
];
|
|
31
|
+
const snapshots = uniqueIds.length ? await readSnapshots(req, uniqueIds, {
|
|
32
|
+
per: 'experiment'
|
|
33
|
+
}) : [];
|
|
21
34
|
const response = Object.create(null);
|
|
22
|
-
for (const id of
|
|
23
|
-
const snapshot = snapshots.find((row)=>String(typeof row.experiment === 'object' ? row.experiment.id : row.experiment) === id)?.snapshot;
|
|
24
|
-
response[
|
|
35
|
+
for (const { key, id } of requested){
|
|
36
|
+
const snapshot = snapshots.find((row)=>String(typeof row.experiment === 'object' ? row.experiment.id : row.experiment) === String(id))?.snapshot;
|
|
37
|
+
response[key] = snapshot ? {
|
|
25
38
|
exposures: snapshot.exposures.total,
|
|
26
39
|
days: Math.max(0, (Date.parse(snapshot.window.to) - Date.parse(snapshot.window.from)) / 86_400_000),
|
|
27
40
|
verdict: {
|
|
@@ -35,7 +48,7 @@ export async function handleSummary(req, { options }) {
|
|
|
35
48
|
};
|
|
36
49
|
}
|
|
37
50
|
try {
|
|
38
|
-
if (await options.access.manage({
|
|
51
|
+
if (uniqueIds.length && await options.access.manage({
|
|
39
52
|
req
|
|
40
53
|
}) === true) {
|
|
41
54
|
const found = await req.payload.find({
|
|
@@ -44,26 +57,24 @@ export async function handleSummary(req, { options }) {
|
|
|
44
57
|
overrideAccess: false,
|
|
45
58
|
depth: 0,
|
|
46
59
|
pagination: false,
|
|
47
|
-
limit:
|
|
60
|
+
limit: uniqueIds.length,
|
|
48
61
|
where: {
|
|
49
62
|
id: {
|
|
50
|
-
in:
|
|
51
|
-
...new Set(ids)
|
|
52
|
-
]
|
|
63
|
+
in: uniqueIds
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
});
|
|
56
67
|
const drafts = found.docs.filter((e)=>e.status === 'draft' || e.status === 'stopped');
|
|
57
68
|
const inputs = await loadStartInputsBatch(req, options, drafts, true);
|
|
58
|
-
for (const id of
|
|
69
|
+
for (const { key, id } of requested){
|
|
59
70
|
// Payload normalises numeric ids (for example 01 to 1); preserve the requested response key.
|
|
60
|
-
const experiment = drafts.find((e)=>String(e.id) ===
|
|
71
|
+
const experiment = drafts.find((e)=>String(e.id) === String(id));
|
|
61
72
|
if (!experiment) continue;
|
|
62
73
|
const input = inputs.get(String(experiment.id));
|
|
63
74
|
try {
|
|
64
|
-
response[
|
|
75
|
+
response[key].ready = !!input && checkStart(input).every((check)=>check.ok);
|
|
65
76
|
} catch {
|
|
66
|
-
response[
|
|
77
|
+
response[key].ready = false;
|
|
67
78
|
}
|
|
68
79
|
}
|
|
69
80
|
}
|
|
@@ -7,6 +7,7 @@ import { decide } from '../transitions/decide.js';
|
|
|
7
7
|
import { releaseHoldout } from '../transitions/releaseHoldout.js';
|
|
8
8
|
import { makeContent } from '../transitions/makeContent.js';
|
|
9
9
|
import { archive } from '../transitions/archive.js';
|
|
10
|
+
import { routeId } from './routeId.js';
|
|
10
11
|
const invalidBody = ()=>Response.json({
|
|
11
12
|
errors: [
|
|
12
13
|
{
|
|
@@ -30,8 +31,8 @@ async function body(req) {
|
|
|
30
31
|
}
|
|
31
32
|
const only = (value, keys)=>Object.keys(value).every((key)=>keys.includes(key));
|
|
32
33
|
async function respond(req, action) {
|
|
33
|
-
const id = req
|
|
34
|
-
if (
|
|
34
|
+
const id = routeId(req);
|
|
35
|
+
if (id === undefined) return Response.json({
|
|
35
36
|
errors: [
|
|
36
37
|
{
|
|
37
38
|
code: 'not-found',
|
package/dist/exports/rsc.d.ts
CHANGED
|
@@ -11,6 +11,3 @@ export { experimentSitemapFilter } from '../server/sitemap.js';
|
|
|
11
11
|
export { experimentMetadata } from '../server/metadata.js';
|
|
12
12
|
export { toClientAssignments } from '../server/toClientAssignments.js';
|
|
13
13
|
export { contentChangesBeforeHook as captureContentBaseline } from '../hooks/contentChanges.js';
|
|
14
|
-
export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
|
|
15
|
-
export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
|
|
16
|
-
export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
|
package/dist/exports/rsc.js
CHANGED
|
@@ -11,6 +11,3 @@ export { experimentSitemapFilter } from '../server/sitemap.js';
|
|
|
11
11
|
export { experimentMetadata } from '../server/metadata.js';
|
|
12
12
|
export { toClientAssignments } from '../server/toClientAssignments.js';
|
|
13
13
|
export { contentChangesBeforeHook as captureContentBaseline } from '../hooks/contentChanges.js';
|
|
14
|
-
export { ExperimentsRootView } from '../admin/views/ExperimentsRootView.js';
|
|
15
|
-
export { CollectionListRedirect } from '../admin/views/CollectionListRedirect.js';
|
|
16
|
-
export { CollectionEditRedirect } from '../admin/views/CollectionEditRedirect.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delmaredigital/payload-experiments",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
3
|
+
"version": "0.2.0-rc.3",
|
|
4
4
|
"description": "No-code A/B testing for Payload CMS and Next.js: block, page and flag experiments with holdouts, layers, exposure logging and honest statistics inside the admin — no vendor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,6 +49,12 @@
|
|
|
49
49
|
"import": "./dist/exports/adminClient.js",
|
|
50
50
|
"default": "./dist/exports/adminClient.js"
|
|
51
51
|
},
|
|
52
|
+
"./admin/rsc": {
|
|
53
|
+
"@delmaredigital/source": "./src/exports/adminRsc.ts",
|
|
54
|
+
"types": "./dist/exports/adminRsc.d.ts",
|
|
55
|
+
"import": "./dist/exports/adminRsc.js",
|
|
56
|
+
"default": "./dist/exports/adminRsc.js"
|
|
57
|
+
},
|
|
52
58
|
"./puck": {
|
|
53
59
|
"@delmaredigital/source": "./src/exports/puck.ts",
|
|
54
60
|
"types": "./dist/exports/puck.d.ts",
|