@createcms/core 0.1.1

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