@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,3367 @@
|
|
|
1
|
+
import { customAlphabet } from 'nanoid';
|
|
2
|
+
import { sql, and, inArray, eq, isNull } from 'drizzle-orm';
|
|
3
|
+
import { createMiddleware, createEndpoint, APIError } from 'better-call';
|
|
4
|
+
import * as z from 'zod';
|
|
5
|
+
import { pgSchema, customType, timestamp, text, index, uniqueIndex, foreignKey, integer, boolean, jsonb, primaryKey } from 'drizzle-orm/pg-core';
|
|
6
|
+
import slugify from 'slugify';
|
|
7
|
+
|
|
8
|
+
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 20);
|
|
9
|
+
const prefixes = {
|
|
10
|
+
root: 'rot',
|
|
11
|
+
commit: 'cmt',
|
|
12
|
+
branch: 'brn',
|
|
13
|
+
blockVersion: 'blv',
|
|
14
|
+
block: 'blk',
|
|
15
|
+
mergeRequest: 'mrq',
|
|
16
|
+
mergeConflict: 'mcf',
|
|
17
|
+
approval: 'apr',
|
|
18
|
+
assetFolder: 'afl',
|
|
19
|
+
asset: 'ast',
|
|
20
|
+
contentUsage: 'cus',
|
|
21
|
+
commentThread: 'cth',
|
|
22
|
+
commentMessage: 'cmg',
|
|
23
|
+
commentMention: 'cmn',
|
|
24
|
+
variable: 'var',
|
|
25
|
+
template: 'tpl',
|
|
26
|
+
tplVarUsage: 'tvu',
|
|
27
|
+
notification: 'ntf',
|
|
28
|
+
si: 'sid',
|
|
29
|
+
redirect: 'rdr'
|
|
30
|
+
};
|
|
31
|
+
const customPrefixes = new Map();
|
|
32
|
+
function registerIdPrefix(key, prefix) {
|
|
33
|
+
if (key in prefixes) {
|
|
34
|
+
throw new Error(`Cannot override core prefix "${key}"`);
|
|
35
|
+
}
|
|
36
|
+
if (prefix.length < 2 || prefix.length > 5) {
|
|
37
|
+
throw new Error(`Prefix "${prefix}" must be 2-5 characters`);
|
|
38
|
+
}
|
|
39
|
+
if (!/^[a-z]+$/.test(prefix)) {
|
|
40
|
+
throw new Error(`Prefix "${prefix}" must be lowercase letters only`);
|
|
41
|
+
}
|
|
42
|
+
customPrefixes.set(key, prefix);
|
|
43
|
+
}
|
|
44
|
+
function newId(prefix) {
|
|
45
|
+
const resolved = prefixes[prefix] ?? customPrefixes.get(prefix);
|
|
46
|
+
if (!resolved) {
|
|
47
|
+
throw new Error(`Unknown ID prefix "${prefix}". Register it with registerIdPrefix() first.`);
|
|
48
|
+
}
|
|
49
|
+
return `${resolved}_${nanoid()}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function definePluginSchema(schema) {
|
|
53
|
+
return {
|
|
54
|
+
...schema
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const abTestStatus = {
|
|
59
|
+
enumName: 'ab_test_status',
|
|
60
|
+
values: [
|
|
61
|
+
'draft',
|
|
62
|
+
'running',
|
|
63
|
+
'paused',
|
|
64
|
+
'completed'
|
|
65
|
+
]
|
|
66
|
+
};
|
|
67
|
+
const abTests = {
|
|
68
|
+
tableName: 'ab_tests',
|
|
69
|
+
indexPrefix: 'abt',
|
|
70
|
+
columns: {
|
|
71
|
+
id: {
|
|
72
|
+
type: 'text',
|
|
73
|
+
primaryKey: true,
|
|
74
|
+
defaultId: true,
|
|
75
|
+
defaultIdPrefix: 'abTest'
|
|
76
|
+
},
|
|
77
|
+
rootId: {
|
|
78
|
+
type: 'text',
|
|
79
|
+
notNull: true,
|
|
80
|
+
references: {
|
|
81
|
+
table: 'roots',
|
|
82
|
+
column: 'id',
|
|
83
|
+
onDelete: 'cascade'
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
collection: {
|
|
87
|
+
type: 'text',
|
|
88
|
+
notNull: true
|
|
89
|
+
},
|
|
90
|
+
name: {
|
|
91
|
+
type: 'text',
|
|
92
|
+
notNull: true
|
|
93
|
+
},
|
|
94
|
+
// The chosen conversion goal (M4): the block instance's trackingId
|
|
95
|
+
// (goalHandle) + the resolved wire name (goalEvent = the stored event_type
|
|
96
|
+
// counted as the conversion). Both nullable — a test may run goal-less and
|
|
97
|
+
// only measure impressions until a goal is picked.
|
|
98
|
+
goalHandle: {
|
|
99
|
+
type: 'text'
|
|
100
|
+
},
|
|
101
|
+
goalEvent: {
|
|
102
|
+
type: 'text'
|
|
103
|
+
},
|
|
104
|
+
status: {
|
|
105
|
+
type: {
|
|
106
|
+
enum: 'abTestStatus'
|
|
107
|
+
},
|
|
108
|
+
notNull: true,
|
|
109
|
+
default: {
|
|
110
|
+
kind: 'literal',
|
|
111
|
+
value: 'draft'
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
trafficPercentage: {
|
|
115
|
+
type: 'integer',
|
|
116
|
+
notNull: true,
|
|
117
|
+
default: {
|
|
118
|
+
kind: 'literal',
|
|
119
|
+
value: 100
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
startedAt: {
|
|
123
|
+
type: 'timestamp'
|
|
124
|
+
},
|
|
125
|
+
endedAt: {
|
|
126
|
+
type: 'timestamp'
|
|
127
|
+
},
|
|
128
|
+
createdBy: {
|
|
129
|
+
type: 'text'
|
|
130
|
+
},
|
|
131
|
+
createdAt: {
|
|
132
|
+
type: 'timestamp',
|
|
133
|
+
notNull: true,
|
|
134
|
+
defaultNow: true
|
|
135
|
+
},
|
|
136
|
+
updatedAt: {
|
|
137
|
+
type: 'timestamp',
|
|
138
|
+
notNull: true,
|
|
139
|
+
defaultNow: true
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
indexes: {
|
|
143
|
+
rootIdx: {
|
|
144
|
+
columns: [
|
|
145
|
+
'rootId'
|
|
146
|
+
]
|
|
147
|
+
},
|
|
148
|
+
statusIdx: {
|
|
149
|
+
columns: [
|
|
150
|
+
'status'
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
collectionIdx: {
|
|
154
|
+
columns: [
|
|
155
|
+
'collection'
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const abTestVariants = {
|
|
161
|
+
tableName: 'ab_test_variants',
|
|
162
|
+
indexPrefix: 'abv',
|
|
163
|
+
columns: {
|
|
164
|
+
id: {
|
|
165
|
+
type: 'text',
|
|
166
|
+
primaryKey: true,
|
|
167
|
+
defaultId: true,
|
|
168
|
+
defaultIdPrefix: 'abTestVariant'
|
|
169
|
+
},
|
|
170
|
+
testId: {
|
|
171
|
+
type: 'text',
|
|
172
|
+
notNull: true,
|
|
173
|
+
references: {
|
|
174
|
+
table: 'abTests',
|
|
175
|
+
column: 'id',
|
|
176
|
+
onDelete: 'cascade'
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
branchId: {
|
|
180
|
+
type: 'text',
|
|
181
|
+
notNull: true,
|
|
182
|
+
references: {
|
|
183
|
+
table: 'branches',
|
|
184
|
+
column: 'id'
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
name: {
|
|
188
|
+
type: 'text',
|
|
189
|
+
notNull: true
|
|
190
|
+
},
|
|
191
|
+
weight: {
|
|
192
|
+
type: 'integer',
|
|
193
|
+
notNull: true
|
|
194
|
+
},
|
|
195
|
+
isControl: {
|
|
196
|
+
type: 'boolean',
|
|
197
|
+
notNull: true,
|
|
198
|
+
default: {
|
|
199
|
+
kind: 'literal',
|
|
200
|
+
value: false
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
indexes: {
|
|
205
|
+
testIdx: {
|
|
206
|
+
columns: [
|
|
207
|
+
'testId'
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const coreTables = {
|
|
213
|
+
abTests,
|
|
214
|
+
abTestVariants
|
|
215
|
+
};
|
|
216
|
+
const defaultAdapterTables = {
|
|
217
|
+
abTestEvents: {
|
|
218
|
+
tableName: 'ab_test_events',
|
|
219
|
+
indexPrefix: 'abe',
|
|
220
|
+
columns: {
|
|
221
|
+
id: {
|
|
222
|
+
type: 'text',
|
|
223
|
+
primaryKey: true,
|
|
224
|
+
defaultId: true,
|
|
225
|
+
defaultIdPrefix: 'abTestEvent'
|
|
226
|
+
},
|
|
227
|
+
// Nullable: non-A/B analytics events (form_submit, page_view) carry no
|
|
228
|
+
// test/variant. A/B events still cascade-delete with their test/variant.
|
|
229
|
+
testId: {
|
|
230
|
+
type: 'text',
|
|
231
|
+
references: {
|
|
232
|
+
table: 'abTests',
|
|
233
|
+
column: 'id',
|
|
234
|
+
onDelete: 'cascade'
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
variantId: {
|
|
238
|
+
type: 'text',
|
|
239
|
+
references: {
|
|
240
|
+
table: 'abTestVariants',
|
|
241
|
+
column: 'id',
|
|
242
|
+
onDelete: 'cascade'
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
// Nullable: anonymous Pattern A events store NO identifier (the variant
|
|
246
|
+
// comes from the URL / variant-cookie). Only the consent-gated
|
|
247
|
+
// unique-visitor / GA4 path sets it.
|
|
248
|
+
visitorId: {
|
|
249
|
+
type: 'text'
|
|
250
|
+
},
|
|
251
|
+
eventType: {
|
|
252
|
+
type: 'text',
|
|
253
|
+
notNull: true
|
|
254
|
+
},
|
|
255
|
+
// Originating functional block instance (the author-assigned trackingId).
|
|
256
|
+
sourceHandle: {
|
|
257
|
+
type: 'text'
|
|
258
|
+
},
|
|
259
|
+
sourceType: {
|
|
260
|
+
type: 'text'
|
|
261
|
+
},
|
|
262
|
+
// Funnel grouping (M4): shared by the attempt + success legs of one
|
|
263
|
+
// interaction (a <TrackedForm> submit). Nullable — most events (impression,
|
|
264
|
+
// a plain click) carry none. Groups, does NOT dedup.
|
|
265
|
+
interactionId: {
|
|
266
|
+
type: 'text'
|
|
267
|
+
},
|
|
268
|
+
metadata: {
|
|
269
|
+
type: 'jsonb',
|
|
270
|
+
jsonType: 'Record<string, unknown>'
|
|
271
|
+
},
|
|
272
|
+
createdAt: {
|
|
273
|
+
type: 'timestamp',
|
|
274
|
+
notNull: true,
|
|
275
|
+
defaultNow: true
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
indexes: {
|
|
279
|
+
testEventIdx: {
|
|
280
|
+
columns: [
|
|
281
|
+
'testId',
|
|
282
|
+
'eventType'
|
|
283
|
+
]
|
|
284
|
+
},
|
|
285
|
+
visitorIdx: {
|
|
286
|
+
columns: [
|
|
287
|
+
'testId',
|
|
288
|
+
'visitorId'
|
|
289
|
+
]
|
|
290
|
+
},
|
|
291
|
+
interactionIdx: {
|
|
292
|
+
columns: [
|
|
293
|
+
'testId',
|
|
294
|
+
'interactionId'
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
function buildSchema(adapter) {
|
|
301
|
+
const tables = {
|
|
302
|
+
...coreTables,
|
|
303
|
+
...adapter?.tables ?? defaultAdapterTables
|
|
304
|
+
};
|
|
305
|
+
return definePluginSchema({
|
|
306
|
+
tables,
|
|
307
|
+
enums: {
|
|
308
|
+
abTestStatus
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function postgresAnalytics() {
|
|
314
|
+
let db;
|
|
315
|
+
return {
|
|
316
|
+
tables: defaultAdapterTables,
|
|
317
|
+
init (instance) {
|
|
318
|
+
db = instance;
|
|
319
|
+
},
|
|
320
|
+
async track (event) {
|
|
321
|
+
// Mint when no usable id is supplied. Guard against a blank id too: `??`
|
|
322
|
+
// would let "" through and a second "" would be swallowed by ON CONFLICT,
|
|
323
|
+
// silently dropping a distinct event.
|
|
324
|
+
const id = event.id && event.id.length > 0 ? event.id : newId('abTestEvent');
|
|
325
|
+
await db.execute(sql`
|
|
326
|
+
INSERT INTO cms.ab_test_events
|
|
327
|
+
(id, test_id, variant_id, visitor_id, event_type, source_handle, source_type, interaction_id, metadata, created_at)
|
|
328
|
+
VALUES (
|
|
329
|
+
${id},
|
|
330
|
+
${event.ab?.testId ?? null},
|
|
331
|
+
${event.ab?.variantId ?? null},
|
|
332
|
+
${event.visitorId ?? null},
|
|
333
|
+
${event.name},
|
|
334
|
+
${event.source?.handle ?? null},
|
|
335
|
+
${event.source?.type ?? null},
|
|
336
|
+
${event.interactionId ?? null},
|
|
337
|
+
${event.metadata ? sql`${JSON.stringify(event.metadata)}::jsonb` : sql`NULL`},
|
|
338
|
+
${event.timestamp}
|
|
339
|
+
)
|
|
340
|
+
ON CONFLICT (id) DO NOTHING
|
|
341
|
+
`);
|
|
342
|
+
},
|
|
343
|
+
async query (testId, options) {
|
|
344
|
+
const fromClause = options?.from ? sql` AND e.created_at >= ${options.from}` : sql``;
|
|
345
|
+
const toClause = options?.to ? sql` AND e.created_at <= ${options.to}` : sql``;
|
|
346
|
+
const rows = await db.execute(sql`
|
|
347
|
+
SELECT
|
|
348
|
+
e.variant_id,
|
|
349
|
+
v.name AS variant_name,
|
|
350
|
+
e.event_type,
|
|
351
|
+
COUNT(*)::int AS count,
|
|
352
|
+
COUNT(DISTINCT e.visitor_id)::int AS unique_visitors,
|
|
353
|
+
COUNT(DISTINCT e.interaction_id)::int AS distinct_interactions
|
|
354
|
+
FROM cms.ab_test_events e
|
|
355
|
+
INNER JOIN cms.ab_test_variants v ON v.id = e.variant_id
|
|
356
|
+
WHERE e.test_id = ${testId}
|
|
357
|
+
${fromClause}
|
|
358
|
+
${toClause}
|
|
359
|
+
GROUP BY e.variant_id, v.name, e.event_type
|
|
360
|
+
ORDER BY e.variant_id, e.event_type
|
|
361
|
+
`);
|
|
362
|
+
// Funnel attempts per variant: total distinct interaction ids (one per
|
|
363
|
+
// <TrackedForm> submit). NOT a sum of per-event distincts — an interaction
|
|
364
|
+
// appears in both its attempt + success legs, so it must be counted once.
|
|
365
|
+
const attemptRows = await db.execute(sql`
|
|
366
|
+
SELECT e.variant_id, COUNT(DISTINCT e.interaction_id)::int AS attempts
|
|
367
|
+
FROM cms.ab_test_events e
|
|
368
|
+
WHERE e.test_id = ${testId}
|
|
369
|
+
AND e.interaction_id IS NOT NULL
|
|
370
|
+
${fromClause}
|
|
371
|
+
${toClause}
|
|
372
|
+
GROUP BY e.variant_id
|
|
373
|
+
`);
|
|
374
|
+
const attemptsByVariant = new Map(attemptRows.rows.map((r)=>[
|
|
375
|
+
r.variant_id,
|
|
376
|
+
r.attempts
|
|
377
|
+
]));
|
|
378
|
+
const variantMap = new Map();
|
|
379
|
+
for (const row of rows.rows){
|
|
380
|
+
let v = variantMap.get(row.variant_id);
|
|
381
|
+
if (!v) {
|
|
382
|
+
v = {
|
|
383
|
+
variantId: row.variant_id,
|
|
384
|
+
variantName: row.variant_name,
|
|
385
|
+
impressions: 0,
|
|
386
|
+
conversions: 0,
|
|
387
|
+
uniqueVisitors: 0,
|
|
388
|
+
conversionRate: 0,
|
|
389
|
+
attempts: attemptsByVariant.get(row.variant_id) ?? 0,
|
|
390
|
+
completionRate: 0,
|
|
391
|
+
eventBreakdown: {}
|
|
392
|
+
};
|
|
393
|
+
variantMap.set(row.variant_id, v);
|
|
394
|
+
}
|
|
395
|
+
v.eventBreakdown[row.event_type] = {
|
|
396
|
+
count: row.count,
|
|
397
|
+
uniqueVisitors: row.unique_visitors,
|
|
398
|
+
distinctInteractions: row.distinct_interactions
|
|
399
|
+
};
|
|
400
|
+
if (row.event_type === 'impression') {
|
|
401
|
+
v.impressions = row.count;
|
|
402
|
+
v.uniqueVisitors = row.unique_visitors;
|
|
403
|
+
} else if (row.event_type === 'conversion') {
|
|
404
|
+
v.conversions = row.count;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const variants = [
|
|
408
|
+
...variantMap.values()
|
|
409
|
+
];
|
|
410
|
+
for (const v of variants){
|
|
411
|
+
v.conversionRate = v.impressions > 0 ? Math.round(v.conversions / v.impressions * 10000) / 100 : 0;
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
testId,
|
|
415
|
+
variants,
|
|
416
|
+
totalImpressions: variants.reduce((s, v)=>s + v.impressions, 0),
|
|
417
|
+
totalConversions: variants.reduce((s, v)=>s + v.conversions, 0)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const cms = pgSchema('cms');
|
|
424
|
+
const tsvectorColumn = customType({
|
|
425
|
+
dataType () {
|
|
426
|
+
return 'tsvector';
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
const approvalStatusEnum = cms.enum("approval_status", [
|
|
430
|
+
"pending",
|
|
431
|
+
"approved",
|
|
432
|
+
"rejected"
|
|
433
|
+
]);
|
|
434
|
+
const assetStatusEnum = cms.enum("asset_status", [
|
|
435
|
+
"private",
|
|
436
|
+
"public"
|
|
437
|
+
]);
|
|
438
|
+
const commentMessageTypeEnum = cms.enum("comment_message_type", [
|
|
439
|
+
"comment",
|
|
440
|
+
"system"
|
|
441
|
+
]);
|
|
442
|
+
const commentSystemTypeEnum = cms.enum("comment_system_type", [
|
|
443
|
+
"threadResolved",
|
|
444
|
+
"threadReopened"
|
|
445
|
+
]);
|
|
446
|
+
const commentThreadStatusEnum = cms.enum("comment_thread_status", [
|
|
447
|
+
"open",
|
|
448
|
+
"resolved"
|
|
449
|
+
]);
|
|
450
|
+
const commentThreadTargetEnum = cms.enum("comment_thread_target", [
|
|
451
|
+
"mergeRequest",
|
|
452
|
+
"block"
|
|
453
|
+
]);
|
|
454
|
+
const conflictResolutionEnum = cms.enum("conflict_resolution", [
|
|
455
|
+
"source",
|
|
456
|
+
"target",
|
|
457
|
+
"manual"
|
|
458
|
+
]);
|
|
459
|
+
const contentUsageTargetEnum = cms.enum("content_usage_target", [
|
|
460
|
+
"asset",
|
|
461
|
+
"variable",
|
|
462
|
+
"reference"
|
|
463
|
+
]);
|
|
464
|
+
const mergeRequestStatusEnum = cms.enum("merge_request_status", [
|
|
465
|
+
"open",
|
|
466
|
+
"merged",
|
|
467
|
+
"closed"
|
|
468
|
+
]);
|
|
469
|
+
const notificationTypeEnum = cms.enum("notification_type", [
|
|
470
|
+
"mention",
|
|
471
|
+
"comment",
|
|
472
|
+
"threadResolved",
|
|
473
|
+
"approvalRequested",
|
|
474
|
+
"approvalApproved",
|
|
475
|
+
"approvalRejected",
|
|
476
|
+
"mergeRequestOpened",
|
|
477
|
+
"mergeRequestMerged",
|
|
478
|
+
"mergeRequestClosed",
|
|
479
|
+
"mergeRequestReopened",
|
|
480
|
+
"published",
|
|
481
|
+
"custom"
|
|
482
|
+
]);
|
|
483
|
+
const redirectEndpointTypeEnum = cms.enum("redirect_endpoint_type", [
|
|
484
|
+
"page",
|
|
485
|
+
"path"
|
|
486
|
+
]);
|
|
487
|
+
cms.table("approvals", {
|
|
488
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("approval")),
|
|
489
|
+
mergeRequestId: text("merge_request_id").references(()=>mergeRequests.id, {
|
|
490
|
+
onDelete: "cascade"
|
|
491
|
+
}),
|
|
492
|
+
branchId: text("branch_id").notNull().references(()=>branches.id),
|
|
493
|
+
commitId: text("commit_id").notNull().references(()=>commits.id),
|
|
494
|
+
status: approvalStatusEnum("status").notNull().default("pending"),
|
|
495
|
+
requestedBy: text("requested_by").notNull(),
|
|
496
|
+
requestedReviewer: text("requested_reviewer").notNull(),
|
|
497
|
+
reviewedBy: text("reviewed_by"),
|
|
498
|
+
message: text("message"),
|
|
499
|
+
rejectionReason: text("rejection_reason"),
|
|
500
|
+
reviewedAt: timestamp("reviewed_at"),
|
|
501
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
502
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
|
503
|
+
}, (table)=>[
|
|
504
|
+
index("approvals_mr_idx").on(table.mergeRequestId),
|
|
505
|
+
index("approvals_branch_idx").on(table.branchId),
|
|
506
|
+
index("approvals_branch_commit_idx").on(table.branchId, table.commitId),
|
|
507
|
+
index("approvals_status_idx").on(table.status),
|
|
508
|
+
index("approvals_requested_reviewer_idx").on(table.requestedReviewer),
|
|
509
|
+
uniqueIndex("approvals_target_reviewer_unique").on(table.mergeRequestId, table.branchId, table.commitId, table.requestedReviewer)
|
|
510
|
+
]);
|
|
511
|
+
const assetFolders = cms.table("asset_folders", {
|
|
512
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("assetFolder")),
|
|
513
|
+
name: text("name").notNull(),
|
|
514
|
+
parentId: text("parent_id"),
|
|
515
|
+
createdBy: text("created_by"),
|
|
516
|
+
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
517
|
+
}, (table)=>[
|
|
518
|
+
foreignKey({
|
|
519
|
+
columns: [
|
|
520
|
+
table.parentId
|
|
521
|
+
],
|
|
522
|
+
foreignColumns: [
|
|
523
|
+
table.id
|
|
524
|
+
],
|
|
525
|
+
name: "asset_folders_parent_fk"
|
|
526
|
+
}).onDelete("cascade"),
|
|
527
|
+
index("asset_folders_parent_idx").on(table.parentId),
|
|
528
|
+
uniqueIndex("asset_folders_name_unique").on(table.parentId, table.name)
|
|
529
|
+
]);
|
|
530
|
+
const assets = cms.table("assets", {
|
|
531
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("asset")),
|
|
532
|
+
slug: text("slug").notNull(),
|
|
533
|
+
mimeType: text("mime_type").notNull(),
|
|
534
|
+
size: integer("size").notNull(),
|
|
535
|
+
objectKey: text("object_key").notNull(),
|
|
536
|
+
status: assetStatusEnum("status").notNull().default("private"),
|
|
537
|
+
folderId: text("folder_id").references(()=>assetFolders.id, {
|
|
538
|
+
onDelete: "set null"
|
|
539
|
+
}),
|
|
540
|
+
variantOf: text("variant_of").references(()=>assets.id, {
|
|
541
|
+
onDelete: "set null"
|
|
542
|
+
}),
|
|
543
|
+
uploadedBy: text("uploaded_by"),
|
|
544
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
545
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
546
|
+
archivedAt: timestamp("archived_at")
|
|
547
|
+
}, (table)=>[
|
|
548
|
+
index("assets_folder_idx").on(table.folderId),
|
|
549
|
+
index("assets_status_idx").on(table.status),
|
|
550
|
+
index("assets_variant_of_idx").on(table.variantOf),
|
|
551
|
+
uniqueIndex("assets_object_key_unique").on(table.objectKey),
|
|
552
|
+
uniqueIndex("assets_slug_unique").on(table.slug)
|
|
553
|
+
]);
|
|
554
|
+
const blockVersions = cms.table("block_versions", {
|
|
555
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("blockVersion")),
|
|
556
|
+
blockId: text("block_id").notNull(),
|
|
557
|
+
rootId: text("root_id").notNull().references(()=>roots.id),
|
|
558
|
+
commitId: text("commit_id").notNull().references(()=>commits.id),
|
|
559
|
+
type: text("type").notNull(),
|
|
560
|
+
properties: jsonb("properties").$type().notNull(),
|
|
561
|
+
children: jsonb("children").$type().notNull().default([]),
|
|
562
|
+
deleted: boolean("deleted").notNull().default(false),
|
|
563
|
+
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
564
|
+
}, (table)=>[
|
|
565
|
+
index("bv_block_id_idx").on(table.blockId),
|
|
566
|
+
index("bv_commit_id_idx").on(table.commitId),
|
|
567
|
+
index("bv_root_id_idx").on(table.rootId),
|
|
568
|
+
uniqueIndex("bv_block_commit_unique").on(table.blockId, table.commitId),
|
|
569
|
+
index("bv_properties_gin").using("gin", table.properties)
|
|
570
|
+
]);
|
|
571
|
+
const branches = cms.table("branches", {
|
|
572
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("branch")),
|
|
573
|
+
rootId: text("root_id").notNull().references(()=>roots.id),
|
|
574
|
+
name: text("name").notNull(),
|
|
575
|
+
headCommitId: text("head_commit_id").notNull().references(()=>commits.id),
|
|
576
|
+
createdBy: text("created_by"),
|
|
577
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
578
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
|
579
|
+
}, (table)=>[
|
|
580
|
+
index("branches_root_id_idx").on(table.rootId),
|
|
581
|
+
uniqueIndex("branches_root_name_unique").on(table.rootId, table.name)
|
|
582
|
+
]);
|
|
583
|
+
cms.table("comment_mentions", {
|
|
584
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("commentMention")),
|
|
585
|
+
messageId: text("message_id").notNull().references(()=>commentMessages.id, {
|
|
586
|
+
onDelete: "cascade"
|
|
587
|
+
}),
|
|
588
|
+
threadId: text("thread_id").notNull().references(()=>commentThreads.id, {
|
|
589
|
+
onDelete: "cascade"
|
|
590
|
+
}),
|
|
591
|
+
mentionedUserId: text("mentioned_user_id").notNull(),
|
|
592
|
+
mentionedBy: text("mentioned_by").notNull(),
|
|
593
|
+
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
594
|
+
}, (table)=>[
|
|
595
|
+
index("cmn_user_idx").on(table.mentionedUserId, table.createdAt),
|
|
596
|
+
index("cmn_message_idx").on(table.messageId),
|
|
597
|
+
index("cmn_thread_user_idx").on(table.threadId, table.mentionedUserId),
|
|
598
|
+
uniqueIndex("cmn_message_user_unique").on(table.messageId, table.mentionedUserId)
|
|
599
|
+
]);
|
|
600
|
+
const commentMessages = cms.table("comment_messages", {
|
|
601
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("commentMessage")),
|
|
602
|
+
threadId: text("thread_id").notNull().references(()=>commentThreads.id, {
|
|
603
|
+
onDelete: "cascade"
|
|
604
|
+
}),
|
|
605
|
+
parentMessageId: text("parent_message_id"),
|
|
606
|
+
authorId: text("author_id"),
|
|
607
|
+
messageType: commentMessageTypeEnum("message_type").notNull().default("comment"),
|
|
608
|
+
systemType: commentSystemTypeEnum("system_type"),
|
|
609
|
+
body: text("body"),
|
|
610
|
+
meta: jsonb("meta").$type(),
|
|
611
|
+
editedAt: timestamp("edited_at"),
|
|
612
|
+
deletedAt: timestamp("deleted_at"),
|
|
613
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
614
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
|
615
|
+
}, (table)=>[
|
|
616
|
+
foreignKey({
|
|
617
|
+
columns: [
|
|
618
|
+
table.parentMessageId
|
|
619
|
+
],
|
|
620
|
+
foreignColumns: [
|
|
621
|
+
table.id
|
|
622
|
+
],
|
|
623
|
+
name: "comment_messages_parent_fk"
|
|
624
|
+
}).onDelete("set null"),
|
|
625
|
+
index("cm_thread_idx").on(table.threadId, table.createdAt),
|
|
626
|
+
index("cm_parent_idx").on(table.parentMessageId),
|
|
627
|
+
index("cm_type_idx").on(table.messageType, table.systemType),
|
|
628
|
+
index("cm_author_idx").on(table.authorId, table.createdAt)
|
|
629
|
+
]);
|
|
630
|
+
const commentThreads = cms.table("comment_threads", {
|
|
631
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("commentThread")),
|
|
632
|
+
rootId: text("root_id").references(()=>roots.id, {
|
|
633
|
+
onDelete: "cascade"
|
|
634
|
+
}),
|
|
635
|
+
collection: text("collection").notNull(),
|
|
636
|
+
targetType: commentThreadTargetEnum("target_type").notNull(),
|
|
637
|
+
mergeRequestId: text("merge_request_id").references(()=>mergeRequests.id, {
|
|
638
|
+
onDelete: "cascade"
|
|
639
|
+
}),
|
|
640
|
+
blockId: text("block_id"),
|
|
641
|
+
commitId: text("commit_id").references(()=>commits.id, {
|
|
642
|
+
onDelete: "set null"
|
|
643
|
+
}),
|
|
644
|
+
status: commentThreadStatusEnum("status").notNull().default("open"),
|
|
645
|
+
resolvedBy: text("resolved_by"),
|
|
646
|
+
resolvedAt: timestamp("resolved_at"),
|
|
647
|
+
createdBy: text("created_by").notNull(),
|
|
648
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
649
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
650
|
+
deletedAt: timestamp("deleted_at")
|
|
651
|
+
}, (table)=>[
|
|
652
|
+
index("ct_collection_idx").on(table.collection, table.createdAt),
|
|
653
|
+
index("ct_mr_idx").on(table.mergeRequestId, table.createdAt),
|
|
654
|
+
index("ct_block_idx").on(table.blockId, table.createdAt),
|
|
655
|
+
index("ct_commit_idx").on(table.commitId, table.createdAt),
|
|
656
|
+
index("ct_root_idx").on(table.rootId, table.createdAt),
|
|
657
|
+
index("ct_status_idx").on(table.status)
|
|
658
|
+
]);
|
|
659
|
+
const commits = cms.table("commits", {
|
|
660
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("commit")),
|
|
661
|
+
rootId: text("root_id").notNull().references(()=>roots.id),
|
|
662
|
+
parentCommitId: text("parent_commit_id"),
|
|
663
|
+
mergeSourceCommitId: text("merge_source_commit_id"),
|
|
664
|
+
message: text("message"),
|
|
665
|
+
createdBy: text("created_by"),
|
|
666
|
+
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
667
|
+
}, (table)=>[
|
|
668
|
+
foreignKey({
|
|
669
|
+
columns: [
|
|
670
|
+
table.parentCommitId
|
|
671
|
+
],
|
|
672
|
+
foreignColumns: [
|
|
673
|
+
table.id
|
|
674
|
+
],
|
|
675
|
+
name: "commits_parent_fk"
|
|
676
|
+
}),
|
|
677
|
+
foreignKey({
|
|
678
|
+
columns: [
|
|
679
|
+
table.mergeSourceCommitId
|
|
680
|
+
],
|
|
681
|
+
foreignColumns: [
|
|
682
|
+
table.id
|
|
683
|
+
],
|
|
684
|
+
name: "commits_merge_source_fk"
|
|
685
|
+
}),
|
|
686
|
+
index("commits_parent_idx").on(table.parentCommitId),
|
|
687
|
+
index("commits_merge_source_idx").on(table.mergeSourceCommitId),
|
|
688
|
+
index("commits_root_created_idx").on(table.rootId, table.createdAt)
|
|
689
|
+
]);
|
|
690
|
+
const commitSnapshots = cms.table("commit_snapshots", {
|
|
691
|
+
commitId: text("commit_id").notNull().references(()=>commits.id, {
|
|
692
|
+
onDelete: "cascade"
|
|
693
|
+
}),
|
|
694
|
+
blockId: text("block_id").notNull(),
|
|
695
|
+
blockVersionId: text("block_version_id").notNull().references(()=>blockVersions.id, {
|
|
696
|
+
onDelete: "cascade"
|
|
697
|
+
})
|
|
698
|
+
}, (table)=>[
|
|
699
|
+
primaryKey({
|
|
700
|
+
columns: [
|
|
701
|
+
table.commitId,
|
|
702
|
+
table.blockId
|
|
703
|
+
]
|
|
704
|
+
}),
|
|
705
|
+
index("cs_block_version_idx").on(table.blockVersionId)
|
|
706
|
+
]);
|
|
707
|
+
const contentUsages = cms.table("content_usages", {
|
|
708
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("contentUsage")),
|
|
709
|
+
targetKind: contentUsageTargetEnum("target_kind").notNull(),
|
|
710
|
+
targetKey: text("target_key").notNull(),
|
|
711
|
+
blockVersionId: text("block_version_id").notNull().references(()=>blockVersions.id, {
|
|
712
|
+
onDelete: "cascade"
|
|
713
|
+
}),
|
|
714
|
+
rootId: text("root_id").notNull().references(()=>roots.id, {
|
|
715
|
+
onDelete: "cascade"
|
|
716
|
+
}),
|
|
717
|
+
blockId: text("block_id").notNull(),
|
|
718
|
+
propertyKey: text("property_key").notNull()
|
|
719
|
+
}, (table)=>[
|
|
720
|
+
uniqueIndex("cu_version_target_prop_unique").on(table.blockVersionId, table.targetKind, table.targetKey, table.propertyKey),
|
|
721
|
+
index("cu_target_idx").on(table.targetKind, table.targetKey),
|
|
722
|
+
index("cu_block_version_idx").on(table.blockVersionId),
|
|
723
|
+
index("cu_root_idx").on(table.rootId)
|
|
724
|
+
]);
|
|
725
|
+
cms.table("merge_conflicts", {
|
|
726
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("mergeConflict")),
|
|
727
|
+
mergeRequestId: text("merge_request_id").notNull().references(()=>mergeRequests.id, {
|
|
728
|
+
onDelete: "cascade"
|
|
729
|
+
}),
|
|
730
|
+
blockId: text("block_id").notNull(),
|
|
731
|
+
sourceVersionId: text("source_version_id").references(()=>blockVersions.id),
|
|
732
|
+
targetVersionId: text("target_version_id").references(()=>blockVersions.id),
|
|
733
|
+
baseVersionId: text("base_version_id").references(()=>blockVersions.id),
|
|
734
|
+
resolution: conflictResolutionEnum("resolution"),
|
|
735
|
+
resolvedVersionId: text("resolved_version_id").references(()=>blockVersions.id),
|
|
736
|
+
resolvedBy: text("resolved_by"),
|
|
737
|
+
resolvedAt: timestamp("resolved_at"),
|
|
738
|
+
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
739
|
+
}, (table)=>[
|
|
740
|
+
index("mc_merge_request_idx").on(table.mergeRequestId),
|
|
741
|
+
uniqueIndex("mc_merge_block_unique").on(table.mergeRequestId, table.blockId)
|
|
742
|
+
]);
|
|
743
|
+
const mergeRequests = cms.table("merge_requests", {
|
|
744
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("mergeRequest")),
|
|
745
|
+
rootId: text("root_id").notNull().references(()=>roots.id),
|
|
746
|
+
sourceBranchId: text("source_branch_id").notNull().references(()=>branches.id),
|
|
747
|
+
targetBranchId: text("target_branch_id").notNull().references(()=>branches.id),
|
|
748
|
+
sourceCommitId: text("source_commit_id").notNull().references(()=>commits.id),
|
|
749
|
+
baseCommitId: text("base_commit_id").references(()=>commits.id),
|
|
750
|
+
mergeCommitId: text("merge_commit_id").references(()=>commits.id),
|
|
751
|
+
status: mergeRequestStatusEnum("status").notNull().default("open"),
|
|
752
|
+
title: text("title"),
|
|
753
|
+
description: text("description"),
|
|
754
|
+
createdBy: text("created_by").notNull(),
|
|
755
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
756
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
|
757
|
+
}, (table)=>[
|
|
758
|
+
index("mr_root_idx").on(table.rootId),
|
|
759
|
+
index("mr_source_branch_idx").on(table.sourceBranchId),
|
|
760
|
+
index("mr_target_branch_idx").on(table.targetBranchId),
|
|
761
|
+
index("mr_status_idx").on(table.status),
|
|
762
|
+
uniqueIndex("mr_open_source_target_unique").on(table.sourceBranchId, table.targetBranchId).where(sql`status = 'open'`)
|
|
763
|
+
]);
|
|
764
|
+
cms.table("notifications", {
|
|
765
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("notification")),
|
|
766
|
+
recipientId: text("recipient_id").notNull(),
|
|
767
|
+
actorId: text("actor_id"),
|
|
768
|
+
type: notificationTypeEnum("type").notNull(),
|
|
769
|
+
title: text("title").notNull(),
|
|
770
|
+
body: text("body"),
|
|
771
|
+
resourceType: text("resource_type"),
|
|
772
|
+
resourceId: text("resource_id"),
|
|
773
|
+
collection: text("collection"),
|
|
774
|
+
meta: jsonb("meta").$type(),
|
|
775
|
+
readAt: timestamp("read_at"),
|
|
776
|
+
archivedAt: timestamp("archived_at"),
|
|
777
|
+
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
778
|
+
}, (table)=>[
|
|
779
|
+
index("ntf_recipient_created_idx").on(table.recipientId, table.createdAt),
|
|
780
|
+
index("ntf_recipient_unread_idx").on(table.recipientId, table.readAt),
|
|
781
|
+
index("ntf_resource_idx").on(table.resourceType, table.resourceId),
|
|
782
|
+
index("ntf_type_idx").on(table.type)
|
|
783
|
+
]);
|
|
784
|
+
cms.table("publications", {
|
|
785
|
+
rootId: text("root_id").notNull().references(()=>roots.id),
|
|
786
|
+
branchId: text("branch_id").notNull().references(()=>branches.id),
|
|
787
|
+
commitId: text("commit_id").notNull().references(()=>commits.id),
|
|
788
|
+
publishedBy: text("published_by").notNull(),
|
|
789
|
+
publishedAt: timestamp("published_at").notNull().defaultNow()
|
|
790
|
+
}, (table)=>[
|
|
791
|
+
primaryKey({
|
|
792
|
+
columns: [
|
|
793
|
+
table.rootId,
|
|
794
|
+
table.branchId
|
|
795
|
+
]
|
|
796
|
+
}),
|
|
797
|
+
index("publications_branch_idx").on(table.branchId)
|
|
798
|
+
]);
|
|
799
|
+
cms.table("redirects", {
|
|
800
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("redirect")),
|
|
801
|
+
collection: text("collection").notNull(),
|
|
802
|
+
sourceType: redirectEndpointTypeEnum("source_type").notNull(),
|
|
803
|
+
sourceRootId: text("source_root_id").references(()=>roots.id, {
|
|
804
|
+
onDelete: "cascade"
|
|
805
|
+
}),
|
|
806
|
+
sourcePath: text("source_path"),
|
|
807
|
+
targetType: redirectEndpointTypeEnum("target_type").notNull(),
|
|
808
|
+
targetRootId: text("target_root_id").references(()=>roots.id, {
|
|
809
|
+
onDelete: "cascade"
|
|
810
|
+
}),
|
|
811
|
+
targetPath: text("target_path"),
|
|
812
|
+
statusCode: integer("status_code").notNull().default(301),
|
|
813
|
+
createdBy: text("created_by"),
|
|
814
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
815
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
816
|
+
archivedAt: timestamp("archived_at")
|
|
817
|
+
}, (table)=>[
|
|
818
|
+
index("rdr_collection_source_path_idx").on(table.collection, table.sourcePath),
|
|
819
|
+
index("rdr_source_root_idx").on(table.sourceRootId),
|
|
820
|
+
index("rdr_collection_idx").on(table.collection),
|
|
821
|
+
index("rdr_archived_at_idx").on(table.archivedAt)
|
|
822
|
+
]);
|
|
823
|
+
const roots = cms.table("roots", {
|
|
824
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("root")),
|
|
825
|
+
collection: text("collection").notNull(),
|
|
826
|
+
parentRootId: text("parent_root_id"),
|
|
827
|
+
slug: text("slug"),
|
|
828
|
+
sortOrder: integer("sort_order").notNull().default(0),
|
|
829
|
+
createdBy: text("created_by"),
|
|
830
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
831
|
+
archivedAt: timestamp("archived_at"),
|
|
832
|
+
lastPrunedAt: timestamp("last_pruned_at")
|
|
833
|
+
}, (table)=>[
|
|
834
|
+
foreignKey({
|
|
835
|
+
columns: [
|
|
836
|
+
table.parentRootId
|
|
837
|
+
],
|
|
838
|
+
foreignColumns: [
|
|
839
|
+
table.id
|
|
840
|
+
],
|
|
841
|
+
name: "roots_parent_fk"
|
|
842
|
+
}).onDelete("cascade"),
|
|
843
|
+
index("roots_collection_idx").on(table.collection),
|
|
844
|
+
index("roots_parent_root_idx").on(table.parentRootId),
|
|
845
|
+
index("roots_slug_idx").on(table.collection, table.parentRootId, table.slug),
|
|
846
|
+
index("roots_archived_at_idx").on(table.archivedAt),
|
|
847
|
+
index("roots_last_pruned_at_idx").on(table.lastPrunedAt)
|
|
848
|
+
]);
|
|
849
|
+
cms.table("search_index", {
|
|
850
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("si")),
|
|
851
|
+
entityType: text("entity_type").notNull(),
|
|
852
|
+
entityId: text("entity_id").notNull(),
|
|
853
|
+
collection: text("collection"),
|
|
854
|
+
rootId: text("root_id"),
|
|
855
|
+
contentVector: tsvectorColumn("content_vector").notNull(),
|
|
856
|
+
title: text("title"),
|
|
857
|
+
snippet: text("snippet"),
|
|
858
|
+
meta: jsonb("meta").$type(),
|
|
859
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
|
860
|
+
}, (table)=>[
|
|
861
|
+
index("si_vector_gin").using("gin", table.contentVector),
|
|
862
|
+
index("si_entity_type_idx").on(table.entityType),
|
|
863
|
+
index("si_collection_idx").on(table.collection),
|
|
864
|
+
index("si_root_idx").on(table.rootId),
|
|
865
|
+
uniqueIndex("si_entity_unique").on(table.entityType, table.entityId)
|
|
866
|
+
]);
|
|
867
|
+
const templates = cms.table("templates", {
|
|
868
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("template")),
|
|
869
|
+
collection: text("collection").notNull(),
|
|
870
|
+
blockType: text("block_type").notNull(),
|
|
871
|
+
propertyKey: text("property_key").notNull(),
|
|
872
|
+
template: text("template").notNull(),
|
|
873
|
+
description: text("description"),
|
|
874
|
+
createdBy: text("created_by"),
|
|
875
|
+
updatedBy: text("updated_by"),
|
|
876
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
877
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
|
878
|
+
}, (table)=>[
|
|
879
|
+
uniqueIndex("templates_collection_block_prop_unique").on(table.collection, table.blockType, table.propertyKey),
|
|
880
|
+
index("templates_collection_idx").on(table.collection),
|
|
881
|
+
index("templates_collection_block_idx").on(table.collection, table.blockType)
|
|
882
|
+
]);
|
|
883
|
+
cms.table("template_variable_usages", {
|
|
884
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("tplVarUsage")),
|
|
885
|
+
variableKey: text("variable_key").notNull(),
|
|
886
|
+
templateId: text("template_id").notNull().references(()=>templates.id, {
|
|
887
|
+
onDelete: "cascade"
|
|
888
|
+
})
|
|
889
|
+
}, (table)=>[
|
|
890
|
+
uniqueIndex("tvu_key_template_unique").on(table.variableKey, table.templateId),
|
|
891
|
+
index("tvu_variable_key_idx").on(table.variableKey),
|
|
892
|
+
index("tvu_template_id_idx").on(table.templateId)
|
|
893
|
+
]);
|
|
894
|
+
cms.table("variables", {
|
|
895
|
+
id: text("id").primaryKey().$defaultFn(()=>newId("variable")),
|
|
896
|
+
key: text("key").notNull(),
|
|
897
|
+
value: text("value").notNull(),
|
|
898
|
+
description: text("description"),
|
|
899
|
+
createdBy: text("created_by"),
|
|
900
|
+
updatedBy: text("updated_by"),
|
|
901
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
902
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow()
|
|
903
|
+
}, (table)=>[
|
|
904
|
+
uniqueIndex("variables_key_unique").on(table.key)
|
|
905
|
+
]);
|
|
906
|
+
|
|
907
|
+
function assembleBlockTree(blocks, rootId) {
|
|
908
|
+
const deletedBlockIds = new Set();
|
|
909
|
+
const nodeMap = new Map();
|
|
910
|
+
for (const [id, block] of blocks){
|
|
911
|
+
if (block.deleted) {
|
|
912
|
+
deletedBlockIds.add(id);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
nodeMap.set(id, {
|
|
916
|
+
blockId: block.blockId,
|
|
917
|
+
type: block.type,
|
|
918
|
+
properties: block.properties,
|
|
919
|
+
children: []
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
for (const [, block] of blocks){
|
|
923
|
+
if (block.deleted) continue;
|
|
924
|
+
const node = nodeMap.get(block.blockId);
|
|
925
|
+
// Drop child references that point at a deleted block or at a block absent
|
|
926
|
+
// from this snapshot. This is intentional: a parent legitimately keeps a
|
|
927
|
+
// reference to a child that a merge excluded (e.g. deleted on one branch).
|
|
928
|
+
// See buildMergedSnapshot in routes/merges.ts for the merge-side reasoning.
|
|
929
|
+
node.children = block.children.filter((childId)=>!deletedBlockIds.has(childId)).map((childId)=>nodeMap.get(childId)).filter((candidate)=>candidate !== undefined);
|
|
930
|
+
}
|
|
931
|
+
const rootNode = nodeMap.get(rootId);
|
|
932
|
+
if (rootNode) {
|
|
933
|
+
// The root block is STORED with type = collection name (see the inverse
|
|
934
|
+
// `type === 'root' ? collectionName : type` in routes/merges.ts), but the
|
|
935
|
+
// consumable tree contract uses the logical `'root'` marker — the renderer
|
|
936
|
+
// skips it to render the page as a fragment, and `getReferencePropertyNames`
|
|
937
|
+
// keys off it. Translate stored → logical HERE, at the single tree-builder,
|
|
938
|
+
// so every consumer (editor read, published render, reference resolution)
|
|
939
|
+
// sees a consistent `type: 'root'` top node.
|
|
940
|
+
rootNode.type = 'root';
|
|
941
|
+
}
|
|
942
|
+
return rootNode ?? null;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Load the (commit_snapshots ⋈ block_versions) rows for a single commit/root.
|
|
946
|
+
* The rootId clause is the scope guard — it keeps reconstruction from reading
|
|
947
|
+
* another root's block versions. Returns [] when the commit has no snapshot.
|
|
948
|
+
*/ async function loadSnapshotRows(db, commitId, rootId) {
|
|
949
|
+
return db.select({
|
|
950
|
+
blockId: commitSnapshots.blockId,
|
|
951
|
+
blockVersionId: blockVersions.id,
|
|
952
|
+
type: blockVersions.type,
|
|
953
|
+
properties: blockVersions.properties,
|
|
954
|
+
children: blockVersions.children,
|
|
955
|
+
deleted: blockVersions.deleted
|
|
956
|
+
}).from(commitSnapshots).innerJoin(blockVersions, eq(blockVersions.id, commitSnapshots.blockVersionId)).where(and(eq(commitSnapshots.commitId, commitId), eq(blockVersions.rootId, rootId)));
|
|
957
|
+
}
|
|
958
|
+
async function loadBlocksAtCommit(db, commitId, rootId) {
|
|
959
|
+
const snapshotRows = await loadSnapshotRows(db, commitId, rootId);
|
|
960
|
+
if (snapshotRows.length > 0) {
|
|
961
|
+
const blocks = new Map();
|
|
962
|
+
for (const row of snapshotRows){
|
|
963
|
+
blocks.set(row.blockId, {
|
|
964
|
+
blockId: row.blockId,
|
|
965
|
+
blockVersionId: row.blockVersionId,
|
|
966
|
+
type: row.type,
|
|
967
|
+
properties: row.properties,
|
|
968
|
+
children: row.children ?? [],
|
|
969
|
+
deleted: row.deleted
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
blocks,
|
|
974
|
+
reconstructed: false
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
const chainResult = await db.execute(sql`
|
|
978
|
+
WITH RECURSIVE chain AS (
|
|
979
|
+
SELECT id, parent_commit_id, 0 AS depth
|
|
980
|
+
FROM cms.commits
|
|
981
|
+
WHERE id = ${commitId} AND root_id = ${rootId}
|
|
982
|
+
UNION ALL
|
|
983
|
+
SELECT c.id, c.parent_commit_id, chain.depth + 1
|
|
984
|
+
FROM cms.commits c
|
|
985
|
+
JOIN chain ON c.id = chain.parent_commit_id
|
|
986
|
+
WHERE chain.depth < 10000
|
|
987
|
+
)
|
|
988
|
+
SELECT chain.id, chain.parent_commit_id, chain.depth,
|
|
989
|
+
EXISTS (SELECT 1 FROM cms.commit_snapshots cs WHERE cs.commit_id = chain.id) AS has_snapshot
|
|
990
|
+
FROM chain
|
|
991
|
+
ORDER BY depth DESC
|
|
992
|
+
`);
|
|
993
|
+
const chainRows = chainResult.rows;
|
|
994
|
+
if (chainRows.length === 0) {
|
|
995
|
+
return {
|
|
996
|
+
blocks: new Map(),
|
|
997
|
+
reconstructed: true
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const commitChain = chainRows.map((row)=>row.id);
|
|
1001
|
+
const commitsWithSnapshots = new Set(chainRows.filter((row)=>row.has_snapshot).map((row)=>row.id));
|
|
1002
|
+
let baseCommitId = null;
|
|
1003
|
+
let baseIndex = -1;
|
|
1004
|
+
for(let i = commitChain.length - 1; i >= 0; i--){
|
|
1005
|
+
if (commitChain[i] !== commitId && commitsWithSnapshots.has(commitChain[i])) {
|
|
1006
|
+
baseCommitId = commitChain[i];
|
|
1007
|
+
baseIndex = i;
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const state = new Map();
|
|
1012
|
+
if (baseCommitId) {
|
|
1013
|
+
const baseRows = await loadSnapshotRows(db, baseCommitId, rootId);
|
|
1014
|
+
for (const row of baseRows){
|
|
1015
|
+
state.set(row.blockId, {
|
|
1016
|
+
blockId: row.blockId,
|
|
1017
|
+
blockVersionId: row.blockVersionId,
|
|
1018
|
+
type: row.type,
|
|
1019
|
+
properties: row.properties,
|
|
1020
|
+
children: row.children ?? [],
|
|
1021
|
+
deleted: row.deleted
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const replayCommitIds = baseCommitId ? commitChain.slice(baseIndex + 1) : commitChain;
|
|
1026
|
+
if (replayCommitIds.length > 0) {
|
|
1027
|
+
const replayVersions = await db.select().from(blockVersions).where(and(inArray(blockVersions.commitId, replayCommitIds), eq(blockVersions.rootId, rootId)));
|
|
1028
|
+
const versionsByCommit = new Map();
|
|
1029
|
+
for (const version of replayVersions){
|
|
1030
|
+
const list = versionsByCommit.get(version.commitId) ?? [];
|
|
1031
|
+
list.push(version);
|
|
1032
|
+
versionsByCommit.set(version.commitId, list);
|
|
1033
|
+
}
|
|
1034
|
+
for (const commitIdInChain of replayCommitIds){
|
|
1035
|
+
const versionsForCommit = versionsByCommit.get(commitIdInChain);
|
|
1036
|
+
if (!versionsForCommit) continue;
|
|
1037
|
+
for (const version of versionsForCommit){
|
|
1038
|
+
state.set(version.blockId, {
|
|
1039
|
+
blockId: version.blockId,
|
|
1040
|
+
blockVersionId: version.id,
|
|
1041
|
+
type: version.type,
|
|
1042
|
+
properties: version.properties,
|
|
1043
|
+
children: version.children ?? [],
|
|
1044
|
+
deleted: version.deleted
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return {
|
|
1050
|
+
blocks: state,
|
|
1051
|
+
reconstructed: true
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const cmsContext = createMiddleware(async ()=>{
|
|
1056
|
+
return {};
|
|
1057
|
+
});
|
|
1058
|
+
const createCMSEndpoint = createEndpoint.create({
|
|
1059
|
+
use: [
|
|
1060
|
+
cmsContext
|
|
1061
|
+
]
|
|
1062
|
+
});
|
|
1063
|
+
function cmsMeta(base, cms) {
|
|
1064
|
+
return {
|
|
1065
|
+
...base,
|
|
1066
|
+
cms
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Resolves the GA4/dataLayer wire name for a block event key: the author's
|
|
1072
|
+
* `EventDeclaration.name` override, else the default `cms_<blockType>_<key>`
|
|
1073
|
+
* (locked measurement decision #7). Pure + framework-free so BOTH the client
|
|
1074
|
+
* tracker (react/tracking.tsx, where a fire happens) and the server goal-picker
|
|
1075
|
+
* (ab-test listGoalEvents, which advertises the goal) resolve names identically
|
|
1076
|
+
* — the stored event_type and the offered goal must be the same string.
|
|
1077
|
+
*/ function resolveWireName(key, blockType, events) {
|
|
1078
|
+
return events?.[key]?.name ?? `cms_${blockType}_${key}`;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const SAFE_COLUMN = /^[a-z_][a-z0-9_]*$/i;
|
|
1082
|
+
/**
|
|
1083
|
+
* Equality conditions for plugin-owned scope columns (e.g. `tenant_slug`)
|
|
1084
|
+
* against an UN-ALIASED `cms.roots`, fully qualified so they bind in raw-SQL
|
|
1085
|
+
* read paths that want defensive scope filtering (a referenced or ancestor root
|
|
1086
|
+
* must be in the active scope). `exclude` drops columns the caller handles
|
|
1087
|
+
* separately (a scoping plugin whose column varies independently of the query).
|
|
1088
|
+
* Column names are validated; values are parameterized. Returns `[]` when no
|
|
1089
|
+
* scoping is active.
|
|
1090
|
+
*/ function rootScopeConditions(scopeColumns, exclude = []) {
|
|
1091
|
+
if (!scopeColumns) return [];
|
|
1092
|
+
const conds = [];
|
|
1093
|
+
for (const [col, val] of Object.entries(scopeColumns)){
|
|
1094
|
+
if (val === undefined || val === null || exclude.includes(col)) continue;
|
|
1095
|
+
if (!SAFE_COLUMN.test(col)) {
|
|
1096
|
+
throw new Error(`rootScopeConditions: unsafe scope column "${col}"`);
|
|
1097
|
+
}
|
|
1098
|
+
conds.push(sql`"cms"."roots".${sql.raw(`"${col}"`)} = ${val}`);
|
|
1099
|
+
}
|
|
1100
|
+
return conds;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* The roots scope columns for CROSS-scope read filtering: the static insert
|
|
1104
|
+
* columns MINUS the plugin-declared `crossScopeExclude` (columns the plugin
|
|
1105
|
+
* varies INDEPENDENTLY of a query — e.g. the i18n plugin's `language`). Pass the
|
|
1106
|
+
* result to reads that legitimately span those columns (reference / host /
|
|
1107
|
+
* usage / co-render reads, the published-root load) so they are NOT filtered by
|
|
1108
|
+
* them. Core names no specific column. (Seam D6.)
|
|
1109
|
+
*/ function crossScopeColumns(rootScope) {
|
|
1110
|
+
const cols = rootScope?.insertColumns;
|
|
1111
|
+
const exclude = rootScope?.crossScopeExclude;
|
|
1112
|
+
if (!cols || !exclude?.length) return cols;
|
|
1113
|
+
return Object.fromEntries(Object.entries(cols).filter(([k])=>!exclude.includes(k)));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// ============================================================================
|
|
1117
|
+
// Reference-resolution seam — core's identity default (Seam B)
|
|
1118
|
+
// ============================================================================
|
|
1119
|
+
/**
|
|
1120
|
+
* Core's identity `ReferenceResolver` — the no-resolver-plugin behaviour (a
|
|
1121
|
+
* stored value renders as itself; no grouping), byte-for-byte. Used wherever
|
|
1122
|
+
* `scope.referenceResolver` is absent.
|
|
1123
|
+
* - resolveRenderTargets: every stored value renders as itself
|
|
1124
|
+
* (loadPublishedRoots then drops unpublished / out-of-scope ones).
|
|
1125
|
+
* - resolveConflictTargets: the existing, non-archived roots among the keys
|
|
1126
|
+
* (by id), scoped to the given (cross-scope) columns.
|
|
1127
|
+
* - expandGroup: identity (no groups without a resolver plugin).
|
|
1128
|
+
* - groupKeysFor: [] (no group keys without a resolver plugin).
|
|
1129
|
+
*
|
|
1130
|
+
* Stateless singleton: `db` and `scopeColumns` are per-call args (so a caller
|
|
1131
|
+
* inside a transaction resolves on its own tx, and tenant scoping uses the
|
|
1132
|
+
* merged columns the caller holds).
|
|
1133
|
+
*/ const coreReferenceResolver = {
|
|
1134
|
+
async resolveRenderTargets (_db, _scopeColumns, _collection, storedValues) {
|
|
1135
|
+
return new Map(storedValues.map((v)=>[
|
|
1136
|
+
v,
|
|
1137
|
+
v
|
|
1138
|
+
]));
|
|
1139
|
+
},
|
|
1140
|
+
async resolveConflictTargets (db, scopeColumns, storedKeys) {
|
|
1141
|
+
if (storedKeys.length === 0) return [];
|
|
1142
|
+
const rows = await db.select({
|
|
1143
|
+
id: roots.id
|
|
1144
|
+
}).from(roots).where(and(inArray(roots.id, storedKeys), isNull(roots.archivedAt), ...rootScopeConditions(scopeColumns)));
|
|
1145
|
+
return rows.map((r)=>r.id);
|
|
1146
|
+
},
|
|
1147
|
+
async expandGroup (_db, _scopeColumns, rootIds) {
|
|
1148
|
+
return rootIds;
|
|
1149
|
+
},
|
|
1150
|
+
async groupKeysFor () {
|
|
1151
|
+
return [];
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
// ============================================================================
|
|
1155
|
+
// Reference edges — the generic live-head graph primitive (exposed for plugins)
|
|
1156
|
+
// ============================================================================
|
|
1157
|
+
/**
|
|
1158
|
+
* Live-head reference edges in one direction. `embeds` filters on the host
|
|
1159
|
+
* `rootId` and returns what those hosts embed (the `targetKey`s); `embeddedBy`
|
|
1160
|
+
* filters on `targetKey` and returns the hosts (`rootId`s). Restricted to
|
|
1161
|
+
* non-archived roots' non-deleted branch-HEAD content, scope-filtered (the
|
|
1162
|
+
* caller passes cross-scope columns — like the read path / delete guard).
|
|
1163
|
+
*/ async function referenceEdges(db, ids, direction, scopeColumns) {
|
|
1164
|
+
if (ids.length === 0) return [];
|
|
1165
|
+
const filterCol = direction === 'embeds' ? contentUsages.rootId : contentUsages.targetKey;
|
|
1166
|
+
const selectCol = direction === 'embeds' ? contentUsages.targetKey : contentUsages.rootId;
|
|
1167
|
+
const rows = await db.selectDistinct({
|
|
1168
|
+
id: selectCol
|
|
1169
|
+
}).from(contentUsages).innerJoin(commitSnapshots, eq(commitSnapshots.blockVersionId, contentUsages.blockVersionId)).innerJoin(branches, eq(branches.headCommitId, commitSnapshots.commitId)).innerJoin(roots, eq(roots.id, branches.rootId)).innerJoin(blockVersions, eq(blockVersions.id, contentUsages.blockVersionId)).where(and(eq(contentUsages.targetKind, 'reference'), inArray(filterCol, ids), isNull(roots.archivedAt), eq(blockVersions.deleted, false), ...rootScopeConditions(scopeColumns)));
|
|
1170
|
+
return rows.map((r)=>r.id);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* GA4 params the SERVER derives + owns. They are stripped from the untrusted
|
|
1175
|
+
* `metadata` (public trackEvent input) before it is merged, so a caller can
|
|
1176
|
+
* never fabricate/overwrite them — not even on a non-A/B event where the
|
|
1177
|
+
* server-derived value happens to be absent (a conditional spread would
|
|
1178
|
+
* otherwise leave the attacker's value in place).
|
|
1179
|
+
*/ const RESERVED_GA4_PARAMS = new Set([
|
|
1180
|
+
'engagement_time_msec',
|
|
1181
|
+
'session_id',
|
|
1182
|
+
'experiment_id',
|
|
1183
|
+
'experiment_variant',
|
|
1184
|
+
'tracking_id',
|
|
1185
|
+
'interaction_id'
|
|
1186
|
+
]);
|
|
1187
|
+
/** A copy of `metadata` with every server-owned GA4 param removed. */ function sanitizeMetadata(metadata) {
|
|
1188
|
+
if (!metadata) return {};
|
|
1189
|
+
const out = {};
|
|
1190
|
+
for (const [key, value] of Object.entries(metadata)){
|
|
1191
|
+
if (!RESERVED_GA4_PARAMS.has(key)) out[key] = value;
|
|
1192
|
+
}
|
|
1193
|
+
return out;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Builds the MP payload from a {@link CMSEvent}. Pure (no I/O) + exported so the
|
|
1197
|
+
* mapping is unit-testable. Returns null when the event cannot be a valid MP hit
|
|
1198
|
+
* — no consent (analytics_storage not granted) or no client_id — so the caller
|
|
1199
|
+
* simply does not forward. The A/B attribution rides as GA4's
|
|
1200
|
+
* `experiment_id`/`experiment_variant` convention (event-scoped custom dims).
|
|
1201
|
+
*/ function buildGa4Payload(event) {
|
|
1202
|
+
if (event.consent?.analytics_storage !== 'granted') return null;
|
|
1203
|
+
const clientId = event.transport?.clientId;
|
|
1204
|
+
if (!clientId) return null;
|
|
1205
|
+
const params = {
|
|
1206
|
+
// metadata FIRST, but with every server-owned param stripped: it is
|
|
1207
|
+
// public-ingest input (trackEvent body), so a caller can never fabricate or
|
|
1208
|
+
// overwrite experiment_id / experiment_variant / session_id /
|
|
1209
|
+
// engagement_time_msec / tracking_id / interaction_id — not even on a
|
|
1210
|
+
// non-A/B event where the server-derived value is absent (a plain
|
|
1211
|
+
// conditional spread would leave the attacker's value standing).
|
|
1212
|
+
...sanitizeMetadata(event.metadata),
|
|
1213
|
+
// GA4 needs a non-zero engagement time, else the hit is realtime-invisible.
|
|
1214
|
+
engagement_time_msec: event.transport?.engagementTimeMsec ?? 1,
|
|
1215
|
+
...event.transport?.sessionId ? {
|
|
1216
|
+
session_id: event.transport.sessionId
|
|
1217
|
+
} : {},
|
|
1218
|
+
...event.ab ? {
|
|
1219
|
+
experiment_id: event.ab.testId,
|
|
1220
|
+
experiment_variant: event.ab.variantId
|
|
1221
|
+
} : {},
|
|
1222
|
+
...event.source?.handle ? {
|
|
1223
|
+
tracking_id: event.source.handle
|
|
1224
|
+
} : {},
|
|
1225
|
+
...event.interactionId ? {
|
|
1226
|
+
interaction_id: event.interactionId
|
|
1227
|
+
} : {}
|
|
1228
|
+
};
|
|
1229
|
+
return {
|
|
1230
|
+
client_id: clientId,
|
|
1231
|
+
events: [
|
|
1232
|
+
{
|
|
1233
|
+
name: event.name,
|
|
1234
|
+
params
|
|
1235
|
+
}
|
|
1236
|
+
],
|
|
1237
|
+
// GA4 Consent Mode block (ad signals). analytics_storage is already gated
|
|
1238
|
+
// above; the ad_* signals tell GA4 how it may use the data.
|
|
1239
|
+
...event.consent ? {
|
|
1240
|
+
consent: {
|
|
1241
|
+
ad_user_data: event.consent.ad_user_data,
|
|
1242
|
+
ad_personalization: event.consent.ad_personalization
|
|
1243
|
+
}
|
|
1244
|
+
} : {}
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
/** Resolves the POST URL for a config (MP appends measurement_id + api_secret). */ function resolveUrl(config) {
|
|
1248
|
+
if (config.type === 'sgtm') return config.endpointUrl;
|
|
1249
|
+
const sep = config.endpointUrl.includes('?') ? '&' : '?';
|
|
1250
|
+
return `${config.endpointUrl}${sep}measurement_id=${encodeURIComponent(config.measurementId)}&api_secret=${encodeURIComponent(config.apiSecret)}`;
|
|
1251
|
+
}
|
|
1252
|
+
/** Max wall-clock the forward may add to the public ingest before it is aborted. */ const FORWARD_TIMEOUT_MS = 2000;
|
|
1253
|
+
/**
|
|
1254
|
+
* Forwards one event to GA4 server-side, IF it is a valid consenting MP hit.
|
|
1255
|
+
* Best-effort + non-fatal: a network/endpoint error never breaks the ingest
|
|
1256
|
+
* (the A/B store write already happened). No-ops on missing consent/client_id.
|
|
1257
|
+
*
|
|
1258
|
+
* The trackEvent handler awaits this, so it is hard-bounded by an
|
|
1259
|
+
* {@link AbortSignal.timeout}: a slow/hung GA4/sGTM endpoint can never stall the
|
|
1260
|
+
* public response — the abort surfaces as a caught error and the ingest returns.
|
|
1261
|
+
*/ async function forwardToGa4(event, config, fetchImpl = fetch) {
|
|
1262
|
+
const payload = buildGa4Payload(event);
|
|
1263
|
+
if (!payload) return; // not a consenting, identified hit → do not forward
|
|
1264
|
+
try {
|
|
1265
|
+
await fetchImpl(resolveUrl(config), {
|
|
1266
|
+
method: 'POST',
|
|
1267
|
+
headers: {
|
|
1268
|
+
'content-type': 'application/json'
|
|
1269
|
+
},
|
|
1270
|
+
body: JSON.stringify(payload),
|
|
1271
|
+
signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS)
|
|
1272
|
+
});
|
|
1273
|
+
} catch {
|
|
1274
|
+
// Non-fatal: the authoritative A/B store write already succeeded (a
|
|
1275
|
+
// network error, a non-2xx, or the timeout abort all land here).
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* MurmurHash3 (32-bit) for deterministic variant assignment.
|
|
1281
|
+
* Inlined to avoid an external dependency for ~30 lines of code.
|
|
1282
|
+
*/ function murmur3(key, seed = 0) {
|
|
1283
|
+
let h = seed >>> 0;
|
|
1284
|
+
const len = key.length;
|
|
1285
|
+
let i = 0;
|
|
1286
|
+
while(i + 4 <= len){
|
|
1287
|
+
let k = key.charCodeAt(i) & 0xff | (key.charCodeAt(i + 1) & 0xff) << 8 | (key.charCodeAt(i + 2) & 0xff) << 16 | (key.charCodeAt(i + 3) & 0xff) << 24;
|
|
1288
|
+
k = Math.imul(k, 0xcc9e2d51);
|
|
1289
|
+
k = k << 15 | k >>> 17;
|
|
1290
|
+
k = Math.imul(k, 0x1b873593);
|
|
1291
|
+
h ^= k;
|
|
1292
|
+
h = h << 13 | h >>> 19;
|
|
1293
|
+
h = Math.imul(h, 5) + 0xe6546b64;
|
|
1294
|
+
i += 4;
|
|
1295
|
+
}
|
|
1296
|
+
let k = 0;
|
|
1297
|
+
switch(len & 3){
|
|
1298
|
+
case 3:
|
|
1299
|
+
k ^= (key.charCodeAt(i + 2) & 0xff) << 16;
|
|
1300
|
+
// falls through
|
|
1301
|
+
case 2:
|
|
1302
|
+
k ^= (key.charCodeAt(i + 1) & 0xff) << 8;
|
|
1303
|
+
// falls through
|
|
1304
|
+
case 1:
|
|
1305
|
+
k ^= key.charCodeAt(i) & 0xff;
|
|
1306
|
+
k = Math.imul(k, 0xcc9e2d51);
|
|
1307
|
+
k = k << 15 | k >>> 17;
|
|
1308
|
+
k = Math.imul(k, 0x1b873593);
|
|
1309
|
+
h ^= k;
|
|
1310
|
+
}
|
|
1311
|
+
h ^= len;
|
|
1312
|
+
h ^= h >>> 16;
|
|
1313
|
+
h = Math.imul(h, 0x85ebca6b);
|
|
1314
|
+
h ^= h >>> 13;
|
|
1315
|
+
h = Math.imul(h, 0xc2b2ae35);
|
|
1316
|
+
h ^= h >>> 16;
|
|
1317
|
+
return h >>> 0;
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Deterministic variant assignment.
|
|
1321
|
+
*
|
|
1322
|
+
* The same `contextKey + testId` always produces the same variant.
|
|
1323
|
+
* No DB writes needed -- pure function.
|
|
1324
|
+
*
|
|
1325
|
+
* @param contextKey - Visitor identifier (user ID or anonymous key)
|
|
1326
|
+
* @param testId - The A/B test ID
|
|
1327
|
+
* @param trafficPercentage - 0-100, how much total traffic enters the test
|
|
1328
|
+
* @param variants - Must be sorted by id for stability
|
|
1329
|
+
*/ function resolveVariant(contextKey, testId, trafficPercentage, variants) {
|
|
1330
|
+
const hash = murmur3(contextKey + ':' + testId);
|
|
1331
|
+
const bucket = hash % 10000;
|
|
1332
|
+
const control = variants.find((v)=>v.isControl);
|
|
1333
|
+
if (bucket >= trafficPercentage * 100) {
|
|
1334
|
+
return {
|
|
1335
|
+
variantId: control.id,
|
|
1336
|
+
inTest: false
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
const sorted = [
|
|
1340
|
+
...variants
|
|
1341
|
+
].sort((a, b)=>a.id.localeCompare(b.id));
|
|
1342
|
+
const normalizedBucket = Math.floor(bucket * 100 / (trafficPercentage * 100));
|
|
1343
|
+
let cumulative = 0;
|
|
1344
|
+
for (const v of sorted){
|
|
1345
|
+
cumulative += v.weight;
|
|
1346
|
+
if (normalizedBucket < cumulative) {
|
|
1347
|
+
return {
|
|
1348
|
+
variantId: v.id,
|
|
1349
|
+
inTest: true
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return {
|
|
1354
|
+
variantId: control.id,
|
|
1355
|
+
inTest: true
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// ============================================================================
|
|
1360
|
+
// Co-render set — the conflict set for the A/B XOR guard (AB_FANOUT_DESIGN F1)
|
|
1361
|
+
// ============================================================================
|
|
1362
|
+
//
|
|
1363
|
+
// The render-tree traversal that powers the A/B cross-embed XOR rule. Built on
|
|
1364
|
+
// core's generic `referenceEdges` (the live-head graph primitive) composed with
|
|
1365
|
+
// `scope.referenceResolver` (group resolution): without the i18n plugin the
|
|
1366
|
+
// resolver is identity and this degrades to a plain rootId graph; with it, the
|
|
1367
|
+
// walk is translation-group aware. Owned by ab-test because the XOR rule is its
|
|
1368
|
+
// concern; core stays free of the A/B closure algorithm.
|
|
1369
|
+
const MAX_CORENDER_DEPTH = 20; // mirrors MAX_REFERENCE_DEPTH (publications.ts)
|
|
1370
|
+
/**
|
|
1371
|
+
* Down-only transitive EMBED closure reachable from `startRoots` — the render
|
|
1372
|
+
* subtree(s) below them. tgr_-aware (resolves group references) + group-aware +
|
|
1373
|
+
* tenant-scoped + bounded. Mutates + reads `seen` for dedup; returns the newly
|
|
1374
|
+
* reached roots (not in `seen` initially).
|
|
1375
|
+
*/ async function embedClosure(db, startRoots, seen, resolver, scopeColumns) {
|
|
1376
|
+
const down = new Set();
|
|
1377
|
+
let frontier = [
|
|
1378
|
+
...startRoots
|
|
1379
|
+
];
|
|
1380
|
+
for(let d = 0; d < MAX_CORENDER_DEPTH && frontier.length > 0; d++){
|
|
1381
|
+
const rawTargets = await referenceEdges(db, frontier, 'embeds', scopeColumns);
|
|
1382
|
+
const resolved = await resolver.resolveConflictTargets(db, scopeColumns, rawTargets);
|
|
1383
|
+
const expanded = await resolver.expandGroup(db, scopeColumns, resolved);
|
|
1384
|
+
const next = [];
|
|
1385
|
+
for (const t of expanded){
|
|
1386
|
+
if (!seen.has(t)) {
|
|
1387
|
+
seen.add(t);
|
|
1388
|
+
down.add(t);
|
|
1389
|
+
next.push(t);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
frontier = next;
|
|
1393
|
+
}
|
|
1394
|
+
return down;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* The roots in `rootId`'s OWN rendered subtree (its transitive embeds), group-
|
|
1398
|
+
* aware, excluding rootId's own group. Used by the publishBranch backstop, which
|
|
1399
|
+
* cares only about the render tree the published root produces.
|
|
1400
|
+
*/ async function collectEmbeddedRoots(db, rootId, resolver, scopeColumns) {
|
|
1401
|
+
const ownGroup = new Set(await resolver.expandGroup(db, scopeColumns, [
|
|
1402
|
+
rootId
|
|
1403
|
+
]));
|
|
1404
|
+
return embedClosure(db, [
|
|
1405
|
+
...ownGroup
|
|
1406
|
+
], new Set(ownGroup), resolver, scopeColumns);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* All roots that can appear in the SAME rendered page tree as `rootId` — the
|
|
1410
|
+
* conflict set for the A/B XOR rule (AB_FANOUT_DESIGN §2). = the transitive HOSTS
|
|
1411
|
+
* of rootId (every root that embeds it, going up), then the transitive EMBEDS of
|
|
1412
|
+
* rootId AND each host (going down) — covering the page above, the blocks below,
|
|
1413
|
+
* and co-embedded siblings. Group-aware AND tgr_-aware (a reference may store a
|
|
1414
|
+
* translation-group key, resolved like the read path), bounded by
|
|
1415
|
+
* MAX_CORENDER_DEPTH; a conservative SUPERSET (over-includes → fails safe).
|
|
1416
|
+
* rootId's OWN translation group is excluded. The caller rejects if any returned
|
|
1417
|
+
* root has a running test.
|
|
1418
|
+
*/ async function collectCoRenderRoots(db, rootId, resolver, scopeColumns) {
|
|
1419
|
+
const ownGroup = new Set(await resolver.expandGroup(db, scopeColumns, [
|
|
1420
|
+
rootId
|
|
1421
|
+
]));
|
|
1422
|
+
// Up: transitive hosts. A host may embed via the rootId OR the group's tgr_
|
|
1423
|
+
// key, so match both forms.
|
|
1424
|
+
const up = new Set();
|
|
1425
|
+
let frontier = [
|
|
1426
|
+
...ownGroup
|
|
1427
|
+
];
|
|
1428
|
+
for(let d = 0; d < MAX_CORENDER_DEPTH && frontier.length > 0; d++){
|
|
1429
|
+
const tgrKeys = await resolver.groupKeysFor(db, scopeColumns, frontier);
|
|
1430
|
+
const hosts = await referenceEdges(db, [
|
|
1431
|
+
...frontier,
|
|
1432
|
+
...tgrKeys
|
|
1433
|
+
], 'embeddedBy', scopeColumns);
|
|
1434
|
+
const expanded = await resolver.expandGroup(db, scopeColumns, hosts);
|
|
1435
|
+
const next = [];
|
|
1436
|
+
for (const h of expanded){
|
|
1437
|
+
if (!up.has(h) && !ownGroup.has(h)) {
|
|
1438
|
+
up.add(h);
|
|
1439
|
+
next.push(h);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
frontier = next;
|
|
1443
|
+
}
|
|
1444
|
+
// Down: transitive embeds of rootId AND every host (so co-embedded siblings
|
|
1445
|
+
// are included), tgr_-aware + group-aware.
|
|
1446
|
+
const seen = new Set([
|
|
1447
|
+
...ownGroup,
|
|
1448
|
+
...up
|
|
1449
|
+
]);
|
|
1450
|
+
const down = await embedClosure(db, [
|
|
1451
|
+
...seen
|
|
1452
|
+
], seen, resolver, scopeColumns);
|
|
1453
|
+
const result = new Set();
|
|
1454
|
+
for (const r of up)if (!ownGroup.has(r)) result.add(r);
|
|
1455
|
+
for (const r of down)if (!ownGroup.has(r)) result.add(r);
|
|
1456
|
+
return result;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const $ERROR_CODES = {
|
|
1460
|
+
AB_TEST_NOT_FOUND: {
|
|
1461
|
+
status: 404,
|
|
1462
|
+
message: 'A/B test not found'
|
|
1463
|
+
},
|
|
1464
|
+
AB_TEST_INVALID_STATUS: {
|
|
1465
|
+
status: 400,
|
|
1466
|
+
message: 'Invalid status transition for this A/B test'
|
|
1467
|
+
},
|
|
1468
|
+
AB_TEST_WEIGHTS_INVALID: {
|
|
1469
|
+
status: 400,
|
|
1470
|
+
message: 'Variant weights must sum to 100'
|
|
1471
|
+
},
|
|
1472
|
+
AB_TEST_DUPLICATE_RUNNING: {
|
|
1473
|
+
status: 409,
|
|
1474
|
+
message: 'Another test is already running for this root'
|
|
1475
|
+
},
|
|
1476
|
+
AB_TEST_CROSS_EMBED_CONFLICT: {
|
|
1477
|
+
status: 409,
|
|
1478
|
+
message: 'Cannot run: a co-rendering root (an embedded reusable block or its host page) already has a running test — at most one A/B axis may vary per render'
|
|
1479
|
+
},
|
|
1480
|
+
AB_TEST_BRANCH_NOT_PUBLISHED: {
|
|
1481
|
+
status: 400,
|
|
1482
|
+
message: 'All variant branches must be published'
|
|
1483
|
+
},
|
|
1484
|
+
AB_TEST_NO_CONTEXT: {
|
|
1485
|
+
status: 400,
|
|
1486
|
+
message: 'No visitor context set. Call identify() first.'
|
|
1487
|
+
},
|
|
1488
|
+
AB_TEST_FLUSH_NOT_SUPPORTED: {
|
|
1489
|
+
status: 400,
|
|
1490
|
+
message: 'Flush is not supported by the current analytics adapter'
|
|
1491
|
+
},
|
|
1492
|
+
AB_TEST_VARIANT_NOT_FOUND: {
|
|
1493
|
+
status: 404,
|
|
1494
|
+
message: 'A/B test variant not found'
|
|
1495
|
+
},
|
|
1496
|
+
AB_TEST_TRACKING_ID_MISSING: {
|
|
1497
|
+
status: 400,
|
|
1498
|
+
message: 'A functional block (one that declares events) is missing its trackingId — every such block must have a non-empty trackingId before the branch can be published'
|
|
1499
|
+
},
|
|
1500
|
+
AB_TEST_TRACKING_ID_DUPLICATE: {
|
|
1501
|
+
status: 400,
|
|
1502
|
+
message: 'Duplicate trackingId in this branch — each functional block must have a unique trackingId'
|
|
1503
|
+
},
|
|
1504
|
+
AB_TEST_TRACKING_ID_DRIFT: {
|
|
1505
|
+
status: 409,
|
|
1506
|
+
message: 'trackingId drift across A/B variant branches — the set of functional trackingIds must be identical across all variant branches of a root, so a chosen goal exists in every arm'
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
const AB_TEST_META = {
|
|
1511
|
+
scope: 'system',
|
|
1512
|
+
permissionResource: 'abTest'
|
|
1513
|
+
};
|
|
1514
|
+
// The PUBLIC event ingest (trackEvent) uses a DISTINCT resource from the admin
|
|
1515
|
+
// 'abTest' resource (createTest/updateTest/getResults/…), so an app can allow
|
|
1516
|
+
// anonymous access to ONLY the ingest — public visitors record impressions/
|
|
1517
|
+
// conversions without a session — while keeping the admin mutations gated.
|
|
1518
|
+
const AB_EVENT_META = {
|
|
1519
|
+
scope: 'system',
|
|
1520
|
+
permissionResource: 'abTestEvent'
|
|
1521
|
+
};
|
|
1522
|
+
// ============================================================================
|
|
1523
|
+
// Zod Schemas
|
|
1524
|
+
// ============================================================================
|
|
1525
|
+
const variantInput = z.object({
|
|
1526
|
+
branchId: z.string(),
|
|
1527
|
+
name: z.string(),
|
|
1528
|
+
weight: z.number().int().min(0).max(100),
|
|
1529
|
+
isControl: z.boolean().optional().default(false)
|
|
1530
|
+
});
|
|
1531
|
+
const variantsSchema = z.array(variantInput).min(2, 'At least 2 variants required').refine((v)=>v.reduce((sum, x)=>sum + x.weight, 0) === 100, {
|
|
1532
|
+
message: 'Variant weights must sum to 100'
|
|
1533
|
+
}).refine((v)=>v.filter((x)=>x.isControl).length === 1, {
|
|
1534
|
+
message: 'Exactly one variant must be marked as control'
|
|
1535
|
+
});
|
|
1536
|
+
const contextSchema = z.object({
|
|
1537
|
+
key: z.string().min(1),
|
|
1538
|
+
anonymous: z.boolean().optional()
|
|
1539
|
+
});
|
|
1540
|
+
function abTestError(code, message) {
|
|
1541
|
+
throw new APIError($ERROR_CODES[code].status, {
|
|
1542
|
+
message: message ?? $ERROR_CODES[code].message,
|
|
1543
|
+
code
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
function getTenantSlug(scope) {
|
|
1547
|
+
return scope.roots?.insertColumns?.tenant_slug ?? null;
|
|
1548
|
+
}
|
|
1549
|
+
function mapTestRow(row) {
|
|
1550
|
+
return {
|
|
1551
|
+
id: row.id,
|
|
1552
|
+
rootId: row.root_id,
|
|
1553
|
+
collection: row.collection,
|
|
1554
|
+
name: row.name,
|
|
1555
|
+
goalHandle: row.goal_handle,
|
|
1556
|
+
goalEvent: row.goal_event,
|
|
1557
|
+
status: row.status,
|
|
1558
|
+
trafficPercentage: row.traffic_percentage,
|
|
1559
|
+
startedAt: row.started_at,
|
|
1560
|
+
endedAt: row.ended_at,
|
|
1561
|
+
createdBy: row.created_by,
|
|
1562
|
+
createdAt: row.created_at,
|
|
1563
|
+
updatedAt: row.updated_at
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
function mapVariantRow(row) {
|
|
1567
|
+
return {
|
|
1568
|
+
id: row.id,
|
|
1569
|
+
branchId: row.branch_id,
|
|
1570
|
+
name: row.name,
|
|
1571
|
+
weight: row.weight,
|
|
1572
|
+
isControl: row.is_control
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
async function findTestOrThrow(db, testId, tenantSlug) {
|
|
1576
|
+
let result;
|
|
1577
|
+
if (tenantSlug) {
|
|
1578
|
+
result = await db.execute(sql`
|
|
1579
|
+
SELECT t.* FROM cms.ab_tests t
|
|
1580
|
+
INNER JOIN cms.roots r ON r.id = t.root_id
|
|
1581
|
+
WHERE t.id = ${testId} AND r.tenant_slug = ${tenantSlug}
|
|
1582
|
+
`);
|
|
1583
|
+
} else {
|
|
1584
|
+
result = await db.execute(sql`
|
|
1585
|
+
SELECT * FROM cms.ab_tests WHERE id = ${testId}
|
|
1586
|
+
`);
|
|
1587
|
+
}
|
|
1588
|
+
if (result.rows.length === 0) abTestError('AB_TEST_NOT_FOUND');
|
|
1589
|
+
return result.rows[0];
|
|
1590
|
+
}
|
|
1591
|
+
async function getVariantsForTest(db, testId) {
|
|
1592
|
+
const result = await db.execute(sql`
|
|
1593
|
+
SELECT * FROM cms.ab_test_variants WHERE test_id = ${testId} ORDER BY id
|
|
1594
|
+
`);
|
|
1595
|
+
return result.rows;
|
|
1596
|
+
}
|
|
1597
|
+
async function validateBranchesPublished(db, rootId, branchIds) {
|
|
1598
|
+
if (branchIds.length === 0) return;
|
|
1599
|
+
const placeholders = branchIds.map((id)=>sql`${id}`);
|
|
1600
|
+
const arrayExpr = sql`ARRAY[${sql.join(placeholders, sql`, `)}]::text[]`;
|
|
1601
|
+
const result = await db.execute(sql`
|
|
1602
|
+
SELECT p.branch_id
|
|
1603
|
+
FROM cms.publications p
|
|
1604
|
+
WHERE p.root_id = ${rootId}
|
|
1605
|
+
AND p.branch_id = ANY(${arrayExpr})
|
|
1606
|
+
`);
|
|
1607
|
+
const publishedSet = new Set(result.rows.map((r)=>r.branch_id));
|
|
1608
|
+
for (const bid of branchIds){
|
|
1609
|
+
if (!publishedSet.has(bid)) {
|
|
1610
|
+
abTestError('AB_TEST_BRANCH_NOT_PUBLISHED', `Branch ${bid} is not published for root ${rootId}`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
async function insertVariants(db, testId, variants) {
|
|
1615
|
+
for (const v of variants){
|
|
1616
|
+
const id = newId('abTestVariant');
|
|
1617
|
+
await db.execute(sql`
|
|
1618
|
+
INSERT INTO cms.ab_test_variants (id, test_id, branch_id, name, weight, is_control)
|
|
1619
|
+
VALUES (${id}, ${testId}, ${v.branchId}, ${v.name}, ${v.weight}, ${v.isControl ?? false})
|
|
1620
|
+
`);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async function deleteVariantsForTest(db, testId) {
|
|
1624
|
+
await db.execute(sql`
|
|
1625
|
+
DELETE FROM cms.ab_test_variants WHERE test_id = ${testId}
|
|
1626
|
+
`);
|
|
1627
|
+
}
|
|
1628
|
+
/** Walk a tree, emitting a goal candidate per (functional block instance × event). */ function collectGoalsFromTree(node, blocks, hostRootId, inVaryingRoot, out) {
|
|
1629
|
+
const def = blocks[node.type];
|
|
1630
|
+
const events = def?.events;
|
|
1631
|
+
if (events && Object.keys(events).length > 0) {
|
|
1632
|
+
const handle = typeof node.properties.trackingId === 'string' ? node.properties.trackingId : null;
|
|
1633
|
+
for (const [event, decl] of Object.entries(events)){
|
|
1634
|
+
out.push({
|
|
1635
|
+
handle,
|
|
1636
|
+
blockType: node.type,
|
|
1637
|
+
blockId: node.blockId,
|
|
1638
|
+
event,
|
|
1639
|
+
name: resolveWireName(event, node.type, events),
|
|
1640
|
+
label: decl.label,
|
|
1641
|
+
params: decl.params ? Object.keys(decl.params) : [],
|
|
1642
|
+
inVaryingRoot,
|
|
1643
|
+
hostRootId
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
for (const child of node.children){
|
|
1648
|
+
collectGoalsFromTree(child, blocks, hostRootId, inVaryingRoot, out);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Loads a root's published tree (the FIRST published branch, deterministically)
|
|
1653
|
+
* + the root's collection, and enumerates goal candidates from that one branch.
|
|
1654
|
+
* Goal anchors are branch-stable ONLY once a test RUNS (the trackingId drift
|
|
1655
|
+
* guard enforces matching sets across running variants) — at pick time the test
|
|
1656
|
+
* is still draft, so a functional block present only on a non-first / not-yet-
|
|
1657
|
+
* published variant branch is NOT offered until that branch is the enumerated
|
|
1658
|
+
* one. Acceptable for the common case (pick a goal present on control); the
|
|
1659
|
+
* running-time drift guard rejects divergent sets later. Returns null when the
|
|
1660
|
+
* root has no published content / is out of scope.
|
|
1661
|
+
*/ async function loadRootPublishedTree(db, rootId, tenantSlug) {
|
|
1662
|
+
const rows = await db.execute(sql`
|
|
1663
|
+
SELECT r.collection AS collection, b.head_commit_id AS commit_id
|
|
1664
|
+
FROM cms.publications p
|
|
1665
|
+
INNER JOIN cms.branches b ON b.id = p.branch_id
|
|
1666
|
+
INNER JOIN cms.roots r ON r.id = p.root_id
|
|
1667
|
+
WHERE p.root_id = ${rootId}
|
|
1668
|
+
${tenantSlug ? sql`AND r.tenant_slug = ${tenantSlug}` : sql``}
|
|
1669
|
+
ORDER BY p.published_at ASC, p.branch_id ASC
|
|
1670
|
+
LIMIT 1
|
|
1671
|
+
`);
|
|
1672
|
+
if (rows.rows.length === 0) return null;
|
|
1673
|
+
const { collection, commit_id } = rows.rows[0];
|
|
1674
|
+
const { blocks } = await loadBlocksAtCommit(db, commit_id, rootId);
|
|
1675
|
+
const tree = assembleBlockTree(blocks, rootId);
|
|
1676
|
+
if (!tree) return null;
|
|
1677
|
+
return {
|
|
1678
|
+
tree,
|
|
1679
|
+
collection
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
// ============================================================================
|
|
1683
|
+
// Endpoints
|
|
1684
|
+
// ============================================================================
|
|
1685
|
+
/**
|
|
1686
|
+
* Creates all A/B test endpoints.
|
|
1687
|
+
*
|
|
1688
|
+
* Every handler reads `db` from `reqCtx.context.db`, which is injected
|
|
1689
|
+
* by the CMS endpoint wrapper at runtime -- just like better-auth does
|
|
1690
|
+
* with `ctx.context.db`. No closure or holder needed.
|
|
1691
|
+
*/ function createABTestEndpoints(adapter, getCollections, ga4Config) {
|
|
1692
|
+
return {
|
|
1693
|
+
createTest: createCMSEndpoint('/abTest/createTest', {
|
|
1694
|
+
method: 'POST',
|
|
1695
|
+
body: z.object({
|
|
1696
|
+
rootId: z.string(),
|
|
1697
|
+
collection: z.string(),
|
|
1698
|
+
name: z.string(),
|
|
1699
|
+
trafficPercentage: z.number().int().min(0).max(100).optional().default(100),
|
|
1700
|
+
goalHandle: z.string().min(1).optional(),
|
|
1701
|
+
goalEvent: z.string().min(1).optional(),
|
|
1702
|
+
variants: variantsSchema
|
|
1703
|
+
}),
|
|
1704
|
+
metadata: cmsMeta({
|
|
1705
|
+
$Infer: {
|
|
1706
|
+
body: {}
|
|
1707
|
+
}
|
|
1708
|
+
}, {
|
|
1709
|
+
operation: 'create',
|
|
1710
|
+
...AB_TEST_META
|
|
1711
|
+
})
|
|
1712
|
+
}, async (reqCtx)=>{
|
|
1713
|
+
const { db, scope } = reqCtx.context;
|
|
1714
|
+
const tenantSlug = getTenantSlug(scope);
|
|
1715
|
+
const { rootId, collection, name, trafficPercentage, goalHandle, goalEvent, variants } = reqCtx.body;
|
|
1716
|
+
const userId = reqCtx.context.userId;
|
|
1717
|
+
if (tenantSlug) {
|
|
1718
|
+
const rootCheck = await db.execute(sql`
|
|
1719
|
+
SELECT 1 FROM cms.roots
|
|
1720
|
+
WHERE id = ${rootId} AND tenant_slug = ${tenantSlug}
|
|
1721
|
+
`);
|
|
1722
|
+
if (rootCheck.rows.length === 0) {
|
|
1723
|
+
abTestError('AB_TEST_NOT_FOUND', 'Root not found for this tenant');
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
await validateBranchesPublished(db, rootId, variants.map((v)=>v.branchId));
|
|
1727
|
+
const testId = newId('abTest');
|
|
1728
|
+
await db.execute(sql`
|
|
1729
|
+
INSERT INTO cms.ab_tests (id, root_id, collection, name, goal_handle, goal_event, status, traffic_percentage, created_by, created_at, updated_at)
|
|
1730
|
+
VALUES (${testId}, ${rootId}, ${collection}, ${name}, ${goalHandle ?? null}, ${goalEvent ?? null}, 'draft', ${trafficPercentage}, ${userId ?? null}, NOW(), NOW())
|
|
1731
|
+
`);
|
|
1732
|
+
await insertVariants(db, testId, variants);
|
|
1733
|
+
return {
|
|
1734
|
+
testId
|
|
1735
|
+
};
|
|
1736
|
+
}),
|
|
1737
|
+
updateTest: createCMSEndpoint('/abTest/updateTest', {
|
|
1738
|
+
method: 'POST',
|
|
1739
|
+
body: z.object({
|
|
1740
|
+
testId: z.string(),
|
|
1741
|
+
name: z.string().optional(),
|
|
1742
|
+
status: z.enum([
|
|
1743
|
+
'draft',
|
|
1744
|
+
'running',
|
|
1745
|
+
'paused',
|
|
1746
|
+
'completed'
|
|
1747
|
+
]).optional(),
|
|
1748
|
+
trafficPercentage: z.number().int().min(0).max(100).optional(),
|
|
1749
|
+
// nullable → an explicit null clears the goal; omitted → unchanged.
|
|
1750
|
+
// min(1) rejects '' so a stored goal is always a usable goal.
|
|
1751
|
+
goalHandle: z.string().min(1).nullable().optional(),
|
|
1752
|
+
goalEvent: z.string().min(1).nullable().optional(),
|
|
1753
|
+
variants: variantsSchema.optional()
|
|
1754
|
+
}),
|
|
1755
|
+
metadata: cmsMeta({
|
|
1756
|
+
$Infer: {
|
|
1757
|
+
body: {}
|
|
1758
|
+
}
|
|
1759
|
+
}, {
|
|
1760
|
+
operation: 'update',
|
|
1761
|
+
...AB_TEST_META
|
|
1762
|
+
})
|
|
1763
|
+
}, async (reqCtx)=>{
|
|
1764
|
+
const { db, scope } = reqCtx.context;
|
|
1765
|
+
const tenantSlug = getTenantSlug(scope);
|
|
1766
|
+
const { testId, name, status, trafficPercentage, goalHandle, goalEvent, variants } = reqCtx.body;
|
|
1767
|
+
const test = await findTestOrThrow(db, testId, tenantSlug);
|
|
1768
|
+
if (status) {
|
|
1769
|
+
const allowed = {
|
|
1770
|
+
draft: [
|
|
1771
|
+
'running'
|
|
1772
|
+
],
|
|
1773
|
+
running: [
|
|
1774
|
+
'paused',
|
|
1775
|
+
'completed'
|
|
1776
|
+
],
|
|
1777
|
+
paused: [
|
|
1778
|
+
'running',
|
|
1779
|
+
'completed'
|
|
1780
|
+
],
|
|
1781
|
+
completed: []
|
|
1782
|
+
};
|
|
1783
|
+
if (!allowed[test.status]?.includes(status)) {
|
|
1784
|
+
abTestError('AB_TEST_INVALID_STATUS', `Cannot transition from "${test.status}" to "${status}"`);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
// For a →running transition, compute the XOR conflict set up-front so we
|
|
1788
|
+
// know which root rows to lock. Locking them FOR UPDATE (id-ordered →
|
|
1789
|
+
// deadlock-free) inside the transaction serialises concurrent →running
|
|
1790
|
+
// calls on overlapping conflict sets, closing the check-then-update race.
|
|
1791
|
+
let coRender = new Set();
|
|
1792
|
+
let lockRootIds = [];
|
|
1793
|
+
if (status === 'running') {
|
|
1794
|
+
coRender = await collectCoRenderRoots(db, test.root_id, scope.referenceResolver ?? coreReferenceResolver, crossScopeColumns(scope.roots));
|
|
1795
|
+
lockRootIds = [
|
|
1796
|
+
test.root_id,
|
|
1797
|
+
...coRender
|
|
1798
|
+
].sort();
|
|
1799
|
+
}
|
|
1800
|
+
await db.transaction(async (tx)=>{
|
|
1801
|
+
if (lockRootIds.length > 0) {
|
|
1802
|
+
await tx.execute(sql`
|
|
1803
|
+
SELECT id FROM cms.roots
|
|
1804
|
+
WHERE id IN (${sql.join(lockRootIds.map((r)=>sql`${r}`), sql`, `)})
|
|
1805
|
+
ORDER BY id
|
|
1806
|
+
FOR UPDATE
|
|
1807
|
+
`);
|
|
1808
|
+
}
|
|
1809
|
+
if (status === 'running') {
|
|
1810
|
+
// Same-root: only one running test per root.
|
|
1811
|
+
const running = await tx.execute(sql`
|
|
1812
|
+
SELECT id FROM cms.ab_tests
|
|
1813
|
+
WHERE root_id = ${test.root_id} AND status = 'running' AND id != ${testId}
|
|
1814
|
+
LIMIT 1
|
|
1815
|
+
`);
|
|
1816
|
+
if (running.rows.length > 0) {
|
|
1817
|
+
abTestError('AB_TEST_DUPLICATE_RUNNING');
|
|
1818
|
+
}
|
|
1819
|
+
// XOR (cross-embed): a co-rendering root — the host page that embeds
|
|
1820
|
+
// this block, a block it embeds, or a co-embedded sibling,
|
|
1821
|
+
// transitively — must not ALSO have a running test, else a single
|
|
1822
|
+
// render would vary on two axes (unattributable). Conservative +
|
|
1823
|
+
// group-aware (AB_FANOUT_DESIGN §2). Re-checked here under the lock.
|
|
1824
|
+
if (coRender.size > 0) {
|
|
1825
|
+
const conflict = await tx.execute(sql`
|
|
1826
|
+
SELECT id FROM cms.ab_tests
|
|
1827
|
+
WHERE root_id IN (${sql.join([
|
|
1828
|
+
...coRender
|
|
1829
|
+
].map((r)=>sql`${r}`), sql`, `)})
|
|
1830
|
+
AND status = 'running' AND id != ${testId}
|
|
1831
|
+
LIMIT 1
|
|
1832
|
+
`);
|
|
1833
|
+
if (conflict.rows.length > 0) {
|
|
1834
|
+
abTestError('AB_TEST_CROSS_EMBED_CONFLICT');
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
if (variants) {
|
|
1839
|
+
if (test.status !== 'draft' && test.status !== 'paused') {
|
|
1840
|
+
abTestError('AB_TEST_INVALID_STATUS', 'Variants can only be updated when test is draft or paused');
|
|
1841
|
+
}
|
|
1842
|
+
await validateBranchesPublished(tx, test.root_id, variants.map((v)=>v.branchId));
|
|
1843
|
+
await deleteVariantsForTest(tx, testId);
|
|
1844
|
+
await insertVariants(tx, testId, variants);
|
|
1845
|
+
}
|
|
1846
|
+
const sets = [
|
|
1847
|
+
sql`updated_at = NOW()`
|
|
1848
|
+
];
|
|
1849
|
+
if (name !== undefined) sets.push(sql`name = ${name}`);
|
|
1850
|
+
if (trafficPercentage !== undefined) sets.push(sql`traffic_percentage = ${trafficPercentage}`);
|
|
1851
|
+
if (goalHandle !== undefined) sets.push(sql`goal_handle = ${goalHandle}`);
|
|
1852
|
+
if (goalEvent !== undefined) sets.push(sql`goal_event = ${goalEvent}`);
|
|
1853
|
+
if (status) {
|
|
1854
|
+
sets.push(sql`status = ${status}`);
|
|
1855
|
+
if (status === 'running' && !test.started_at) {
|
|
1856
|
+
sets.push(sql`started_at = NOW()`);
|
|
1857
|
+
}
|
|
1858
|
+
if (status === 'completed') {
|
|
1859
|
+
sets.push(sql`ended_at = NOW()`);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
const setClause = sql.join(sets, sql`, `);
|
|
1863
|
+
await tx.execute(sql`UPDATE cms.ab_tests SET ${setClause} WHERE id = ${testId}`);
|
|
1864
|
+
});
|
|
1865
|
+
// Toggling the test into/out of `running` changes what
|
|
1866
|
+
// getPublishedContent returns for the root (the page-level `abTest`
|
|
1867
|
+
// descriptor + variant fan-out appear/disappear). That is NOT a content
|
|
1868
|
+
// write, so the normal write-action revalidation never sees it — fire a
|
|
1869
|
+
// manual revalidation for the root so the app busts that page's render
|
|
1870
|
+
// caches (unstable_cache + the variant-coded ISR entries). Without this,
|
|
1871
|
+
// a freshly started/stopped test serves stale (pre-toggle) renders.
|
|
1872
|
+
const togglesRunning = status !== undefined && test.status === 'running' !== (status === 'running');
|
|
1873
|
+
if (togglesRunning && reqCtx.context.revalidationRunner) {
|
|
1874
|
+
const allVariants = await getVariantsForTest(db, testId);
|
|
1875
|
+
const control = allVariants.find((v)=>v.is_control) ?? allVariants[0];
|
|
1876
|
+
if (control) {
|
|
1877
|
+
await reqCtx.context.revalidationRunner.fireManual({
|
|
1878
|
+
collection: test.collection,
|
|
1879
|
+
rootId: test.root_id,
|
|
1880
|
+
branchId: control.branch_id
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
return {
|
|
1885
|
+
testId
|
|
1886
|
+
};
|
|
1887
|
+
}),
|
|
1888
|
+
deleteTest: createCMSEndpoint('/abTest/deleteTest', {
|
|
1889
|
+
method: 'POST',
|
|
1890
|
+
body: z.object({
|
|
1891
|
+
testId: z.string()
|
|
1892
|
+
}),
|
|
1893
|
+
metadata: cmsMeta({
|
|
1894
|
+
$Infer: {
|
|
1895
|
+
body: {}
|
|
1896
|
+
}
|
|
1897
|
+
}, {
|
|
1898
|
+
operation: 'delete',
|
|
1899
|
+
...AB_TEST_META
|
|
1900
|
+
})
|
|
1901
|
+
}, async (reqCtx)=>{
|
|
1902
|
+
const { db, scope } = reqCtx.context;
|
|
1903
|
+
const tenantSlug = getTenantSlug(scope);
|
|
1904
|
+
const test = await findTestOrThrow(db, reqCtx.body.testId, tenantSlug);
|
|
1905
|
+
if (test.status !== 'draft' && test.status !== 'completed') {
|
|
1906
|
+
abTestError('AB_TEST_INVALID_STATUS', 'Can only delete tests in draft or completed status');
|
|
1907
|
+
}
|
|
1908
|
+
await db.execute(sql`
|
|
1909
|
+
DELETE FROM cms.ab_tests WHERE id = ${reqCtx.body.testId}
|
|
1910
|
+
`);
|
|
1911
|
+
return {
|
|
1912
|
+
testId: reqCtx.body.testId
|
|
1913
|
+
};
|
|
1914
|
+
}),
|
|
1915
|
+
getTest: createCMSEndpoint('/abTest/getTest', {
|
|
1916
|
+
method: 'GET',
|
|
1917
|
+
query: z.object({
|
|
1918
|
+
testId: z.string()
|
|
1919
|
+
}),
|
|
1920
|
+
metadata: cmsMeta({
|
|
1921
|
+
$Infer: {
|
|
1922
|
+
query: {}
|
|
1923
|
+
}
|
|
1924
|
+
}, {
|
|
1925
|
+
operation: 'read',
|
|
1926
|
+
...AB_TEST_META
|
|
1927
|
+
})
|
|
1928
|
+
}, async (reqCtx)=>{
|
|
1929
|
+
const { db, scope } = reqCtx.context;
|
|
1930
|
+
const tenantSlug = getTenantSlug(scope);
|
|
1931
|
+
const test = await findTestOrThrow(db, reqCtx.query.testId, tenantSlug);
|
|
1932
|
+
const variants = await getVariantsForTest(db, test.id);
|
|
1933
|
+
return {
|
|
1934
|
+
...mapTestRow(test),
|
|
1935
|
+
variants: variants.map(mapVariantRow)
|
|
1936
|
+
};
|
|
1937
|
+
}),
|
|
1938
|
+
/**
|
|
1939
|
+
* M4 goal-picker: the pickable A/B goals for a root. Reads each block type's
|
|
1940
|
+
* declared `events` (off the collection definitions) for the blocks present
|
|
1941
|
+
* in the root's published tree, returning one candidate per (functional block
|
|
1942
|
+
* instance × event). Candidates in the tested root's own tree are
|
|
1943
|
+
* `inVaryingRoot: true`; candidates in embedded reusable blocks are
|
|
1944
|
+
* `inVaryingRoot: false` (§6g attribution caution). The `name` is the
|
|
1945
|
+
* resolved wire name (the same string fire() stores as event_type), so the
|
|
1946
|
+
* UI-pickable goals are exactly the code-fireable events.
|
|
1947
|
+
*/ listGoalEvents: createCMSEndpoint('/abTest/listGoalEvents', {
|
|
1948
|
+
method: 'GET',
|
|
1949
|
+
query: z.object({
|
|
1950
|
+
rootId: z.string()
|
|
1951
|
+
}),
|
|
1952
|
+
metadata: cmsMeta({
|
|
1953
|
+
$Infer: {
|
|
1954
|
+
query: {}
|
|
1955
|
+
}
|
|
1956
|
+
}, {
|
|
1957
|
+
operation: 'read',
|
|
1958
|
+
...AB_TEST_META
|
|
1959
|
+
})
|
|
1960
|
+
}, async (reqCtx)=>{
|
|
1961
|
+
const { db, scope } = reqCtx.context;
|
|
1962
|
+
const tenantSlug = getTenantSlug(scope);
|
|
1963
|
+
const collections = getCollections();
|
|
1964
|
+
const { rootId } = reqCtx.query;
|
|
1965
|
+
const goals = [];
|
|
1966
|
+
// The tested root's OWN tree → candidates in the varying render.
|
|
1967
|
+
const own = await loadRootPublishedTree(db, rootId, tenantSlug);
|
|
1968
|
+
if (own) {
|
|
1969
|
+
const blocks = collections[own.collection]?.blocks ?? {};
|
|
1970
|
+
collectGoalsFromTree(own.tree, blocks, rootId, true, goals);
|
|
1971
|
+
}
|
|
1972
|
+
// Embedded reusable blocks (down-only) → shared, co-rendered content;
|
|
1973
|
+
// their goals are offered but flagged inVaryingRoot:false (§6g caution).
|
|
1974
|
+
const resolver = scope.referenceResolver ?? coreReferenceResolver;
|
|
1975
|
+
const scopeColumns = crossScopeColumns(scope.roots);
|
|
1976
|
+
const embedded = await collectEmbeddedRoots(db, rootId, resolver, scopeColumns);
|
|
1977
|
+
for (const embRootId of embedded){
|
|
1978
|
+
const emb = await loadRootPublishedTree(db, embRootId, tenantSlug);
|
|
1979
|
+
if (!emb) continue;
|
|
1980
|
+
const blocks = collections[emb.collection]?.blocks ?? {};
|
|
1981
|
+
collectGoalsFromTree(emb.tree, blocks, embRootId, false, goals);
|
|
1982
|
+
}
|
|
1983
|
+
return {
|
|
1984
|
+
rootId,
|
|
1985
|
+
goals
|
|
1986
|
+
};
|
|
1987
|
+
}),
|
|
1988
|
+
listTests: createCMSEndpoint('/abTest/listTests', {
|
|
1989
|
+
method: 'GET',
|
|
1990
|
+
query: z.object({
|
|
1991
|
+
collection: z.string().optional(),
|
|
1992
|
+
status: z.enum([
|
|
1993
|
+
'draft',
|
|
1994
|
+
'running',
|
|
1995
|
+
'paused',
|
|
1996
|
+
'completed'
|
|
1997
|
+
]).optional(),
|
|
1998
|
+
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
|
|
1999
|
+
offset: z.coerce.number().int().min(0).optional().default(0)
|
|
2000
|
+
}),
|
|
2001
|
+
metadata: cmsMeta({
|
|
2002
|
+
$Infer: {
|
|
2003
|
+
query: {}
|
|
2004
|
+
}
|
|
2005
|
+
}, {
|
|
2006
|
+
operation: 'read',
|
|
2007
|
+
...AB_TEST_META
|
|
2008
|
+
})
|
|
2009
|
+
}, async (reqCtx)=>{
|
|
2010
|
+
const { db, scope } = reqCtx.context;
|
|
2011
|
+
const tenantSlug = getTenantSlug(scope);
|
|
2012
|
+
const { collection, status, limit, offset } = reqCtx.query;
|
|
2013
|
+
const conditions = [
|
|
2014
|
+
sql`1=1`
|
|
2015
|
+
];
|
|
2016
|
+
if (collection) conditions.push(sql`t.collection = ${collection}`);
|
|
2017
|
+
if (status) conditions.push(sql`t.status = ${status}`);
|
|
2018
|
+
const tenantJoin = tenantSlug ? sql`INNER JOIN cms.roots r ON r.id = t.root_id` : sql``;
|
|
2019
|
+
if (tenantSlug) {
|
|
2020
|
+
conditions.push(sql`r.tenant_slug = ${tenantSlug}`);
|
|
2021
|
+
}
|
|
2022
|
+
const where = sql.join(conditions, sql` AND `);
|
|
2023
|
+
const countResult = await db.execute(sql`
|
|
2024
|
+
SELECT COUNT(*)::int AS total FROM cms.ab_tests t ${tenantJoin} WHERE ${where}
|
|
2025
|
+
`);
|
|
2026
|
+
const result = await db.execute(sql`
|
|
2027
|
+
SELECT t.* FROM cms.ab_tests t
|
|
2028
|
+
${tenantJoin}
|
|
2029
|
+
WHERE ${where}
|
|
2030
|
+
ORDER BY t.created_at DESC
|
|
2031
|
+
LIMIT ${limit} OFFSET ${offset}
|
|
2032
|
+
`);
|
|
2033
|
+
return {
|
|
2034
|
+
tests: result.rows.map(mapTestRow),
|
|
2035
|
+
total: countResult.rows[0].total,
|
|
2036
|
+
hasMore: (offset ?? 0) + result.rows.length < countResult.rows[0].total
|
|
2037
|
+
};
|
|
2038
|
+
}),
|
|
2039
|
+
assignVariant: createCMSEndpoint('/abTest/assignVariant', {
|
|
2040
|
+
method: 'POST',
|
|
2041
|
+
body: z.object({
|
|
2042
|
+
testId: z.string(),
|
|
2043
|
+
context: contextSchema
|
|
2044
|
+
}),
|
|
2045
|
+
metadata: cmsMeta({
|
|
2046
|
+
$Infer: {
|
|
2047
|
+
body: {}
|
|
2048
|
+
}
|
|
2049
|
+
}, {
|
|
2050
|
+
operation: 'read',
|
|
2051
|
+
...AB_TEST_META
|
|
2052
|
+
})
|
|
2053
|
+
}, async (reqCtx)=>{
|
|
2054
|
+
const { db, scope } = reqCtx.context;
|
|
2055
|
+
const tenantSlug = getTenantSlug(scope);
|
|
2056
|
+
const { testId, context } = reqCtx.body;
|
|
2057
|
+
const test = await findTestOrThrow(db, testId, tenantSlug);
|
|
2058
|
+
if (test.status !== 'running') {
|
|
2059
|
+
abTestError('AB_TEST_INVALID_STATUS', 'Can only assign variants for running tests');
|
|
2060
|
+
}
|
|
2061
|
+
const variants = await getVariantsForTest(db, testId);
|
|
2062
|
+
const result = resolveVariant(context.key, testId, test.traffic_percentage, variants.map((v)=>({
|
|
2063
|
+
id: v.id,
|
|
2064
|
+
weight: v.weight,
|
|
2065
|
+
isControl: v.is_control
|
|
2066
|
+
})));
|
|
2067
|
+
const variant = variants.find((v)=>v.id === result.variantId);
|
|
2068
|
+
return {
|
|
2069
|
+
variantId: result.variantId,
|
|
2070
|
+
branchId: variant?.branch_id ?? '',
|
|
2071
|
+
inTest: result.inTest
|
|
2072
|
+
};
|
|
2073
|
+
}),
|
|
2074
|
+
trackEvent: createCMSEndpoint('/abTest/trackEvent', {
|
|
2075
|
+
method: 'POST',
|
|
2076
|
+
body: z.object({
|
|
2077
|
+
// A/B attribution is optional: non-A/B analytics events
|
|
2078
|
+
// (form_submit, page_view) omit testId/variantId.
|
|
2079
|
+
testId: z.string().optional(),
|
|
2080
|
+
variantId: z.string().optional(),
|
|
2081
|
+
// Pattern A: the edge/render route knows the served BRANCH, not the
|
|
2082
|
+
// variant id. Sending branchId (with testId) resolves the variant id
|
|
2083
|
+
// server-side — the FA4 impression beacon uses this.
|
|
2084
|
+
branchId: z.string().optional(),
|
|
2085
|
+
// Optional: the anonymous Pattern A path stores NO identifier (the
|
|
2086
|
+
// variant comes from the URL/variant-cookie, not a visitor id). A
|
|
2087
|
+
// visitor id is only sent for the consent-gated unique-visitor / GA4
|
|
2088
|
+
// path.
|
|
2089
|
+
visitorId: z.string().min(1).optional(),
|
|
2090
|
+
anonymous: z.boolean().optional().default(false),
|
|
2091
|
+
// Open vocabulary (blocks declare their own event names) but bounded,
|
|
2092
|
+
// so this ingest path never accepts arbitrary unbounded input.
|
|
2093
|
+
eventType: z.string().min(1).max(80),
|
|
2094
|
+
metadata: z.record(z.string(), z.unknown()).optional().refine((m)=>!m || JSON.stringify(m).length <= 8192, {
|
|
2095
|
+
message: 'metadata exceeds 8KB'
|
|
2096
|
+
}),
|
|
2097
|
+
source: z.object({
|
|
2098
|
+
handle: z.string().max(128).optional(),
|
|
2099
|
+
type: z.string().max(128).optional()
|
|
2100
|
+
}).optional(),
|
|
2101
|
+
// Funnel grouping (M4): shared by the attempt + success legs of one
|
|
2102
|
+
// interaction. Bounded; groups, does NOT dedup.
|
|
2103
|
+
interactionId: z.string().min(1).max(128).optional(),
|
|
2104
|
+
// GA4 stitching ids (M5): the client sends these ONLY when consent is
|
|
2105
|
+
// granted, so the server-MP forward can attribute the hit. Bounded.
|
|
2106
|
+
transport: z.object({
|
|
2107
|
+
clientId: z.string().min(1).max(128).optional(),
|
|
2108
|
+
sessionId: z.string().min(1).max(128).optional(),
|
|
2109
|
+
engagementTimeMsec: z.number().int().min(0).max(86_400_000).optional()
|
|
2110
|
+
}).optional(),
|
|
2111
|
+
// Consent Mode v2 state the client emitted under (optional). Used for
|
|
2112
|
+
// a server-side denial guard + forwarded to consent-aware sinks.
|
|
2113
|
+
consent: z.object({
|
|
2114
|
+
analytics_storage: z.enum([
|
|
2115
|
+
'granted',
|
|
2116
|
+
'denied'
|
|
2117
|
+
]),
|
|
2118
|
+
ad_storage: z.enum([
|
|
2119
|
+
'granted',
|
|
2120
|
+
'denied'
|
|
2121
|
+
]),
|
|
2122
|
+
ad_user_data: z.enum([
|
|
2123
|
+
'granted',
|
|
2124
|
+
'denied'
|
|
2125
|
+
]),
|
|
2126
|
+
ad_personalization: z.enum([
|
|
2127
|
+
'granted',
|
|
2128
|
+
'denied'
|
|
2129
|
+
])
|
|
2130
|
+
}).optional()
|
|
2131
|
+
}),
|
|
2132
|
+
metadata: cmsMeta({
|
|
2133
|
+
$Infer: {
|
|
2134
|
+
body: {}
|
|
2135
|
+
}
|
|
2136
|
+
}, {
|
|
2137
|
+
operation: 'create',
|
|
2138
|
+
...AB_EVENT_META
|
|
2139
|
+
})
|
|
2140
|
+
}, async (reqCtx)=>{
|
|
2141
|
+
const { db, scope } = reqCtx.context;
|
|
2142
|
+
const tenantSlug = getTenantSlug(scope);
|
|
2143
|
+
const { testId, variantId, branchId, visitorId, anonymous, eventType, metadata, source, interactionId, transport, consent } = reqCtx.body;
|
|
2144
|
+
// Courtesy no-op for a self-reported denial: if a caller explicitly
|
|
2145
|
+
// sends analytics_storage='denied', don't record. This is NOT a server
|
|
2146
|
+
// enforcement boundary — `consent` is optional, so a caller can simply
|
|
2147
|
+
// omit it. True server-read consent gating is deferred to M5; the client
|
|
2148
|
+
// gate remains the consent authority.
|
|
2149
|
+
if (consent && consent.analytics_storage === 'denied') {
|
|
2150
|
+
return {};
|
|
2151
|
+
}
|
|
2152
|
+
let ab;
|
|
2153
|
+
if (testId !== undefined || variantId !== undefined || branchId !== undefined) {
|
|
2154
|
+
// A/B-attributed event: it must resolve to a variant that belongs to
|
|
2155
|
+
// the test — otherwise a caller could record events against an
|
|
2156
|
+
// arbitrary (or another test's) variant and poison the analytics.
|
|
2157
|
+
if (!testId) {
|
|
2158
|
+
abTestError('AB_TEST_VARIANT_NOT_FOUND', 'A/B events require testId');
|
|
2159
|
+
}
|
|
2160
|
+
await findTestOrThrow(db, testId, tenantSlug);
|
|
2161
|
+
const variants = await getVariantsForTest(db, testId);
|
|
2162
|
+
// Accept either an explicit variantId or a branchId (Pattern A).
|
|
2163
|
+
const resolvedVariantId = variantId ?? (branchId ? variants.find((v)=>v.branch_id === branchId)?.id : undefined);
|
|
2164
|
+
if (!resolvedVariantId || !variants.some((v)=>v.id === resolvedVariantId)) {
|
|
2165
|
+
abTestError('AB_TEST_VARIANT_NOT_FOUND', 'A/B events require a variantId or a branchId that belongs to the test');
|
|
2166
|
+
}
|
|
2167
|
+
ab = {
|
|
2168
|
+
testId,
|
|
2169
|
+
variantId: resolvedVariantId
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
// The storage PK is always server-minted in M0 (id omitted here). A
|
|
2173
|
+
// client-supplied, tenant-namespaced idempotency key — distinct from
|
|
2174
|
+
// the PK — is an M3 concern (see AB_MEASUREMENT_DESIGN §9 carry-forward).
|
|
2175
|
+
const event = {
|
|
2176
|
+
name: eventType,
|
|
2177
|
+
visitorId,
|
|
2178
|
+
anonymous: anonymous ?? false,
|
|
2179
|
+
ab,
|
|
2180
|
+
source,
|
|
2181
|
+
interactionId,
|
|
2182
|
+
transport,
|
|
2183
|
+
consent,
|
|
2184
|
+
metadata,
|
|
2185
|
+
timestamp: new Date()
|
|
2186
|
+
};
|
|
2187
|
+
await adapter.track(event);
|
|
2188
|
+
// M5: opt-in server-side GA4 forward. No-op without a configured
|
|
2189
|
+
// endpoint, or when the event is not a consenting, client_id-bearing hit
|
|
2190
|
+
// (buildGa4Payload returns null). Best-effort — never breaks the ingest.
|
|
2191
|
+
if (ga4Config) await forwardToGa4(event, ga4Config);
|
|
2192
|
+
return {};
|
|
2193
|
+
}),
|
|
2194
|
+
getResults: createCMSEndpoint('/abTest/getResults', {
|
|
2195
|
+
method: 'GET',
|
|
2196
|
+
query: z.object({
|
|
2197
|
+
testId: z.string(),
|
|
2198
|
+
from: z.coerce.date().optional(),
|
|
2199
|
+
to: z.coerce.date().optional()
|
|
2200
|
+
}),
|
|
2201
|
+
metadata: cmsMeta({
|
|
2202
|
+
$Infer: {
|
|
2203
|
+
query: {}
|
|
2204
|
+
}
|
|
2205
|
+
}, {
|
|
2206
|
+
operation: 'read',
|
|
2207
|
+
...AB_TEST_META
|
|
2208
|
+
})
|
|
2209
|
+
}, async (reqCtx)=>{
|
|
2210
|
+
const { db, scope } = reqCtx.context;
|
|
2211
|
+
const tenantSlug = getTenantSlug(scope);
|
|
2212
|
+
const { testId, from, to } = reqCtx.query;
|
|
2213
|
+
const test = await findTestOrThrow(db, testId, tenantSlug);
|
|
2214
|
+
const results = await adapter.query(testId, {
|
|
2215
|
+
from,
|
|
2216
|
+
to
|
|
2217
|
+
});
|
|
2218
|
+
// M4: when the test has a chosen goal, count ITS event (the resolved
|
|
2219
|
+
// wire name, already present in each variant's eventBreakdown) as the
|
|
2220
|
+
// conversion + recompute the rate. The adapter's default 'conversion'
|
|
2221
|
+
// eventType is the goal-less fallback (unchanged when no goal is set).
|
|
2222
|
+
// `|| null` coerces a legacy/degenerate '' to the goal-less path.
|
|
2223
|
+
const goal = test.goal_event || null;
|
|
2224
|
+
if (goal) {
|
|
2225
|
+
for (const v of results.variants){
|
|
2226
|
+
v.conversions = v.eventBreakdown[goal]?.count ?? 0;
|
|
2227
|
+
v.conversionRate = v.impressions > 0 ? Math.round(v.conversions / v.impressions * 10000) / 100 : 0;
|
|
2228
|
+
// Funnel (M4): of the interactions started (attempts = distinct
|
|
2229
|
+
// interaction ids), how many reached the goal event. 0 when the goal
|
|
2230
|
+
// is a non-funnel event (no interaction ids) → attempts is 0.
|
|
2231
|
+
const goalInteractions = v.eventBreakdown[goal]?.distinctInteractions ?? 0;
|
|
2232
|
+
v.completionRate = v.attempts > 0 ? Math.round(goalInteractions / v.attempts * 10000) / 100 : 0;
|
|
2233
|
+
}
|
|
2234
|
+
results.totalConversions = results.variants.reduce((s, v)=>s + v.conversions, 0);
|
|
2235
|
+
}
|
|
2236
|
+
return results;
|
|
2237
|
+
}),
|
|
2238
|
+
flushEvents: createCMSEndpoint('/abTest/flushEvents', {
|
|
2239
|
+
method: 'POST',
|
|
2240
|
+
body: z.object({
|
|
2241
|
+
testId: z.string().optional()
|
|
2242
|
+
}),
|
|
2243
|
+
metadata: cmsMeta({
|
|
2244
|
+
$Infer: {
|
|
2245
|
+
body: {}
|
|
2246
|
+
}
|
|
2247
|
+
}, {
|
|
2248
|
+
operation: 'update',
|
|
2249
|
+
...AB_TEST_META
|
|
2250
|
+
})
|
|
2251
|
+
}, async (reqCtx)=>{
|
|
2252
|
+
if (!adapter.flush) {
|
|
2253
|
+
abTestError('AB_TEST_FLUSH_NOT_SUPPORTED');
|
|
2254
|
+
}
|
|
2255
|
+
if (reqCtx.body.testId) {
|
|
2256
|
+
const { db, scope } = reqCtx.context;
|
|
2257
|
+
const tenantSlug = getTenantSlug(scope);
|
|
2258
|
+
await findTestOrThrow(db, reqCtx.body.testId, tenantSlug);
|
|
2259
|
+
}
|
|
2260
|
+
return adapter.flush(reqCtx.body.testId);
|
|
2261
|
+
})
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/**
|
|
2266
|
+
* The ab-test plugin's implementation of the core {@link AbTestResolver} seam
|
|
2267
|
+
* (Seam F): given a set of already render-resolved root ids, report which have a
|
|
2268
|
+
* RUNNING test, with that test's variant branches. The read path uses this to
|
|
2269
|
+
* fan a varying block's published branches out to the client (AB_FANOUT F2).
|
|
2270
|
+
*
|
|
2271
|
+
* Stateless — one instance is registered once via a scope factory in the
|
|
2272
|
+
* plugin's `init`. Raw SQL (like {@link assertNoCoRenderConflictOnPublish}'s
|
|
2273
|
+
* helper) because `cms.ab_tests` is plugin-owned and not in core's Drizzle
|
|
2274
|
+
* schema. The `AB_TEST_DUPLICATE_RUNNING` guard ensures at most one running test
|
|
2275
|
+
* per root, so grouping by root id is unambiguous.
|
|
2276
|
+
*/ function buildAbTestResolver() {
|
|
2277
|
+
return {
|
|
2278
|
+
async runningTests (db, scopeColumns, rootIds) {
|
|
2279
|
+
const out = new Map();
|
|
2280
|
+
if (rootIds.length === 0) return out;
|
|
2281
|
+
// Scope the lookup to the active tenant (same predicate every other read
|
|
2282
|
+
// applies): JOIN roots so rootScopeConditions can filter by the scope
|
|
2283
|
+
// columns. The passed rootIds are already render-resolved, so this is
|
|
2284
|
+
// defense-in-depth — it must never report a test on an out-of-scope root.
|
|
2285
|
+
const scopeConds = rootScopeConditions(scopeColumns);
|
|
2286
|
+
const result = await db.execute(sql`
|
|
2287
|
+
SELECT t.id AS test_id, t.root_id, t.traffic_percentage,
|
|
2288
|
+
v.branch_id, v.is_control
|
|
2289
|
+
FROM cms.ab_tests t
|
|
2290
|
+
JOIN cms.roots ON cms.roots.id = t.root_id
|
|
2291
|
+
JOIN cms.ab_test_variants v ON v.test_id = t.id
|
|
2292
|
+
WHERE t.status = 'running'
|
|
2293
|
+
AND t.root_id IN (${sql.join(rootIds.map((r)=>sql`${r}`), sql`, `)})
|
|
2294
|
+
${scopeConds.length ? sql`AND ${sql.join(scopeConds, sql` AND `)}` : sql``}
|
|
2295
|
+
`);
|
|
2296
|
+
for (const row of result.rows){
|
|
2297
|
+
let test = out.get(row.root_id);
|
|
2298
|
+
if (!test) {
|
|
2299
|
+
test = {
|
|
2300
|
+
testId: row.test_id,
|
|
2301
|
+
trafficPercentage: row.traffic_percentage,
|
|
2302
|
+
variants: []
|
|
2303
|
+
};
|
|
2304
|
+
out.set(row.root_id, test);
|
|
2305
|
+
}
|
|
2306
|
+
// Defensive: a root has at most one running test (guarded), so ignore
|
|
2307
|
+
// rows from any other test id rather than mixing variants.
|
|
2308
|
+
if (test.testId !== row.test_id) continue;
|
|
2309
|
+
test.variants.push({
|
|
2310
|
+
branchId: row.branch_id,
|
|
2311
|
+
isControl: row.is_control
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
return out;
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// ============================================================================
|
|
2320
|
+
// Anonymous trackEvent ingest rate-limit (opt-in)
|
|
2321
|
+
// ============================================================================
|
|
2322
|
+
//
|
|
2323
|
+
// `/abTest/trackEvent` is the ONE unauthenticated write path: it is anonymous +
|
|
2324
|
+
// consent-free BY DESIGN (fresh ad traffic must record aggregate impression /
|
|
2325
|
+
// conversion counts without a session). Open + unauthenticated means a flood
|
|
2326
|
+
// can (a) SKEW the aggregate that decides the A/B winner — there is no visitor
|
|
2327
|
+
// id (consent-free), so volume is the only thing to defend on; (b) bloat the
|
|
2328
|
+
// `ab_test_events` table; and (c) — once server-MP (M5) is configured — amplify
|
|
2329
|
+
// into one outbound GA4 POST per event. This caps the ingest per client key, as
|
|
2330
|
+
// EARLY as possible (the plugin `onRequest`, before any routing/DB work).
|
|
2331
|
+
//
|
|
2332
|
+
// Opt-in via `abTest({ rateLimit })`. The default counter is in-memory (per
|
|
2333
|
+
// instance); for multiple instances / serverless, inject a distributed `store`.
|
|
2334
|
+
/**
|
|
2335
|
+
* In-memory fixed-window counter. Memory is HARD-bounded at `maxKeys`: when a
|
|
2336
|
+
* new key would exceed the cap, the oldest-inserted entry is evicted in O(1)
|
|
2337
|
+
* (Map preserves insertion order) — so even a within-window flood of DISTINCT
|
|
2338
|
+
* keys (e.g. an IP-rotating attacker) cannot grow the map past `maxKeys`, and
|
|
2339
|
+
* there is no O(maxKeys) scan on the hot path. A live key being hit again is
|
|
2340
|
+
* O(1) and never triggers eviction. Eviction can reset an old key's window
|
|
2341
|
+
* under a flood (the standard bounded-limiter tradeoff). Per-instance only (see
|
|
2342
|
+
* the module header) — inject a distributed store for multi-instance/serverless.
|
|
2343
|
+
*/ function createInMemoryRateLimitStore(maxKeys = 10_000) {
|
|
2344
|
+
const windows = new Map();
|
|
2345
|
+
return {
|
|
2346
|
+
hit (key, windowMs, now) {
|
|
2347
|
+
const entry = windows.get(key);
|
|
2348
|
+
if (entry && now - entry.windowStart < windowMs) {
|
|
2349
|
+
entry.count += 1;
|
|
2350
|
+
return entry.count;
|
|
2351
|
+
}
|
|
2352
|
+
// New key, or its window expired → start a fresh window. Delete first so a
|
|
2353
|
+
// re-set moves the key to the most-recent insertion position (it should
|
|
2354
|
+
// not be the next eviction victim).
|
|
2355
|
+
windows.delete(key);
|
|
2356
|
+
if (windows.size >= maxKeys) {
|
|
2357
|
+
// Hard cap: evict the oldest-inserted entry (front of the Map). O(1),
|
|
2358
|
+
// bounds memory even when every resident key is still within its window.
|
|
2359
|
+
const oldest = windows.keys().next().value;
|
|
2360
|
+
if (oldest !== undefined) windows.delete(oldest);
|
|
2361
|
+
}
|
|
2362
|
+
windows.set(key, {
|
|
2363
|
+
count: 1,
|
|
2364
|
+
windowStart: now
|
|
2365
|
+
});
|
|
2366
|
+
return 1;
|
|
2367
|
+
}
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Default rate-limit key: the trusted client IP.
|
|
2372
|
+
*
|
|
2373
|
+
* `x-forwarded-for` is a client→proxy→…→server CHAIN. An appending proxy
|
|
2374
|
+
* (Vercel, most CDNs) appends the real connecting IP as the LAST entry; the
|
|
2375
|
+
* FIRST entry is whatever the client sent and is trivially spoofable. So we take
|
|
2376
|
+
* the RIGHTMOST entry, never the first — using the leftmost would let an
|
|
2377
|
+
* attacker rotate `x-forwarded-for` to mint a fresh bucket per request and evade
|
|
2378
|
+
* the limit entirely. (This assumes ONE trusted appending proxy; behind multiple
|
|
2379
|
+
* proxies or a non-appending one, override `getKey`.) Falls back to `x-real-ip`
|
|
2380
|
+
* (set by nginx/Vercel to the connecting IP).
|
|
2381
|
+
*
|
|
2382
|
+
* Returns null when neither header is present — the caller then does NOT limit
|
|
2383
|
+
* (fail-open). NOTE: a deployment NOT behind a proxy (a directly-exposed server
|
|
2384
|
+
* with no `x-forwarded-for`/`x-real-ip`) therefore gets NO limiting from this
|
|
2385
|
+
* default — provide a `getKey` that reads your real client IP.
|
|
2386
|
+
*/ function defaultRateLimitKey(request) {
|
|
2387
|
+
const xff = request.headers.get('x-forwarded-for');
|
|
2388
|
+
if (xff) {
|
|
2389
|
+
const parts = xff.split(',').map((p)=>p.trim()).filter(Boolean);
|
|
2390
|
+
if (parts.length > 0) return parts[parts.length - 1];
|
|
2391
|
+
}
|
|
2392
|
+
const realIp = request.headers.get('x-real-ip')?.trim();
|
|
2393
|
+
return realIp ? realIp : null;
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Enforces the ingest rate-limit for one request. Returns a 429 Response to
|
|
2397
|
+
* short-circuit when the limit is exceeded, or null to let the request proceed.
|
|
2398
|
+
* No-ops (null) when the key cannot be resolved. The caller (plugin onRequest)
|
|
2399
|
+
* binds this to POST `/abTest/trackEvent`. `store` is created ONCE per plugin
|
|
2400
|
+
* instance (never per request) so the window survives across requests.
|
|
2401
|
+
*/ async function enforceTrackEventRateLimit(request, options, store, now = Date.now()) {
|
|
2402
|
+
const getKey = options.getKey ?? defaultRateLimitKey;
|
|
2403
|
+
const key = getKey(request);
|
|
2404
|
+
if (key === null) return null; // not rate-limited (no resolvable key)
|
|
2405
|
+
const count = await store.hit(key, options.windowMs, now);
|
|
2406
|
+
if (count <= options.limit) return null;
|
|
2407
|
+
const retryAfterSec = Math.max(1, Math.ceil(options.windowMs / 1000));
|
|
2408
|
+
return new Response(JSON.stringify({
|
|
2409
|
+
error: 'rate_limited',
|
|
2410
|
+
message: 'Too many A/B events; slow down.'
|
|
2411
|
+
}), {
|
|
2412
|
+
status: 429,
|
|
2413
|
+
headers: {
|
|
2414
|
+
'content-type': 'application/json',
|
|
2415
|
+
'retry-after': String(retryAfterSec)
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const CMS_ERRORS = {
|
|
2421
|
+
BRANCH_NOT_FOUND: {
|
|
2422
|
+
status: 404,
|
|
2423
|
+
message: 'Branch not found'
|
|
2424
|
+
},
|
|
2425
|
+
BLOCK_NOT_FOUND: {
|
|
2426
|
+
status: 404,
|
|
2427
|
+
message: 'Block not found in snapshot'
|
|
2428
|
+
},
|
|
2429
|
+
PARENT_NOT_FOUND: {
|
|
2430
|
+
status: 404,
|
|
2431
|
+
message: 'Parent block not found'
|
|
2432
|
+
},
|
|
2433
|
+
ROOT_NOT_FOUND: {
|
|
2434
|
+
status: 404,
|
|
2435
|
+
message: 'Root block not found in snapshot'
|
|
2436
|
+
},
|
|
2437
|
+
ROOT_HAS_CHILDREN: {
|
|
2438
|
+
status: 400,
|
|
2439
|
+
message: 'Cannot delete a page that has child pages; archive or move the children first'
|
|
2440
|
+
},
|
|
2441
|
+
ROOT_IN_USE: {
|
|
2442
|
+
status: 409,
|
|
2443
|
+
message: 'Cannot delete: this root is embedded as a reusable block on live pages; remove those references first'
|
|
2444
|
+
},
|
|
2445
|
+
COMMIT_NOT_FOUND: {
|
|
2446
|
+
status: 404,
|
|
2447
|
+
message: 'Commit not found'
|
|
2448
|
+
},
|
|
2449
|
+
FOLDER_NOT_FOUND: {
|
|
2450
|
+
status: 404,
|
|
2451
|
+
message: 'Folder not found'
|
|
2452
|
+
},
|
|
2453
|
+
FOLDER_HAS_CONTENT: {
|
|
2454
|
+
status: 400,
|
|
2455
|
+
message: 'Cannot delete folder that contains assets or subfolders'
|
|
2456
|
+
},
|
|
2457
|
+
EMPTY_SNAPSHOT: {
|
|
2458
|
+
status: 400,
|
|
2459
|
+
message: 'Empty snapshot — no versions found'
|
|
2460
|
+
},
|
|
2461
|
+
BLOCK_ALREADY_DELETED: {
|
|
2462
|
+
status: 400,
|
|
2463
|
+
message: 'Block is already deleted'
|
|
2464
|
+
},
|
|
2465
|
+
TYPE_MISMATCH: {
|
|
2466
|
+
status: 400,
|
|
2467
|
+
message: 'Block type does not match the expected type'
|
|
2468
|
+
},
|
|
2469
|
+
USER_ID_REQUIRED: {
|
|
2470
|
+
status: 400,
|
|
2471
|
+
message: 'userId is required for this route when neither the request nor middleware provides one'
|
|
2472
|
+
},
|
|
2473
|
+
CANNOT_MOVE_ROOT: {
|
|
2474
|
+
status: 400,
|
|
2475
|
+
message: 'Cannot move the root block'
|
|
2476
|
+
},
|
|
2477
|
+
CANNOT_MOVE_INTO_SELF: {
|
|
2478
|
+
status: 400,
|
|
2479
|
+
message: 'Cannot move an item into itself'
|
|
2480
|
+
},
|
|
2481
|
+
CANNOT_MOVE_INTO_DESCENDANT: {
|
|
2482
|
+
status: 400,
|
|
2483
|
+
message: 'Cannot move an item into its own descendant'
|
|
2484
|
+
},
|
|
2485
|
+
MISSING_TARGET_PROPERTIES: {
|
|
2486
|
+
status: 400,
|
|
2487
|
+
message: 'targetProperties is required when duplicating a root'
|
|
2488
|
+
},
|
|
2489
|
+
BRANCH_NAME_ALREADY_EXISTS: {
|
|
2490
|
+
status: 400,
|
|
2491
|
+
message: 'A branch with this name already exists for this root'
|
|
2492
|
+
},
|
|
2493
|
+
CANNOT_RENAME_MAIN_BRANCH: {
|
|
2494
|
+
status: 400,
|
|
2495
|
+
message: 'The main branch cannot be renamed'
|
|
2496
|
+
},
|
|
2497
|
+
CANNOT_DELETE_MAIN_BRANCH: {
|
|
2498
|
+
status: 400,
|
|
2499
|
+
message: 'The main branch cannot be deleted'
|
|
2500
|
+
},
|
|
2501
|
+
BRANCH_HAS_PUBLICATIONS: {
|
|
2502
|
+
status: 400,
|
|
2503
|
+
message: 'Cannot delete a branch that has active publications'
|
|
2504
|
+
},
|
|
2505
|
+
BRANCH_HAS_OPEN_MERGE_REQUESTS: {
|
|
2506
|
+
status: 400,
|
|
2507
|
+
message: 'Cannot delete a branch that is part of open merge requests'
|
|
2508
|
+
},
|
|
2509
|
+
NO_COMMON_ANCESTOR: {
|
|
2510
|
+
status: 400,
|
|
2511
|
+
message: 'The two branches share no common ancestor'
|
|
2512
|
+
},
|
|
2513
|
+
MERGE_REQUEST_NOT_FOUND: {
|
|
2514
|
+
status: 404,
|
|
2515
|
+
message: 'Merge request not found'
|
|
2516
|
+
},
|
|
2517
|
+
MERGE_REQUEST_NOT_OPEN: {
|
|
2518
|
+
status: 400,
|
|
2519
|
+
message: 'Merge request is not open'
|
|
2520
|
+
},
|
|
2521
|
+
MERGE_REQUEST_NOT_CLOSED: {
|
|
2522
|
+
status: 400,
|
|
2523
|
+
message: 'Merge request is not closed'
|
|
2524
|
+
},
|
|
2525
|
+
MERGE_REQUEST_ALREADY_MERGED: {
|
|
2526
|
+
status: 400,
|
|
2527
|
+
message: 'Merge request has already been merged and cannot be reopened'
|
|
2528
|
+
},
|
|
2529
|
+
MERGE_REQUEST_ALREADY_EXISTS: {
|
|
2530
|
+
status: 400,
|
|
2531
|
+
message: 'An open merge request already exists for this source and target branch'
|
|
2532
|
+
},
|
|
2533
|
+
MERGE_REQUEST_OUTDATED: {
|
|
2534
|
+
status: 400,
|
|
2535
|
+
message: 'Merge request is outdated because the source branch changed after it was opened'
|
|
2536
|
+
},
|
|
2537
|
+
UNRESOLVED_CONFLICTS: {
|
|
2538
|
+
status: 400,
|
|
2539
|
+
message: 'Cannot merge: there are unresolved conflicts'
|
|
2540
|
+
},
|
|
2541
|
+
CONFLICT_NOT_FOUND: {
|
|
2542
|
+
status: 404,
|
|
2543
|
+
message: 'Merge conflict not found'
|
|
2544
|
+
},
|
|
2545
|
+
RESOLVED_VERSION_NOT_FOUND: {
|
|
2546
|
+
status: 404,
|
|
2547
|
+
message: 'The provided resolvedVersionId does not reference an existing block version'
|
|
2548
|
+
},
|
|
2549
|
+
APPROVAL_NOT_FOUND: {
|
|
2550
|
+
status: 404,
|
|
2551
|
+
message: 'Approval not found'
|
|
2552
|
+
},
|
|
2553
|
+
APPROVAL_ALREADY_REQUESTED: {
|
|
2554
|
+
status: 400,
|
|
2555
|
+
message: 'An approval has already been requested from this reviewer'
|
|
2556
|
+
},
|
|
2557
|
+
APPROVAL_NOT_PENDING: {
|
|
2558
|
+
status: 400,
|
|
2559
|
+
message: 'Approval is not pending'
|
|
2560
|
+
},
|
|
2561
|
+
APPROVAL_REVIEWER_MISMATCH: {
|
|
2562
|
+
status: 403,
|
|
2563
|
+
message: 'Only the requested reviewer can approve or reject this request'
|
|
2564
|
+
},
|
|
2565
|
+
APPROVAL_STALE: {
|
|
2566
|
+
status: 400,
|
|
2567
|
+
message: 'Approval is stale: the branch has advanced past the approved commit'
|
|
2568
|
+
},
|
|
2569
|
+
MERGE_APPROVAL_REQUIRED: {
|
|
2570
|
+
status: 400,
|
|
2571
|
+
message: 'Cannot merge: approval is required before execution'
|
|
2572
|
+
},
|
|
2573
|
+
PUBLICATION_APPROVAL_REQUIRED: {
|
|
2574
|
+
status: 400,
|
|
2575
|
+
message: 'Cannot publish: approval is required before publication'
|
|
2576
|
+
},
|
|
2577
|
+
APPROVALS_NOT_FULLY_APPROVED: {
|
|
2578
|
+
status: 400,
|
|
2579
|
+
message: 'Cannot proceed: not all requested approvals are approved'
|
|
2580
|
+
},
|
|
2581
|
+
BRANCHES_NOT_SAME_ROOT: {
|
|
2582
|
+
status: 400,
|
|
2583
|
+
message: 'Source and target branches must belong to the same root'
|
|
2584
|
+
},
|
|
2585
|
+
PUBLICATION_NOT_FOUND: {
|
|
2586
|
+
status: 404,
|
|
2587
|
+
message: 'Publication not found for this branch'
|
|
2588
|
+
},
|
|
2589
|
+
PUBLISHED_CONTENT_NOT_FOUND: {
|
|
2590
|
+
status: 404,
|
|
2591
|
+
message: 'No published content found'
|
|
2592
|
+
},
|
|
2593
|
+
AMBIGUOUS_SLUG: {
|
|
2594
|
+
status: 400,
|
|
2595
|
+
message: 'Multiple roots match this slug — use rootId for an unambiguous lookup'
|
|
2596
|
+
},
|
|
2597
|
+
DATA_RETENTION_NOT_CONFIGURED: {
|
|
2598
|
+
status: 400,
|
|
2599
|
+
message: 'dataRetention is not configured for this CMS instance'
|
|
2600
|
+
},
|
|
2601
|
+
MISSING_REQUIRED_S3_PARAMETERS: {
|
|
2602
|
+
status: 400,
|
|
2603
|
+
message: 'Missing required S3 parameters: hostname, accessKeyId, or secretAccessKey'
|
|
2604
|
+
},
|
|
2605
|
+
UNKNOWN_S3_PROVIDER: {
|
|
2606
|
+
status: 400,
|
|
2607
|
+
message: 'Unknown S3 provider specified'
|
|
2608
|
+
},
|
|
2609
|
+
SLUG_GENERATION_FAILED: {
|
|
2610
|
+
status: 500,
|
|
2611
|
+
message: 'Failed to generate a unique slug after maximum attempts'
|
|
2612
|
+
},
|
|
2613
|
+
TOO_MANY_FILES: {
|
|
2614
|
+
status: 400,
|
|
2615
|
+
message: 'Too many files in upload batch'
|
|
2616
|
+
},
|
|
2617
|
+
FILE_TOO_LARGE: {
|
|
2618
|
+
status: 400,
|
|
2619
|
+
message: 'One or more files exceed the maximum allowed size'
|
|
2620
|
+
},
|
|
2621
|
+
INVALID_FILE_TYPE: {
|
|
2622
|
+
status: 400,
|
|
2623
|
+
message: 'One or more files have a disallowed MIME type'
|
|
2624
|
+
},
|
|
2625
|
+
UPLOAD_FAILED: {
|
|
2626
|
+
status: 500,
|
|
2627
|
+
message: 'Server-side upload to S3 failed'
|
|
2628
|
+
},
|
|
2629
|
+
SLUG_ALREADY_EXISTS: {
|
|
2630
|
+
status: 409,
|
|
2631
|
+
message: 'A root with this slug on this collection with this parentRootId already exists'
|
|
2632
|
+
},
|
|
2633
|
+
SLUG_NOT_ENABLED: {
|
|
2634
|
+
status: 400,
|
|
2635
|
+
message: 'This collection does not have slugs enabled'
|
|
2636
|
+
},
|
|
2637
|
+
REDIRECT_NOT_FOUND: {
|
|
2638
|
+
status: 404,
|
|
2639
|
+
message: 'Redirect not found'
|
|
2640
|
+
},
|
|
2641
|
+
REDIRECT_INVALID: {
|
|
2642
|
+
status: 400,
|
|
2643
|
+
message: 'A redirect endpoint must be a page (rootId) or a path, matching its type'
|
|
2644
|
+
},
|
|
2645
|
+
REDIRECT_SOURCE_EXISTS: {
|
|
2646
|
+
status: 409,
|
|
2647
|
+
message: 'An active redirect already exists for this source'
|
|
2648
|
+
},
|
|
2649
|
+
SLUG_EMPTY_NOT_ALLOWED: {
|
|
2650
|
+
status: 400,
|
|
2651
|
+
message: 'Empty slug is not allowed for this collection (allowRoot is false)'
|
|
2652
|
+
},
|
|
2653
|
+
NESTING_NOT_ENABLED: {
|
|
2654
|
+
status: 400,
|
|
2655
|
+
message: 'parentRootId is not allowed — this collection does not have nested pages enabled'
|
|
2656
|
+
},
|
|
2657
|
+
CIRCULAR_REFERENCE: {
|
|
2658
|
+
status: 400,
|
|
2659
|
+
message: 'Cannot move a page under itself or one of its descendants'
|
|
2660
|
+
},
|
|
2661
|
+
PARENT_ROOT_NOT_FOUND: {
|
|
2662
|
+
status: 404,
|
|
2663
|
+
message: 'Parent root not found in this collection'
|
|
2664
|
+
},
|
|
2665
|
+
REFERENCE_DEPTH_EXCEEDED: {
|
|
2666
|
+
status: 422,
|
|
2667
|
+
message: 'Reference nesting is too deep (a reusable block embeds others past the limit)'
|
|
2668
|
+
},
|
|
2669
|
+
ASSET_NOT_FOUND: {
|
|
2670
|
+
status: 404,
|
|
2671
|
+
message: 'Asset not found'
|
|
2672
|
+
},
|
|
2673
|
+
VARIABLE_NOT_FOUND: {
|
|
2674
|
+
status: 404,
|
|
2675
|
+
message: 'Variable not found'
|
|
2676
|
+
},
|
|
2677
|
+
VARIABLE_KEY_EXISTS: {
|
|
2678
|
+
status: 409,
|
|
2679
|
+
message: 'A variable with this key already exists'
|
|
2680
|
+
},
|
|
2681
|
+
VARIABLE_IN_USE: {
|
|
2682
|
+
status: 409,
|
|
2683
|
+
message: 'Cannot delete variable: it is still in use'
|
|
2684
|
+
},
|
|
2685
|
+
TEMPLATE_NOT_FOUND: {
|
|
2686
|
+
status: 404,
|
|
2687
|
+
message: 'Template not found'
|
|
2688
|
+
},
|
|
2689
|
+
TEMPLATE_KEY_EXISTS: {
|
|
2690
|
+
status: 409,
|
|
2691
|
+
message: 'A template for this collection/block/property combination already exists'
|
|
2692
|
+
},
|
|
2693
|
+
ASSET_ACCESS_DENIED: {
|
|
2694
|
+
status: 403,
|
|
2695
|
+
message: 'This asset is private and requires authentication'
|
|
2696
|
+
},
|
|
2697
|
+
COMMENT_THREAD_NOT_FOUND: {
|
|
2698
|
+
status: 404,
|
|
2699
|
+
message: 'Comment thread not found'
|
|
2700
|
+
},
|
|
2701
|
+
COMMENT_THREAD_ALREADY_RESOLVED: {
|
|
2702
|
+
status: 400,
|
|
2703
|
+
message: 'Comment thread is already resolved'
|
|
2704
|
+
},
|
|
2705
|
+
COMMENT_THREAD_NOT_RESOLVED: {
|
|
2706
|
+
status: 400,
|
|
2707
|
+
message: 'Comment thread is not resolved'
|
|
2708
|
+
},
|
|
2709
|
+
COMMENT_MESSAGE_NOT_FOUND: {
|
|
2710
|
+
status: 404,
|
|
2711
|
+
message: 'Comment message not found'
|
|
2712
|
+
},
|
|
2713
|
+
COMMENT_MESSAGE_DELETED: {
|
|
2714
|
+
status: 400,
|
|
2715
|
+
message: 'Comment message has been deleted'
|
|
2716
|
+
},
|
|
2717
|
+
COMMENT_BODY_REQUIRED: {
|
|
2718
|
+
status: 400,
|
|
2719
|
+
message: 'Body is required for comment messages'
|
|
2720
|
+
},
|
|
2721
|
+
COMMENT_AUTHOR_MISMATCH: {
|
|
2722
|
+
status: 403,
|
|
2723
|
+
message: 'Only the author can edit or delete this message'
|
|
2724
|
+
},
|
|
2725
|
+
NOTIFICATION_NOT_FOUND: {
|
|
2726
|
+
status: 404,
|
|
2727
|
+
message: 'Notification not found'
|
|
2728
|
+
},
|
|
2729
|
+
NOTIFICATION_RECIPIENT_MISMATCH: {
|
|
2730
|
+
status: 403,
|
|
2731
|
+
message: 'You can only access your own notifications'
|
|
2732
|
+
}
|
|
2733
|
+
};
|
|
2734
|
+
/**
|
|
2735
|
+
* Type-safe CMS error that extends better-call's APIError.
|
|
2736
|
+
* The `code` parameter is a string-literal union of all CMS error codes,
|
|
2737
|
+
* so typos are caught at compile time.
|
|
2738
|
+
*/ class CMSError extends APIError {
|
|
2739
|
+
constructor(code, overrides){
|
|
2740
|
+
const def = CMS_ERRORS[code];
|
|
2741
|
+
super(def.status, {
|
|
2742
|
+
message: overrides?.message ?? def.message,
|
|
2743
|
+
code
|
|
2744
|
+
});
|
|
2745
|
+
this.cmsCode = code;
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
function normalizeSlug(raw) {
|
|
2750
|
+
return slugify(raw, {
|
|
2751
|
+
lower: true,
|
|
2752
|
+
strict: true,
|
|
2753
|
+
trim: true
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Strip the collection root prefix from a URL path and split into segments.
|
|
2758
|
+
* Optionally normalizes each segment when `slugConfig.normalize` is true.
|
|
2759
|
+
*/ function splitPath(slugConfig, path) {
|
|
2760
|
+
const root = slugConfig.root.replace(/\/+$/, '');
|
|
2761
|
+
let relative = path;
|
|
2762
|
+
// Strip the collection root prefix only at a path boundary, so a sibling top
|
|
2763
|
+
// path that merely string-starts with the root (e.g. '/pages-archive' vs root
|
|
2764
|
+
// '/pages') is not mangled into '-archive'.
|
|
2765
|
+
if (root && (relative === root || relative.startsWith(`${root}/`))) {
|
|
2766
|
+
relative = relative.slice(root.length);
|
|
2767
|
+
}
|
|
2768
|
+
relative = relative.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
2769
|
+
if (!relative) return [];
|
|
2770
|
+
const segments = relative.split('/');
|
|
2771
|
+
return slugConfig.normalize ? segments.map(normalizeSlug) : segments;
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* Validate that a slug segment is unique among siblings in the same collection.
|
|
2775
|
+
* Throws `SLUG_ALREADY_EXISTS` if a conflict is found.
|
|
2776
|
+
*/ const SAFE_SCOPE_COLUMN = /^[a-z_][a-z0-9_]*$/i;
|
|
2777
|
+
/**
|
|
2778
|
+
* Resolve a URL path to a root ID by walking segments top-down using a recursive CTE.
|
|
2779
|
+
* Returns null if no match is found.
|
|
2780
|
+
*/ async function resolvePathToRootId(db, collection, segments, scopeColumns) {
|
|
2781
|
+
// Resolve the path WITHIN the active root scope: a scoping plugin's per-row
|
|
2782
|
+
// scope columns (the same it stamps on insert) are ANDed in at EVERY level of
|
|
2783
|
+
// the slug chain, so a shared slug in another scope neither matches nor causes
|
|
2784
|
+
// ambiguity. Built as `r`-aliased predicates here because the CTE aliases
|
|
2785
|
+
// cms.roots as `r` (a table-qualified scope `where` would not bind to `r`).
|
|
2786
|
+
// Inert in single-scope installs (no columns). Mirrors validateSlugUniqueness.
|
|
2787
|
+
const scopeConds = scopeColumns ? Object.entries(scopeColumns).flatMap(([col, val])=>{
|
|
2788
|
+
if (val === undefined || val === null) return [];
|
|
2789
|
+
if (!SAFE_SCOPE_COLUMN.test(col)) {
|
|
2790
|
+
throw new Error(`resolvePathToRootId: unsafe scope column "${col}"`);
|
|
2791
|
+
}
|
|
2792
|
+
return [
|
|
2793
|
+
sql`AND r.${sql.raw(col)} = ${val}`
|
|
2794
|
+
];
|
|
2795
|
+
}) : [];
|
|
2796
|
+
const scopeCondition = scopeConds.length > 0 ? sql.join(scopeConds, sql` `) : sql``;
|
|
2797
|
+
if (segments.length === 0) {
|
|
2798
|
+
const result = await db.execute(sql`
|
|
2799
|
+
SELECT r.id FROM cms.roots r
|
|
2800
|
+
WHERE r.collection = ${collection}
|
|
2801
|
+
AND r.parent_root_id IS NULL
|
|
2802
|
+
AND (r.slug IS NULL OR r.slug = '')
|
|
2803
|
+
AND r.archived_at IS NULL
|
|
2804
|
+
${scopeCondition}
|
|
2805
|
+
LIMIT 1
|
|
2806
|
+
`);
|
|
2807
|
+
return result.rows[0]?.id ?? null;
|
|
2808
|
+
}
|
|
2809
|
+
// Build a recursive CTE that walks segments one by one
|
|
2810
|
+
const placeholders = segments.map((s, i)=>sql`(${i + 1}::int, ${s}::text)`);
|
|
2811
|
+
const valuesClause = sql.join(placeholders, sql`, `);
|
|
2812
|
+
const result = await db.execute(sql`
|
|
2813
|
+
WITH RECURSIVE
|
|
2814
|
+
path_segments(depth, segment) AS (
|
|
2815
|
+
VALUES ${valuesClause}
|
|
2816
|
+
),
|
|
2817
|
+
walk AS (
|
|
2818
|
+
SELECT r.id, 1 AS depth
|
|
2819
|
+
FROM cms.roots r
|
|
2820
|
+
JOIN path_segments ps ON ps.depth = 1 AND ps.segment = r.slug
|
|
2821
|
+
WHERE r.collection = ${collection}
|
|
2822
|
+
AND r.parent_root_id IS NULL
|
|
2823
|
+
AND r.archived_at IS NULL
|
|
2824
|
+
${scopeCondition}
|
|
2825
|
+
|
|
2826
|
+
UNION ALL
|
|
2827
|
+
|
|
2828
|
+
SELECT r.id, w.depth + 1
|
|
2829
|
+
FROM cms.roots r
|
|
2830
|
+
JOIN walk w ON r.parent_root_id = w.id
|
|
2831
|
+
JOIN path_segments ps ON ps.depth = w.depth + 1 AND ps.segment = r.slug
|
|
2832
|
+
WHERE r.collection = ${collection}
|
|
2833
|
+
AND r.archived_at IS NULL
|
|
2834
|
+
${scopeCondition}
|
|
2835
|
+
)
|
|
2836
|
+
SELECT id FROM walk
|
|
2837
|
+
WHERE depth = ${segments.length}
|
|
2838
|
+
LIMIT 1
|
|
2839
|
+
`);
|
|
2840
|
+
return result.rows[0]?.id ?? null;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
/**
|
|
2844
|
+
* The per-collection `resolveAbVariant` endpoint (Seam A / FA1). Surfaces at
|
|
2845
|
+
* `cms.api.<collection>.resolveAbVariant({ query: { path } })`. Lives as a
|
|
2846
|
+
* collection endpoint so it has the collection's slug config for `splitPath`.
|
|
2847
|
+
*/ function createAbResolveEndpoints(def) {
|
|
2848
|
+
const collectionName = def.name;
|
|
2849
|
+
const slugCfg = def.slug;
|
|
2850
|
+
return {
|
|
2851
|
+
resolveAbVariant: createCMSEndpoint(`/${collectionName}/resolveAbVariant`, {
|
|
2852
|
+
method: 'GET',
|
|
2853
|
+
query: z.object({
|
|
2854
|
+
path: z.string()
|
|
2855
|
+
}),
|
|
2856
|
+
metadata: cmsMeta({
|
|
2857
|
+
$Infer: {
|
|
2858
|
+
query: {}
|
|
2859
|
+
}
|
|
2860
|
+
}, {
|
|
2861
|
+
// Publicly readable (like getPublishedContent) so the edge can fetch
|
|
2862
|
+
// + cache it without auth; carries no visitor-specific data.
|
|
2863
|
+
permissionResource: 'publishedContent',
|
|
2864
|
+
operation: 'read',
|
|
2865
|
+
scope: 'collection',
|
|
2866
|
+
collection: collectionName
|
|
2867
|
+
})
|
|
2868
|
+
}, async (reqCtx)=>{
|
|
2869
|
+
// Pure function of (path, scope) — declared edge/CDN-cacheable. The
|
|
2870
|
+
// DEFAULT abTestMiddleware fetch is NOT served by the Next.js Data Cache
|
|
2871
|
+
// (a middleware-fetch caveat), so it runs this (cheap) query per request
|
|
2872
|
+
// → test start/stop reflects on the edge ~immediately. The short s-maxage
|
|
2873
|
+
// only bounds staleness IF some CDN/HTTP layer caches the response (then
|
|
2874
|
+
// a test activates within ≤ s-maxage, serving valid control meanwhile —
|
|
2875
|
+
// never wrong content; revalidateTag does NOT touch this HTTP cache). For
|
|
2876
|
+
// guaranteed-instant activation AT SCALE, back the middleware's `resolve`
|
|
2877
|
+
// with Edge Config / KV written on test start/stop (the injectable path).
|
|
2878
|
+
// setHeader is a no-op off-request (direct server calls / tests).
|
|
2879
|
+
reqCtx.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=30');
|
|
2880
|
+
const { db, scope } = reqCtx.context;
|
|
2881
|
+
const { path } = reqCtx.query;
|
|
2882
|
+
if (!slugCfg?.enabled) return {
|
|
2883
|
+
test: null
|
|
2884
|
+
};
|
|
2885
|
+
const rootId = await resolvePathToRootId(db, collectionName, splitPath(slugCfg, path), scope.roots?.insertColumns);
|
|
2886
|
+
if (!rootId) return {
|
|
2887
|
+
test: null
|
|
2888
|
+
};
|
|
2889
|
+
// The page's full render set: the page root + its transitive embeds
|
|
2890
|
+
// (group-aware, cross-scope — a sibling-language/host embed still counts),
|
|
2891
|
+
// mirroring the F1 XOR closure. The one running test among them varies
|
|
2892
|
+
// this render.
|
|
2893
|
+
const resolver = scope.referenceResolver ?? coreReferenceResolver;
|
|
2894
|
+
const scopeColumns = crossScopeColumns(scope.roots);
|
|
2895
|
+
const embeds = await collectEmbeddedRoots(db, rootId, resolver, scopeColumns);
|
|
2896
|
+
const renderSet = [
|
|
2897
|
+
rootId,
|
|
2898
|
+
...embeds
|
|
2899
|
+
];
|
|
2900
|
+
// One query: the running test(s) on the render set, restricted to their
|
|
2901
|
+
// PUBLISHED variant branches (JOIN publications — mirrors F2's skip of
|
|
2902
|
+
// unpublished branches) and re-scoped to the active tenant (defense in
|
|
2903
|
+
// depth; the render set is already scope-resolved).
|
|
2904
|
+
const scopeConds = rootScopeConditions(scopeColumns);
|
|
2905
|
+
const rows = await db.execute(sql`
|
|
2906
|
+
SELECT t.id AS test_id, t.root_id, t.traffic_percentage,
|
|
2907
|
+
v.id AS variant_id, v.branch_id, v.weight, v.is_control
|
|
2908
|
+
FROM cms.ab_tests t
|
|
2909
|
+
JOIN cms.roots ON cms.roots.id = t.root_id
|
|
2910
|
+
JOIN cms.ab_test_variants v ON v.test_id = t.id
|
|
2911
|
+
JOIN cms.publications p
|
|
2912
|
+
ON p.root_id = t.root_id AND p.branch_id = v.branch_id
|
|
2913
|
+
WHERE t.status = 'running'
|
|
2914
|
+
AND t.root_id IN (${sql.join(renderSet.map((r)=>sql`${r}`), sql`, `)})
|
|
2915
|
+
${scopeConds.length ? sql`AND ${sql.join(scopeConds, sql` AND `)}` : sql``}
|
|
2916
|
+
ORDER BY t.root_id, v.id
|
|
2917
|
+
`);
|
|
2918
|
+
if (rows.rows.length === 0) return {
|
|
2919
|
+
test: null
|
|
2920
|
+
};
|
|
2921
|
+
// Fail-closed: if the render set somehow carries >1 running test (an XOR
|
|
2922
|
+
// breach / graph drift), serve control to everyone rather than pick one
|
|
2923
|
+
// arbitrarily by root_id order.
|
|
2924
|
+
const testIds = new Set(rows.rows.map((r)=>r.test_id));
|
|
2925
|
+
if (testIds.size > 1) return {
|
|
2926
|
+
test: null
|
|
2927
|
+
};
|
|
2928
|
+
const first = rows.rows[0];
|
|
2929
|
+
// Dedup variants (a branch may have several publication rows).
|
|
2930
|
+
const variantsById = new Map();
|
|
2931
|
+
for (const r of rows.rows){
|
|
2932
|
+
if (!variantsById.has(r.variant_id)) {
|
|
2933
|
+
variantsById.set(r.variant_id, {
|
|
2934
|
+
variantId: r.variant_id,
|
|
2935
|
+
branchId: r.branch_id,
|
|
2936
|
+
weight: r.weight,
|
|
2937
|
+
isControl: r.is_control
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
const variants = [
|
|
2942
|
+
...variantsById.values()
|
|
2943
|
+
];
|
|
2944
|
+
// Degrade to "no fan-out" (→ control) when fewer than two variant
|
|
2945
|
+
// branches are published or the control branch is unpublished — exactly
|
|
2946
|
+
// F2's loadPublishedRoots fallback.
|
|
2947
|
+
if (variants.length < 2 || !variants.some((v)=>v.isControl)) {
|
|
2948
|
+
return {
|
|
2949
|
+
test: null
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
return {
|
|
2953
|
+
test: {
|
|
2954
|
+
testId: first.test_id,
|
|
2955
|
+
rootId: first.root_id,
|
|
2956
|
+
trafficPercentage: first.traffic_percentage,
|
|
2957
|
+
variants
|
|
2958
|
+
}
|
|
2959
|
+
};
|
|
2960
|
+
})
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/**
|
|
2965
|
+
* Loads a root by id, scoped to the collection AND the active plugin scope
|
|
2966
|
+
* (e.g. multi-tenant's `tenant_slug` predicate). Throws when the root does not
|
|
2967
|
+
* exist or lies outside the caller's scope.
|
|
2968
|
+
*
|
|
2969
|
+
* This is the single choke point that closes IDOR on by-id endpoints: a caller in one scope
|
|
2970
|
+
* cannot read or mutate a root in another scope by guessing its id, because the
|
|
2971
|
+
* scope predicate is ANDed into the existence check. Pass the active
|
|
2972
|
+
* transaction (or `db`) as `exec` so the guard participates in the same tx.
|
|
2973
|
+
*
|
|
2974
|
+
* Soft-archived roots (`archivedAt` set) are treated as gone: they are excluded
|
|
2975
|
+
* here, so every by-id read/mutation 404s on an archived root. Physical removal
|
|
2976
|
+
* is the pruning layer's job; deleteRoot and pruning query roots directly.
|
|
2977
|
+
*/ async function requireRootInScope(exec, rootId, collection, rootScope, // A core error code (default ROOT_NOT_FOUND) OR a factory returning the error
|
|
2978
|
+
// to throw — the latter lets a plugin raise its OWN error (e.g. the i18n
|
|
2979
|
+
// plugin's TRANSLATION_SOURCE_NOT_FOUND) without core naming a plugin code.
|
|
2980
|
+
notFound = 'ROOT_NOT_FOUND') {
|
|
2981
|
+
const [row] = await exec.select({
|
|
2982
|
+
id: roots.id
|
|
2983
|
+
}).from(roots).where(and(eq(roots.id, rootId), eq(roots.collection, collection), isNull(roots.archivedAt), rootScope?.where)).limit(1);
|
|
2984
|
+
if (!row) {
|
|
2985
|
+
throw typeof notFound === 'function' ? notFound() : new CMSError(notFound);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function trackingError(code, message) {
|
|
2990
|
+
throw new APIError($ERROR_CODES[code].status, {
|
|
2991
|
+
message: message ?? $ERROR_CODES[code].message,
|
|
2992
|
+
code
|
|
2993
|
+
});
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Confirms the root is within the caller's scope BEFORE any block read, using
|
|
2997
|
+
* the SAME authoritative predicate the core publishBranch handler trusts
|
|
2998
|
+
* (`scope.roots.where` — tenant AND i18n language AND not-archived, via
|
|
2999
|
+
* {@link requireRootInScope}). Returns false when the root is out of scope — the
|
|
3000
|
+
* guard then no-ops and the core handler rejects with ROOT_NOT_FOUND. This keeps
|
|
3001
|
+
* the unscoped before-hook from reading another scope's (tenant OR language)
|
|
3002
|
+
* blocks. With no scope predicate (single-tenant, no i18n) every root is in
|
|
3003
|
+
* scope, so the existence check just confirms the root exists.
|
|
3004
|
+
*/ async function rootIsInScope(db, rootId, collectionName, scope) {
|
|
3005
|
+
try {
|
|
3006
|
+
await requireRootInScope(db, rootId, collectionName, scope?.roots);
|
|
3007
|
+
return true;
|
|
3008
|
+
} catch (err) {
|
|
3009
|
+
if (err instanceof CMSError) return false; // out of scope → guard no-ops
|
|
3010
|
+
throw err;
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
/** Live (non-deleted) instances of the given block types at a branch's head. */ async function readFunctionalInstances(db, rootId, branchId, functionalTypes) {
|
|
3014
|
+
if (functionalTypes.length === 0) return [];
|
|
3015
|
+
const result = await db.execute(sql`
|
|
3016
|
+
SELECT bv.block_id AS block_id,
|
|
3017
|
+
bv.type AS type,
|
|
3018
|
+
bv.properties ->> 'trackingId' AS tracking_id
|
|
3019
|
+
FROM cms.branches b
|
|
3020
|
+
JOIN cms.commit_snapshots cs ON cs.commit_id = b.head_commit_id
|
|
3021
|
+
JOIN cms.block_versions bv ON bv.id = cs.block_version_id
|
|
3022
|
+
WHERE b.id = ${branchId}
|
|
3023
|
+
AND b.root_id = ${rootId}
|
|
3024
|
+
AND bv.deleted = false
|
|
3025
|
+
AND bv.type IN (${sql.join(functionalTypes.map((t)=>sql`${t}`), sql`, `)})
|
|
3026
|
+
`);
|
|
3027
|
+
return result.rows.map((r)=>({
|
|
3028
|
+
blockId: r.block_id,
|
|
3029
|
+
type: r.type,
|
|
3030
|
+
trackingId: r.tracking_id
|
|
3031
|
+
}));
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Sibling variant branches that share a RUNNING test with `branchId` on this
|
|
3035
|
+
* root. Scoped to the SAME test(s) the branch participates in (not every test
|
|
3036
|
+
* on the root) and to `running` status only — so drift is enforced exactly
|
|
3037
|
+
* where it renders, and editing variant branches of draft/paused/completed
|
|
3038
|
+
* tests is never blocked.
|
|
3039
|
+
*/ async function getRunningSiblingBranchIds(db, rootId, branchId) {
|
|
3040
|
+
const result = await db.execute(sql`
|
|
3041
|
+
SELECT DISTINCT v2.branch_id AS branch_id
|
|
3042
|
+
FROM cms.ab_test_variants v1
|
|
3043
|
+
JOIN cms.ab_test_variants v2 ON v2.test_id = v1.test_id
|
|
3044
|
+
JOIN cms.ab_tests t ON t.id = v1.test_id
|
|
3045
|
+
WHERE v1.branch_id = ${branchId}
|
|
3046
|
+
AND t.root_id = ${rootId}
|
|
3047
|
+
AND t.status = 'running'
|
|
3048
|
+
AND v2.branch_id <> ${branchId}
|
|
3049
|
+
`);
|
|
3050
|
+
return result.rows.map((r)=>r.branch_id);
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Publish-time tracking-id guard for functional blocks. Runs as a `publishBranch`
|
|
3054
|
+
* before-hook (so it aborts before any write), after confirming tenant ownership:
|
|
3055
|
+
* - MISSING: every functional-block instance must have a non-empty trackingId.
|
|
3056
|
+
* - DUPLICATE: trackingIds must be unique within the branch.
|
|
3057
|
+
* - DRIFT: when the branch shares a RUNNING test with sibling variant
|
|
3058
|
+
* branches, the SET of functional trackingIds must equal each
|
|
3059
|
+
* sibling's set — so a goal chosen in the UI exists in every arm
|
|
3060
|
+
* (set-equality policy; positional matching intentionally not
|
|
3061
|
+
* required).
|
|
3062
|
+
*/ async function assertTrackingIntegrity(opts) {
|
|
3063
|
+
const { db, collections, collectionName, rootId, branchId, scope } = opts;
|
|
3064
|
+
const blocks = collections[collectionName]?.blocks;
|
|
3065
|
+
if (!blocks) return;
|
|
3066
|
+
const functionalTypes = Object.entries(blocks).filter(([, def])=>def.events && Object.keys(def.events).length > 0).map(([type])=>type);
|
|
3067
|
+
if (functionalTypes.length === 0) return;
|
|
3068
|
+
// Scope ownership FIRST — never read another scope's blocks (tenant OR i18n
|
|
3069
|
+
// language) from this unscoped before-hook.
|
|
3070
|
+
if (!await rootIsInScope(db, rootId, collectionName, scope)) return;
|
|
3071
|
+
const instances = await readFunctionalInstances(db, rootId, branchId, functionalTypes);
|
|
3072
|
+
// 1. missing
|
|
3073
|
+
for (const inst of instances){
|
|
3074
|
+
if (!inst.trackingId || inst.trackingId.length === 0) {
|
|
3075
|
+
trackingError('AB_TEST_TRACKING_ID_MISSING', `Functional block "${inst.blockId}" (${inst.type}) is missing its trackingId`);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
// 2. duplicate within the branch
|
|
3079
|
+
const seen = new Map();
|
|
3080
|
+
for (const inst of instances){
|
|
3081
|
+
const tid = inst.trackingId;
|
|
3082
|
+
const prev = seen.get(tid);
|
|
3083
|
+
if (prev) {
|
|
3084
|
+
trackingError('AB_TEST_TRACKING_ID_DUPLICATE', `trackingId "${tid}" is used by both "${prev}" and "${inst.blockId}"`);
|
|
3085
|
+
}
|
|
3086
|
+
seen.set(tid, inst.blockId);
|
|
3087
|
+
}
|
|
3088
|
+
// 3. drift across the SAME running test's sibling variant branches.
|
|
3089
|
+
const siblingBranchIds = await getRunningSiblingBranchIds(db, rootId, branchId);
|
|
3090
|
+
if (siblingBranchIds.length === 0) return;
|
|
3091
|
+
const thisSet = new Set(instances.map((i)=>i.trackingId));
|
|
3092
|
+
for (const siblingId of siblingBranchIds){
|
|
3093
|
+
const sibling = await readFunctionalInstances(db, rootId, siblingId, functionalTypes);
|
|
3094
|
+
const siblingTracking = sibling.map((i)=>i.trackingId);
|
|
3095
|
+
const siblingClean = siblingTracking.filter((t)=>!!t);
|
|
3096
|
+
const siblingSet = new Set(siblingClean);
|
|
3097
|
+
// A sibling arm must itself be cleanly anchored — no missing/empty and no
|
|
3098
|
+
// intra-arm duplicate trackingId — else the count won't match its clean
|
|
3099
|
+
// set. (A null/dup is only reachable from a sibling's live head ahead of its
|
|
3100
|
+
// publish snapshot; treating it as drift fails closed.)
|
|
3101
|
+
const siblingMisanchored = siblingClean.length !== siblingTracking.length || siblingSet.size !== siblingClean.length;
|
|
3102
|
+
const sameSize = thisSet.size === siblingSet.size;
|
|
3103
|
+
const subset = [
|
|
3104
|
+
...thisSet
|
|
3105
|
+
].every((t)=>siblingSet.has(t));
|
|
3106
|
+
if (siblingMisanchored || !sameSize || !subset) {
|
|
3107
|
+
trackingError('AB_TEST_TRACKING_ID_DRIFT', `trackingId set differs across A/B variant arms of a running test (branch "${siblingId}")`);
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
/** Root ids (of the given candidates) that currently have a running A/B test. */ async function runningTestRoots(db, rootIds) {
|
|
3113
|
+
if (rootIds.length === 0) return new Set();
|
|
3114
|
+
const rows = await db.execute(sql`
|
|
3115
|
+
SELECT DISTINCT root_id FROM cms.ab_tests
|
|
3116
|
+
WHERE status = 'running'
|
|
3117
|
+
AND root_id IN (${sql.join(rootIds.map((r)=>sql`${r}`), sql`, `)})
|
|
3118
|
+
`);
|
|
3119
|
+
return new Set(rows.rows.map((r)=>r.root_id));
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* publishBranch TOCTOU backstop for the A/B XOR rule (AB_FANOUT_DESIGN §2.2).
|
|
3123
|
+
* The start-time guard keeps any co-render closure at <=1 running test AT START,
|
|
3124
|
+
* but a later publish can introduce an embed that makes two already-running
|
|
3125
|
+
* tests co-render — which the start-time guard never saw.
|
|
3126
|
+
*
|
|
3127
|
+
* On publish of `rootId`, reject if publishing would create a render where >=2
|
|
3128
|
+
* roots VARY. Precisely, a 2-axis render arises through `rootId` iff either:
|
|
3129
|
+
* (1) rootId's OWN render subtree (its group + transitive embeds) contains >=2
|
|
3130
|
+
* running tests; or
|
|
3131
|
+
* (2) a host that transcludes rootId varies AND rootId's subtree also varies
|
|
3132
|
+
* (the host's render then shows two varying axes through rootId).
|
|
3133
|
+
* Two INDEPENDENT hosts of an untested shared block both running is NOT a
|
|
3134
|
+
* conflict (they never co-render with each other) — so a flat closure count is
|
|
3135
|
+
* wrong; we separate the subtree (down) from the hosts (up).
|
|
3136
|
+
*
|
|
3137
|
+
* Runs as an (unscoped) before-hook, so it verifies ownership FIRST via the same
|
|
3138
|
+
* predicate the core handler trusts — never reading another tenant's/language's
|
|
3139
|
+
* content; an out-of-scope root no-ops out (the core handler then rejects).
|
|
3140
|
+
*/ async function assertNoCoRenderConflictOnPublish(opts) {
|
|
3141
|
+
const { db, collectionName, rootId, scope } = opts;
|
|
3142
|
+
// Cross-scope columns (the plugin's cross-scope columns — e.g. language —
|
|
3143
|
+
// removed): the co-render walk must span them (a host in any sibling scope
|
|
3144
|
+
// co-renders), so they must not filter the walk's queries.
|
|
3145
|
+
const scopeColumns = crossScopeColumns(scope?.roots);
|
|
3146
|
+
const resolver = scope?.referenceResolver ?? coreReferenceResolver;
|
|
3147
|
+
try {
|
|
3148
|
+
await requireRootInScope(db, rootId, collectionName, scope?.roots);
|
|
3149
|
+
} catch (err) {
|
|
3150
|
+
if (err instanceof CMSError) return; // out of scope → core rejects
|
|
3151
|
+
throw err;
|
|
3152
|
+
}
|
|
3153
|
+
const ownGroup = await resolver.expandGroup(db, scopeColumns, [
|
|
3154
|
+
rootId
|
|
3155
|
+
]);
|
|
3156
|
+
const subtree = await collectEmbeddedRoots(db, rootId, resolver, scopeColumns); // down only
|
|
3157
|
+
const full = await collectCoRenderRoots(db, rootId, resolver, scopeColumns); // up + down
|
|
3158
|
+
// up = full \ down (the hosts above rootId).
|
|
3159
|
+
const up = new Set();
|
|
3160
|
+
for (const r of full)if (!subtree.has(r)) up.add(r);
|
|
3161
|
+
const running = await runningTestRoots(db, [
|
|
3162
|
+
...ownGroup,
|
|
3163
|
+
...full
|
|
3164
|
+
]);
|
|
3165
|
+
// rootId's own render subtree (group + transitive embeds).
|
|
3166
|
+
let subtreeRunning = 0;
|
|
3167
|
+
for (const r of ownGroup)if (running.has(r)) subtreeRunning++;
|
|
3168
|
+
for (const r of subtree)if (running.has(r)) subtreeRunning++;
|
|
3169
|
+
let upRunning = 0;
|
|
3170
|
+
for (const r of up)if (running.has(r)) upRunning++;
|
|
3171
|
+
// (1) the published tree itself varies on >=2 axes, OR (2) a host above varies
|
|
3172
|
+
// AND something in the published tree varies (they co-render through rootId).
|
|
3173
|
+
if (subtreeRunning >= 2 || subtreeRunning >= 1 && upRunning >= 1) {
|
|
3174
|
+
throw new APIError($ERROR_CODES.AB_TEST_CROSS_EMBED_CONFLICT.status, {
|
|
3175
|
+
message: $ERROR_CODES.AB_TEST_CROSS_EMBED_CONFLICT.message,
|
|
3176
|
+
code: 'AB_TEST_CROSS_EMBED_CONFLICT'
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
/**
|
|
3182
|
+
* The A/B measurement privacy-notice items. The `_ga` read is ALWAYS listed: the
|
|
3183
|
+
* client reads `_ga` whenever `analytics_storage` is granted (to obtain the GA4
|
|
3184
|
+
* client_id), independent of server-MP. Pass `ga4: true` when the server-MP
|
|
3185
|
+
* forward (M5) is configured — it only changes the `_ga` recipient/purpose to
|
|
3186
|
+
* name Google Analytics 4 as the destination of the forwarded hit.
|
|
3187
|
+
* `variantCookiePrefix` must match the middleware's `variantCookiePrefix`.
|
|
3188
|
+
*/ function getPrivacyNoticeItems(options) {
|
|
3189
|
+
const prefix = options?.variantCookiePrefix ?? 'ab_';
|
|
3190
|
+
const items = [
|
|
3191
|
+
{
|
|
3192
|
+
name: `${prefix}<testId>`,
|
|
3193
|
+
type: 'cookie',
|
|
3194
|
+
purpose: 'Keeps the served A/B test variant consistent across requests — stores ONLY the variant code, no identifier.',
|
|
3195
|
+
lifetime: '30 days',
|
|
3196
|
+
isIdentifier: false,
|
|
3197
|
+
// ePrivacy "strictly necessary": first-party, no behavioural data, never
|
|
3198
|
+
// sent to a third party → no consent required.
|
|
3199
|
+
consentRequired: null,
|
|
3200
|
+
recipient: 'First-party (this site)'
|
|
3201
|
+
},
|
|
3202
|
+
{
|
|
3203
|
+
name: 'ab_test_impressions',
|
|
3204
|
+
type: 'sessionStorage',
|
|
3205
|
+
purpose: 'Per-session dedup of anonymous A/B impression/goal beacons (which tests this tab already counted).',
|
|
3206
|
+
lifetime: 'Session (cleared when the tab closes)',
|
|
3207
|
+
isIdentifier: false,
|
|
3208
|
+
consentRequired: null,
|
|
3209
|
+
recipient: 'First-party (never transmitted)'
|
|
3210
|
+
},
|
|
3211
|
+
{
|
|
3212
|
+
name: 'ab_test_vid',
|
|
3213
|
+
type: 'cookie',
|
|
3214
|
+
purpose: 'A unique visitor id for the consent-gated unique-visitor / GA4 path. Written only after consent.',
|
|
3215
|
+
lifetime: '1 year',
|
|
3216
|
+
isIdentifier: true,
|
|
3217
|
+
consentRequired: 'analytics_storage',
|
|
3218
|
+
recipient: 'First-party A/B store'
|
|
3219
|
+
},
|
|
3220
|
+
{
|
|
3221
|
+
name: 'ab_test_assignments, ab_test_context',
|
|
3222
|
+
type: 'localStorage',
|
|
3223
|
+
purpose: "Persists the visitor's variant assignments + context after consent so they stay stable across visits.",
|
|
3224
|
+
lifetime: 'Persistent (until cleared, or abTest.reset())',
|
|
3225
|
+
isIdentifier: true,
|
|
3226
|
+
consentRequired: 'analytics_storage',
|
|
3227
|
+
recipient: 'First-party (never transmitted)'
|
|
3228
|
+
},
|
|
3229
|
+
{
|
|
3230
|
+
name: '_ga, _ga_<stream>',
|
|
3231
|
+
type: 'external-cookie-read',
|
|
3232
|
+
// Always disclosed: the client reads `_ga` whenever analytics_storage is
|
|
3233
|
+
// granted to obtain the GA4 client_id/session_id. Whether that id is then
|
|
3234
|
+
// forwarded to GA4 depends on the server-MP (`ga4`) config — reflected in
|
|
3235
|
+
// the recipient below.
|
|
3236
|
+
purpose: options?.ga4 ? 'READ (never set by the CMS) to obtain the GA4 client_id / session_id, forwarded server-side via the Measurement Protocol.' : 'READ (never set by the CMS) to obtain the GA4 client_id / session_id for analytics stitching.',
|
|
3237
|
+
lifetime: 'Per your Google Analytics configuration (typically 2 years)',
|
|
3238
|
+
isIdentifier: true,
|
|
3239
|
+
consentRequired: 'analytics_storage',
|
|
3240
|
+
recipient: options?.ga4 ? 'Google Analytics 4 (via the server-MP forward)' : 'First-party A/B store'
|
|
3241
|
+
}
|
|
3242
|
+
];
|
|
3243
|
+
return items;
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
const _upstashRealtimeId = [
|
|
3247
|
+
'@upstash',
|
|
3248
|
+
'realtime'
|
|
3249
|
+
].join('/');
|
|
3250
|
+
const _importUpstashRealtime = ()=>new Function('id', 'return import(id)')(_upstashRealtimeId);
|
|
3251
|
+
const PLUGIN_ID = 'abTest';
|
|
3252
|
+
registerIdPrefix('abTest', 'abt');
|
|
3253
|
+
registerIdPrefix('abTestVariant', 'abv');
|
|
3254
|
+
registerIdPrefix('abTestEvent', 'abe');
|
|
3255
|
+
registerIdPrefix('abTestAgg', 'aba');
|
|
3256
|
+
function abTest(options) {
|
|
3257
|
+
const adapter = options?.analytics ?? postgresAnalytics();
|
|
3258
|
+
const schema = buildSchema(adapter);
|
|
3259
|
+
// Created ONCE per plugin instance (never per request) so the rate-limit
|
|
3260
|
+
// window survives across requests. In-memory unless a distributed store is
|
|
3261
|
+
// injected. Undefined when rate-limiting is not configured.
|
|
3262
|
+
const rateLimitStore = options?.rateLimit ? options.rateLimit.store ?? createInMemoryRateLimitStore() : undefined;
|
|
3263
|
+
// Captured at init() — read by the publishBranch guard (which block types are
|
|
3264
|
+
// functional) AND by the listGoalEvents endpoint (the goal-picker reads each
|
|
3265
|
+
// block's declared `events`). A getter is threaded into the endpoint factory
|
|
3266
|
+
// because the endpoints are built here, before init() populates this.
|
|
3267
|
+
let pluginCollections = {};
|
|
3268
|
+
const endpoints = createABTestEndpoints(adapter, ()=>pluginCollections, options?.ga4);
|
|
3269
|
+
return {
|
|
3270
|
+
id: PLUGIN_ID,
|
|
3271
|
+
schema,
|
|
3272
|
+
endpoints,
|
|
3273
|
+
// FA1 (AB_FANOUT Pattern A): the edge-readable resolve seam, per collection.
|
|
3274
|
+
collectionEndpoints: (def)=>createAbResolveEndpoints(def),
|
|
3275
|
+
$ERROR_CODES,
|
|
3276
|
+
hooks: {
|
|
3277
|
+
before: [
|
|
3278
|
+
{
|
|
3279
|
+
// Publish-time tracking-id integrity guard (missing/duplicate/drift).
|
|
3280
|
+
action: 'publishBranch',
|
|
3281
|
+
handler: async (ctx)=>{
|
|
3282
|
+
const rootId = ctx.input.rootId;
|
|
3283
|
+
const branchId = ctx.input.branchId;
|
|
3284
|
+
if (!rootId || !branchId) return;
|
|
3285
|
+
await assertTrackingIntegrity({
|
|
3286
|
+
db: ctx.db,
|
|
3287
|
+
collections: pluginCollections,
|
|
3288
|
+
collectionName: ctx.collection,
|
|
3289
|
+
rootId,
|
|
3290
|
+
branchId,
|
|
3291
|
+
scope: ctx.scope
|
|
3292
|
+
});
|
|
3293
|
+
}
|
|
3294
|
+
},
|
|
3295
|
+
{
|
|
3296
|
+
// XOR TOCTOU backstop: reject a publish that would make two running
|
|
3297
|
+
// tests co-render (AB_FANOUT_DESIGN §2.2).
|
|
3298
|
+
action: 'publishBranch',
|
|
3299
|
+
handler: async (ctx)=>{
|
|
3300
|
+
const rootId = ctx.input.rootId;
|
|
3301
|
+
if (!rootId) return;
|
|
3302
|
+
await assertNoCoRenderConflictOnPublish({
|
|
3303
|
+
db: ctx.db,
|
|
3304
|
+
collectionName: ctx.collection,
|
|
3305
|
+
rootId,
|
|
3306
|
+
scope: ctx.scope
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
]
|
|
3311
|
+
},
|
|
3312
|
+
async init (ctx) {
|
|
3313
|
+
pluginCollections = ctx.collections;
|
|
3314
|
+
if (adapter.init) await adapter.init(ctx.db);
|
|
3315
|
+
// Register the read-path running-test resolver (AB_FANOUT F2 server
|
|
3316
|
+
// fan-out, Seam F). Stateless + request-independent, so a constant scope
|
|
3317
|
+
// factory just hands the same instance to every request's resolved scope.
|
|
3318
|
+
const abTestResolver = buildAbTestResolver();
|
|
3319
|
+
return {
|
|
3320
|
+
context: {
|
|
3321
|
+
scopeConditions: [
|
|
3322
|
+
()=>({
|
|
3323
|
+
abTestResolver
|
|
3324
|
+
})
|
|
3325
|
+
]
|
|
3326
|
+
}
|
|
3327
|
+
};
|
|
3328
|
+
},
|
|
3329
|
+
async onRequest (request, _ctx) {
|
|
3330
|
+
const url = new URL(request.url);
|
|
3331
|
+
// Rate-limit the anonymous trackEvent ingest as early as possible —
|
|
3332
|
+
// before routing / auth / DB work — when configured. A 429 short-circuits.
|
|
3333
|
+
if (rateLimitStore && options?.rateLimit && request.method === 'POST' && url.pathname.endsWith('/abTest/trackEvent')) {
|
|
3334
|
+
const limited = await enforceTrackEventRateLimit(request, options.rateLimit, rateLimitStore);
|
|
3335
|
+
if (limited) return {
|
|
3336
|
+
response: limited
|
|
3337
|
+
};
|
|
3338
|
+
}
|
|
3339
|
+
const realtimeInstance = adapter.realtimeInstance;
|
|
3340
|
+
if (!realtimeInstance) return;
|
|
3341
|
+
if (!url.pathname.endsWith('/abTest/realtime')) return;
|
|
3342
|
+
try {
|
|
3343
|
+
const upstashRealtime = await _importUpstashRealtime();
|
|
3344
|
+
const { handle } = upstashRealtime;
|
|
3345
|
+
const handler = handle({
|
|
3346
|
+
realtime: realtimeInstance,
|
|
3347
|
+
middleware: async ({ channels })=>{
|
|
3348
|
+
for (const ch of channels){
|
|
3349
|
+
if (!ch.startsWith('ab:live:')) {
|
|
3350
|
+
return new Response('Invalid channel', {
|
|
3351
|
+
status: 403
|
|
3352
|
+
});
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
});
|
|
3357
|
+
return {
|
|
3358
|
+
response: await handler(request)
|
|
3359
|
+
};
|
|
3360
|
+
} catch {
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
};
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
export { $ERROR_CODES, abTest, buildGa4Payload, createInMemoryRateLimitStore, defaultRateLimitKey, enforceTrackEventRateLimit, forwardToGa4, getPrivacyNoticeItems, postgresAnalytics };
|