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