@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.
Files changed (83) hide show
  1. package/README.md +169 -0
  2. package/dist/ab-edge/index.cjs +214 -0
  3. package/dist/ab-edge/index.d.cts +121 -0
  4. package/dist/ab-edge/index.d.ts +121 -0
  5. package/dist/ab-edge/index.js +205 -0
  6. package/dist/bin/createcms.js +3082 -0
  7. package/dist/db.cjs +496 -0
  8. package/dist/db.d.cts +128 -0
  9. package/dist/db.d.ts +128 -0
  10. package/dist/db.js +488 -0
  11. package/dist/index.cjs +13789 -0
  12. package/dist/index.d.cts +10277 -0
  13. package/dist/index.d.ts +10277 -0
  14. package/dist/index.js +13737 -0
  15. package/dist/nanoid.cjs +50 -0
  16. package/dist/nanoid.d.cts +29 -0
  17. package/dist/nanoid.d.ts +29 -0
  18. package/dist/nanoid.js +47 -0
  19. package/dist/next/index.cjs +60 -0
  20. package/dist/next/index.d.cts +141 -0
  21. package/dist/next/index.d.ts +141 -0
  22. package/dist/next/index.js +58 -0
  23. package/dist/next/middleware.cjs +113 -0
  24. package/dist/next/middleware.d.cts +77 -0
  25. package/dist/next/middleware.d.ts +77 -0
  26. package/dist/next/middleware.js +111 -0
  27. package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
  28. package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
  29. package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
  30. package/dist/plugins/ab-test/analytics/upstash.js +343 -0
  31. package/dist/plugins/ab-test/client.cjs +686 -0
  32. package/dist/plugins/ab-test/client.d.cts +233 -0
  33. package/dist/plugins/ab-test/client.d.ts +233 -0
  34. package/dist/plugins/ab-test/client.js +684 -0
  35. package/dist/plugins/ab-test/index.cjs +3400 -0
  36. package/dist/plugins/ab-test/index.d.cts +1131 -0
  37. package/dist/plugins/ab-test/index.d.ts +1131 -0
  38. package/dist/plugins/ab-test/index.js +3367 -0
  39. package/dist/plugins/client.cjs +20 -0
  40. package/dist/plugins/client.d.cts +3 -0
  41. package/dist/plugins/client.d.ts +3 -0
  42. package/dist/plugins/client.js +3 -0
  43. package/dist/plugins/consent/client.cjs +315 -0
  44. package/dist/plugins/consent/client.d.cts +145 -0
  45. package/dist/plugins/consent/client.d.ts +145 -0
  46. package/dist/plugins/consent/client.js +313 -0
  47. package/dist/plugins/consent/index.cjs +267 -0
  48. package/dist/plugins/consent/index.d.cts +618 -0
  49. package/dist/plugins/consent/index.d.ts +618 -0
  50. package/dist/plugins/consent/index.js +258 -0
  51. package/dist/plugins/i18n/index.cjs +2177 -0
  52. package/dist/plugins/i18n/index.d.cts +562 -0
  53. package/dist/plugins/i18n/index.d.ts +562 -0
  54. package/dist/plugins/i18n/index.js +2150 -0
  55. package/dist/plugins/media-optimize/index.cjs +315 -0
  56. package/dist/plugins/media-optimize/index.d.cts +144 -0
  57. package/dist/plugins/media-optimize/index.d.ts +144 -0
  58. package/dist/plugins/media-optimize/index.js +311 -0
  59. package/dist/plugins/multi-tenant/index.cjs +210 -0
  60. package/dist/plugins/multi-tenant/index.d.cts +431 -0
  61. package/dist/plugins/multi-tenant/index.d.ts +431 -0
  62. package/dist/plugins/multi-tenant/index.js +207 -0
  63. package/dist/plugins/server.cjs +24 -0
  64. package/dist/plugins/server.d.cts +3 -0
  65. package/dist/plugins/server.d.ts +3 -0
  66. package/dist/plugins/server.js +3 -0
  67. package/dist/react/blocks.cjs +233 -0
  68. package/dist/react/blocks.d.cts +320 -0
  69. package/dist/react/blocks.d.ts +320 -0
  70. package/dist/react/blocks.js +226 -0
  71. package/dist/react/index.cjs +901 -0
  72. package/dist/react/index.d.cts +992 -0
  73. package/dist/react/index.d.ts +992 -0
  74. package/dist/react/index.js +872 -0
  75. package/dist/react/tracking.cjs +243 -0
  76. package/dist/react/tracking.d.cts +364 -0
  77. package/dist/react/tracking.d.ts +364 -0
  78. package/dist/react/tracking.js +216 -0
  79. package/dist/react/variant.cjs +59 -0
  80. package/dist/react/variant.d.cts +26 -0
  81. package/dist/react/variant.d.ts +26 -0
  82. package/dist/react/variant.js +57 -0
  83. 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;