@createcms/core 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/dist/ab-edge/index.cjs +214 -0
- package/dist/ab-edge/index.d.cts +121 -0
- package/dist/ab-edge/index.d.ts +121 -0
- package/dist/ab-edge/index.js +205 -0
- package/dist/bin/createcms.js +3082 -0
- package/dist/db.cjs +496 -0
- package/dist/db.d.cts +128 -0
- package/dist/db.d.ts +128 -0
- package/dist/db.js +488 -0
- package/dist/index.cjs +13789 -0
- package/dist/index.d.cts +10277 -0
- package/dist/index.d.ts +10277 -0
- package/dist/index.js +13737 -0
- package/dist/nanoid.cjs +50 -0
- package/dist/nanoid.d.cts +29 -0
- package/dist/nanoid.d.ts +29 -0
- package/dist/nanoid.js +47 -0
- package/dist/next/index.cjs +60 -0
- package/dist/next/index.d.cts +141 -0
- package/dist/next/index.d.ts +141 -0
- package/dist/next/index.js +58 -0
- package/dist/next/middleware.cjs +113 -0
- package/dist/next/middleware.d.cts +77 -0
- package/dist/next/middleware.d.ts +77 -0
- package/dist/next/middleware.js +111 -0
- package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
- package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.js +343 -0
- package/dist/plugins/ab-test/client.cjs +686 -0
- package/dist/plugins/ab-test/client.d.cts +233 -0
- package/dist/plugins/ab-test/client.d.ts +233 -0
- package/dist/plugins/ab-test/client.js +684 -0
- package/dist/plugins/ab-test/index.cjs +3400 -0
- package/dist/plugins/ab-test/index.d.cts +1131 -0
- package/dist/plugins/ab-test/index.d.ts +1131 -0
- package/dist/plugins/ab-test/index.js +3367 -0
- package/dist/plugins/client.cjs +20 -0
- package/dist/plugins/client.d.cts +3 -0
- package/dist/plugins/client.d.ts +3 -0
- package/dist/plugins/client.js +3 -0
- package/dist/plugins/consent/client.cjs +315 -0
- package/dist/plugins/consent/client.d.cts +145 -0
- package/dist/plugins/consent/client.d.ts +145 -0
- package/dist/plugins/consent/client.js +313 -0
- package/dist/plugins/consent/index.cjs +267 -0
- package/dist/plugins/consent/index.d.cts +618 -0
- package/dist/plugins/consent/index.d.ts +618 -0
- package/dist/plugins/consent/index.js +258 -0
- package/dist/plugins/i18n/index.cjs +2177 -0
- package/dist/plugins/i18n/index.d.cts +562 -0
- package/dist/plugins/i18n/index.d.ts +562 -0
- package/dist/plugins/i18n/index.js +2150 -0
- package/dist/plugins/media-optimize/index.cjs +315 -0
- package/dist/plugins/media-optimize/index.d.cts +144 -0
- package/dist/plugins/media-optimize/index.d.ts +144 -0
- package/dist/plugins/media-optimize/index.js +311 -0
- package/dist/plugins/multi-tenant/index.cjs +210 -0
- package/dist/plugins/multi-tenant/index.d.cts +431 -0
- package/dist/plugins/multi-tenant/index.d.ts +431 -0
- package/dist/plugins/multi-tenant/index.js +207 -0
- package/dist/plugins/server.cjs +24 -0
- package/dist/plugins/server.d.cts +3 -0
- package/dist/plugins/server.d.ts +3 -0
- package/dist/plugins/server.js +3 -0
- package/dist/react/blocks.cjs +233 -0
- package/dist/react/blocks.d.cts +320 -0
- package/dist/react/blocks.d.ts +320 -0
- package/dist/react/blocks.js +226 -0
- package/dist/react/index.cjs +901 -0
- package/dist/react/index.d.cts +992 -0
- package/dist/react/index.d.ts +992 -0
- package/dist/react/index.js +872 -0
- package/dist/react/tracking.cjs +243 -0
- package/dist/react/tracking.d.cts +364 -0
- package/dist/react/tracking.d.ts +364 -0
- package/dist/react/tracking.js +216 -0
- package/dist/react/variant.cjs +59 -0
- package/dist/react/variant.d.cts +26 -0
- package/dist/react/variant.d.ts +26 -0
- package/dist/react/variant.js +57 -0
- package/package.json +303 -0
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
|
|
2
|
+
import { PgDatabase, AnyPgTable } from 'drizzle-orm/pg-core';
|
|
3
|
+
import { AnyColumn, SQL } from 'drizzle-orm';
|
|
4
|
+
import * as better_call from 'better-call';
|
|
5
|
+
import { ConsentState, ConsentPurpose } from '../consent/index.js';
|
|
6
|
+
export { ConsentPurpose, ConsentSignal, ConsentState } from '../consent/index.js';
|
|
7
|
+
|
|
8
|
+
type DrizzleInstance = PgDatabase<any, Record<string, unknown>, any>;
|
|
9
|
+
|
|
10
|
+
declare const notificationTypeEnum: drizzle_orm_pg_core.PgEnum<["mention", "comment", "threadResolved", "approvalRequested", "approvalApproved", "approvalRejected", "mergeRequestOpened", "mergeRequestMerged", "mergeRequestClosed", "mergeRequestReopened", "published", "custom"]>;
|
|
11
|
+
|
|
12
|
+
type NotificationType = (typeof notificationTypeEnum.enumValues)[number];
|
|
13
|
+
type NotificationPayload = {
|
|
14
|
+
id: string;
|
|
15
|
+
recipientId: string;
|
|
16
|
+
actorId: string | null;
|
|
17
|
+
type: NotificationType;
|
|
18
|
+
title: string;
|
|
19
|
+
body: string | null;
|
|
20
|
+
resourceType: string | null;
|
|
21
|
+
resourceId: string | null;
|
|
22
|
+
collection: string | null;
|
|
23
|
+
meta: Record<string, unknown> | null;
|
|
24
|
+
createdAt: Date;
|
|
25
|
+
};
|
|
26
|
+
type OnNotificationHandler = (notification: NotificationPayload) => void | Promise<void>;
|
|
27
|
+
type NotificationInput = Omit<NotificationPayload, 'id' | 'createdAt'>;
|
|
28
|
+
|
|
29
|
+
type NotificationService = ReturnType<typeof createNotificationService>;
|
|
30
|
+
declare function createNotificationService(db: DrizzleInstance, handlers: OnNotificationHandler[]): {
|
|
31
|
+
notify(input: NotificationInput): Promise<NotificationPayload>;
|
|
32
|
+
notifyMany(inputs: NotificationInput[]): Promise<NotificationPayload[]>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ResolvedUserConfig = {
|
|
36
|
+
table: AnyPgTable;
|
|
37
|
+
tableName: string;
|
|
38
|
+
schemaName: string | null;
|
|
39
|
+
idColumn: AnyColumn;
|
|
40
|
+
/** The camelCase key used in the Drizzle table definition (e.g. "id"). */
|
|
41
|
+
idColumnKey: string;
|
|
42
|
+
/** The actual database column name (e.g. "id" or "user_id"). */
|
|
43
|
+
idColumnDbName: string;
|
|
44
|
+
allColumns: Record<string, AnyColumn>;
|
|
45
|
+
/** Allowlist (camelCase keys) of columns exposable via `withUser`. */
|
|
46
|
+
exposeColumns: string[];
|
|
47
|
+
sqlTableRef: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type SchemaNamespace = 'cms';
|
|
51
|
+
type ColumnScalarType = 'text' | 'boolean' | 'integer' | 'timestamp' | 'jsonb' | 'tsvector';
|
|
52
|
+
type ColumnType<EnumTarget extends string = string> = ColumnScalarType | {
|
|
53
|
+
enum: EnumTarget;
|
|
54
|
+
};
|
|
55
|
+
type DefaultValue = {
|
|
56
|
+
kind: 'literal';
|
|
57
|
+
value: boolean | number | string | string[] | Record<string, unknown>;
|
|
58
|
+
} | {
|
|
59
|
+
kind: 'sql';
|
|
60
|
+
value: string;
|
|
61
|
+
};
|
|
62
|
+
type ForeignKeyAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
|
|
63
|
+
type IndexUsing = 'btree' | 'gin';
|
|
64
|
+
type ColumnDefinition<ReferenceTarget extends string = string, EnumTarget extends string = string> = {
|
|
65
|
+
type: ColumnType<EnumTarget>;
|
|
66
|
+
columnName?: string;
|
|
67
|
+
notNull?: boolean;
|
|
68
|
+
primaryKey?: boolean;
|
|
69
|
+
unique?: boolean;
|
|
70
|
+
default?: DefaultValue;
|
|
71
|
+
defaultId?: boolean;
|
|
72
|
+
defaultIdPrefix?: string;
|
|
73
|
+
defaultNow?: boolean;
|
|
74
|
+
jsonType?: string;
|
|
75
|
+
references?: {
|
|
76
|
+
table: ReferenceTarget;
|
|
77
|
+
column: string;
|
|
78
|
+
onDelete?: ForeignKeyAction;
|
|
79
|
+
onUpdate?: ForeignKeyAction;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
type TableColumns<ReferenceTarget extends string = string, EnumTarget extends string = string> = Record<string, ColumnDefinition<ReferenceTarget, EnumTarget>>;
|
|
83
|
+
type IndexDefinition<ColumnName extends string> = {
|
|
84
|
+
columns: readonly ColumnName[];
|
|
85
|
+
unique?: boolean;
|
|
86
|
+
using?: IndexUsing;
|
|
87
|
+
where?: string;
|
|
88
|
+
};
|
|
89
|
+
type CompositePrimaryKey<ColumnName extends string> = {
|
|
90
|
+
columns: readonly ColumnName[];
|
|
91
|
+
};
|
|
92
|
+
type TableLevelForeignKey<ColumnName extends string = string> = {
|
|
93
|
+
columns: readonly ColumnName[];
|
|
94
|
+
foreignTable: string;
|
|
95
|
+
foreignColumns: readonly string[];
|
|
96
|
+
name?: string;
|
|
97
|
+
onDelete?: ForeignKeyAction;
|
|
98
|
+
onUpdate?: ForeignKeyAction;
|
|
99
|
+
};
|
|
100
|
+
type TableDefinition<Columns extends TableColumns = TableColumns, ReferenceTarget extends string = string, EnumTarget extends string = string> = {
|
|
101
|
+
tableName?: string;
|
|
102
|
+
indexPrefix?: string;
|
|
103
|
+
columns: Columns;
|
|
104
|
+
indexes?: Record<string, IndexDefinition<Extract<keyof Columns, string>>>;
|
|
105
|
+
compositePrimaryKey?: CompositePrimaryKey<Extract<keyof Columns, string>>;
|
|
106
|
+
foreignKeys?: TableLevelForeignKey<Extract<keyof Columns, string>>[];
|
|
107
|
+
};
|
|
108
|
+
type EnumDefinition = {
|
|
109
|
+
values: readonly string[];
|
|
110
|
+
enumName?: string;
|
|
111
|
+
};
|
|
112
|
+
type EnumMap = Record<string, EnumDefinition>;
|
|
113
|
+
type TableMap = Record<string, TableDefinition>;
|
|
114
|
+
type SchemaModule<_Namespace extends SchemaNamespace = SchemaNamespace, Tables extends TableMap = {}, Enums extends EnumMap = {}, Extensions = {}> = {
|
|
115
|
+
enums?: Enums;
|
|
116
|
+
tables?: Tables;
|
|
117
|
+
extend?: Extensions;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Endpoint key used as a hook action identifier.
|
|
122
|
+
* Accepts any string so internal plumbing doesn't need the full union,
|
|
123
|
+
* but the exported `CMSEndpointKey` (from `index.ts`) provides a narrowed
|
|
124
|
+
* union with autocomplete for hook authors.
|
|
125
|
+
*/
|
|
126
|
+
type CMSHookAction = string & {};
|
|
127
|
+
type CMSBeforeHookContext = {
|
|
128
|
+
action: CMSHookAction;
|
|
129
|
+
collection: string;
|
|
130
|
+
db: DrizzleInstance;
|
|
131
|
+
input: Record<string, unknown>;
|
|
132
|
+
/**
|
|
133
|
+
* The resolved per-request scope (tenant/i18n), available to before-hooks so
|
|
134
|
+
* they can tenant-scope any cross-resource reads they perform. Set by the
|
|
135
|
+
* endpoint wrapper before hooks run; may be undefined for hooks invoked
|
|
136
|
+
* outside that path.
|
|
137
|
+
*/
|
|
138
|
+
scope?: ResolvedScope;
|
|
139
|
+
};
|
|
140
|
+
type CMSPluginContext = CMSProcedureCtx & {
|
|
141
|
+
collections: Record<string, CollectionWithName>;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Per-request scope produced by a ScopeConditionFactory.
|
|
146
|
+
* `where` — appended to SELECT/UPDATE/DELETE queries.
|
|
147
|
+
* `insertColumns` — snake_case column name → value pairs merged directly
|
|
148
|
+
* into the raw SQL INSERT via `scopedInsert` / `scopedInsertBatch`.
|
|
149
|
+
*/
|
|
150
|
+
type TableScope = {
|
|
151
|
+
where?: SQL;
|
|
152
|
+
insertColumns?: Record<string, unknown>;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* `roots` scope additionally supports a per-NEW-ENTRY column contributor: a
|
|
156
|
+
* plugin can compute fresh insert columns once per newly-created logical entry
|
|
157
|
+
* (e.g. a freshly minted translation-group id), which the static `insertColumns`
|
|
158
|
+
* channel (same value on every row) can't express. Called once per
|
|
159
|
+
* createRoot / root-duplication. Generic — core names no column. (Seam D.)
|
|
160
|
+
*/
|
|
161
|
+
type RootTableScope = TableScope & {
|
|
162
|
+
newEntryColumns?: () => Record<string, unknown>;
|
|
163
|
+
/**
|
|
164
|
+
* Scope columns to EXCLUDE from cross-scope read filtering — columns the
|
|
165
|
+
* plugin varies INDEPENDENTLY of a query so that cross-scope reads (a
|
|
166
|
+
* reference/host/usage that legitimately spans them) must not filter on them.
|
|
167
|
+
* The i18n plugin declares `['language']` (a host/reference in any sibling
|
|
168
|
+
* language still counts; the read path already resolved a specific sibling).
|
|
169
|
+
* Generic — core names no column; passed to `rootScopeConditions` as its
|
|
170
|
+
* `exclude`. Empty/absent → every scope column filters. (Seam D6.)
|
|
171
|
+
*/
|
|
172
|
+
crossScopeExclude?: readonly string[];
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* A plugin-provided resolver for reference values (rootId / group-key strings),
|
|
176
|
+
* carried on the resolved scope and consumed by the read path and the A/B
|
|
177
|
+
* co-render walk. Core ships an IDENTITY default (`coreReferenceResolver`)
|
|
178
|
+
* reproducing the single-language, no-plugin behaviour byte-for-byte; the i18n
|
|
179
|
+
* plugin supplies a real one that understands translation groups + the fallback
|
|
180
|
+
* chain. Core never names any i18n concept — it knows only this interface.
|
|
181
|
+
*
|
|
182
|
+
* `db` AND `scopeColumns` are passed PER CALL (not closed over): `db` so a
|
|
183
|
+
* caller inside a transaction (e.g. the A/B →running guard under FOR UPDATE)
|
|
184
|
+
* resolves against its own tx handle; `scopeColumns` because the MERGED root
|
|
185
|
+
* scope columns (tenant + language) exist only AFTER every scope factory has
|
|
186
|
+
* run — the i18n factory that builds the resolver sees only its OWN column at
|
|
187
|
+
* build time. The resolver therefore closes over just its resolution POLICY
|
|
188
|
+
* (e.g. the i18n active language + fallback chain). `scopeColumns` is the
|
|
189
|
+
* scope predicate; the resolver excludes its own cross-scope columns.
|
|
190
|
+
* (Seam B.)
|
|
191
|
+
*/
|
|
192
|
+
type ReferenceResolver = {
|
|
193
|
+
/**
|
|
194
|
+
* Read-time render pick: stored reference value → the ONE rootId it renders
|
|
195
|
+
* as (omit a key to leave it unresolved). Identity default: `value → value`.
|
|
196
|
+
*/
|
|
197
|
+
resolveRenderTargets(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, collection: string, storedValues: string[]): Promise<Map<string, string>>;
|
|
198
|
+
/**
|
|
199
|
+
* Conflict superset: stored reference keys → ALL rootIds they could render as
|
|
200
|
+
* (a group key expands to its whole group). Used by the A/B co-render walk;
|
|
201
|
+
* collection-agnostic (a reference may target any collection). Identity
|
|
202
|
+
* default: the existing, non-archived roots among `storedKeys` (by id).
|
|
203
|
+
*/
|
|
204
|
+
resolveConflictTargets(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, storedKeys: string[]): Promise<string[]>;
|
|
205
|
+
/** rootIds → all their group siblings. Identity default: the input rootIds. */
|
|
206
|
+
expandGroup(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, rootIds: string[]): Promise<string[]>;
|
|
207
|
+
/** rootIds → the group keys a host could embed them by. Default: `[]`. */
|
|
208
|
+
groupKeysFor(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, rootIds: string[]): Promise<string[]>;
|
|
209
|
+
};
|
|
210
|
+
/** One variant branch of a running A/B test on a referenced root. */
|
|
211
|
+
type RunningAbTestVariant = {
|
|
212
|
+
branchId: string;
|
|
213
|
+
isControl: boolean;
|
|
214
|
+
};
|
|
215
|
+
/** A running A/B test on one root: the test plus its variant branches. */
|
|
216
|
+
type RunningAbTest = {
|
|
217
|
+
testId: string;
|
|
218
|
+
trafficPercentage: number;
|
|
219
|
+
variants: RunningAbTestVariant[];
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* A plugin-provided resolver that reports which referenced roots currently have
|
|
223
|
+
* a RUNNING A/B test (with that test's variant branches). Carried on the
|
|
224
|
+
* resolved scope and consumed by the read path's reference loader to fan the one
|
|
225
|
+
* XOR-guaranteed varying block's branches out to the client (AB_FANOUT F2). Core
|
|
226
|
+
* ships NO default — when absent (no ab-test plugin) the read path assumes no
|
|
227
|
+
* running tests and every embed stays on its deterministic single pick (F0).
|
|
228
|
+
* Core never names any A/B concept beyond this interface. (Seam F.)
|
|
229
|
+
*/
|
|
230
|
+
type AbTestResolver = {
|
|
231
|
+
/**
|
|
232
|
+
* The subset of `rootIds` that have a running test, each mapped to its test +
|
|
233
|
+
* variant branches. `db` AND `scopeColumns` are passed PER CALL (same
|
|
234
|
+
* rationale as {@link ReferenceResolver}). The caller passes already
|
|
235
|
+
* render-resolved (active-language) rootIds, so this needs no group expansion.
|
|
236
|
+
*/
|
|
237
|
+
runningTests(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, rootIds: string[]): Promise<Map<string, RunningAbTest>>;
|
|
238
|
+
};
|
|
239
|
+
type ResolvedScope = {
|
|
240
|
+
roots?: RootTableScope;
|
|
241
|
+
assets?: TableScope;
|
|
242
|
+
assetFolders?: TableScope;
|
|
243
|
+
redirects?: TableScope;
|
|
244
|
+
/**
|
|
245
|
+
* Plugin-provided reference resolver (i18n translation-group resolution). When
|
|
246
|
+
* absent, callers use core's identity default. Generic — see `ReferenceResolver`.
|
|
247
|
+
*/
|
|
248
|
+
referenceResolver?: ReferenceResolver;
|
|
249
|
+
/**
|
|
250
|
+
* Plugin-provided running-A/B-test resolver (AB_FANOUT F2 server fan-out).
|
|
251
|
+
* When absent, the read path assumes no running tests. Generic — see
|
|
252
|
+
* {@link AbTestResolver}.
|
|
253
|
+
*/
|
|
254
|
+
abTestResolver?: AbTestResolver;
|
|
255
|
+
/**
|
|
256
|
+
* Opaque per-plugin context slots, keyed by plugin id. Core never reads it;
|
|
257
|
+
* each plugin stashes its own per-request context here from a scope factory
|
|
258
|
+
* and reads it back via its own exported accessor. Merged generically in
|
|
259
|
+
* computeScope (shallow, last-writer-wins per slot).
|
|
260
|
+
*/
|
|
261
|
+
pluginContext?: Record<string, unknown>;
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Factory registered by plugins during `init`.
|
|
265
|
+
* Called once per request with the middleware result to produce
|
|
266
|
+
* table-level WHERE conditions and extra INSERT values.
|
|
267
|
+
*/
|
|
268
|
+
type ScopeConditionFactory = (mwResult: MiddlewareResult) => ResolvedScope;
|
|
269
|
+
type CMSOperation = 'read' | 'create' | 'update' | 'delete';
|
|
270
|
+
type BlockTypes = {
|
|
271
|
+
string: string;
|
|
272
|
+
number: number;
|
|
273
|
+
boolean: boolean;
|
|
274
|
+
date: string;
|
|
275
|
+
richText: string;
|
|
276
|
+
image: string;
|
|
277
|
+
select: string;
|
|
278
|
+
reference: string;
|
|
279
|
+
};
|
|
280
|
+
type BlockPropertyType = keyof BlockTypes;
|
|
281
|
+
type SelectOption = {
|
|
282
|
+
readonly label: string;
|
|
283
|
+
readonly value: string;
|
|
284
|
+
};
|
|
285
|
+
type BlockPropertySpec<T extends BlockPropertyType> = {
|
|
286
|
+
type: T;
|
|
287
|
+
required?: boolean;
|
|
288
|
+
defaultValue?: BlockTypes[T];
|
|
289
|
+
label: string;
|
|
290
|
+
description?: string;
|
|
291
|
+
placeholder?: string;
|
|
292
|
+
} & (T extends 'select' ? {
|
|
293
|
+
options: readonly SelectOption[];
|
|
294
|
+
} : {}) & (T extends 'reference' ? {
|
|
295
|
+
collection: string;
|
|
296
|
+
} : {});
|
|
297
|
+
/** Discriminated union over all concrete block-property specs. */
|
|
298
|
+
type BlockProperty = {
|
|
299
|
+
[K in BlockPropertyType]: BlockPropertySpec<K>;
|
|
300
|
+
}[BlockPropertyType];
|
|
301
|
+
/** Scalar property subset usable as an event parameter (no references/media). */
|
|
302
|
+
type ScalarBlockProperty = Extract<BlockProperty, {
|
|
303
|
+
type: 'string' | 'number' | 'boolean' | 'select' | 'date';
|
|
304
|
+
}>;
|
|
305
|
+
/**
|
|
306
|
+
* Declares a meaningful event a functional block can emit (e.g. a form's
|
|
307
|
+
* `submitSuccess`). Living on the block DEFINITION makes it the single source of
|
|
308
|
+
* truth for the typed `fire(...)` union, the test-creation goal picker, and the
|
|
309
|
+
* analytics wire name. `name` overrides the GA4/dataLayer wire name (defaults to
|
|
310
|
+
* `cms_<blockType>_<eventKey>`, computed by the measurement layer). Whether an
|
|
311
|
+
* event counts as a conversion is decided per test in the UI, not here.
|
|
312
|
+
*/
|
|
313
|
+
type EventDeclaration = {
|
|
314
|
+
/** Analytics wire-name override (snake_case). Defaults to cms_<type>_<key>. */
|
|
315
|
+
name?: string;
|
|
316
|
+
/** Typed parameters carried with the event (scalar only). */
|
|
317
|
+
params?: Record<string, ScalarBlockProperty>;
|
|
318
|
+
/** Human label for the goal picker. */
|
|
319
|
+
label?: string;
|
|
320
|
+
};
|
|
321
|
+
type BlockDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>, TEvents extends Record<string, EventDeclaration> = Record<string, never>> = {
|
|
322
|
+
properties: TProps;
|
|
323
|
+
label: string;
|
|
324
|
+
description?: string;
|
|
325
|
+
previewImageUrl?: string;
|
|
326
|
+
/** Events this (functional) block can emit — see {@link EventDeclaration}. */
|
|
327
|
+
events?: TEvents;
|
|
328
|
+
} & ({
|
|
329
|
+
allowChildren?: false;
|
|
330
|
+
} | {
|
|
331
|
+
allowChildren: true;
|
|
332
|
+
allowedChildBlocks?: string[];
|
|
333
|
+
});
|
|
334
|
+
type AnyBlockDefinition = BlockDefinition<Record<string, BlockProperty>, Record<string, EventDeclaration>>;
|
|
335
|
+
type RootDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>> = {
|
|
336
|
+
properties: TProps;
|
|
337
|
+
};
|
|
338
|
+
type SlugConfig = {
|
|
339
|
+
enabled: false;
|
|
340
|
+
} | {
|
|
341
|
+
enabled: true;
|
|
342
|
+
root: string;
|
|
343
|
+
allowRoot?: boolean;
|
|
344
|
+
normalize?: boolean;
|
|
345
|
+
nested?: boolean;
|
|
346
|
+
};
|
|
347
|
+
type CollectionDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>, TBlocks extends Record<string, AnyBlockDefinition> = Record<string, AnyBlockDefinition>> = {
|
|
348
|
+
slug?: SlugConfig;
|
|
349
|
+
root: RootDefinition<TProps>;
|
|
350
|
+
blocks?: TBlocks;
|
|
351
|
+
label: string;
|
|
352
|
+
description?: string;
|
|
353
|
+
/**
|
|
354
|
+
* Marks this collection as one whose roots are meant to be EMBEDDED into other
|
|
355
|
+
* roots via a `reference` property (a "reusable block" library). Purely an
|
|
356
|
+
* ergonomic hint — it informs editor pickers and which endpoints to surface; it
|
|
357
|
+
* NEVER gates safety (the delete-in-use guard protects every referenced root
|
|
358
|
+
* regardless of this flag). Any collection can still be a reference target.
|
|
359
|
+
*/
|
|
360
|
+
reusableBlock?: boolean;
|
|
361
|
+
};
|
|
362
|
+
type AnyCollectionDefinition = CollectionDefinition<Record<string, BlockProperty>, Record<string, AnyBlockDefinition>>;
|
|
363
|
+
type CollectionWithName = Omit<AnyCollectionDefinition, 'blocks'> & {
|
|
364
|
+
name: string;
|
|
365
|
+
blocks: Record<string, AnyBlockDefinition>;
|
|
366
|
+
};
|
|
367
|
+
type DataRetentionConfig = {
|
|
368
|
+
keepDays: number;
|
|
369
|
+
keepMinCommits: number;
|
|
370
|
+
/**
|
|
371
|
+
* Grace period (days) before a soft-archived root (`archivedAt`) is physically
|
|
372
|
+
* hard-deleted by pruning. Defaults to `keepDays` when omitted — a trash
|
|
373
|
+
* window after which the page and its whole history are reclaimed.
|
|
374
|
+
*/
|
|
375
|
+
archiveKeepDays?: number;
|
|
376
|
+
};
|
|
377
|
+
/** Result that user middleware can return to extend context */
|
|
378
|
+
type MiddlewareResult = {
|
|
379
|
+
userId?: string;
|
|
380
|
+
[key: string]: unknown;
|
|
381
|
+
};
|
|
382
|
+
/** Base ctx injected by withCMSContext middleware. */
|
|
383
|
+
type CMSProcedureCtx = {
|
|
384
|
+
db: DrizzleInstance;
|
|
385
|
+
collections: Record<string, CollectionWithName>;
|
|
386
|
+
dataRetention?: DataRetentionConfig;
|
|
387
|
+
scopeConditions?: ScopeConditionFactory[];
|
|
388
|
+
notificationService?: NotificationService;
|
|
389
|
+
resolvedUser?: ResolvedUserConfig;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
type CMSEndpointMeta = {
|
|
393
|
+
permissionResource?: string;
|
|
394
|
+
operation: CMSOperation;
|
|
395
|
+
scope: 'collection' | 'system';
|
|
396
|
+
collection?: string;
|
|
397
|
+
/**
|
|
398
|
+
* When `true`, this endpoint is intentionally exempt from the auth /
|
|
399
|
+
* permission / scope / hook chain — it handles its own access control
|
|
400
|
+
* (e.g. a public asset redirect). Every other endpoint must carry full
|
|
401
|
+
* `cms` metadata; `toCMSEndpoints` throws on a missing one (fail-closed).
|
|
402
|
+
*/
|
|
403
|
+
public?: boolean;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/** One variant branch of the resolved test (enough for edge bucketing). */
|
|
407
|
+
type ResolvedAbVariant = {
|
|
408
|
+
/** ab_test_variants.id — the `variantId` resolveVariant buckets to. */
|
|
409
|
+
variantId: string;
|
|
410
|
+
/** The published branch this variant renders. */
|
|
411
|
+
branchId: string;
|
|
412
|
+
weight: number;
|
|
413
|
+
isControl: boolean;
|
|
414
|
+
};
|
|
415
|
+
type AbResolveResult = {
|
|
416
|
+
test: {
|
|
417
|
+
testId: string;
|
|
418
|
+
/** The root the test attaches to (page root or an embedded block root). */
|
|
419
|
+
rootId: string;
|
|
420
|
+
trafficPercentage: number;
|
|
421
|
+
variants: ResolvedAbVariant[];
|
|
422
|
+
} | null;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
type ABTestContext = {
|
|
426
|
+
key: string;
|
|
427
|
+
anonymous?: boolean;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/** Where an event originated — a functional block instance. */
|
|
431
|
+
type CMSEventSource = {
|
|
432
|
+
/** Stable, author-assigned instance handle (the block's `trackingId`). */
|
|
433
|
+
handle?: string;
|
|
434
|
+
/** Block type that emitted the event (e.g. `'signupForm'`). */
|
|
435
|
+
type?: string;
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* The decoupled, analytics-agnostic event core. A/B attribution is **optional**
|
|
439
|
+
* (`ab`), so non-A/B events (page views, form submits) are first-class and can
|
|
440
|
+
* flow to non-A/B sinks (GA4, GTM) without inventing a fake test/variant.
|
|
441
|
+
*
|
|
442
|
+
* `ABTestEvent` is the derived view where `ab` is mandatory.
|
|
443
|
+
*/
|
|
444
|
+
type CMSEvent = {
|
|
445
|
+
/**
|
|
446
|
+
* Optional id used as the storage row key. When absent, the sink mints one.
|
|
447
|
+
* Dedup behaviour is **sink-specific**: the postgres sink dedups on it via
|
|
448
|
+
* `ON CONFLICT (id) DO NOTHING`; the upstash sink does not. (A tenant-scoped,
|
|
449
|
+
* client-supplied idempotency key — distinct from this row key — arrives with
|
|
450
|
+
* the M3 client legs.)
|
|
451
|
+
*/
|
|
452
|
+
id?: string;
|
|
453
|
+
/** Canonical event name, e.g. `'impression' | 'conversion' | 'form_submit'`. */
|
|
454
|
+
name: string;
|
|
455
|
+
/**
|
|
456
|
+
* Optional: the anonymous Pattern A path stores NO identifier (variant comes
|
|
457
|
+
* from the URL/variant-cookie). Only set for the consent-gated unique-visitor /
|
|
458
|
+
* GA4 path. Stored as NULL when absent → excluded from unique-visitor counts.
|
|
459
|
+
*/
|
|
460
|
+
visitorId?: string;
|
|
461
|
+
anonymous: boolean;
|
|
462
|
+
/** A/B attribution. Absent for non-A/B analytics events. */
|
|
463
|
+
ab?: {
|
|
464
|
+
testId: string;
|
|
465
|
+
variantId: string;
|
|
466
|
+
};
|
|
467
|
+
/** Originating functional block instance, if any. */
|
|
468
|
+
source?: CMSEventSource;
|
|
469
|
+
/**
|
|
470
|
+
* Consent state under which the event was emitted (Consent Mode v2).
|
|
471
|
+
* Forwarded to consent-aware sinks (e.g. the M5 server-MP consent block). NOTE:
|
|
472
|
+
* M1 forwards but does NOT persist this — there is no consent column yet.
|
|
473
|
+
*/
|
|
474
|
+
consent?: ConsentState;
|
|
475
|
+
/**
|
|
476
|
+
* Funnel grouping id (M4): a client-minted id shared by the attempt + success
|
|
477
|
+
* legs of ONE interaction (e.g. a <TrackedForm> submit). Lets completion_rate
|
|
478
|
+
* pair them. Distinct from any storage/dedup key — it groups, it doesn't dedup.
|
|
479
|
+
*/
|
|
480
|
+
interactionId?: string;
|
|
481
|
+
/**
|
|
482
|
+
* GA4 stitching identifiers (M5 server-MP): the client reads them from the
|
|
483
|
+
* `_ga` / `_ga_<id>` cookies and sends them ONLY when analytics_storage is
|
|
484
|
+
* granted, so the server-side ga4ServerSink can attribute the Measurement
|
|
485
|
+
* Protocol hit to the same user/session (else GA4 shows `(not set)`). Absent on
|
|
486
|
+
* the anonymous consent-free path — its presence is what gates the GA4 forward.
|
|
487
|
+
*/
|
|
488
|
+
transport?: {
|
|
489
|
+
clientId?: string;
|
|
490
|
+
sessionId?: string;
|
|
491
|
+
engagementTimeMsec?: number;
|
|
492
|
+
};
|
|
493
|
+
metadata?: Record<string, unknown>;
|
|
494
|
+
timestamp: Date;
|
|
495
|
+
};
|
|
496
|
+
/** A {@link CMSEvent} that carries mandatory A/B attribution (the A/B view). */
|
|
497
|
+
type ABTestEvent = CMSEvent & {
|
|
498
|
+
ab: {
|
|
499
|
+
testId: string;
|
|
500
|
+
variantId: string;
|
|
501
|
+
};
|
|
502
|
+
};
|
|
503
|
+
type AggregatedVariantResult = {
|
|
504
|
+
variantId: string;
|
|
505
|
+
variantName: string;
|
|
506
|
+
impressions: number;
|
|
507
|
+
conversions: number;
|
|
508
|
+
uniqueVisitors: number;
|
|
509
|
+
conversionRate: number;
|
|
510
|
+
/**
|
|
511
|
+
* Funnel (M4): total distinct interaction ids (each <TrackedForm> submit mints
|
|
512
|
+
* one) = the attempts. completionRate = distinct interactions that reached the
|
|
513
|
+
* goal event / attempts (computed by getResults when a goal is set).
|
|
514
|
+
*/
|
|
515
|
+
attempts: number;
|
|
516
|
+
completionRate: number;
|
|
517
|
+
eventBreakdown: Record<string, {
|
|
518
|
+
count: number;
|
|
519
|
+
uniqueVisitors: number;
|
|
520
|
+
distinctInteractions: number;
|
|
521
|
+
}>;
|
|
522
|
+
};
|
|
523
|
+
type AggregatedResults = {
|
|
524
|
+
testId: string;
|
|
525
|
+
variants: AggregatedVariantResult[];
|
|
526
|
+
totalImpressions: number;
|
|
527
|
+
totalConversions: number;
|
|
528
|
+
};
|
|
529
|
+
type ABTestAnalyticsAdapter = {
|
|
530
|
+
/** Adapter-specific Postgres tables to merge into the plugin schema. */
|
|
531
|
+
tables?: Record<string, TableDefinition>;
|
|
532
|
+
/** Called once during plugin init. Receives the Drizzle DB instance. */
|
|
533
|
+
init?(db: DrizzleInstance): Promise<void> | void;
|
|
534
|
+
/**
|
|
535
|
+
* Record a single event. Accepts any {@link CMSEvent}: A/B-attributed events
|
|
536
|
+
* (impression/conversion) carry `ab`, non-A/B analytics events (form_submit,
|
|
537
|
+
* page_view) omit it.
|
|
538
|
+
*/
|
|
539
|
+
track(event: CMSEvent): Promise<void>;
|
|
540
|
+
/** Query aggregated results for a test. */
|
|
541
|
+
query(testId: string, options?: {
|
|
542
|
+
from?: Date;
|
|
543
|
+
to?: Date;
|
|
544
|
+
}): Promise<AggregatedResults>;
|
|
545
|
+
/** Optional batch flush (e.g. Upstash -> Postgres). */
|
|
546
|
+
flush?(testId?: string): Promise<{
|
|
547
|
+
flushed: number;
|
|
548
|
+
}>;
|
|
549
|
+
};
|
|
550
|
+
type LiveDelta = {
|
|
551
|
+
variantId: string;
|
|
552
|
+
eventType: string;
|
|
553
|
+
count: 1;
|
|
554
|
+
timestamp: number;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Where the server forwards events. Both variants are server-only (an api_secret
|
|
559
|
+
* must never reach the browser). `endpointUrl` is required — supply the GA4 MP
|
|
560
|
+
* URL (https://www.google-analytics.com/mp/collect) or your sGTM container URL,
|
|
561
|
+
* so a regional/proxy endpoint is a config change, not a code change.
|
|
562
|
+
*/
|
|
563
|
+
type GA4ServerConfig = {
|
|
564
|
+
type: 'measurementProtocol';
|
|
565
|
+
endpointUrl: string;
|
|
566
|
+
measurementId: string;
|
|
567
|
+
apiSecret: string;
|
|
568
|
+
} | {
|
|
569
|
+
type: 'sgtm';
|
|
570
|
+
endpointUrl: string;
|
|
571
|
+
};
|
|
572
|
+
/** The GA4 Measurement Protocol request body (the shape MP/sGTM expects). */
|
|
573
|
+
type GA4Payload = {
|
|
574
|
+
client_id: string;
|
|
575
|
+
events: Array<{
|
|
576
|
+
name: string;
|
|
577
|
+
params: Record<string, unknown>;
|
|
578
|
+
}>;
|
|
579
|
+
consent?: {
|
|
580
|
+
ad_user_data: string;
|
|
581
|
+
ad_personalization: string;
|
|
582
|
+
};
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* Builds the MP payload from a {@link CMSEvent}. Pure (no I/O) + exported so the
|
|
586
|
+
* mapping is unit-testable. Returns null when the event cannot be a valid MP hit
|
|
587
|
+
* — no consent (analytics_storage not granted) or no client_id — so the caller
|
|
588
|
+
* simply does not forward. The A/B attribution rides as GA4's
|
|
589
|
+
* `experiment_id`/`experiment_variant` convention (event-scoped custom dims).
|
|
590
|
+
*/
|
|
591
|
+
declare function buildGa4Payload(event: CMSEvent): GA4Payload | null;
|
|
592
|
+
/**
|
|
593
|
+
* Forwards one event to GA4 server-side, IF it is a valid consenting MP hit.
|
|
594
|
+
* Best-effort + non-fatal: a network/endpoint error never breaks the ingest
|
|
595
|
+
* (the A/B store write already happened). No-ops on missing consent/client_id.
|
|
596
|
+
*
|
|
597
|
+
* The trackEvent handler awaits this, so it is hard-bounded by an
|
|
598
|
+
* {@link AbortSignal.timeout}: a slow/hung GA4/sGTM endpoint can never stall the
|
|
599
|
+
* public response — the abort surfaces as a caught error and the ingest returns.
|
|
600
|
+
*/
|
|
601
|
+
declare function forwardToGa4(event: CMSEvent, config: GA4ServerConfig, fetchImpl?: typeof fetch): Promise<void>;
|
|
602
|
+
|
|
603
|
+
/** One pickable A/B goal: a declared event on a functional block INSTANCE. */
|
|
604
|
+
type GoalCandidate = {
|
|
605
|
+
/** The block instance's authored trackingId (stable goal anchor); null if unset. */
|
|
606
|
+
handle: string | null;
|
|
607
|
+
blockType: string;
|
|
608
|
+
blockId: string;
|
|
609
|
+
/** The declared event KEY (what code fires). */
|
|
610
|
+
event: string;
|
|
611
|
+
/** The resolved GA4/dataLayer wire name + stored event_type (what to match on). */
|
|
612
|
+
name: string;
|
|
613
|
+
label?: string;
|
|
614
|
+
/** Declared scalar param keys. */
|
|
615
|
+
params: string[];
|
|
616
|
+
/**
|
|
617
|
+
* True when this candidate sits in the tested root's OWN tree (the varying
|
|
618
|
+
* render). False for a candidate in an EMBEDDED reusable block — shared,
|
|
619
|
+
* co-rendered content whose conversions won't reflect THIS page's variants
|
|
620
|
+
* cleanly (§6g attribution caution).
|
|
621
|
+
*/
|
|
622
|
+
inVaryingRoot: boolean;
|
|
623
|
+
/** The root whose tree this candidate lives in. */
|
|
624
|
+
hostRootId: string;
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
declare function postgresAnalytics(): ABTestAnalyticsAdapter;
|
|
628
|
+
|
|
629
|
+
type RateLimitStore = {
|
|
630
|
+
/**
|
|
631
|
+
* Records one hit for `key` and returns how many hits fall in the current
|
|
632
|
+
* `windowMs` window (this one included). The default store is in-memory
|
|
633
|
+
* (fixed window). Provide a distributed store (e.g. Redis/Upstash) when
|
|
634
|
+
* running multiple instances or serverless — an in-memory count is per
|
|
635
|
+
* instance and resets on cold start, so it only bounds a single instance.
|
|
636
|
+
*/
|
|
637
|
+
hit(key: string, windowMs: number, now: number): number | Promise<number>;
|
|
638
|
+
};
|
|
639
|
+
type ABTestRateLimitOptions = {
|
|
640
|
+
/** Max `/abTest/trackEvent` requests allowed per `windowMs` per key. */
|
|
641
|
+
limit: number;
|
|
642
|
+
/** Window length in milliseconds. */
|
|
643
|
+
windowMs: number;
|
|
644
|
+
/**
|
|
645
|
+
* Derive the rate-limit key from the request. Default {@link defaultRateLimitKey}:
|
|
646
|
+
* the trusted client IP (rightmost `x-forwarded-for` hop, else `x-real-ip`).
|
|
647
|
+
* Two caveats worth knowing:
|
|
648
|
+
* - The default assumes ONE trusted appending proxy (Vercel/most CDNs). Behind
|
|
649
|
+
* multiple proxies, or a proxy that does not append, override this.
|
|
650
|
+
* - Keying on IP means a shared egress (CGNAT / corporate NAT) shares one
|
|
651
|
+
* budget; size `limit` for the busiest legitimate egress, not a single user.
|
|
652
|
+
* Return null to SKIP limiting a request (the default does so when no proxy
|
|
653
|
+
* header is present — see {@link defaultRateLimitKey}).
|
|
654
|
+
*/
|
|
655
|
+
getKey?: (request: Request) => string | null;
|
|
656
|
+
/** Counter store. Default: in-memory fixed-window (per instance). */
|
|
657
|
+
store?: RateLimitStore;
|
|
658
|
+
};
|
|
659
|
+
/**
|
|
660
|
+
* In-memory fixed-window counter. Memory is HARD-bounded at `maxKeys`: when a
|
|
661
|
+
* new key would exceed the cap, the oldest-inserted entry is evicted in O(1)
|
|
662
|
+
* (Map preserves insertion order) — so even a within-window flood of DISTINCT
|
|
663
|
+
* keys (e.g. an IP-rotating attacker) cannot grow the map past `maxKeys`, and
|
|
664
|
+
* there is no O(maxKeys) scan on the hot path. A live key being hit again is
|
|
665
|
+
* O(1) and never triggers eviction. Eviction can reset an old key's window
|
|
666
|
+
* under a flood (the standard bounded-limiter tradeoff). Per-instance only (see
|
|
667
|
+
* the module header) — inject a distributed store for multi-instance/serverless.
|
|
668
|
+
*/
|
|
669
|
+
declare function createInMemoryRateLimitStore(maxKeys?: number): RateLimitStore;
|
|
670
|
+
/**
|
|
671
|
+
* Default rate-limit key: the trusted client IP.
|
|
672
|
+
*
|
|
673
|
+
* `x-forwarded-for` is a client→proxy→…→server CHAIN. An appending proxy
|
|
674
|
+
* (Vercel, most CDNs) appends the real connecting IP as the LAST entry; the
|
|
675
|
+
* FIRST entry is whatever the client sent and is trivially spoofable. So we take
|
|
676
|
+
* the RIGHTMOST entry, never the first — using the leftmost would let an
|
|
677
|
+
* attacker rotate `x-forwarded-for` to mint a fresh bucket per request and evade
|
|
678
|
+
* the limit entirely. (This assumes ONE trusted appending proxy; behind multiple
|
|
679
|
+
* proxies or a non-appending one, override `getKey`.) Falls back to `x-real-ip`
|
|
680
|
+
* (set by nginx/Vercel to the connecting IP).
|
|
681
|
+
*
|
|
682
|
+
* Returns null when neither header is present — the caller then does NOT limit
|
|
683
|
+
* (fail-open). NOTE: a deployment NOT behind a proxy (a directly-exposed server
|
|
684
|
+
* with no `x-forwarded-for`/`x-real-ip`) therefore gets NO limiting from this
|
|
685
|
+
* default — provide a `getKey` that reads your real client IP.
|
|
686
|
+
*/
|
|
687
|
+
declare function defaultRateLimitKey(request: Request): string | null;
|
|
688
|
+
/**
|
|
689
|
+
* Enforces the ingest rate-limit for one request. Returns a 429 Response to
|
|
690
|
+
* short-circuit when the limit is exceeded, or null to let the request proceed.
|
|
691
|
+
* No-ops (null) when the key cannot be resolved. The caller (plugin onRequest)
|
|
692
|
+
* binds this to POST `/abTest/trackEvent`. `store` is created ONCE per plugin
|
|
693
|
+
* instance (never per request) so the window survives across requests.
|
|
694
|
+
*/
|
|
695
|
+
declare function enforceTrackEventRateLimit(request: Request, options: ABTestRateLimitOptions, store: RateLimitStore, now?: number): Promise<Response | null>;
|
|
696
|
+
|
|
697
|
+
type PrivacyNoticeItem = {
|
|
698
|
+
name: string;
|
|
699
|
+
type: 'cookie' | 'sessionStorage' | 'localStorage' | 'external-cookie-read';
|
|
700
|
+
purpose: string;
|
|
701
|
+
lifetime: string;
|
|
702
|
+
/** Whether the value can identify/re-identify a visitor. */
|
|
703
|
+
isIdentifier: boolean;
|
|
704
|
+
/** The consent purpose gating it, or null when strictly-necessary (no consent). */
|
|
705
|
+
consentRequired: ConsentPurpose | null;
|
|
706
|
+
recipient: string;
|
|
707
|
+
};
|
|
708
|
+
/**
|
|
709
|
+
* The A/B measurement privacy-notice items. The `_ga` read is ALWAYS listed: the
|
|
710
|
+
* client reads `_ga` whenever `analytics_storage` is granted (to obtain the GA4
|
|
711
|
+
* client_id), independent of server-MP. Pass `ga4: true` when the server-MP
|
|
712
|
+
* forward (M5) is configured — it only changes the `_ga` recipient/purpose to
|
|
713
|
+
* name Google Analytics 4 as the destination of the forwarded hit.
|
|
714
|
+
* `variantCookiePrefix` must match the middleware's `variantCookiePrefix`.
|
|
715
|
+
*/
|
|
716
|
+
declare function getPrivacyNoticeItems(options?: {
|
|
717
|
+
ga4?: boolean;
|
|
718
|
+
variantCookiePrefix?: string;
|
|
719
|
+
}): PrivacyNoticeItem[];
|
|
720
|
+
|
|
721
|
+
declare const $ERROR_CODES: {
|
|
722
|
+
readonly AB_TEST_NOT_FOUND: {
|
|
723
|
+
readonly status: 404;
|
|
724
|
+
readonly message: "A/B test not found";
|
|
725
|
+
};
|
|
726
|
+
readonly AB_TEST_INVALID_STATUS: {
|
|
727
|
+
readonly status: 400;
|
|
728
|
+
readonly message: "Invalid status transition for this A/B test";
|
|
729
|
+
};
|
|
730
|
+
readonly AB_TEST_WEIGHTS_INVALID: {
|
|
731
|
+
readonly status: 400;
|
|
732
|
+
readonly message: "Variant weights must sum to 100";
|
|
733
|
+
};
|
|
734
|
+
readonly AB_TEST_DUPLICATE_RUNNING: {
|
|
735
|
+
readonly status: 409;
|
|
736
|
+
readonly message: "Another test is already running for this root";
|
|
737
|
+
};
|
|
738
|
+
readonly AB_TEST_CROSS_EMBED_CONFLICT: {
|
|
739
|
+
readonly status: 409;
|
|
740
|
+
readonly 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";
|
|
741
|
+
};
|
|
742
|
+
readonly AB_TEST_BRANCH_NOT_PUBLISHED: {
|
|
743
|
+
readonly status: 400;
|
|
744
|
+
readonly message: "All variant branches must be published";
|
|
745
|
+
};
|
|
746
|
+
readonly AB_TEST_NO_CONTEXT: {
|
|
747
|
+
readonly status: 400;
|
|
748
|
+
readonly message: "No visitor context set. Call identify() first.";
|
|
749
|
+
};
|
|
750
|
+
readonly AB_TEST_FLUSH_NOT_SUPPORTED: {
|
|
751
|
+
readonly status: 400;
|
|
752
|
+
readonly message: "Flush is not supported by the current analytics adapter";
|
|
753
|
+
};
|
|
754
|
+
readonly AB_TEST_VARIANT_NOT_FOUND: {
|
|
755
|
+
readonly status: 404;
|
|
756
|
+
readonly message: "A/B test variant not found";
|
|
757
|
+
};
|
|
758
|
+
readonly AB_TEST_TRACKING_ID_MISSING: {
|
|
759
|
+
readonly status: 400;
|
|
760
|
+
readonly 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";
|
|
761
|
+
};
|
|
762
|
+
readonly AB_TEST_TRACKING_ID_DUPLICATE: {
|
|
763
|
+
readonly status: 400;
|
|
764
|
+
readonly message: "Duplicate trackingId in this branch — each functional block must have a unique trackingId";
|
|
765
|
+
};
|
|
766
|
+
readonly AB_TEST_TRACKING_ID_DRIFT: {
|
|
767
|
+
readonly status: 409;
|
|
768
|
+
readonly 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";
|
|
769
|
+
};
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
type ABTestPluginOptions = {
|
|
773
|
+
analytics?: ABTestAnalyticsAdapter;
|
|
774
|
+
/**
|
|
775
|
+
* Opt-in server-side GA4 forwarding (M5). When set, each consenting,
|
|
776
|
+
* client_id-bearing event is POSTed to your GA4 Measurement Protocol / sGTM
|
|
777
|
+
* endpoint server-side (ad-blocker-resistant). Omit to use only the
|
|
778
|
+
* client-side dataLayer path. See {@link GA4ServerConfig}.
|
|
779
|
+
*/
|
|
780
|
+
ga4?: GA4ServerConfig;
|
|
781
|
+
/**
|
|
782
|
+
* Opt-in rate-limit for the anonymous `/abTest/trackEvent` ingest — the one
|
|
783
|
+
* unauthenticated write path. Strongly recommended before production: an open
|
|
784
|
+
* ingest can skew the A/B aggregate, bloat the events table, and (with `ga4`)
|
|
785
|
+
* amplify into outbound GA4 POSTs. Default key = client IP; default counter =
|
|
786
|
+
* in-memory (per instance — inject a distributed `store` for serverless /
|
|
787
|
+
* multi-instance). See {@link ABTestRateLimitOptions}.
|
|
788
|
+
*/
|
|
789
|
+
rateLimit?: ABTestRateLimitOptions;
|
|
790
|
+
};
|
|
791
|
+
declare function abTest(options?: ABTestPluginOptions): {
|
|
792
|
+
id: "abTest";
|
|
793
|
+
schema: SchemaModule<"cms", {}, {}, {}>;
|
|
794
|
+
endpoints: {
|
|
795
|
+
createTest: better_call.Endpoint<"/abTest/createTest", "POST", {
|
|
796
|
+
rootId: string;
|
|
797
|
+
collection: string;
|
|
798
|
+
name: string;
|
|
799
|
+
trafficPercentage?: number;
|
|
800
|
+
goalHandle?: string;
|
|
801
|
+
goalEvent?: string;
|
|
802
|
+
variants: Array<{
|
|
803
|
+
branchId: string;
|
|
804
|
+
name: string;
|
|
805
|
+
weight: number;
|
|
806
|
+
isControl?: boolean;
|
|
807
|
+
}>;
|
|
808
|
+
}, Record<string, any> | undefined, [], {
|
|
809
|
+
testId: string;
|
|
810
|
+
}, {
|
|
811
|
+
$Infer: {
|
|
812
|
+
body: {
|
|
813
|
+
rootId: string;
|
|
814
|
+
collection: string;
|
|
815
|
+
name: string;
|
|
816
|
+
trafficPercentage?: number;
|
|
817
|
+
goalHandle?: string;
|
|
818
|
+
goalEvent?: string;
|
|
819
|
+
variants: Array<{
|
|
820
|
+
branchId: string;
|
|
821
|
+
name: string;
|
|
822
|
+
weight: number;
|
|
823
|
+
isControl?: boolean;
|
|
824
|
+
}>;
|
|
825
|
+
};
|
|
826
|
+
};
|
|
827
|
+
cms: CMSEndpointMeta;
|
|
828
|
+
}, undefined>;
|
|
829
|
+
updateTest: better_call.Endpoint<"/abTest/updateTest", "POST", {
|
|
830
|
+
testId: string;
|
|
831
|
+
name?: string;
|
|
832
|
+
status?: "draft" | "running" | "paused" | "completed";
|
|
833
|
+
trafficPercentage?: number;
|
|
834
|
+
goalHandle?: string | null;
|
|
835
|
+
goalEvent?: string | null;
|
|
836
|
+
variants?: Array<{
|
|
837
|
+
branchId: string;
|
|
838
|
+
name: string;
|
|
839
|
+
weight: number;
|
|
840
|
+
isControl?: boolean;
|
|
841
|
+
}>;
|
|
842
|
+
}, Record<string, any> | undefined, [], {
|
|
843
|
+
testId: string;
|
|
844
|
+
}, {
|
|
845
|
+
$Infer: {
|
|
846
|
+
body: {
|
|
847
|
+
testId: string;
|
|
848
|
+
name?: string;
|
|
849
|
+
status?: "draft" | "running" | "paused" | "completed";
|
|
850
|
+
trafficPercentage?: number;
|
|
851
|
+
goalHandle?: string | null;
|
|
852
|
+
goalEvent?: string | null;
|
|
853
|
+
variants?: Array<{
|
|
854
|
+
branchId: string;
|
|
855
|
+
name: string;
|
|
856
|
+
weight: number;
|
|
857
|
+
isControl?: boolean;
|
|
858
|
+
}>;
|
|
859
|
+
};
|
|
860
|
+
};
|
|
861
|
+
cms: CMSEndpointMeta;
|
|
862
|
+
}, undefined>;
|
|
863
|
+
deleteTest: better_call.Endpoint<"/abTest/deleteTest", "POST", {
|
|
864
|
+
testId: string;
|
|
865
|
+
}, Record<string, any> | undefined, [], {
|
|
866
|
+
testId: string;
|
|
867
|
+
}, {
|
|
868
|
+
$Infer: {
|
|
869
|
+
body: {
|
|
870
|
+
testId: string;
|
|
871
|
+
};
|
|
872
|
+
};
|
|
873
|
+
cms: CMSEndpointMeta;
|
|
874
|
+
}, undefined>;
|
|
875
|
+
getTest: better_call.Endpoint<"/abTest/getTest", "GET", undefined, {
|
|
876
|
+
testId: string;
|
|
877
|
+
}, [], {
|
|
878
|
+
variants: {
|
|
879
|
+
id: string;
|
|
880
|
+
branchId: string;
|
|
881
|
+
name: string;
|
|
882
|
+
weight: number;
|
|
883
|
+
isControl: boolean;
|
|
884
|
+
}[];
|
|
885
|
+
id: string;
|
|
886
|
+
rootId: string;
|
|
887
|
+
collection: string;
|
|
888
|
+
name: string;
|
|
889
|
+
goalHandle: string | null;
|
|
890
|
+
goalEvent: string | null;
|
|
891
|
+
status: string;
|
|
892
|
+
trafficPercentage: number;
|
|
893
|
+
startedAt: string | null;
|
|
894
|
+
endedAt: string | null;
|
|
895
|
+
createdBy: string | null;
|
|
896
|
+
createdAt: string;
|
|
897
|
+
updatedAt: string;
|
|
898
|
+
}, {
|
|
899
|
+
$Infer: {
|
|
900
|
+
query: {
|
|
901
|
+
testId: string;
|
|
902
|
+
};
|
|
903
|
+
};
|
|
904
|
+
cms: CMSEndpointMeta;
|
|
905
|
+
}, undefined>;
|
|
906
|
+
/**
|
|
907
|
+
* M4 goal-picker: the pickable A/B goals for a root. Reads each block type's
|
|
908
|
+
* declared `events` (off the collection definitions) for the blocks present
|
|
909
|
+
* in the root's published tree, returning one candidate per (functional block
|
|
910
|
+
* instance × event). Candidates in the tested root's own tree are
|
|
911
|
+
* `inVaryingRoot: true`; candidates in embedded reusable blocks are
|
|
912
|
+
* `inVaryingRoot: false` (§6g attribution caution). The `name` is the
|
|
913
|
+
* resolved wire name (the same string fire() stores as event_type), so the
|
|
914
|
+
* UI-pickable goals are exactly the code-fireable events.
|
|
915
|
+
*/
|
|
916
|
+
listGoalEvents: better_call.Endpoint<"/abTest/listGoalEvents", "GET", undefined, {
|
|
917
|
+
rootId: string;
|
|
918
|
+
}, [], {
|
|
919
|
+
rootId: string;
|
|
920
|
+
goals: GoalCandidate[];
|
|
921
|
+
}, {
|
|
922
|
+
$Infer: {
|
|
923
|
+
query: {
|
|
924
|
+
rootId: string;
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
cms: CMSEndpointMeta;
|
|
928
|
+
}, undefined>;
|
|
929
|
+
listTests: better_call.Endpoint<"/abTest/listTests", "GET", undefined, {
|
|
930
|
+
collection?: string;
|
|
931
|
+
status?: string;
|
|
932
|
+
limit?: number;
|
|
933
|
+
offset?: number;
|
|
934
|
+
}, [], {
|
|
935
|
+
tests: {
|
|
936
|
+
id: string;
|
|
937
|
+
rootId: string;
|
|
938
|
+
collection: string;
|
|
939
|
+
name: string;
|
|
940
|
+
goalHandle: string | null;
|
|
941
|
+
goalEvent: string | null;
|
|
942
|
+
status: string;
|
|
943
|
+
trafficPercentage: number;
|
|
944
|
+
startedAt: string | null;
|
|
945
|
+
endedAt: string | null;
|
|
946
|
+
createdBy: string | null;
|
|
947
|
+
createdAt: string;
|
|
948
|
+
updatedAt: string;
|
|
949
|
+
}[];
|
|
950
|
+
total: number;
|
|
951
|
+
hasMore: boolean;
|
|
952
|
+
}, {
|
|
953
|
+
$Infer: {
|
|
954
|
+
query: {
|
|
955
|
+
collection?: string;
|
|
956
|
+
status?: string;
|
|
957
|
+
limit?: number;
|
|
958
|
+
offset?: number;
|
|
959
|
+
};
|
|
960
|
+
};
|
|
961
|
+
cms: CMSEndpointMeta;
|
|
962
|
+
}, undefined>;
|
|
963
|
+
assignVariant: better_call.Endpoint<"/abTest/assignVariant", "POST", {
|
|
964
|
+
testId: string;
|
|
965
|
+
context: ABTestContext;
|
|
966
|
+
}, Record<string, any> | undefined, [], {
|
|
967
|
+
variantId: string;
|
|
968
|
+
branchId: string;
|
|
969
|
+
inTest: boolean;
|
|
970
|
+
}, {
|
|
971
|
+
$Infer: {
|
|
972
|
+
body: {
|
|
973
|
+
testId: string;
|
|
974
|
+
context: ABTestContext;
|
|
975
|
+
};
|
|
976
|
+
};
|
|
977
|
+
cms: CMSEndpointMeta;
|
|
978
|
+
}, undefined>;
|
|
979
|
+
trackEvent: better_call.Endpoint<"/abTest/trackEvent", "POST", {
|
|
980
|
+
testId?: string;
|
|
981
|
+
variantId?: string;
|
|
982
|
+
branchId?: string;
|
|
983
|
+
visitorId?: string;
|
|
984
|
+
anonymous?: boolean;
|
|
985
|
+
eventType: string;
|
|
986
|
+
metadata?: Record<string, unknown>;
|
|
987
|
+
source?: {
|
|
988
|
+
handle?: string;
|
|
989
|
+
type?: string;
|
|
990
|
+
};
|
|
991
|
+
interactionId?: string;
|
|
992
|
+
transport?: {
|
|
993
|
+
clientId?: string;
|
|
994
|
+
sessionId?: string;
|
|
995
|
+
engagementTimeMsec?: number;
|
|
996
|
+
};
|
|
997
|
+
consent?: ConsentState;
|
|
998
|
+
}, Record<string, any> | undefined, [], {}, {
|
|
999
|
+
$Infer: {
|
|
1000
|
+
body: {
|
|
1001
|
+
testId?: string;
|
|
1002
|
+
variantId?: string;
|
|
1003
|
+
branchId?: string;
|
|
1004
|
+
visitorId?: string;
|
|
1005
|
+
anonymous?: boolean;
|
|
1006
|
+
eventType: string;
|
|
1007
|
+
metadata?: Record<string, unknown>;
|
|
1008
|
+
source?: {
|
|
1009
|
+
handle?: string;
|
|
1010
|
+
type?: string;
|
|
1011
|
+
};
|
|
1012
|
+
interactionId?: string;
|
|
1013
|
+
transport?: {
|
|
1014
|
+
clientId?: string;
|
|
1015
|
+
sessionId?: string;
|
|
1016
|
+
engagementTimeMsec?: number;
|
|
1017
|
+
};
|
|
1018
|
+
consent?: ConsentState;
|
|
1019
|
+
};
|
|
1020
|
+
};
|
|
1021
|
+
cms: CMSEndpointMeta;
|
|
1022
|
+
}, undefined>;
|
|
1023
|
+
getResults: better_call.Endpoint<"/abTest/getResults", "GET", undefined, {
|
|
1024
|
+
testId: string;
|
|
1025
|
+
from?: Date;
|
|
1026
|
+
to?: Date;
|
|
1027
|
+
}, [], AggregatedResults, {
|
|
1028
|
+
$Infer: {
|
|
1029
|
+
query: {
|
|
1030
|
+
testId: string;
|
|
1031
|
+
from?: Date;
|
|
1032
|
+
to?: Date;
|
|
1033
|
+
};
|
|
1034
|
+
};
|
|
1035
|
+
cms: CMSEndpointMeta;
|
|
1036
|
+
}, undefined>;
|
|
1037
|
+
flushEvents: better_call.Endpoint<"/abTest/flushEvents", "POST", {
|
|
1038
|
+
testId?: string;
|
|
1039
|
+
}, Record<string, any> | undefined, [], {
|
|
1040
|
+
flushed: number;
|
|
1041
|
+
}, {
|
|
1042
|
+
$Infer: {
|
|
1043
|
+
body: {
|
|
1044
|
+
testId?: string;
|
|
1045
|
+
};
|
|
1046
|
+
};
|
|
1047
|
+
cms: CMSEndpointMeta;
|
|
1048
|
+
}, undefined>;
|
|
1049
|
+
};
|
|
1050
|
+
collectionEndpoints: (def: CollectionWithName) => {
|
|
1051
|
+
resolveAbVariant: better_call.Endpoint<`/${string}/resolveAbVariant`, "GET", undefined, {
|
|
1052
|
+
path: string;
|
|
1053
|
+
}, [], AbResolveResult, {
|
|
1054
|
+
$Infer: {
|
|
1055
|
+
query: {
|
|
1056
|
+
path: string;
|
|
1057
|
+
};
|
|
1058
|
+
};
|
|
1059
|
+
cms: CMSEndpointMeta;
|
|
1060
|
+
}, undefined>;
|
|
1061
|
+
};
|
|
1062
|
+
$ERROR_CODES: {
|
|
1063
|
+
readonly AB_TEST_NOT_FOUND: {
|
|
1064
|
+
readonly status: 404;
|
|
1065
|
+
readonly message: "A/B test not found";
|
|
1066
|
+
};
|
|
1067
|
+
readonly AB_TEST_INVALID_STATUS: {
|
|
1068
|
+
readonly status: 400;
|
|
1069
|
+
readonly message: "Invalid status transition for this A/B test";
|
|
1070
|
+
};
|
|
1071
|
+
readonly AB_TEST_WEIGHTS_INVALID: {
|
|
1072
|
+
readonly status: 400;
|
|
1073
|
+
readonly message: "Variant weights must sum to 100";
|
|
1074
|
+
};
|
|
1075
|
+
readonly AB_TEST_DUPLICATE_RUNNING: {
|
|
1076
|
+
readonly status: 409;
|
|
1077
|
+
readonly message: "Another test is already running for this root";
|
|
1078
|
+
};
|
|
1079
|
+
readonly AB_TEST_CROSS_EMBED_CONFLICT: {
|
|
1080
|
+
readonly status: 409;
|
|
1081
|
+
readonly 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";
|
|
1082
|
+
};
|
|
1083
|
+
readonly AB_TEST_BRANCH_NOT_PUBLISHED: {
|
|
1084
|
+
readonly status: 400;
|
|
1085
|
+
readonly message: "All variant branches must be published";
|
|
1086
|
+
};
|
|
1087
|
+
readonly AB_TEST_NO_CONTEXT: {
|
|
1088
|
+
readonly status: 400;
|
|
1089
|
+
readonly message: "No visitor context set. Call identify() first.";
|
|
1090
|
+
};
|
|
1091
|
+
readonly AB_TEST_FLUSH_NOT_SUPPORTED: {
|
|
1092
|
+
readonly status: 400;
|
|
1093
|
+
readonly message: "Flush is not supported by the current analytics adapter";
|
|
1094
|
+
};
|
|
1095
|
+
readonly AB_TEST_VARIANT_NOT_FOUND: {
|
|
1096
|
+
readonly status: 404;
|
|
1097
|
+
readonly message: "A/B test variant not found";
|
|
1098
|
+
};
|
|
1099
|
+
readonly AB_TEST_TRACKING_ID_MISSING: {
|
|
1100
|
+
readonly status: 400;
|
|
1101
|
+
readonly 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";
|
|
1102
|
+
};
|
|
1103
|
+
readonly AB_TEST_TRACKING_ID_DUPLICATE: {
|
|
1104
|
+
readonly status: 400;
|
|
1105
|
+
readonly message: "Duplicate trackingId in this branch — each functional block must have a unique trackingId";
|
|
1106
|
+
};
|
|
1107
|
+
readonly AB_TEST_TRACKING_ID_DRIFT: {
|
|
1108
|
+
readonly status: 409;
|
|
1109
|
+
readonly 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";
|
|
1110
|
+
};
|
|
1111
|
+
};
|
|
1112
|
+
hooks: {
|
|
1113
|
+
before: {
|
|
1114
|
+
action: "publishBranch";
|
|
1115
|
+
handler: (ctx: CMSBeforeHookContext) => Promise<void>;
|
|
1116
|
+
}[];
|
|
1117
|
+
};
|
|
1118
|
+
init(ctx: CMSPluginContext): Promise<{
|
|
1119
|
+
context: {
|
|
1120
|
+
scopeConditions: (() => {
|
|
1121
|
+
abTestResolver: AbTestResolver;
|
|
1122
|
+
})[];
|
|
1123
|
+
};
|
|
1124
|
+
}>;
|
|
1125
|
+
onRequest(request: Request, _ctx: CMSPluginContext): Promise<{
|
|
1126
|
+
response: any;
|
|
1127
|
+
} | undefined>;
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
export { $ERROR_CODES, abTest, buildGa4Payload, createInMemoryRateLimitStore, defaultRateLimitKey, enforceTrackEventRateLimit, forwardToGa4, getPrivacyNoticeItems, postgresAnalytics };
|
|
1131
|
+
export type { ABTestAnalyticsAdapter, ABTestContext, ABTestEvent, ABTestPluginOptions, ABTestRateLimitOptions, AggregatedResults, AggregatedVariantResult, CMSEvent, CMSEventSource, GA4Payload, GA4ServerConfig, LiveDelta, PrivacyNoticeItem, RateLimitStore };
|