@delmaredigital/payload-experiments 0.2.0-rc.2 → 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.
@@ -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.routeParams?.id;
4
- const result = typeof id === 'string' || typeof id === 'number' ? await preflightData(req, {
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.routeParams?.id;
18
- if (typeof id !== 'string' && typeof id !== 'number') return refuse(404, 'not-found', 'Experiment not found');
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.routeParams?.id;
5
- if (typeof id !== 'string' && typeof id !== 'number') return resultsError(404, 'not-found', 'Experiment not found');
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.routeParams?.id;
56
- if (typeof id !== 'string' && typeof id !== 'number') return resultsError(404, 'not-found', 'Experiment not found');
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
- // Split before decoding: an opaque id can itself contain a comma.
8
- const raw = new URL(req.url ?? 'http://localhost').search.slice(1).split('&').find((part)=>part.split('=')[0] === 'ids')?.slice(4);
9
- if (raw === undefined) return resultsError(400, 'invalid-body', 'Provide experiment ids');
10
- ids = raw.split(',').map((id)=>decodeURIComponent(id.replaceAll('+', ' ')));
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.some((id)=>!id.trim())) return resultsError(422, 'invalid-ids', 'Experiment ids must be nonempty');
16
- const snapshots = await readSnapshots(req, [
17
- ...new Set(ids)
18
- ], {
19
- per: 'experiment'
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 ids){
23
- const snapshot = snapshots.find((row)=>String(typeof row.experiment === 'object' ? row.experiment.id : row.experiment) === id)?.snapshot;
24
- response[id] = snapshot ? {
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: ids.length,
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 ids){
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) === id || typeof e.id === 'number' && Number(id) === 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[id].ready = !!input && checkStart(input).every((check)=>check.ok);
75
+ response[key].ready = !!input && checkStart(input).every((check)=>check.ok);
65
76
  } catch {
66
- response[id].ready = false;
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.routeParams?.id;
34
- if (typeof id !== 'string' && typeof id !== 'number') return Response.json({
34
+ const id = routeId(req);
35
+ if (id === undefined) return Response.json({
35
36
  errors: [
36
37
  {
37
38
  code: 'not-found',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-experiments",
3
- "version": "0.2.0-rc.2",
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",