@delmaredigital/payload-experiments 0.1.0-rc.3 → 0.1.0-rc.7
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 +14 -6
- package/dist/collections/experiments/flagControlParams.js +8 -14
- package/dist/collections/layers.js +2 -1
- package/dist/collections/serialize.js +2 -2
- package/dist/events/rows.js +1 -1
- package/dist/options.js +13 -5
- package/dist/server/runResults.js +1 -1
- package/dist/types/contracts.d.ts +10 -2
- package/dist/types/contracts.js +1 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -372,12 +372,20 @@ is frozen for the round. Capping affects decision statistics, while results
|
|
|
372
372
|
also show uncapped totals. Count and binomial metrics are never capped.
|
|
373
373
|
|
|
374
374
|
For simple yes/no outcomes, list names in `eventSources`, for example
|
|
375
|
-
`eventSources: ['signup']`.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
375
|
+
`eventSources: ['signup']`. A bare string allows client writes; the object form
|
|
376
|
+
sets that permission explicitly:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
eventSources: ['signup', { name: 'donation_completed', clientWritable: false }]
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Names must match `/^[a-z0-9_]+$/` and be unique across both forms. Editors can
|
|
383
|
+
choose `events:signup` or `events:donation_completed` as binomial sources backed
|
|
384
|
+
by stored conversions with the corresponding `eventName`. Writable sources
|
|
385
|
+
accept both `trackConversion` and `trackConversionBeacon`; server-only sources
|
|
386
|
+
are recorded with `trackConversion`. Beacon conversions for a non-writable name
|
|
387
|
+
are dropped silently. Only listed names become `events:<name>` sources. They
|
|
388
|
+
support visitor and user units and do not represent counts or revenue in v1.
|
|
381
389
|
|
|
382
390
|
## Security
|
|
383
391
|
|
|
@@ -6,20 +6,14 @@ export const flagControlParams = async ({ data, originalDoc, operation, req })=>
|
|
|
6
6
|
const pointId = data.flagPoint ?? originalDoc?.flagPoint;
|
|
7
7
|
const point = xpOptionsFromReq(req).flagPoints?.find((point)=>point.id === pointId);
|
|
8
8
|
if (!point) return data;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
params = defaults?.[point.id]?.params ?? params;
|
|
18
|
-
} catch (cause) {
|
|
19
|
-
const error = cause;
|
|
20
|
-
if (error?.status !== 403 && error?.name !== 'Forbidden') throw cause;
|
|
21
|
-
// A refused state read must not prevent using the registered point defaults.
|
|
22
|
-
}
|
|
9
|
+
// the caller's write already passed experiments manage access; a second gate inside its hook would kill the caller's transaction on Forbidden (Payload killTransaction)
|
|
10
|
+
const state = await req.payload.findGlobal({
|
|
11
|
+
slug: 'xp-state',
|
|
12
|
+
req,
|
|
13
|
+
overrideAccess: true
|
|
14
|
+
});
|
|
15
|
+
const defaults = state.flagDefaults;
|
|
16
|
+
const params = defaults?.[point.id]?.params ?? point.defaultParams;
|
|
23
17
|
const arms = data.arms ?? originalDoc?.arms;
|
|
24
18
|
if (!arms) return data;
|
|
25
19
|
return {
|
|
@@ -58,6 +58,7 @@ export function layersCollection(options) {
|
|
|
58
58
|
hooks: {
|
|
59
59
|
beforeDelete: [
|
|
60
60
|
async ({ id, req })=>{
|
|
61
|
+
// The delete already passed layers access; a refused nested read would kill the caller's transaction (Payload killTransaction) and could hide a referencing experiment.
|
|
61
62
|
const references = await req.payload.find({
|
|
62
63
|
collection: 'xp-experiments',
|
|
63
64
|
where: {
|
|
@@ -66,7 +67,7 @@ export function layersCollection(options) {
|
|
|
66
67
|
}
|
|
67
68
|
},
|
|
68
69
|
limit: 1,
|
|
69
|
-
overrideAccess:
|
|
70
|
+
overrideAccess: true,
|
|
70
71
|
req
|
|
71
72
|
});
|
|
72
73
|
if (references.docs.length) throw new XpRefusal('invalid-status', 'This layer is still referenced by an experiment.', undefined, 409);
|
|
@@ -16,12 +16,12 @@ export function serializeMetricSources(options) {
|
|
|
16
16
|
keyedBy,
|
|
17
17
|
clientWritable: clientWritable === true
|
|
18
18
|
})),
|
|
19
|
-
...(options.eventSources ?? []).map((name)=>({
|
|
19
|
+
...(options.eventSources ?? []).map(({ name, clientWritable })=>({
|
|
20
20
|
id: `events:${name}`,
|
|
21
21
|
label: name,
|
|
22
22
|
kind: 'binomial',
|
|
23
23
|
keyedBy: 'visitor',
|
|
24
|
-
clientWritable
|
|
24
|
+
clientWritable
|
|
25
25
|
}))
|
|
26
26
|
];
|
|
27
27
|
}
|
package/dist/events/rows.js
CHANGED
|
@@ -42,7 +42,7 @@ export function eventRows(events, manifest, assignments, options, visitorId, use
|
|
|
42
42
|
} else if (event.type === 'conversion') {
|
|
43
43
|
if (typeof event.eventName !== 'string' || typeof event.idempotencyKey !== 'string' || !event.idempotencyKey.trim() || event.value !== undefined && (typeof event.value !== 'number' || !Number.isFinite(event.value))) continue;
|
|
44
44
|
const source = options.metricSources?.find((source)=>source.id === event.eventName);
|
|
45
|
-
if (source ? source.clientWritable !== true || source.kind === 'revenue' : !options.eventSources?.
|
|
45
|
+
if (source ? source.clientWritable !== true || source.kind === 'revenue' : !options.eventSources?.some((source)=>source.name === event.eventName && source.clientWritable)) continue;
|
|
46
46
|
row = {
|
|
47
47
|
type: 'conversion',
|
|
48
48
|
eventName: event.eventName,
|
package/dist/options.js
CHANGED
|
@@ -42,9 +42,19 @@ export function resolveOptions(input) {
|
|
|
42
42
|
throw new Error(`Experiments attribute values must be strings: ${key}`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
const eventNames = new Set();
|
|
46
|
+
const eventSources = (input.eventSources ?? []).map((entry)=>{
|
|
47
|
+
const object = entry !== null && typeof entry === 'object' && !Array.isArray(entry);
|
|
48
|
+
const name = typeof entry === 'string' ? entry : object ? entry.name : undefined;
|
|
46
49
|
if (typeof name !== 'string' || !/^[a-z0-9_]+$/.test(name)) throw new Error('Experiments event source names must match /^[a-z0-9_]+$/');
|
|
47
|
-
|
|
50
|
+
if (object && typeof entry.clientWritable !== 'boolean') throw new Error('Experiments event source clientWritable must be a boolean');
|
|
51
|
+
if (eventNames.has(name)) throw new Error('Experiments event source names must be unique');
|
|
52
|
+
eventNames.add(name);
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
clientWritable: typeof entry === 'string' ? true : entry.clientWritable
|
|
56
|
+
};
|
|
57
|
+
});
|
|
48
58
|
const retentionDays = input.events?.retentionDays ?? 180;
|
|
49
59
|
const maxBodyBytes = input.events?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
50
60
|
for (const [name, value] of Object.entries({
|
|
@@ -68,9 +78,7 @@ export function resolveOptions(input) {
|
|
|
68
78
|
metricSources: [
|
|
69
79
|
...input.metricSources ?? []
|
|
70
80
|
],
|
|
71
|
-
eventSources
|
|
72
|
-
...input.eventSources ?? []
|
|
73
|
-
],
|
|
81
|
+
eventSources,
|
|
74
82
|
attributeKeys: Object.fromEntries(Object.entries(input.attributeKeys ?? {}).map(([key, attribute])=>[
|
|
75
83
|
key,
|
|
76
84
|
{
|
|
@@ -88,7 +88,7 @@ async function analyseCandidate(exec, candidate, options, now, deadline, unavail
|
|
|
88
88
|
let events, covariates;
|
|
89
89
|
if (definition.source.startsWith('events:')) {
|
|
90
90
|
const eventName = definition.source.slice(7);
|
|
91
|
-
if (!options.eventSources?.
|
|
91
|
+
if (!options.eventSources?.some((source)=>source.name === eventName)) throw new Error('Metric source unavailable');
|
|
92
92
|
const read = async (start)=>{
|
|
93
93
|
if (candidate.unitType === 'user') return metricEventRows(exec, {
|
|
94
94
|
eventName,
|
|
@@ -105,7 +105,10 @@ export interface ExperimentsPluginOptions<TUser extends {
|
|
|
105
105
|
readIdentities?: ExperimentsAccess;
|
|
106
106
|
};
|
|
107
107
|
metricSources?: MetricSource[];
|
|
108
|
-
eventSources?: string
|
|
108
|
+
eventSources?: Array<string | {
|
|
109
|
+
name: string;
|
|
110
|
+
clientWritable: boolean;
|
|
111
|
+
}>;
|
|
109
112
|
flagPoints?: FlagPoint[];
|
|
110
113
|
attributeKeys?: Record<string, {
|
|
111
114
|
type: 'string' | 'number' | 'boolean';
|
|
@@ -130,4 +133,9 @@ export type ResolvedOptions<TUser extends {
|
|
|
130
133
|
id: string | number;
|
|
131
134
|
} = {
|
|
132
135
|
id: string | number;
|
|
133
|
-
}> = ExperimentsPluginOptions<TUser
|
|
136
|
+
}> = Omit<ExperimentsPluginOptions<TUser>, 'eventSources'> & {
|
|
137
|
+
eventSources?: Array<{
|
|
138
|
+
name: string;
|
|
139
|
+
clientWritable: boolean;
|
|
140
|
+
}>;
|
|
141
|
+
};
|
package/dist/types/contracts.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
/** Options required by the route table; CORE-02c resolves optional defaults. */
|
|
2
|
-
export { };
|
|
1
|
+
/** Options required by the route table; CORE-02c resolves optional defaults. */ export { };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delmaredigital/payload-experiments",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.7",
|
|
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",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"types": "./dist/exports/puckEditor.d.ts",
|
|
61
61
|
"import": "./dist/exports/puckEditor.js",
|
|
62
62
|
"default": "./dist/exports/puckEditor.js"
|
|
63
|
-
}
|
|
63
|
+
},
|
|
64
|
+
"./package.json": "./package.json"
|
|
64
65
|
},
|
|
65
66
|
"files": [
|
|
66
67
|
"dist",
|