@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,431 @@
|
|
|
1
|
+
import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
|
|
2
|
+
import { AnyPgTable, PgDatabase } from 'drizzle-orm/pg-core';
|
|
3
|
+
import { AnyColumn, SQL } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
type ResolvedUserConfig = {
|
|
6
|
+
table: AnyPgTable;
|
|
7
|
+
tableName: string;
|
|
8
|
+
schemaName: string | null;
|
|
9
|
+
idColumn: AnyColumn;
|
|
10
|
+
/** The camelCase key used in the Drizzle table definition (e.g. "id"). */
|
|
11
|
+
idColumnKey: string;
|
|
12
|
+
/** The actual database column name (e.g. "id" or "user_id"). */
|
|
13
|
+
idColumnDbName: string;
|
|
14
|
+
allColumns: Record<string, AnyColumn>;
|
|
15
|
+
/** Allowlist (camelCase keys) of columns exposable via `withUser`. */
|
|
16
|
+
exposeColumns: string[];
|
|
17
|
+
sqlTableRef: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type DrizzleInstance = PgDatabase<any, Record<string, unknown>, any>;
|
|
21
|
+
|
|
22
|
+
declare const notificationTypeEnum: drizzle_orm_pg_core.PgEnum<["mention", "comment", "threadResolved", "approvalRequested", "approvalApproved", "approvalRejected", "mergeRequestOpened", "mergeRequestMerged", "mergeRequestClosed", "mergeRequestReopened", "published", "custom"]>;
|
|
23
|
+
|
|
24
|
+
type NotificationType = (typeof notificationTypeEnum.enumValues)[number];
|
|
25
|
+
type NotificationPayload = {
|
|
26
|
+
id: string;
|
|
27
|
+
recipientId: string;
|
|
28
|
+
actorId: string | null;
|
|
29
|
+
type: NotificationType;
|
|
30
|
+
title: string;
|
|
31
|
+
body: string | null;
|
|
32
|
+
resourceType: string | null;
|
|
33
|
+
resourceId: string | null;
|
|
34
|
+
collection: string | null;
|
|
35
|
+
meta: Record<string, unknown> | null;
|
|
36
|
+
createdAt: Date;
|
|
37
|
+
};
|
|
38
|
+
type OnNotificationHandler = (notification: NotificationPayload) => void | Promise<void>;
|
|
39
|
+
type NotificationInput = Omit<NotificationPayload, 'id' | 'createdAt'>;
|
|
40
|
+
|
|
41
|
+
type NotificationService = ReturnType<typeof createNotificationService>;
|
|
42
|
+
declare function createNotificationService(db: DrizzleInstance, handlers: OnNotificationHandler[]): {
|
|
43
|
+
notify(input: NotificationInput): Promise<NotificationPayload>;
|
|
44
|
+
notifyMany(inputs: NotificationInput[]): Promise<NotificationPayload[]>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Per-request scope produced by a ScopeConditionFactory.
|
|
49
|
+
* `where` — appended to SELECT/UPDATE/DELETE queries.
|
|
50
|
+
* `insertColumns` — snake_case column name → value pairs merged directly
|
|
51
|
+
* into the raw SQL INSERT via `scopedInsert` / `scopedInsertBatch`.
|
|
52
|
+
*/
|
|
53
|
+
type TableScope = {
|
|
54
|
+
where?: SQL;
|
|
55
|
+
insertColumns?: Record<string, unknown>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* `roots` scope additionally supports a per-NEW-ENTRY column contributor: a
|
|
59
|
+
* plugin can compute fresh insert columns once per newly-created logical entry
|
|
60
|
+
* (e.g. a freshly minted translation-group id), which the static `insertColumns`
|
|
61
|
+
* channel (same value on every row) can't express. Called once per
|
|
62
|
+
* createRoot / root-duplication. Generic — core names no column. (Seam D.)
|
|
63
|
+
*/
|
|
64
|
+
type RootTableScope = TableScope & {
|
|
65
|
+
newEntryColumns?: () => Record<string, unknown>;
|
|
66
|
+
/**
|
|
67
|
+
* Scope columns to EXCLUDE from cross-scope read filtering — columns the
|
|
68
|
+
* plugin varies INDEPENDENTLY of a query so that cross-scope reads (a
|
|
69
|
+
* reference/host/usage that legitimately spans them) must not filter on them.
|
|
70
|
+
* The i18n plugin declares `['language']` (a host/reference in any sibling
|
|
71
|
+
* language still counts; the read path already resolved a specific sibling).
|
|
72
|
+
* Generic — core names no column; passed to `rootScopeConditions` as its
|
|
73
|
+
* `exclude`. Empty/absent → every scope column filters. (Seam D6.)
|
|
74
|
+
*/
|
|
75
|
+
crossScopeExclude?: readonly string[];
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* A plugin-provided resolver for reference values (rootId / group-key strings),
|
|
79
|
+
* carried on the resolved scope and consumed by the read path and the A/B
|
|
80
|
+
* co-render walk. Core ships an IDENTITY default (`coreReferenceResolver`)
|
|
81
|
+
* reproducing the single-language, no-plugin behaviour byte-for-byte; the i18n
|
|
82
|
+
* plugin supplies a real one that understands translation groups + the fallback
|
|
83
|
+
* chain. Core never names any i18n concept — it knows only this interface.
|
|
84
|
+
*
|
|
85
|
+
* `db` AND `scopeColumns` are passed PER CALL (not closed over): `db` so a
|
|
86
|
+
* caller inside a transaction (e.g. the A/B →running guard under FOR UPDATE)
|
|
87
|
+
* resolves against its own tx handle; `scopeColumns` because the MERGED root
|
|
88
|
+
* scope columns (tenant + language) exist only AFTER every scope factory has
|
|
89
|
+
* run — the i18n factory that builds the resolver sees only its OWN column at
|
|
90
|
+
* build time. The resolver therefore closes over just its resolution POLICY
|
|
91
|
+
* (e.g. the i18n active language + fallback chain). `scopeColumns` is the
|
|
92
|
+
* scope predicate; the resolver excludes its own cross-scope columns.
|
|
93
|
+
* (Seam B.)
|
|
94
|
+
*/
|
|
95
|
+
type ReferenceResolver = {
|
|
96
|
+
/**
|
|
97
|
+
* Read-time render pick: stored reference value → the ONE rootId it renders
|
|
98
|
+
* as (omit a key to leave it unresolved). Identity default: `value → value`.
|
|
99
|
+
*/
|
|
100
|
+
resolveRenderTargets(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, collection: string, storedValues: string[]): Promise<Map<string, string>>;
|
|
101
|
+
/**
|
|
102
|
+
* Conflict superset: stored reference keys → ALL rootIds they could render as
|
|
103
|
+
* (a group key expands to its whole group). Used by the A/B co-render walk;
|
|
104
|
+
* collection-agnostic (a reference may target any collection). Identity
|
|
105
|
+
* default: the existing, non-archived roots among `storedKeys` (by id).
|
|
106
|
+
*/
|
|
107
|
+
resolveConflictTargets(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, storedKeys: string[]): Promise<string[]>;
|
|
108
|
+
/** rootIds → all their group siblings. Identity default: the input rootIds. */
|
|
109
|
+
expandGroup(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, rootIds: string[]): Promise<string[]>;
|
|
110
|
+
/** rootIds → the group keys a host could embed them by. Default: `[]`. */
|
|
111
|
+
groupKeysFor(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, rootIds: string[]): Promise<string[]>;
|
|
112
|
+
};
|
|
113
|
+
/** One variant branch of a running A/B test on a referenced root. */
|
|
114
|
+
type RunningAbTestVariant = {
|
|
115
|
+
branchId: string;
|
|
116
|
+
isControl: boolean;
|
|
117
|
+
};
|
|
118
|
+
/** A running A/B test on one root: the test plus its variant branches. */
|
|
119
|
+
type RunningAbTest = {
|
|
120
|
+
testId: string;
|
|
121
|
+
trafficPercentage: number;
|
|
122
|
+
variants: RunningAbTestVariant[];
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* A plugin-provided resolver that reports which referenced roots currently have
|
|
126
|
+
* a RUNNING A/B test (with that test's variant branches). Carried on the
|
|
127
|
+
* resolved scope and consumed by the read path's reference loader to fan the one
|
|
128
|
+
* XOR-guaranteed varying block's branches out to the client (AB_FANOUT F2). Core
|
|
129
|
+
* ships NO default — when absent (no ab-test plugin) the read path assumes no
|
|
130
|
+
* running tests and every embed stays on its deterministic single pick (F0).
|
|
131
|
+
* Core never names any A/B concept beyond this interface. (Seam F.)
|
|
132
|
+
*/
|
|
133
|
+
type AbTestResolver = {
|
|
134
|
+
/**
|
|
135
|
+
* The subset of `rootIds` that have a running test, each mapped to its test +
|
|
136
|
+
* variant branches. `db` AND `scopeColumns` are passed PER CALL (same
|
|
137
|
+
* rationale as {@link ReferenceResolver}). The caller passes already
|
|
138
|
+
* render-resolved (active-language) rootIds, so this needs no group expansion.
|
|
139
|
+
*/
|
|
140
|
+
runningTests(db: DrizzleInstance, scopeColumns: Record<string, unknown> | undefined, rootIds: string[]): Promise<Map<string, RunningAbTest>>;
|
|
141
|
+
};
|
|
142
|
+
type ResolvedScope = {
|
|
143
|
+
roots?: RootTableScope;
|
|
144
|
+
assets?: TableScope;
|
|
145
|
+
assetFolders?: TableScope;
|
|
146
|
+
redirects?: TableScope;
|
|
147
|
+
/**
|
|
148
|
+
* Plugin-provided reference resolver (i18n translation-group resolution). When
|
|
149
|
+
* absent, callers use core's identity default. Generic — see `ReferenceResolver`.
|
|
150
|
+
*/
|
|
151
|
+
referenceResolver?: ReferenceResolver;
|
|
152
|
+
/**
|
|
153
|
+
* Plugin-provided running-A/B-test resolver (AB_FANOUT F2 server fan-out).
|
|
154
|
+
* When absent, the read path assumes no running tests. Generic — see
|
|
155
|
+
* {@link AbTestResolver}.
|
|
156
|
+
*/
|
|
157
|
+
abTestResolver?: AbTestResolver;
|
|
158
|
+
/**
|
|
159
|
+
* Opaque per-plugin context slots, keyed by plugin id. Core never reads it;
|
|
160
|
+
* each plugin stashes its own per-request context here from a scope factory
|
|
161
|
+
* and reads it back via its own exported accessor. Merged generically in
|
|
162
|
+
* computeScope (shallow, last-writer-wins per slot).
|
|
163
|
+
*/
|
|
164
|
+
pluginContext?: Record<string, unknown>;
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Factory registered by plugins during `init`.
|
|
168
|
+
* Called once per request with the middleware result to produce
|
|
169
|
+
* table-level WHERE conditions and extra INSERT values.
|
|
170
|
+
*/
|
|
171
|
+
type ScopeConditionFactory = (mwResult: MiddlewareResult) => ResolvedScope;
|
|
172
|
+
type BlockTypes = {
|
|
173
|
+
string: string;
|
|
174
|
+
number: number;
|
|
175
|
+
boolean: boolean;
|
|
176
|
+
date: string;
|
|
177
|
+
richText: string;
|
|
178
|
+
image: string;
|
|
179
|
+
select: string;
|
|
180
|
+
reference: string;
|
|
181
|
+
};
|
|
182
|
+
type BlockPropertyType = keyof BlockTypes;
|
|
183
|
+
type SelectOption = {
|
|
184
|
+
readonly label: string;
|
|
185
|
+
readonly value: string;
|
|
186
|
+
};
|
|
187
|
+
type BlockPropertySpec<T extends BlockPropertyType> = {
|
|
188
|
+
type: T;
|
|
189
|
+
required?: boolean;
|
|
190
|
+
defaultValue?: BlockTypes[T];
|
|
191
|
+
label: string;
|
|
192
|
+
description?: string;
|
|
193
|
+
placeholder?: string;
|
|
194
|
+
} & (T extends 'select' ? {
|
|
195
|
+
options: readonly SelectOption[];
|
|
196
|
+
} : {}) & (T extends 'reference' ? {
|
|
197
|
+
collection: string;
|
|
198
|
+
} : {});
|
|
199
|
+
/** Discriminated union over all concrete block-property specs. */
|
|
200
|
+
type BlockProperty = {
|
|
201
|
+
[K in BlockPropertyType]: BlockPropertySpec<K>;
|
|
202
|
+
}[BlockPropertyType];
|
|
203
|
+
/** Scalar property subset usable as an event parameter (no references/media). */
|
|
204
|
+
type ScalarBlockProperty = Extract<BlockProperty, {
|
|
205
|
+
type: 'string' | 'number' | 'boolean' | 'select' | 'date';
|
|
206
|
+
}>;
|
|
207
|
+
/**
|
|
208
|
+
* Declares a meaningful event a functional block can emit (e.g. a form's
|
|
209
|
+
* `submitSuccess`). Living on the block DEFINITION makes it the single source of
|
|
210
|
+
* truth for the typed `fire(...)` union, the test-creation goal picker, and the
|
|
211
|
+
* analytics wire name. `name` overrides the GA4/dataLayer wire name (defaults to
|
|
212
|
+
* `cms_<blockType>_<eventKey>`, computed by the measurement layer). Whether an
|
|
213
|
+
* event counts as a conversion is decided per test in the UI, not here.
|
|
214
|
+
*/
|
|
215
|
+
type EventDeclaration = {
|
|
216
|
+
/** Analytics wire-name override (snake_case). Defaults to cms_<type>_<key>. */
|
|
217
|
+
name?: string;
|
|
218
|
+
/** Typed parameters carried with the event (scalar only). */
|
|
219
|
+
params?: Record<string, ScalarBlockProperty>;
|
|
220
|
+
/** Human label for the goal picker. */
|
|
221
|
+
label?: string;
|
|
222
|
+
};
|
|
223
|
+
type BlockDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>, TEvents extends Record<string, EventDeclaration> = Record<string, never>> = {
|
|
224
|
+
properties: TProps;
|
|
225
|
+
label: string;
|
|
226
|
+
description?: string;
|
|
227
|
+
previewImageUrl?: string;
|
|
228
|
+
/** Events this (functional) block can emit — see {@link EventDeclaration}. */
|
|
229
|
+
events?: TEvents;
|
|
230
|
+
} & ({
|
|
231
|
+
allowChildren?: false;
|
|
232
|
+
} | {
|
|
233
|
+
allowChildren: true;
|
|
234
|
+
allowedChildBlocks?: string[];
|
|
235
|
+
});
|
|
236
|
+
type AnyBlockDefinition = BlockDefinition<Record<string, BlockProperty>, Record<string, EventDeclaration>>;
|
|
237
|
+
type RootDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>> = {
|
|
238
|
+
properties: TProps;
|
|
239
|
+
};
|
|
240
|
+
type SlugConfig = {
|
|
241
|
+
enabled: false;
|
|
242
|
+
} | {
|
|
243
|
+
enabled: true;
|
|
244
|
+
root: string;
|
|
245
|
+
allowRoot?: boolean;
|
|
246
|
+
normalize?: boolean;
|
|
247
|
+
nested?: boolean;
|
|
248
|
+
};
|
|
249
|
+
type CollectionDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>, TBlocks extends Record<string, AnyBlockDefinition> = Record<string, AnyBlockDefinition>> = {
|
|
250
|
+
slug?: SlugConfig;
|
|
251
|
+
root: RootDefinition<TProps>;
|
|
252
|
+
blocks?: TBlocks;
|
|
253
|
+
label: string;
|
|
254
|
+
description?: string;
|
|
255
|
+
/**
|
|
256
|
+
* Marks this collection as one whose roots are meant to be EMBEDDED into other
|
|
257
|
+
* roots via a `reference` property (a "reusable block" library). Purely an
|
|
258
|
+
* ergonomic hint — it informs editor pickers and which endpoints to surface; it
|
|
259
|
+
* NEVER gates safety (the delete-in-use guard protects every referenced root
|
|
260
|
+
* regardless of this flag). Any collection can still be a reference target.
|
|
261
|
+
*/
|
|
262
|
+
reusableBlock?: boolean;
|
|
263
|
+
};
|
|
264
|
+
type AnyCollectionDefinition = CollectionDefinition<Record<string, BlockProperty>, Record<string, AnyBlockDefinition>>;
|
|
265
|
+
type CollectionWithName = Omit<AnyCollectionDefinition, 'blocks'> & {
|
|
266
|
+
name: string;
|
|
267
|
+
blocks: Record<string, AnyBlockDefinition>;
|
|
268
|
+
};
|
|
269
|
+
type DataRetentionConfig = {
|
|
270
|
+
keepDays: number;
|
|
271
|
+
keepMinCommits: number;
|
|
272
|
+
/**
|
|
273
|
+
* Grace period (days) before a soft-archived root (`archivedAt`) is physically
|
|
274
|
+
* hard-deleted by pruning. Defaults to `keepDays` when omitted — a trash
|
|
275
|
+
* window after which the page and its whole history are reclaimed.
|
|
276
|
+
*/
|
|
277
|
+
archiveKeepDays?: number;
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Subset of the incoming request forwarded to the authMiddleware.
|
|
281
|
+
* Available for all call styles (HTTP router and direct server-side calls).
|
|
282
|
+
*/
|
|
283
|
+
type CMSMiddlewareRequest = {
|
|
284
|
+
body?: Record<string, unknown>;
|
|
285
|
+
query?: Record<string, unknown>;
|
|
286
|
+
params?: Record<string, unknown>;
|
|
287
|
+
headers?: HeadersInit;
|
|
288
|
+
request?: Request;
|
|
289
|
+
};
|
|
290
|
+
/** Result that user middleware can return to extend context */
|
|
291
|
+
type MiddlewareResult = {
|
|
292
|
+
userId?: string;
|
|
293
|
+
[key: string]: unknown;
|
|
294
|
+
};
|
|
295
|
+
/** Base ctx injected by withCMSContext middleware. */
|
|
296
|
+
type CMSProcedureCtx = {
|
|
297
|
+
db: DrizzleInstance;
|
|
298
|
+
collections: Record<string, CollectionWithName>;
|
|
299
|
+
dataRetention?: DataRetentionConfig;
|
|
300
|
+
scopeConditions?: ScopeConditionFactory[];
|
|
301
|
+
notificationService?: NotificationService;
|
|
302
|
+
resolvedUser?: ResolvedUserConfig;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
type SchemaNamespace = 'cms';
|
|
306
|
+
type ColumnScalarType = 'text' | 'boolean' | 'integer' | 'timestamp' | 'jsonb' | 'tsvector';
|
|
307
|
+
type ColumnType<EnumTarget extends string = string> = ColumnScalarType | {
|
|
308
|
+
enum: EnumTarget;
|
|
309
|
+
};
|
|
310
|
+
type DefaultValue = {
|
|
311
|
+
kind: 'literal';
|
|
312
|
+
value: boolean | number | string | string[] | Record<string, unknown>;
|
|
313
|
+
} | {
|
|
314
|
+
kind: 'sql';
|
|
315
|
+
value: string;
|
|
316
|
+
};
|
|
317
|
+
type ForeignKeyAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
|
|
318
|
+
type IndexUsing = 'btree' | 'gin';
|
|
319
|
+
type ColumnDefinition<ReferenceTarget extends string = string, EnumTarget extends string = string> = {
|
|
320
|
+
type: ColumnType<EnumTarget>;
|
|
321
|
+
columnName?: string;
|
|
322
|
+
notNull?: boolean;
|
|
323
|
+
primaryKey?: boolean;
|
|
324
|
+
unique?: boolean;
|
|
325
|
+
default?: DefaultValue;
|
|
326
|
+
defaultId?: boolean;
|
|
327
|
+
defaultIdPrefix?: string;
|
|
328
|
+
defaultNow?: boolean;
|
|
329
|
+
jsonType?: string;
|
|
330
|
+
references?: {
|
|
331
|
+
table: ReferenceTarget;
|
|
332
|
+
column: string;
|
|
333
|
+
onDelete?: ForeignKeyAction;
|
|
334
|
+
onUpdate?: ForeignKeyAction;
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
type TableColumns<ReferenceTarget extends string = string, EnumTarget extends string = string> = Record<string, ColumnDefinition<ReferenceTarget, EnumTarget>>;
|
|
338
|
+
type IndexDefinition<ColumnName extends string> = {
|
|
339
|
+
columns: readonly ColumnName[];
|
|
340
|
+
unique?: boolean;
|
|
341
|
+
using?: IndexUsing;
|
|
342
|
+
where?: string;
|
|
343
|
+
};
|
|
344
|
+
type CompositePrimaryKey<ColumnName extends string> = {
|
|
345
|
+
columns: readonly ColumnName[];
|
|
346
|
+
};
|
|
347
|
+
type TableLevelForeignKey<ColumnName extends string = string> = {
|
|
348
|
+
columns: readonly ColumnName[];
|
|
349
|
+
foreignTable: string;
|
|
350
|
+
foreignColumns: readonly string[];
|
|
351
|
+
name?: string;
|
|
352
|
+
onDelete?: ForeignKeyAction;
|
|
353
|
+
onUpdate?: ForeignKeyAction;
|
|
354
|
+
};
|
|
355
|
+
type TableDefinition<Columns extends TableColumns = TableColumns, ReferenceTarget extends string = string, EnumTarget extends string = string> = {
|
|
356
|
+
tableName?: string;
|
|
357
|
+
indexPrefix?: string;
|
|
358
|
+
columns: Columns;
|
|
359
|
+
indexes?: Record<string, IndexDefinition<Extract<keyof Columns, string>>>;
|
|
360
|
+
compositePrimaryKey?: CompositePrimaryKey<Extract<keyof Columns, string>>;
|
|
361
|
+
foreignKeys?: TableLevelForeignKey<Extract<keyof Columns, string>>[];
|
|
362
|
+
};
|
|
363
|
+
type EnumDefinition = {
|
|
364
|
+
values: readonly string[];
|
|
365
|
+
enumName?: string;
|
|
366
|
+
};
|
|
367
|
+
type EnumMap = Record<string, EnumDefinition>;
|
|
368
|
+
type TableMap = Record<string, TableDefinition>;
|
|
369
|
+
type SchemaModule<_Namespace extends SchemaNamespace = SchemaNamespace, Tables extends TableMap = {}, Enums extends EnumMap = {}, Extensions = {}> = {
|
|
370
|
+
enums?: Enums;
|
|
371
|
+
tables?: Tables;
|
|
372
|
+
extend?: Extensions;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
type CMSPluginContext = CMSProcedureCtx & {
|
|
376
|
+
collections: Record<string, CollectionWithName>;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Extends the core MiddlewareResult to require tenantSlug.
|
|
381
|
+
* Use this to type your authMiddleware when using multiTenant.
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* ```ts
|
|
385
|
+
* import { resolveTenantSlug } from '@createcms/core/plugins/multi-tenant';
|
|
386
|
+
*
|
|
387
|
+
* authMiddleware: async (ctx): Promise<MultiTenantMiddlewareResult> => {
|
|
388
|
+
* const session = await getSession(ctx);
|
|
389
|
+
* const tenantSlug = resolveTenantSlug(ctx, session.organizationSlug);
|
|
390
|
+
* return {
|
|
391
|
+
* userId: session.userId,
|
|
392
|
+
* tenantSlug,
|
|
393
|
+
* };
|
|
394
|
+
* }
|
|
395
|
+
* ```
|
|
396
|
+
*/
|
|
397
|
+
type MultiTenantMiddlewareResult = MiddlewareResult & {
|
|
398
|
+
tenantSlug: string;
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* Resolves the tenant slug from the incoming request context.
|
|
402
|
+
* Priority: body.tenantSlug -> query.tenantSlug -> fallback.
|
|
403
|
+
*
|
|
404
|
+
* Use this inside your `authMiddleware` to support per-request tenant
|
|
405
|
+
* overrides (e.g. for admin cross-tenant access) while keeping a
|
|
406
|
+
* sensible default from the user's session.
|
|
407
|
+
*
|
|
408
|
+
* @param ctx - The middleware context (must have `request`)
|
|
409
|
+
* @param fallback - Default tenant slug (e.g. `session.organizationSlug`)
|
|
410
|
+
*/
|
|
411
|
+
declare function resolveTenantSlug(ctx: {
|
|
412
|
+
request?: CMSMiddlewareRequest;
|
|
413
|
+
}, fallback?: string): string | undefined;
|
|
414
|
+
declare function multiTenant(): {
|
|
415
|
+
id: "multi-tenant";
|
|
416
|
+
schema: SchemaModule<"cms", {}, {}, {}>;
|
|
417
|
+
$ERROR_CODES: {
|
|
418
|
+
readonly TENANT_SLUG_REQUIRED: {
|
|
419
|
+
readonly status: 400;
|
|
420
|
+
readonly message: "tenantSlug is required -- authMiddleware must return { tenantSlug } when multiTenant plugin is active";
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
init(_ctx: CMSPluginContext): {
|
|
424
|
+
context: {
|
|
425
|
+
scopeConditions: ScopeConditionFactory[];
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
export { multiTenant, resolveTenantSlug };
|
|
431
|
+
export type { MultiTenantMiddlewareResult };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { APIError } from 'better-call';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
|
|
4
|
+
function definePluginSchema(schema) {
|
|
5
|
+
return {
|
|
6
|
+
...schema
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plugin schema that adds the `tenantSlug` column and tenant-scoped indexes
|
|
12
|
+
* to the core tables. The column does not exist in the core schema — it is
|
|
13
|
+
* entirely owned by this plugin.
|
|
14
|
+
*/ const multiTenantSchema = definePluginSchema({
|
|
15
|
+
extend: {
|
|
16
|
+
roots: {
|
|
17
|
+
columns: {
|
|
18
|
+
tenantSlug: {
|
|
19
|
+
type: 'text',
|
|
20
|
+
notNull: true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
indexes: {
|
|
24
|
+
tenantCollectionIdx: {
|
|
25
|
+
columns: [
|
|
26
|
+
'tenantSlug',
|
|
27
|
+
'collection'
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
// Per-tenant slug uniqueness — the DB backstop for the (now per-tenant)
|
|
31
|
+
// app-level validateSlugUniqueness (the core slug index was demoted to
|
|
32
|
+
// non-unique). NESTED-only in practice (a NULL parentRootId is distinct in
|
|
33
|
+
// Postgres, so top-level relies on the app-level check); composes with the
|
|
34
|
+
// i18n plugin's (language,…) unique because each tenant+language has its
|
|
35
|
+
// own parent tree, so neither over-constrains the other.
|
|
36
|
+
tenantRootSlugUnique: {
|
|
37
|
+
columns: [
|
|
38
|
+
'tenantSlug',
|
|
39
|
+
'collection',
|
|
40
|
+
'parentRootId',
|
|
41
|
+
'slug'
|
|
42
|
+
],
|
|
43
|
+
unique: true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
assetFolders: {
|
|
48
|
+
columns: {
|
|
49
|
+
tenantSlug: {
|
|
50
|
+
type: 'text',
|
|
51
|
+
notNull: true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
indexes: {
|
|
55
|
+
tenantNameUnique: {
|
|
56
|
+
columns: [
|
|
57
|
+
'tenantSlug',
|
|
58
|
+
'parentId',
|
|
59
|
+
'name'
|
|
60
|
+
],
|
|
61
|
+
unique: true
|
|
62
|
+
},
|
|
63
|
+
tenantIdx: {
|
|
64
|
+
columns: [
|
|
65
|
+
'tenantSlug'
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
assets: {
|
|
71
|
+
columns: {
|
|
72
|
+
tenantSlug: {
|
|
73
|
+
type: 'text',
|
|
74
|
+
notNull: true
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
indexes: {
|
|
78
|
+
tenantIdx: {
|
|
79
|
+
columns: [
|
|
80
|
+
'tenantSlug'
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
tenantSlugUnique: {
|
|
84
|
+
columns: [
|
|
85
|
+
'tenantSlug',
|
|
86
|
+
'slug'
|
|
87
|
+
],
|
|
88
|
+
unique: true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
// Redirects have NO core unique index (uniqueness is app-level), so the
|
|
93
|
+
// plugin owns the real per-tenant DB guarantee. PARTIAL (active rows only)
|
|
94
|
+
// mirrors the app-level checks: archiving a redirect frees its source.
|
|
95
|
+
redirects: {
|
|
96
|
+
columns: {
|
|
97
|
+
tenantSlug: {
|
|
98
|
+
type: 'text',
|
|
99
|
+
notNull: true
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
indexes: {
|
|
103
|
+
// NOTE: no per-tenant PATH-source unique. A path can legitimately have a
|
|
104
|
+
// different redirect per language (the i18n plugin adds `language`), and
|
|
105
|
+
// the correct compound key (tenant_slug, language, collection, sourcePath)
|
|
106
|
+
// can't be expressed by either plugin alone — so path-source uniqueness is
|
|
107
|
+
// the app-level authority (assertSourceUnique + the auto-create pre-check,
|
|
108
|
+
// both scope.redirects-aware). Lookup is still indexed below.
|
|
109
|
+
//
|
|
110
|
+
// PAGE-source IS safely per-tenant-unique: sourceRootId is a specific root
|
|
111
|
+
// (a single language), so this never over-constrains under i18n.
|
|
112
|
+
tenantSourceRootUnique: {
|
|
113
|
+
columns: [
|
|
114
|
+
'tenantSlug',
|
|
115
|
+
'sourceRootId'
|
|
116
|
+
],
|
|
117
|
+
unique: true,
|
|
118
|
+
where: 'archived_at IS NULL'
|
|
119
|
+
},
|
|
120
|
+
// Per-tenant lookup/listing (+ the path-source lookup, since the unique
|
|
121
|
+
// that used to cover it is gone).
|
|
122
|
+
tenantCollectionIdx: {
|
|
123
|
+
columns: [
|
|
124
|
+
'tenantSlug',
|
|
125
|
+
'collection'
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
tenantSourcePathIdx: {
|
|
129
|
+
columns: [
|
|
130
|
+
'tenantSlug',
|
|
131
|
+
'collection',
|
|
132
|
+
'sourcePath'
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolves the tenant slug from the incoming request context.
|
|
142
|
+
* Priority: body.tenantSlug -> query.tenantSlug -> fallback.
|
|
143
|
+
*
|
|
144
|
+
* Use this inside your `authMiddleware` to support per-request tenant
|
|
145
|
+
* overrides (e.g. for admin cross-tenant access) while keeping a
|
|
146
|
+
* sensible default from the user's session.
|
|
147
|
+
*
|
|
148
|
+
* @param ctx - The middleware context (must have `request`)
|
|
149
|
+
* @param fallback - Default tenant slug (e.g. `session.organizationSlug`)
|
|
150
|
+
*/ function resolveTenantSlug(ctx, fallback) {
|
|
151
|
+
return ctx.request?.body?.tenantSlug ?? ctx.request?.query?.tenantSlug ?? fallback;
|
|
152
|
+
}
|
|
153
|
+
const PLUGIN_ID = 'multi-tenant';
|
|
154
|
+
const $ERROR_CODES = {
|
|
155
|
+
TENANT_SLUG_REQUIRED: {
|
|
156
|
+
status: 400,
|
|
157
|
+
message: 'tenantSlug is required -- authMiddleware must return { tenantSlug } when multiTenant plugin is active'
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function multiTenant() {
|
|
161
|
+
return {
|
|
162
|
+
id: PLUGIN_ID,
|
|
163
|
+
schema: multiTenantSchema,
|
|
164
|
+
$ERROR_CODES,
|
|
165
|
+
init (_ctx) {
|
|
166
|
+
const factory = (mwResult)=>{
|
|
167
|
+
const tenantSlug = mwResult.tenantSlug;
|
|
168
|
+
if (typeof tenantSlug !== 'string' || tenantSlug.length === 0) {
|
|
169
|
+
throw new APIError(400, {
|
|
170
|
+
message: $ERROR_CODES.TENANT_SLUG_REQUIRED.message,
|
|
171
|
+
code: 'TENANT_SLUG_REQUIRED'
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const insertColumns = {
|
|
175
|
+
tenant_slug: tenantSlug
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
roots: {
|
|
179
|
+
where: sql`"cms"."roots"."tenant_slug" = ${tenantSlug}`,
|
|
180
|
+
insertColumns
|
|
181
|
+
},
|
|
182
|
+
assets: {
|
|
183
|
+
where: sql`"cms"."assets"."tenant_slug" = ${tenantSlug}`,
|
|
184
|
+
insertColumns
|
|
185
|
+
},
|
|
186
|
+
assetFolders: {
|
|
187
|
+
where: sql`"cms"."asset_folders"."tenant_slug" = ${tenantSlug}`,
|
|
188
|
+
insertColumns
|
|
189
|
+
},
|
|
190
|
+
redirects: {
|
|
191
|
+
where: sql`"cms"."redirects"."tenant_slug" = ${tenantSlug}`,
|
|
192
|
+
insertColumns
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
context: {
|
|
198
|
+
scopeConditions: [
|
|
199
|
+
factory
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export { multiTenant, resolveTenantSlug };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
var index_cjs = require('./ab-test/index.cjs');
|
|
4
|
+
var index_cjs$1 = require('./consent/index.cjs');
|
|
5
|
+
var index_cjs$2 = require('./multi-tenant/index.cjs');
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
Object.defineProperty(exports, "abTest", {
|
|
10
|
+
enumerable: true,
|
|
11
|
+
get: function () { return index_cjs.abTest; }
|
|
12
|
+
});
|
|
13
|
+
Object.defineProperty(exports, "consent", {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
get: function () { return index_cjs$1.consent; }
|
|
16
|
+
});
|
|
17
|
+
Object.defineProperty(exports, "multiTenant", {
|
|
18
|
+
enumerable: true,
|
|
19
|
+
get: function () { return index_cjs$2.multiTenant; }
|
|
20
|
+
});
|
|
21
|
+
Object.defineProperty(exports, "resolveTenantSlug", {
|
|
22
|
+
enumerable: true,
|
|
23
|
+
get: function () { return index_cjs$2.resolveTenantSlug; }
|
|
24
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ABTestAnalyticsAdapter, ABTestContext, ABTestEvent, ABTestPluginOptions, AggregatedResults, AggregatedVariantResult, CMSEvent, CMSEventSource, LiveDelta, abTest } from './ab-test/index.cjs';
|
|
2
|
+
export { ConsentPurpose, ConsentSignal, ConsentState, consent } from './consent/index.cjs';
|
|
3
|
+
export { MultiTenantMiddlewareResult, multiTenant, resolveTenantSlug } from './multi-tenant/index.cjs';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { ABTestAnalyticsAdapter, ABTestContext, ABTestEvent, ABTestPluginOptions, AggregatedResults, AggregatedVariantResult, CMSEvent, CMSEventSource, LiveDelta, abTest } from './ab-test/index.js';
|
|
2
|
+
export { ConsentPurpose, ConsentSignal, ConsentState, consent } from './consent/index.js';
|
|
3
|
+
export { MultiTenantMiddlewareResult, multiTenant, resolveTenantSlug } from './multi-tenant/index.js';
|