@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,2177 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
var betterCall = require('better-call');
|
|
4
|
+
var drizzleOrm = require('drizzle-orm');
|
|
5
|
+
var nanoid$1 = require('nanoid');
|
|
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
|
+
const cms$1 = pgCore.pgSchema('cms');
|
|
78
|
+
const tsvectorColumn = pgCore.customType({
|
|
79
|
+
dataType () {
|
|
80
|
+
return 'tsvector';
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const approvalStatusEnum = cms$1.enum("approval_status", [
|
|
84
|
+
"pending",
|
|
85
|
+
"approved",
|
|
86
|
+
"rejected"
|
|
87
|
+
]);
|
|
88
|
+
const assetStatusEnum = cms$1.enum("asset_status", [
|
|
89
|
+
"private",
|
|
90
|
+
"public"
|
|
91
|
+
]);
|
|
92
|
+
const commentMessageTypeEnum = cms$1.enum("comment_message_type", [
|
|
93
|
+
"comment",
|
|
94
|
+
"system"
|
|
95
|
+
]);
|
|
96
|
+
const commentSystemTypeEnum = cms$1.enum("comment_system_type", [
|
|
97
|
+
"threadResolved",
|
|
98
|
+
"threadReopened"
|
|
99
|
+
]);
|
|
100
|
+
const commentThreadStatusEnum = cms$1.enum("comment_thread_status", [
|
|
101
|
+
"open",
|
|
102
|
+
"resolved"
|
|
103
|
+
]);
|
|
104
|
+
const commentThreadTargetEnum = cms$1.enum("comment_thread_target", [
|
|
105
|
+
"mergeRequest",
|
|
106
|
+
"block"
|
|
107
|
+
]);
|
|
108
|
+
const conflictResolutionEnum = cms$1.enum("conflict_resolution", [
|
|
109
|
+
"source",
|
|
110
|
+
"target",
|
|
111
|
+
"manual"
|
|
112
|
+
]);
|
|
113
|
+
const contentUsageTargetEnum = cms$1.enum("content_usage_target", [
|
|
114
|
+
"asset",
|
|
115
|
+
"variable",
|
|
116
|
+
"reference"
|
|
117
|
+
]);
|
|
118
|
+
const mergeRequestStatusEnum = cms$1.enum("merge_request_status", [
|
|
119
|
+
"open",
|
|
120
|
+
"merged",
|
|
121
|
+
"closed"
|
|
122
|
+
]);
|
|
123
|
+
const notificationTypeEnum = cms$1.enum("notification_type", [
|
|
124
|
+
"mention",
|
|
125
|
+
"comment",
|
|
126
|
+
"threadResolved",
|
|
127
|
+
"approvalRequested",
|
|
128
|
+
"approvalApproved",
|
|
129
|
+
"approvalRejected",
|
|
130
|
+
"mergeRequestOpened",
|
|
131
|
+
"mergeRequestMerged",
|
|
132
|
+
"mergeRequestClosed",
|
|
133
|
+
"mergeRequestReopened",
|
|
134
|
+
"published",
|
|
135
|
+
"custom"
|
|
136
|
+
]);
|
|
137
|
+
const redirectEndpointTypeEnum = cms$1.enum("redirect_endpoint_type", [
|
|
138
|
+
"page",
|
|
139
|
+
"path"
|
|
140
|
+
]);
|
|
141
|
+
cms$1.table("approvals", {
|
|
142
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("approval")),
|
|
143
|
+
mergeRequestId: pgCore.text("merge_request_id").references(()=>mergeRequests.id, {
|
|
144
|
+
onDelete: "cascade"
|
|
145
|
+
}),
|
|
146
|
+
branchId: pgCore.text("branch_id").notNull().references(()=>branches.id),
|
|
147
|
+
commitId: pgCore.text("commit_id").notNull().references(()=>commits.id),
|
|
148
|
+
status: approvalStatusEnum("status").notNull().default("pending"),
|
|
149
|
+
requestedBy: pgCore.text("requested_by").notNull(),
|
|
150
|
+
requestedReviewer: pgCore.text("requested_reviewer").notNull(),
|
|
151
|
+
reviewedBy: pgCore.text("reviewed_by"),
|
|
152
|
+
message: pgCore.text("message"),
|
|
153
|
+
rejectionReason: pgCore.text("rejection_reason"),
|
|
154
|
+
reviewedAt: pgCore.timestamp("reviewed_at"),
|
|
155
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
156
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow()
|
|
157
|
+
}, (table)=>[
|
|
158
|
+
pgCore.index("approvals_mr_idx").on(table.mergeRequestId),
|
|
159
|
+
pgCore.index("approvals_branch_idx").on(table.branchId),
|
|
160
|
+
pgCore.index("approvals_branch_commit_idx").on(table.branchId, table.commitId),
|
|
161
|
+
pgCore.index("approvals_status_idx").on(table.status),
|
|
162
|
+
pgCore.index("approvals_requested_reviewer_idx").on(table.requestedReviewer),
|
|
163
|
+
pgCore.uniqueIndex("approvals_target_reviewer_unique").on(table.mergeRequestId, table.branchId, table.commitId, table.requestedReviewer)
|
|
164
|
+
]);
|
|
165
|
+
const assetFolders = cms$1.table("asset_folders", {
|
|
166
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("assetFolder")),
|
|
167
|
+
name: pgCore.text("name").notNull(),
|
|
168
|
+
parentId: pgCore.text("parent_id"),
|
|
169
|
+
createdBy: pgCore.text("created_by"),
|
|
170
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow()
|
|
171
|
+
}, (table)=>[
|
|
172
|
+
pgCore.foreignKey({
|
|
173
|
+
columns: [
|
|
174
|
+
table.parentId
|
|
175
|
+
],
|
|
176
|
+
foreignColumns: [
|
|
177
|
+
table.id
|
|
178
|
+
],
|
|
179
|
+
name: "asset_folders_parent_fk"
|
|
180
|
+
}).onDelete("cascade"),
|
|
181
|
+
pgCore.index("asset_folders_parent_idx").on(table.parentId),
|
|
182
|
+
pgCore.uniqueIndex("asset_folders_name_unique").on(table.parentId, table.name)
|
|
183
|
+
]);
|
|
184
|
+
const assets = cms$1.table("assets", {
|
|
185
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("asset")),
|
|
186
|
+
slug: pgCore.text("slug").notNull(),
|
|
187
|
+
mimeType: pgCore.text("mime_type").notNull(),
|
|
188
|
+
size: pgCore.integer("size").notNull(),
|
|
189
|
+
objectKey: pgCore.text("object_key").notNull(),
|
|
190
|
+
status: assetStatusEnum("status").notNull().default("private"),
|
|
191
|
+
folderId: pgCore.text("folder_id").references(()=>assetFolders.id, {
|
|
192
|
+
onDelete: "set null"
|
|
193
|
+
}),
|
|
194
|
+
variantOf: pgCore.text("variant_of").references(()=>assets.id, {
|
|
195
|
+
onDelete: "set null"
|
|
196
|
+
}),
|
|
197
|
+
uploadedBy: pgCore.text("uploaded_by"),
|
|
198
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
199
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow(),
|
|
200
|
+
archivedAt: pgCore.timestamp("archived_at")
|
|
201
|
+
}, (table)=>[
|
|
202
|
+
pgCore.index("assets_folder_idx").on(table.folderId),
|
|
203
|
+
pgCore.index("assets_status_idx").on(table.status),
|
|
204
|
+
pgCore.index("assets_variant_of_idx").on(table.variantOf),
|
|
205
|
+
pgCore.uniqueIndex("assets_object_key_unique").on(table.objectKey),
|
|
206
|
+
pgCore.uniqueIndex("assets_slug_unique").on(table.slug)
|
|
207
|
+
]);
|
|
208
|
+
const blockVersions = cms$1.table("block_versions", {
|
|
209
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("blockVersion")),
|
|
210
|
+
blockId: pgCore.text("block_id").notNull(),
|
|
211
|
+
rootId: pgCore.text("root_id").notNull().references(()=>roots.id),
|
|
212
|
+
commitId: pgCore.text("commit_id").notNull().references(()=>commits.id),
|
|
213
|
+
type: pgCore.text("type").notNull(),
|
|
214
|
+
properties: pgCore.jsonb("properties").$type().notNull(),
|
|
215
|
+
children: pgCore.jsonb("children").$type().notNull().default([]),
|
|
216
|
+
deleted: pgCore.boolean("deleted").notNull().default(false),
|
|
217
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow()
|
|
218
|
+
}, (table)=>[
|
|
219
|
+
pgCore.index("bv_block_id_idx").on(table.blockId),
|
|
220
|
+
pgCore.index("bv_commit_id_idx").on(table.commitId),
|
|
221
|
+
pgCore.index("bv_root_id_idx").on(table.rootId),
|
|
222
|
+
pgCore.uniqueIndex("bv_block_commit_unique").on(table.blockId, table.commitId),
|
|
223
|
+
pgCore.index("bv_properties_gin").using("gin", table.properties)
|
|
224
|
+
]);
|
|
225
|
+
const branches = cms$1.table("branches", {
|
|
226
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("branch")),
|
|
227
|
+
rootId: pgCore.text("root_id").notNull().references(()=>roots.id),
|
|
228
|
+
name: pgCore.text("name").notNull(),
|
|
229
|
+
headCommitId: pgCore.text("head_commit_id").notNull().references(()=>commits.id),
|
|
230
|
+
createdBy: pgCore.text("created_by"),
|
|
231
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
232
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow()
|
|
233
|
+
}, (table)=>[
|
|
234
|
+
pgCore.index("branches_root_id_idx").on(table.rootId),
|
|
235
|
+
pgCore.uniqueIndex("branches_root_name_unique").on(table.rootId, table.name)
|
|
236
|
+
]);
|
|
237
|
+
cms$1.table("comment_mentions", {
|
|
238
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("commentMention")),
|
|
239
|
+
messageId: pgCore.text("message_id").notNull().references(()=>commentMessages.id, {
|
|
240
|
+
onDelete: "cascade"
|
|
241
|
+
}),
|
|
242
|
+
threadId: pgCore.text("thread_id").notNull().references(()=>commentThreads.id, {
|
|
243
|
+
onDelete: "cascade"
|
|
244
|
+
}),
|
|
245
|
+
mentionedUserId: pgCore.text("mentioned_user_id").notNull(),
|
|
246
|
+
mentionedBy: pgCore.text("mentioned_by").notNull(),
|
|
247
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow()
|
|
248
|
+
}, (table)=>[
|
|
249
|
+
pgCore.index("cmn_user_idx").on(table.mentionedUserId, table.createdAt),
|
|
250
|
+
pgCore.index("cmn_message_idx").on(table.messageId),
|
|
251
|
+
pgCore.index("cmn_thread_user_idx").on(table.threadId, table.mentionedUserId),
|
|
252
|
+
pgCore.uniqueIndex("cmn_message_user_unique").on(table.messageId, table.mentionedUserId)
|
|
253
|
+
]);
|
|
254
|
+
const commentMessages = cms$1.table("comment_messages", {
|
|
255
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("commentMessage")),
|
|
256
|
+
threadId: pgCore.text("thread_id").notNull().references(()=>commentThreads.id, {
|
|
257
|
+
onDelete: "cascade"
|
|
258
|
+
}),
|
|
259
|
+
parentMessageId: pgCore.text("parent_message_id"),
|
|
260
|
+
authorId: pgCore.text("author_id"),
|
|
261
|
+
messageType: commentMessageTypeEnum("message_type").notNull().default("comment"),
|
|
262
|
+
systemType: commentSystemTypeEnum("system_type"),
|
|
263
|
+
body: pgCore.text("body"),
|
|
264
|
+
meta: pgCore.jsonb("meta").$type(),
|
|
265
|
+
editedAt: pgCore.timestamp("edited_at"),
|
|
266
|
+
deletedAt: pgCore.timestamp("deleted_at"),
|
|
267
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
268
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow()
|
|
269
|
+
}, (table)=>[
|
|
270
|
+
pgCore.foreignKey({
|
|
271
|
+
columns: [
|
|
272
|
+
table.parentMessageId
|
|
273
|
+
],
|
|
274
|
+
foreignColumns: [
|
|
275
|
+
table.id
|
|
276
|
+
],
|
|
277
|
+
name: "comment_messages_parent_fk"
|
|
278
|
+
}).onDelete("set null"),
|
|
279
|
+
pgCore.index("cm_thread_idx").on(table.threadId, table.createdAt),
|
|
280
|
+
pgCore.index("cm_parent_idx").on(table.parentMessageId),
|
|
281
|
+
pgCore.index("cm_type_idx").on(table.messageType, table.systemType),
|
|
282
|
+
pgCore.index("cm_author_idx").on(table.authorId, table.createdAt)
|
|
283
|
+
]);
|
|
284
|
+
const commentThreads = cms$1.table("comment_threads", {
|
|
285
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("commentThread")),
|
|
286
|
+
rootId: pgCore.text("root_id").references(()=>roots.id, {
|
|
287
|
+
onDelete: "cascade"
|
|
288
|
+
}),
|
|
289
|
+
collection: pgCore.text("collection").notNull(),
|
|
290
|
+
targetType: commentThreadTargetEnum("target_type").notNull(),
|
|
291
|
+
mergeRequestId: pgCore.text("merge_request_id").references(()=>mergeRequests.id, {
|
|
292
|
+
onDelete: "cascade"
|
|
293
|
+
}),
|
|
294
|
+
blockId: pgCore.text("block_id"),
|
|
295
|
+
commitId: pgCore.text("commit_id").references(()=>commits.id, {
|
|
296
|
+
onDelete: "set null"
|
|
297
|
+
}),
|
|
298
|
+
status: commentThreadStatusEnum("status").notNull().default("open"),
|
|
299
|
+
resolvedBy: pgCore.text("resolved_by"),
|
|
300
|
+
resolvedAt: pgCore.timestamp("resolved_at"),
|
|
301
|
+
createdBy: pgCore.text("created_by").notNull(),
|
|
302
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
303
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow(),
|
|
304
|
+
deletedAt: pgCore.timestamp("deleted_at")
|
|
305
|
+
}, (table)=>[
|
|
306
|
+
pgCore.index("ct_collection_idx").on(table.collection, table.createdAt),
|
|
307
|
+
pgCore.index("ct_mr_idx").on(table.mergeRequestId, table.createdAt),
|
|
308
|
+
pgCore.index("ct_block_idx").on(table.blockId, table.createdAt),
|
|
309
|
+
pgCore.index("ct_commit_idx").on(table.commitId, table.createdAt),
|
|
310
|
+
pgCore.index("ct_root_idx").on(table.rootId, table.createdAt),
|
|
311
|
+
pgCore.index("ct_status_idx").on(table.status)
|
|
312
|
+
]);
|
|
313
|
+
const commits = cms$1.table("commits", {
|
|
314
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("commit")),
|
|
315
|
+
rootId: pgCore.text("root_id").notNull().references(()=>roots.id),
|
|
316
|
+
parentCommitId: pgCore.text("parent_commit_id"),
|
|
317
|
+
mergeSourceCommitId: pgCore.text("merge_source_commit_id"),
|
|
318
|
+
message: pgCore.text("message"),
|
|
319
|
+
createdBy: pgCore.text("created_by"),
|
|
320
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow()
|
|
321
|
+
}, (table)=>[
|
|
322
|
+
pgCore.foreignKey({
|
|
323
|
+
columns: [
|
|
324
|
+
table.parentCommitId
|
|
325
|
+
],
|
|
326
|
+
foreignColumns: [
|
|
327
|
+
table.id
|
|
328
|
+
],
|
|
329
|
+
name: "commits_parent_fk"
|
|
330
|
+
}),
|
|
331
|
+
pgCore.foreignKey({
|
|
332
|
+
columns: [
|
|
333
|
+
table.mergeSourceCommitId
|
|
334
|
+
],
|
|
335
|
+
foreignColumns: [
|
|
336
|
+
table.id
|
|
337
|
+
],
|
|
338
|
+
name: "commits_merge_source_fk"
|
|
339
|
+
}),
|
|
340
|
+
pgCore.index("commits_parent_idx").on(table.parentCommitId),
|
|
341
|
+
pgCore.index("commits_merge_source_idx").on(table.mergeSourceCommitId),
|
|
342
|
+
pgCore.index("commits_root_created_idx").on(table.rootId, table.createdAt)
|
|
343
|
+
]);
|
|
344
|
+
const commitSnapshots = cms$1.table("commit_snapshots", {
|
|
345
|
+
commitId: pgCore.text("commit_id").notNull().references(()=>commits.id, {
|
|
346
|
+
onDelete: "cascade"
|
|
347
|
+
}),
|
|
348
|
+
blockId: pgCore.text("block_id").notNull(),
|
|
349
|
+
blockVersionId: pgCore.text("block_version_id").notNull().references(()=>blockVersions.id, {
|
|
350
|
+
onDelete: "cascade"
|
|
351
|
+
})
|
|
352
|
+
}, (table)=>[
|
|
353
|
+
pgCore.primaryKey({
|
|
354
|
+
columns: [
|
|
355
|
+
table.commitId,
|
|
356
|
+
table.blockId
|
|
357
|
+
]
|
|
358
|
+
}),
|
|
359
|
+
pgCore.index("cs_block_version_idx").on(table.blockVersionId)
|
|
360
|
+
]);
|
|
361
|
+
const contentUsages = cms$1.table("content_usages", {
|
|
362
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("contentUsage")),
|
|
363
|
+
targetKind: contentUsageTargetEnum("target_kind").notNull(),
|
|
364
|
+
targetKey: pgCore.text("target_key").notNull(),
|
|
365
|
+
blockVersionId: pgCore.text("block_version_id").notNull().references(()=>blockVersions.id, {
|
|
366
|
+
onDelete: "cascade"
|
|
367
|
+
}),
|
|
368
|
+
rootId: pgCore.text("root_id").notNull().references(()=>roots.id, {
|
|
369
|
+
onDelete: "cascade"
|
|
370
|
+
}),
|
|
371
|
+
blockId: pgCore.text("block_id").notNull(),
|
|
372
|
+
propertyKey: pgCore.text("property_key").notNull()
|
|
373
|
+
}, (table)=>[
|
|
374
|
+
pgCore.uniqueIndex("cu_version_target_prop_unique").on(table.blockVersionId, table.targetKind, table.targetKey, table.propertyKey),
|
|
375
|
+
pgCore.index("cu_target_idx").on(table.targetKind, table.targetKey),
|
|
376
|
+
pgCore.index("cu_block_version_idx").on(table.blockVersionId),
|
|
377
|
+
pgCore.index("cu_root_idx").on(table.rootId)
|
|
378
|
+
]);
|
|
379
|
+
cms$1.table("merge_conflicts", {
|
|
380
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("mergeConflict")),
|
|
381
|
+
mergeRequestId: pgCore.text("merge_request_id").notNull().references(()=>mergeRequests.id, {
|
|
382
|
+
onDelete: "cascade"
|
|
383
|
+
}),
|
|
384
|
+
blockId: pgCore.text("block_id").notNull(),
|
|
385
|
+
sourceVersionId: pgCore.text("source_version_id").references(()=>blockVersions.id),
|
|
386
|
+
targetVersionId: pgCore.text("target_version_id").references(()=>blockVersions.id),
|
|
387
|
+
baseVersionId: pgCore.text("base_version_id").references(()=>blockVersions.id),
|
|
388
|
+
resolution: conflictResolutionEnum("resolution"),
|
|
389
|
+
resolvedVersionId: pgCore.text("resolved_version_id").references(()=>blockVersions.id),
|
|
390
|
+
resolvedBy: pgCore.text("resolved_by"),
|
|
391
|
+
resolvedAt: pgCore.timestamp("resolved_at"),
|
|
392
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow()
|
|
393
|
+
}, (table)=>[
|
|
394
|
+
pgCore.index("mc_merge_request_idx").on(table.mergeRequestId),
|
|
395
|
+
pgCore.uniqueIndex("mc_merge_block_unique").on(table.mergeRequestId, table.blockId)
|
|
396
|
+
]);
|
|
397
|
+
const mergeRequests = cms$1.table("merge_requests", {
|
|
398
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("mergeRequest")),
|
|
399
|
+
rootId: pgCore.text("root_id").notNull().references(()=>roots.id),
|
|
400
|
+
sourceBranchId: pgCore.text("source_branch_id").notNull().references(()=>branches.id),
|
|
401
|
+
targetBranchId: pgCore.text("target_branch_id").notNull().references(()=>branches.id),
|
|
402
|
+
sourceCommitId: pgCore.text("source_commit_id").notNull().references(()=>commits.id),
|
|
403
|
+
baseCommitId: pgCore.text("base_commit_id").references(()=>commits.id),
|
|
404
|
+
mergeCommitId: pgCore.text("merge_commit_id").references(()=>commits.id),
|
|
405
|
+
status: mergeRequestStatusEnum("status").notNull().default("open"),
|
|
406
|
+
title: pgCore.text("title"),
|
|
407
|
+
description: pgCore.text("description"),
|
|
408
|
+
createdBy: pgCore.text("created_by").notNull(),
|
|
409
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
410
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow()
|
|
411
|
+
}, (table)=>[
|
|
412
|
+
pgCore.index("mr_root_idx").on(table.rootId),
|
|
413
|
+
pgCore.index("mr_source_branch_idx").on(table.sourceBranchId),
|
|
414
|
+
pgCore.index("mr_target_branch_idx").on(table.targetBranchId),
|
|
415
|
+
pgCore.index("mr_status_idx").on(table.status),
|
|
416
|
+
pgCore.uniqueIndex("mr_open_source_target_unique").on(table.sourceBranchId, table.targetBranchId).where(drizzleOrm.sql`status = 'open'`)
|
|
417
|
+
]);
|
|
418
|
+
cms$1.table("notifications", {
|
|
419
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("notification")),
|
|
420
|
+
recipientId: pgCore.text("recipient_id").notNull(),
|
|
421
|
+
actorId: pgCore.text("actor_id"),
|
|
422
|
+
type: notificationTypeEnum("type").notNull(),
|
|
423
|
+
title: pgCore.text("title").notNull(),
|
|
424
|
+
body: pgCore.text("body"),
|
|
425
|
+
resourceType: pgCore.text("resource_type"),
|
|
426
|
+
resourceId: pgCore.text("resource_id"),
|
|
427
|
+
collection: pgCore.text("collection"),
|
|
428
|
+
meta: pgCore.jsonb("meta").$type(),
|
|
429
|
+
readAt: pgCore.timestamp("read_at"),
|
|
430
|
+
archivedAt: pgCore.timestamp("archived_at"),
|
|
431
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow()
|
|
432
|
+
}, (table)=>[
|
|
433
|
+
pgCore.index("ntf_recipient_created_idx").on(table.recipientId, table.createdAt),
|
|
434
|
+
pgCore.index("ntf_recipient_unread_idx").on(table.recipientId, table.readAt),
|
|
435
|
+
pgCore.index("ntf_resource_idx").on(table.resourceType, table.resourceId),
|
|
436
|
+
pgCore.index("ntf_type_idx").on(table.type)
|
|
437
|
+
]);
|
|
438
|
+
cms$1.table("publications", {
|
|
439
|
+
rootId: pgCore.text("root_id").notNull().references(()=>roots.id),
|
|
440
|
+
branchId: pgCore.text("branch_id").notNull().references(()=>branches.id),
|
|
441
|
+
commitId: pgCore.text("commit_id").notNull().references(()=>commits.id),
|
|
442
|
+
publishedBy: pgCore.text("published_by").notNull(),
|
|
443
|
+
publishedAt: pgCore.timestamp("published_at").notNull().defaultNow()
|
|
444
|
+
}, (table)=>[
|
|
445
|
+
pgCore.primaryKey({
|
|
446
|
+
columns: [
|
|
447
|
+
table.rootId,
|
|
448
|
+
table.branchId
|
|
449
|
+
]
|
|
450
|
+
}),
|
|
451
|
+
pgCore.index("publications_branch_idx").on(table.branchId)
|
|
452
|
+
]);
|
|
453
|
+
cms$1.table("redirects", {
|
|
454
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("redirect")),
|
|
455
|
+
collection: pgCore.text("collection").notNull(),
|
|
456
|
+
sourceType: redirectEndpointTypeEnum("source_type").notNull(),
|
|
457
|
+
sourceRootId: pgCore.text("source_root_id").references(()=>roots.id, {
|
|
458
|
+
onDelete: "cascade"
|
|
459
|
+
}),
|
|
460
|
+
sourcePath: pgCore.text("source_path"),
|
|
461
|
+
targetType: redirectEndpointTypeEnum("target_type").notNull(),
|
|
462
|
+
targetRootId: pgCore.text("target_root_id").references(()=>roots.id, {
|
|
463
|
+
onDelete: "cascade"
|
|
464
|
+
}),
|
|
465
|
+
targetPath: pgCore.text("target_path"),
|
|
466
|
+
statusCode: pgCore.integer("status_code").notNull().default(301),
|
|
467
|
+
createdBy: pgCore.text("created_by"),
|
|
468
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
469
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow(),
|
|
470
|
+
archivedAt: pgCore.timestamp("archived_at")
|
|
471
|
+
}, (table)=>[
|
|
472
|
+
pgCore.index("rdr_collection_source_path_idx").on(table.collection, table.sourcePath),
|
|
473
|
+
pgCore.index("rdr_source_root_idx").on(table.sourceRootId),
|
|
474
|
+
pgCore.index("rdr_collection_idx").on(table.collection),
|
|
475
|
+
pgCore.index("rdr_archived_at_idx").on(table.archivedAt)
|
|
476
|
+
]);
|
|
477
|
+
const roots = cms$1.table("roots", {
|
|
478
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("root")),
|
|
479
|
+
collection: pgCore.text("collection").notNull(),
|
|
480
|
+
parentRootId: pgCore.text("parent_root_id"),
|
|
481
|
+
slug: pgCore.text("slug"),
|
|
482
|
+
sortOrder: pgCore.integer("sort_order").notNull().default(0),
|
|
483
|
+
createdBy: pgCore.text("created_by"),
|
|
484
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
485
|
+
archivedAt: pgCore.timestamp("archived_at"),
|
|
486
|
+
lastPrunedAt: pgCore.timestamp("last_pruned_at")
|
|
487
|
+
}, (table)=>[
|
|
488
|
+
pgCore.foreignKey({
|
|
489
|
+
columns: [
|
|
490
|
+
table.parentRootId
|
|
491
|
+
],
|
|
492
|
+
foreignColumns: [
|
|
493
|
+
table.id
|
|
494
|
+
],
|
|
495
|
+
name: "roots_parent_fk"
|
|
496
|
+
}).onDelete("cascade"),
|
|
497
|
+
pgCore.index("roots_collection_idx").on(table.collection),
|
|
498
|
+
pgCore.index("roots_parent_root_idx").on(table.parentRootId),
|
|
499
|
+
pgCore.index("roots_slug_idx").on(table.collection, table.parentRootId, table.slug),
|
|
500
|
+
pgCore.index("roots_archived_at_idx").on(table.archivedAt),
|
|
501
|
+
pgCore.index("roots_last_pruned_at_idx").on(table.lastPrunedAt)
|
|
502
|
+
]);
|
|
503
|
+
cms$1.table("search_index", {
|
|
504
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("si")),
|
|
505
|
+
entityType: pgCore.text("entity_type").notNull(),
|
|
506
|
+
entityId: pgCore.text("entity_id").notNull(),
|
|
507
|
+
collection: pgCore.text("collection"),
|
|
508
|
+
rootId: pgCore.text("root_id"),
|
|
509
|
+
contentVector: tsvectorColumn("content_vector").notNull(),
|
|
510
|
+
title: pgCore.text("title"),
|
|
511
|
+
snippet: pgCore.text("snippet"),
|
|
512
|
+
meta: pgCore.jsonb("meta").$type(),
|
|
513
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow()
|
|
514
|
+
}, (table)=>[
|
|
515
|
+
pgCore.index("si_vector_gin").using("gin", table.contentVector),
|
|
516
|
+
pgCore.index("si_entity_type_idx").on(table.entityType),
|
|
517
|
+
pgCore.index("si_collection_idx").on(table.collection),
|
|
518
|
+
pgCore.index("si_root_idx").on(table.rootId),
|
|
519
|
+
pgCore.uniqueIndex("si_entity_unique").on(table.entityType, table.entityId)
|
|
520
|
+
]);
|
|
521
|
+
const templates = cms$1.table("templates", {
|
|
522
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("template")),
|
|
523
|
+
collection: pgCore.text("collection").notNull(),
|
|
524
|
+
blockType: pgCore.text("block_type").notNull(),
|
|
525
|
+
propertyKey: pgCore.text("property_key").notNull(),
|
|
526
|
+
template: pgCore.text("template").notNull(),
|
|
527
|
+
description: pgCore.text("description"),
|
|
528
|
+
createdBy: pgCore.text("created_by"),
|
|
529
|
+
updatedBy: pgCore.text("updated_by"),
|
|
530
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
531
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow()
|
|
532
|
+
}, (table)=>[
|
|
533
|
+
pgCore.uniqueIndex("templates_collection_block_prop_unique").on(table.collection, table.blockType, table.propertyKey),
|
|
534
|
+
pgCore.index("templates_collection_idx").on(table.collection),
|
|
535
|
+
pgCore.index("templates_collection_block_idx").on(table.collection, table.blockType)
|
|
536
|
+
]);
|
|
537
|
+
cms$1.table("template_variable_usages", {
|
|
538
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("tplVarUsage")),
|
|
539
|
+
variableKey: pgCore.text("variable_key").notNull(),
|
|
540
|
+
templateId: pgCore.text("template_id").notNull().references(()=>templates.id, {
|
|
541
|
+
onDelete: "cascade"
|
|
542
|
+
})
|
|
543
|
+
}, (table)=>[
|
|
544
|
+
pgCore.uniqueIndex("tvu_key_template_unique").on(table.variableKey, table.templateId),
|
|
545
|
+
pgCore.index("tvu_variable_key_idx").on(table.variableKey),
|
|
546
|
+
pgCore.index("tvu_template_id_idx").on(table.templateId)
|
|
547
|
+
]);
|
|
548
|
+
cms$1.table("variables", {
|
|
549
|
+
id: pgCore.text("id").primaryKey().$defaultFn(()=>newId("variable")),
|
|
550
|
+
key: pgCore.text("key").notNull(),
|
|
551
|
+
value: pgCore.text("value").notNull(),
|
|
552
|
+
description: pgCore.text("description"),
|
|
553
|
+
createdBy: pgCore.text("created_by"),
|
|
554
|
+
updatedBy: pgCore.text("updated_by"),
|
|
555
|
+
createdAt: pgCore.timestamp("created_at").notNull().defaultNow(),
|
|
556
|
+
updatedAt: pgCore.timestamp("updated_at").notNull().defaultNow()
|
|
557
|
+
}, (table)=>[
|
|
558
|
+
pgCore.uniqueIndex("variables_key_unique").on(table.key)
|
|
559
|
+
]);
|
|
560
|
+
|
|
561
|
+
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_.]*$/i;
|
|
562
|
+
const SAFE_COLUMN = /^[a-z_][a-z0-9_]*$/i;
|
|
563
|
+
/**
|
|
564
|
+
* Equality conditions for plugin-owned scope columns (e.g. `tenant_slug`)
|
|
565
|
+
* against an UN-ALIASED `cms.roots`, fully qualified so they bind in raw-SQL
|
|
566
|
+
* read paths that want defensive scope filtering (a referenced or ancestor root
|
|
567
|
+
* must be in the active scope). `exclude` drops columns the caller handles
|
|
568
|
+
* separately (a scoping plugin whose column varies independently of the query).
|
|
569
|
+
* Column names are validated; values are parameterized. Returns `[]` when no
|
|
570
|
+
* scoping is active.
|
|
571
|
+
*/ function rootScopeConditions(scopeColumns, exclude = []) {
|
|
572
|
+
if (!scopeColumns) return [];
|
|
573
|
+
const conds = [];
|
|
574
|
+
for (const [col, val] of Object.entries(scopeColumns)){
|
|
575
|
+
if (val === undefined || val === null || exclude.includes(col)) continue;
|
|
576
|
+
if (!SAFE_COLUMN.test(col)) {
|
|
577
|
+
throw new Error(`rootScopeConditions: unsafe scope column "${col}"`);
|
|
578
|
+
}
|
|
579
|
+
conds.push(drizzleOrm.sql`"cms"."roots".${drizzleOrm.sql.raw(`"${col}"`)} = ${val}`);
|
|
580
|
+
}
|
|
581
|
+
return conds;
|
|
582
|
+
}
|
|
583
|
+
function assertSafeIdentifier(name) {
|
|
584
|
+
if (!SAFE_IDENTIFIER.test(name)) {
|
|
585
|
+
throw new Error(`Unsafe SQL identifier rejected: "${name}"`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Builds a single raw SQL INSERT that includes both the Drizzle-known columns
|
|
590
|
+
* and any plugin-injected scope columns (e.g. `tenant_slug`) in one statement.
|
|
591
|
+
*
|
|
592
|
+
* Returns all columns via RETURNING *.
|
|
593
|
+
*/ async function scopedInsert(db, tableName, values, scope) {
|
|
594
|
+
assertSafeIdentifier(tableName);
|
|
595
|
+
const merged = {
|
|
596
|
+
...values,
|
|
597
|
+
...scope?.insertColumns
|
|
598
|
+
};
|
|
599
|
+
const entries = Object.entries(merged);
|
|
600
|
+
const columns = drizzleOrm.sql.join(entries.map(([col])=>{
|
|
601
|
+
assertSafeIdentifier(col);
|
|
602
|
+
return drizzleOrm.sql.raw(`"${col}"`);
|
|
603
|
+
}), drizzleOrm.sql`, `);
|
|
604
|
+
const params = drizzleOrm.sql.join(entries.map(([, val])=>drizzleOrm.sql`${val ?? null}`), drizzleOrm.sql`, `);
|
|
605
|
+
const result = await db.execute(drizzleOrm.sql`INSERT INTO ${drizzleOrm.sql.raw(tableName)} (${columns}) VALUES (${params}) RETURNING *`);
|
|
606
|
+
if (!result.rows[0]) {
|
|
607
|
+
throw new Error(`scopedInsert into ${tableName} returned no rows`);
|
|
608
|
+
}
|
|
609
|
+
return result.rows[0];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Asset ids are nanoids like `ast_<20 chars>`; the generic id shape is matched
|
|
613
|
+
// then validated against the assets table (assetId is a real FK), so other ids
|
|
614
|
+
// that happen to match are never inserted.
|
|
615
|
+
const ASSET_ID_PATTERN = /^[a-z]{2,5}_[0-9a-z]{20}$/;
|
|
616
|
+
/**
|
|
617
|
+
* Recursively collects candidate asset-id strings from a property value,
|
|
618
|
+
* descending into nested objects/arrays (galleries, rich-text reference nodes).
|
|
619
|
+
*/ function collectAssetIdCandidates(value, out) {
|
|
620
|
+
if (typeof value === 'string') {
|
|
621
|
+
if (ASSET_ID_PATTERN.test(value)) out.add(value);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (Array.isArray(value)) {
|
|
625
|
+
for (const item of value)collectAssetIdCandidates(item, out);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (value && typeof value === 'object') {
|
|
629
|
+
for (const v of Object.values(value)){
|
|
630
|
+
collectAssetIdCandidates(v, out);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Maps each top-level property key to the asset-id candidates found anywhere
|
|
636
|
+
* within its (possibly nested) value.
|
|
637
|
+
*/ function extractAssetIdsFromProperties(properties) {
|
|
638
|
+
const result = new Map();
|
|
639
|
+
for (const [propKey, value] of Object.entries(properties)){
|
|
640
|
+
const found = new Set();
|
|
641
|
+
collectAssetIdCandidates(value, found);
|
|
642
|
+
if (found.size > 0) result.set(propKey, [
|
|
643
|
+
...found
|
|
644
|
+
]);
|
|
645
|
+
}
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Inserts content_usages asset rows for newly-created block versions, within the same
|
|
650
|
+
* transaction that created them. Insert-only: there is no delete-then-reinsert
|
|
651
|
+
* (the branch-blind anti-pattern). MUST be called at EVERY block-version insert
|
|
652
|
+
* site (commit-writer's writeCommit + createInitialCommit, and merges'
|
|
653
|
+
* createMergeBlockVersion) — a version that references an asset but skips this
|
|
654
|
+
* call would be invisible to the GC, which could then delete a live asset.
|
|
655
|
+
*
|
|
656
|
+
* Candidates are validated against the assets table because assetId is an FK.
|
|
657
|
+
*/ async function insertAssetReferencesForVersions(tx, rootId, versions) {
|
|
658
|
+
const pending = [];
|
|
659
|
+
const candidateIds = new Set();
|
|
660
|
+
for (const version of versions){
|
|
661
|
+
const extracted = extractAssetIdsFromProperties(version.properties);
|
|
662
|
+
for (const [propertyKey, ids] of extracted){
|
|
663
|
+
for (const id of ids){
|
|
664
|
+
pending.push({
|
|
665
|
+
blockVersionId: version.blockVersionId,
|
|
666
|
+
blockId: version.blockId,
|
|
667
|
+
propertyKey,
|
|
668
|
+
assetId: id
|
|
669
|
+
});
|
|
670
|
+
candidateIds.add(id);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (pending.length === 0) return;
|
|
675
|
+
const realAssetIds = new Set((await tx.select({
|
|
676
|
+
id: assets.id
|
|
677
|
+
}).from(assets).where(drizzleOrm.inArray(assets.id, [
|
|
678
|
+
...candidateIds
|
|
679
|
+
]))).map((r)=>r.id));
|
|
680
|
+
if (realAssetIds.size === 0) return;
|
|
681
|
+
const rows = pending.filter((p)=>realAssetIds.has(p.assetId)).map((p)=>({
|
|
682
|
+
id: newId('contentUsage'),
|
|
683
|
+
targetKind: 'asset',
|
|
684
|
+
targetKey: p.assetId,
|
|
685
|
+
blockVersionId: p.blockVersionId,
|
|
686
|
+
rootId,
|
|
687
|
+
blockId: p.blockId,
|
|
688
|
+
propertyKey: p.propertyKey
|
|
689
|
+
}));
|
|
690
|
+
if (rows.length > 0) {
|
|
691
|
+
await tx.insert(contentUsages).values(rows).onConflictDoNothing();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Maps each `reference`-type property of a block (root or child) to the NAME of
|
|
697
|
+
* the collection it targets, by reading the collection definition. Shared by the
|
|
698
|
+
* read-time resolver (`resolveTreeReferences`, publications.ts) AND the write-time
|
|
699
|
+
* usage indexer below, so both agree on exactly which properties are references
|
|
700
|
+
* and where they point — a single source of truth.
|
|
701
|
+
*/ function getReferencePropertyNames(collectionDef, blockType) {
|
|
702
|
+
const refProps = new Map();
|
|
703
|
+
if (blockType === collectionDef.name || blockType === 'root') {
|
|
704
|
+
for (const [key, spec] of Object.entries(collectionDef.root.properties)){
|
|
705
|
+
if (spec.type === 'reference') {
|
|
706
|
+
refProps.set(key, spec.collection);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return refProps;
|
|
710
|
+
}
|
|
711
|
+
const blockDef = collectionDef.blocks?.[blockType];
|
|
712
|
+
if (!blockDef) return refProps;
|
|
713
|
+
for (const [key, spec] of Object.entries(blockDef.properties)){
|
|
714
|
+
if (spec.type === 'reference') {
|
|
715
|
+
refProps.set(key, spec.collection);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return refProps;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Inserts content_usages `reference` rows for newly-created block versions, within
|
|
722
|
+
* the same transaction that created them — the third sibling of the asset and
|
|
723
|
+
* variable indexers (see core/content-index.ts). A reference is a top-level
|
|
724
|
+
* block property of type `reference` (per the collection def); its stored VALUE
|
|
725
|
+
* is the raw reference string (a `rot_` rootId, or under i18n a `tgr_`
|
|
726
|
+
* translationKey), recorded verbatim as `targetKey` so the reverse "who embeds
|
|
727
|
+
* me" query (RB2+) can match the anchor rootId directly.
|
|
728
|
+
*
|
|
729
|
+
* INSERT-only and keyed by the immutable blockVersionId, like its siblings. The
|
|
730
|
+
* `collectionDef` is REQUIRED (no default) so every version-insert site must
|
|
731
|
+
* thread it — a missed site would silently under-index, which is load-bearing for
|
|
732
|
+
* the reusable-block delete guard (RB4). Ships dark in RB1: rows populate, nothing
|
|
733
|
+
* reads them yet.
|
|
734
|
+
*/ async function insertReferenceUsagesForVersions(tx, rootId, versions, collectionDef) {
|
|
735
|
+
const rows = [];
|
|
736
|
+
for (const version of versions){
|
|
737
|
+
const refProps = getReferencePropertyNames(collectionDef, version.type);
|
|
738
|
+
for (const [propKey] of refProps){
|
|
739
|
+
const value = version.properties[propKey];
|
|
740
|
+
if (typeof value !== 'string' || !value) continue;
|
|
741
|
+
rows.push({
|
|
742
|
+
id: newId('contentUsage'),
|
|
743
|
+
targetKind: 'reference',
|
|
744
|
+
targetKey: value,
|
|
745
|
+
blockVersionId: version.blockVersionId,
|
|
746
|
+
rootId,
|
|
747
|
+
blockId: version.blockId,
|
|
748
|
+
propertyKey: propKey
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (rows.length > 0) {
|
|
753
|
+
await tx.insert(contentUsages).values(rows).onConflictDoNothing();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const VAR_PATTERN = /\{\{(\w+)\}\}/g;
|
|
758
|
+
/**
|
|
759
|
+
* Extracts all variable keys referenced in a string value.
|
|
760
|
+
* Returns a deduplicated array of keys.
|
|
761
|
+
*/ function extractVariableKeys(value) {
|
|
762
|
+
const keys = new Set();
|
|
763
|
+
let match;
|
|
764
|
+
VAR_PATTERN.lastIndex = 0;
|
|
765
|
+
while((match = VAR_PATTERN.exec(value)) !== null){
|
|
766
|
+
keys.add(match[1]);
|
|
767
|
+
}
|
|
768
|
+
return [
|
|
769
|
+
...keys
|
|
770
|
+
];
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Scans all string properties of a block and returns a map of
|
|
774
|
+
* propertyKey -> variableKeys[] for properties that contain {{...}} patterns.
|
|
775
|
+
*/ function extractVariableKeysFromProperties(properties) {
|
|
776
|
+
const result = new Map();
|
|
777
|
+
for (const [propKey, value] of Object.entries(properties)){
|
|
778
|
+
if (typeof value !== 'string') continue;
|
|
779
|
+
const keys = extractVariableKeys(value);
|
|
780
|
+
if (keys.length > 0) {
|
|
781
|
+
result.set(propKey, keys);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Inserts content_usages variable rows for newly-created block versions, within the same
|
|
788
|
+
* transaction that created them. Insert-only and keyed by the immutable
|
|
789
|
+
* blockVersionId — the version-keyed counterpart of
|
|
790
|
+
* insertAssetReferencesForVersions; see its doc for why this replaces the old
|
|
791
|
+
* branch-blind delete-then-reinsert. MUST be called at every block-version
|
|
792
|
+
* insert site (variable keys are free text, so there is no FK to validate).
|
|
793
|
+
*/ async function insertVariableUsagesForVersions(tx, rootId, versions) {
|
|
794
|
+
const rows = [];
|
|
795
|
+
for (const version of versions){
|
|
796
|
+
const extracted = extractVariableKeysFromProperties(version.properties);
|
|
797
|
+
for (const [propKey, varKeys] of extracted){
|
|
798
|
+
for (const varKey of varKeys){
|
|
799
|
+
rows.push({
|
|
800
|
+
id: newId('contentUsage'),
|
|
801
|
+
targetKind: 'variable',
|
|
802
|
+
targetKey: varKey,
|
|
803
|
+
blockVersionId: version.blockVersionId,
|
|
804
|
+
rootId,
|
|
805
|
+
blockId: version.blockId,
|
|
806
|
+
propertyKey: propKey
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (rows.length > 0) {
|
|
812
|
+
await tx.insert(contentUsages).values(rows).onConflictDoNothing();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Single entry point that populates ALL content-derived usage indexes — the one
|
|
818
|
+
* generalist `content_usages` table (asset + variable rows today; reference rows
|
|
819
|
+
* from RB1) — for freshly-created block versions.
|
|
820
|
+
*
|
|
821
|
+
* These indexes are keyed by the immutable blockVersionId and are insert-only:
|
|
822
|
+
* they are written exactly once, here, in the same transaction that creates the
|
|
823
|
+
* versions, and are never re-synced (rows fall away by FK cascade when the
|
|
824
|
+
* version is pruned). Liveness is decided by joining to branch-head snapshots,
|
|
825
|
+
* so superseded versions simply stop counting without any delete.
|
|
826
|
+
*
|
|
827
|
+
* MUST be invoked at EVERY block-version insert site — the only three are
|
|
828
|
+
* commit-writer's writeCommit + createInitialCommit and merges'
|
|
829
|
+
* createMergeBlockVersion. A new insert site that forgets this call would make
|
|
830
|
+
* its content invisible to the GC (asset data-loss) and to the usage UI. The
|
|
831
|
+
* REQUIRED `collectionDef` (no default) is what the reference indexer needs to
|
|
832
|
+
* know which properties are references — keeping it required means the compiler
|
|
833
|
+
* flags any insert site that fails to thread it.
|
|
834
|
+
*
|
|
835
|
+
* Tombstones (deleted=true) carry old properties forward but never appear in a
|
|
836
|
+
* live view, so they are skipped — keeping the index to live-capable versions.
|
|
837
|
+
*/ async function indexVersionContent(tx, rootId, versions, collectionDef) {
|
|
838
|
+
const live = versions.filter((v)=>!v.deleted);
|
|
839
|
+
if (live.length === 0) return;
|
|
840
|
+
const payload = live.map((v)=>({
|
|
841
|
+
blockVersionId: v.blockVersionId,
|
|
842
|
+
blockId: v.blockId,
|
|
843
|
+
type: v.type,
|
|
844
|
+
properties: v.properties
|
|
845
|
+
}));
|
|
846
|
+
await insertAssetReferencesForVersions(tx, rootId, payload);
|
|
847
|
+
await insertVariableUsagesForVersions(tx, rootId, payload);
|
|
848
|
+
await insertReferenceUsagesForVersions(tx, rootId, payload, collectionDef);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Write the very first commit of a new root: creates the commit (no parent),
|
|
853
|
+
* creates the `main` branch pointing at it, inserts every version, and writes a
|
|
854
|
+
* snapshot row per version (no copy-forward — there is no parent snapshot).
|
|
855
|
+
*
|
|
856
|
+
* Used by createRoot and the root-mode of duplicateBlock. The caller inserts the
|
|
857
|
+
* `roots` row itself (scopedInsert) before calling this.
|
|
858
|
+
*/ async function createInitialCommit(tx, // See writeCommit — required so reference indexing is never silently skipped.
|
|
859
|
+
collectionDef, args) {
|
|
860
|
+
const [commit] = await tx.insert(commits).values({
|
|
861
|
+
rootId: args.rootId,
|
|
862
|
+
message: args.message,
|
|
863
|
+
createdBy: args.createdBy
|
|
864
|
+
}).returning();
|
|
865
|
+
const [branch] = await tx.insert(branches).values({
|
|
866
|
+
rootId: args.rootId,
|
|
867
|
+
name: args.branchName ?? 'main',
|
|
868
|
+
headCommitId: commit.id,
|
|
869
|
+
createdBy: args.createdBy
|
|
870
|
+
}).returning();
|
|
871
|
+
const versionIdByBlockId = new Map();
|
|
872
|
+
if (args.versions.length > 0) {
|
|
873
|
+
const inserted = await tx.insert(blockVersions).values(args.versions.map((v)=>({
|
|
874
|
+
blockId: v.blockId,
|
|
875
|
+
rootId: args.rootId,
|
|
876
|
+
commitId: commit.id,
|
|
877
|
+
type: v.type,
|
|
878
|
+
properties: v.properties,
|
|
879
|
+
children: v.children,
|
|
880
|
+
deleted: v.deleted ?? false
|
|
881
|
+
}))).returning();
|
|
882
|
+
for (const v of inserted){
|
|
883
|
+
versionIdByBlockId.set(v.blockId, v.id);
|
|
884
|
+
}
|
|
885
|
+
await tx.insert(commitSnapshots).values(inserted.map((v)=>({
|
|
886
|
+
commitId: commit.id,
|
|
887
|
+
blockId: v.blockId,
|
|
888
|
+
blockVersionId: v.id
|
|
889
|
+
})));
|
|
890
|
+
await indexVersionContent(tx, args.rootId, inserted.map((v)=>({
|
|
891
|
+
blockVersionId: v.id,
|
|
892
|
+
blockId: v.blockId,
|
|
893
|
+
type: v.type,
|
|
894
|
+
properties: v.properties,
|
|
895
|
+
deleted: v.deleted
|
|
896
|
+
})), collectionDef);
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
commitId: commit.id,
|
|
900
|
+
branchId: branch.id,
|
|
901
|
+
versionIdByBlockId
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function deepCopySubtree(versionByBlockId, startBlockId) {
|
|
906
|
+
const idMap = new Map();
|
|
907
|
+
const collectIds = (blockId)=>{
|
|
908
|
+
idMap.set(blockId, newId('block'));
|
|
909
|
+
const version = versionByBlockId.get(blockId);
|
|
910
|
+
if (!version) return;
|
|
911
|
+
for (const childId of version.children ?? []){
|
|
912
|
+
collectIds(childId);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
collectIds(startBlockId);
|
|
916
|
+
const copies = [];
|
|
917
|
+
const buildCopies = (blockId)=>{
|
|
918
|
+
const version = versionByBlockId.get(blockId);
|
|
919
|
+
if (!version) return;
|
|
920
|
+
const newBlockId = idMap.get(blockId);
|
|
921
|
+
const newChildren = (version.children ?? []).map((id)=>idMap.get(id));
|
|
922
|
+
copies.push({
|
|
923
|
+
oldBlockId: blockId,
|
|
924
|
+
newBlockId,
|
|
925
|
+
type: version.type,
|
|
926
|
+
properties: version.properties,
|
|
927
|
+
newChildren
|
|
928
|
+
});
|
|
929
|
+
for (const childId of version.children ?? []){
|
|
930
|
+
buildCopies(childId);
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
buildCopies(startBlockId);
|
|
934
|
+
return {
|
|
935
|
+
copies,
|
|
936
|
+
idMap
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const CMS_ERRORS = {
|
|
941
|
+
BRANCH_NOT_FOUND: {
|
|
942
|
+
status: 404,
|
|
943
|
+
message: 'Branch not found'
|
|
944
|
+
},
|
|
945
|
+
BLOCK_NOT_FOUND: {
|
|
946
|
+
status: 404,
|
|
947
|
+
message: 'Block not found in snapshot'
|
|
948
|
+
},
|
|
949
|
+
PARENT_NOT_FOUND: {
|
|
950
|
+
status: 404,
|
|
951
|
+
message: 'Parent block not found'
|
|
952
|
+
},
|
|
953
|
+
ROOT_NOT_FOUND: {
|
|
954
|
+
status: 404,
|
|
955
|
+
message: 'Root block not found in snapshot'
|
|
956
|
+
},
|
|
957
|
+
ROOT_HAS_CHILDREN: {
|
|
958
|
+
status: 400,
|
|
959
|
+
message: 'Cannot delete a page that has child pages; archive or move the children first'
|
|
960
|
+
},
|
|
961
|
+
ROOT_IN_USE: {
|
|
962
|
+
status: 409,
|
|
963
|
+
message: 'Cannot delete: this root is embedded as a reusable block on live pages; remove those references first'
|
|
964
|
+
},
|
|
965
|
+
COMMIT_NOT_FOUND: {
|
|
966
|
+
status: 404,
|
|
967
|
+
message: 'Commit not found'
|
|
968
|
+
},
|
|
969
|
+
FOLDER_NOT_FOUND: {
|
|
970
|
+
status: 404,
|
|
971
|
+
message: 'Folder not found'
|
|
972
|
+
},
|
|
973
|
+
FOLDER_HAS_CONTENT: {
|
|
974
|
+
status: 400,
|
|
975
|
+
message: 'Cannot delete folder that contains assets or subfolders'
|
|
976
|
+
},
|
|
977
|
+
EMPTY_SNAPSHOT: {
|
|
978
|
+
status: 400,
|
|
979
|
+
message: 'Empty snapshot — no versions found'
|
|
980
|
+
},
|
|
981
|
+
BLOCK_ALREADY_DELETED: {
|
|
982
|
+
status: 400,
|
|
983
|
+
message: 'Block is already deleted'
|
|
984
|
+
},
|
|
985
|
+
TYPE_MISMATCH: {
|
|
986
|
+
status: 400,
|
|
987
|
+
message: 'Block type does not match the expected type'
|
|
988
|
+
},
|
|
989
|
+
USER_ID_REQUIRED: {
|
|
990
|
+
status: 400,
|
|
991
|
+
message: 'userId is required for this route when neither the request nor middleware provides one'
|
|
992
|
+
},
|
|
993
|
+
CANNOT_MOVE_ROOT: {
|
|
994
|
+
status: 400,
|
|
995
|
+
message: 'Cannot move the root block'
|
|
996
|
+
},
|
|
997
|
+
CANNOT_MOVE_INTO_SELF: {
|
|
998
|
+
status: 400,
|
|
999
|
+
message: 'Cannot move an item into itself'
|
|
1000
|
+
},
|
|
1001
|
+
CANNOT_MOVE_INTO_DESCENDANT: {
|
|
1002
|
+
status: 400,
|
|
1003
|
+
message: 'Cannot move an item into its own descendant'
|
|
1004
|
+
},
|
|
1005
|
+
MISSING_TARGET_PROPERTIES: {
|
|
1006
|
+
status: 400,
|
|
1007
|
+
message: 'targetProperties is required when duplicating a root'
|
|
1008
|
+
},
|
|
1009
|
+
BRANCH_NAME_ALREADY_EXISTS: {
|
|
1010
|
+
status: 400,
|
|
1011
|
+
message: 'A branch with this name already exists for this root'
|
|
1012
|
+
},
|
|
1013
|
+
CANNOT_RENAME_MAIN_BRANCH: {
|
|
1014
|
+
status: 400,
|
|
1015
|
+
message: 'The main branch cannot be renamed'
|
|
1016
|
+
},
|
|
1017
|
+
CANNOT_DELETE_MAIN_BRANCH: {
|
|
1018
|
+
status: 400,
|
|
1019
|
+
message: 'The main branch cannot be deleted'
|
|
1020
|
+
},
|
|
1021
|
+
BRANCH_HAS_PUBLICATIONS: {
|
|
1022
|
+
status: 400,
|
|
1023
|
+
message: 'Cannot delete a branch that has active publications'
|
|
1024
|
+
},
|
|
1025
|
+
BRANCH_HAS_OPEN_MERGE_REQUESTS: {
|
|
1026
|
+
status: 400,
|
|
1027
|
+
message: 'Cannot delete a branch that is part of open merge requests'
|
|
1028
|
+
},
|
|
1029
|
+
NO_COMMON_ANCESTOR: {
|
|
1030
|
+
status: 400,
|
|
1031
|
+
message: 'The two branches share no common ancestor'
|
|
1032
|
+
},
|
|
1033
|
+
MERGE_REQUEST_NOT_FOUND: {
|
|
1034
|
+
status: 404,
|
|
1035
|
+
message: 'Merge request not found'
|
|
1036
|
+
},
|
|
1037
|
+
MERGE_REQUEST_NOT_OPEN: {
|
|
1038
|
+
status: 400,
|
|
1039
|
+
message: 'Merge request is not open'
|
|
1040
|
+
},
|
|
1041
|
+
MERGE_REQUEST_NOT_CLOSED: {
|
|
1042
|
+
status: 400,
|
|
1043
|
+
message: 'Merge request is not closed'
|
|
1044
|
+
},
|
|
1045
|
+
MERGE_REQUEST_ALREADY_MERGED: {
|
|
1046
|
+
status: 400,
|
|
1047
|
+
message: 'Merge request has already been merged and cannot be reopened'
|
|
1048
|
+
},
|
|
1049
|
+
MERGE_REQUEST_ALREADY_EXISTS: {
|
|
1050
|
+
status: 400,
|
|
1051
|
+
message: 'An open merge request already exists for this source and target branch'
|
|
1052
|
+
},
|
|
1053
|
+
MERGE_REQUEST_OUTDATED: {
|
|
1054
|
+
status: 400,
|
|
1055
|
+
message: 'Merge request is outdated because the source branch changed after it was opened'
|
|
1056
|
+
},
|
|
1057
|
+
UNRESOLVED_CONFLICTS: {
|
|
1058
|
+
status: 400,
|
|
1059
|
+
message: 'Cannot merge: there are unresolved conflicts'
|
|
1060
|
+
},
|
|
1061
|
+
CONFLICT_NOT_FOUND: {
|
|
1062
|
+
status: 404,
|
|
1063
|
+
message: 'Merge conflict not found'
|
|
1064
|
+
},
|
|
1065
|
+
RESOLVED_VERSION_NOT_FOUND: {
|
|
1066
|
+
status: 404,
|
|
1067
|
+
message: 'The provided resolvedVersionId does not reference an existing block version'
|
|
1068
|
+
},
|
|
1069
|
+
APPROVAL_NOT_FOUND: {
|
|
1070
|
+
status: 404,
|
|
1071
|
+
message: 'Approval not found'
|
|
1072
|
+
},
|
|
1073
|
+
APPROVAL_ALREADY_REQUESTED: {
|
|
1074
|
+
status: 400,
|
|
1075
|
+
message: 'An approval has already been requested from this reviewer'
|
|
1076
|
+
},
|
|
1077
|
+
APPROVAL_NOT_PENDING: {
|
|
1078
|
+
status: 400,
|
|
1079
|
+
message: 'Approval is not pending'
|
|
1080
|
+
},
|
|
1081
|
+
APPROVAL_REVIEWER_MISMATCH: {
|
|
1082
|
+
status: 403,
|
|
1083
|
+
message: 'Only the requested reviewer can approve or reject this request'
|
|
1084
|
+
},
|
|
1085
|
+
APPROVAL_STALE: {
|
|
1086
|
+
status: 400,
|
|
1087
|
+
message: 'Approval is stale: the branch has advanced past the approved commit'
|
|
1088
|
+
},
|
|
1089
|
+
MERGE_APPROVAL_REQUIRED: {
|
|
1090
|
+
status: 400,
|
|
1091
|
+
message: 'Cannot merge: approval is required before execution'
|
|
1092
|
+
},
|
|
1093
|
+
PUBLICATION_APPROVAL_REQUIRED: {
|
|
1094
|
+
status: 400,
|
|
1095
|
+
message: 'Cannot publish: approval is required before publication'
|
|
1096
|
+
},
|
|
1097
|
+
APPROVALS_NOT_FULLY_APPROVED: {
|
|
1098
|
+
status: 400,
|
|
1099
|
+
message: 'Cannot proceed: not all requested approvals are approved'
|
|
1100
|
+
},
|
|
1101
|
+
BRANCHES_NOT_SAME_ROOT: {
|
|
1102
|
+
status: 400,
|
|
1103
|
+
message: 'Source and target branches must belong to the same root'
|
|
1104
|
+
},
|
|
1105
|
+
PUBLICATION_NOT_FOUND: {
|
|
1106
|
+
status: 404,
|
|
1107
|
+
message: 'Publication not found for this branch'
|
|
1108
|
+
},
|
|
1109
|
+
PUBLISHED_CONTENT_NOT_FOUND: {
|
|
1110
|
+
status: 404,
|
|
1111
|
+
message: 'No published content found'
|
|
1112
|
+
},
|
|
1113
|
+
AMBIGUOUS_SLUG: {
|
|
1114
|
+
status: 400,
|
|
1115
|
+
message: 'Multiple roots match this slug — use rootId for an unambiguous lookup'
|
|
1116
|
+
},
|
|
1117
|
+
DATA_RETENTION_NOT_CONFIGURED: {
|
|
1118
|
+
status: 400,
|
|
1119
|
+
message: 'dataRetention is not configured for this CMS instance'
|
|
1120
|
+
},
|
|
1121
|
+
MISSING_REQUIRED_S3_PARAMETERS: {
|
|
1122
|
+
status: 400,
|
|
1123
|
+
message: 'Missing required S3 parameters: hostname, accessKeyId, or secretAccessKey'
|
|
1124
|
+
},
|
|
1125
|
+
UNKNOWN_S3_PROVIDER: {
|
|
1126
|
+
status: 400,
|
|
1127
|
+
message: 'Unknown S3 provider specified'
|
|
1128
|
+
},
|
|
1129
|
+
SLUG_GENERATION_FAILED: {
|
|
1130
|
+
status: 500,
|
|
1131
|
+
message: 'Failed to generate a unique slug after maximum attempts'
|
|
1132
|
+
},
|
|
1133
|
+
TOO_MANY_FILES: {
|
|
1134
|
+
status: 400,
|
|
1135
|
+
message: 'Too many files in upload batch'
|
|
1136
|
+
},
|
|
1137
|
+
FILE_TOO_LARGE: {
|
|
1138
|
+
status: 400,
|
|
1139
|
+
message: 'One or more files exceed the maximum allowed size'
|
|
1140
|
+
},
|
|
1141
|
+
INVALID_FILE_TYPE: {
|
|
1142
|
+
status: 400,
|
|
1143
|
+
message: 'One or more files have a disallowed MIME type'
|
|
1144
|
+
},
|
|
1145
|
+
UPLOAD_FAILED: {
|
|
1146
|
+
status: 500,
|
|
1147
|
+
message: 'Server-side upload to S3 failed'
|
|
1148
|
+
},
|
|
1149
|
+
SLUG_ALREADY_EXISTS: {
|
|
1150
|
+
status: 409,
|
|
1151
|
+
message: 'A root with this slug on this collection with this parentRootId already exists'
|
|
1152
|
+
},
|
|
1153
|
+
SLUG_NOT_ENABLED: {
|
|
1154
|
+
status: 400,
|
|
1155
|
+
message: 'This collection does not have slugs enabled'
|
|
1156
|
+
},
|
|
1157
|
+
REDIRECT_NOT_FOUND: {
|
|
1158
|
+
status: 404,
|
|
1159
|
+
message: 'Redirect not found'
|
|
1160
|
+
},
|
|
1161
|
+
REDIRECT_INVALID: {
|
|
1162
|
+
status: 400,
|
|
1163
|
+
message: 'A redirect endpoint must be a page (rootId) or a path, matching its type'
|
|
1164
|
+
},
|
|
1165
|
+
REDIRECT_SOURCE_EXISTS: {
|
|
1166
|
+
status: 409,
|
|
1167
|
+
message: 'An active redirect already exists for this source'
|
|
1168
|
+
},
|
|
1169
|
+
SLUG_EMPTY_NOT_ALLOWED: {
|
|
1170
|
+
status: 400,
|
|
1171
|
+
message: 'Empty slug is not allowed for this collection (allowRoot is false)'
|
|
1172
|
+
},
|
|
1173
|
+
NESTING_NOT_ENABLED: {
|
|
1174
|
+
status: 400,
|
|
1175
|
+
message: 'parentRootId is not allowed — this collection does not have nested pages enabled'
|
|
1176
|
+
},
|
|
1177
|
+
CIRCULAR_REFERENCE: {
|
|
1178
|
+
status: 400,
|
|
1179
|
+
message: 'Cannot move a page under itself or one of its descendants'
|
|
1180
|
+
},
|
|
1181
|
+
PARENT_ROOT_NOT_FOUND: {
|
|
1182
|
+
status: 404,
|
|
1183
|
+
message: 'Parent root not found in this collection'
|
|
1184
|
+
},
|
|
1185
|
+
REFERENCE_DEPTH_EXCEEDED: {
|
|
1186
|
+
status: 422,
|
|
1187
|
+
message: 'Reference nesting is too deep (a reusable block embeds others past the limit)'
|
|
1188
|
+
},
|
|
1189
|
+
ASSET_NOT_FOUND: {
|
|
1190
|
+
status: 404,
|
|
1191
|
+
message: 'Asset not found'
|
|
1192
|
+
},
|
|
1193
|
+
VARIABLE_NOT_FOUND: {
|
|
1194
|
+
status: 404,
|
|
1195
|
+
message: 'Variable not found'
|
|
1196
|
+
},
|
|
1197
|
+
VARIABLE_KEY_EXISTS: {
|
|
1198
|
+
status: 409,
|
|
1199
|
+
message: 'A variable with this key already exists'
|
|
1200
|
+
},
|
|
1201
|
+
VARIABLE_IN_USE: {
|
|
1202
|
+
status: 409,
|
|
1203
|
+
message: 'Cannot delete variable: it is still in use'
|
|
1204
|
+
},
|
|
1205
|
+
TEMPLATE_NOT_FOUND: {
|
|
1206
|
+
status: 404,
|
|
1207
|
+
message: 'Template not found'
|
|
1208
|
+
},
|
|
1209
|
+
TEMPLATE_KEY_EXISTS: {
|
|
1210
|
+
status: 409,
|
|
1211
|
+
message: 'A template for this collection/block/property combination already exists'
|
|
1212
|
+
},
|
|
1213
|
+
ASSET_ACCESS_DENIED: {
|
|
1214
|
+
status: 403,
|
|
1215
|
+
message: 'This asset is private and requires authentication'
|
|
1216
|
+
},
|
|
1217
|
+
COMMENT_THREAD_NOT_FOUND: {
|
|
1218
|
+
status: 404,
|
|
1219
|
+
message: 'Comment thread not found'
|
|
1220
|
+
},
|
|
1221
|
+
COMMENT_THREAD_ALREADY_RESOLVED: {
|
|
1222
|
+
status: 400,
|
|
1223
|
+
message: 'Comment thread is already resolved'
|
|
1224
|
+
},
|
|
1225
|
+
COMMENT_THREAD_NOT_RESOLVED: {
|
|
1226
|
+
status: 400,
|
|
1227
|
+
message: 'Comment thread is not resolved'
|
|
1228
|
+
},
|
|
1229
|
+
COMMENT_MESSAGE_NOT_FOUND: {
|
|
1230
|
+
status: 404,
|
|
1231
|
+
message: 'Comment message not found'
|
|
1232
|
+
},
|
|
1233
|
+
COMMENT_MESSAGE_DELETED: {
|
|
1234
|
+
status: 400,
|
|
1235
|
+
message: 'Comment message has been deleted'
|
|
1236
|
+
},
|
|
1237
|
+
COMMENT_BODY_REQUIRED: {
|
|
1238
|
+
status: 400,
|
|
1239
|
+
message: 'Body is required for comment messages'
|
|
1240
|
+
},
|
|
1241
|
+
COMMENT_AUTHOR_MISMATCH: {
|
|
1242
|
+
status: 403,
|
|
1243
|
+
message: 'Only the author can edit or delete this message'
|
|
1244
|
+
},
|
|
1245
|
+
NOTIFICATION_NOT_FOUND: {
|
|
1246
|
+
status: 404,
|
|
1247
|
+
message: 'Notification not found'
|
|
1248
|
+
},
|
|
1249
|
+
NOTIFICATION_RECIPIENT_MISMATCH: {
|
|
1250
|
+
status: 403,
|
|
1251
|
+
message: 'You can only access your own notifications'
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
/**
|
|
1255
|
+
* Type-safe CMS error that extends better-call's APIError.
|
|
1256
|
+
* The `code` parameter is a string-literal union of all CMS error codes,
|
|
1257
|
+
* so typos are caught at compile time.
|
|
1258
|
+
*/ class CMSError extends betterCall.APIError {
|
|
1259
|
+
constructor(code, overrides){
|
|
1260
|
+
const def = CMS_ERRORS[code];
|
|
1261
|
+
super(def.status, {
|
|
1262
|
+
message: overrides?.message ?? def.message,
|
|
1263
|
+
code
|
|
1264
|
+
});
|
|
1265
|
+
this.cmsCode = code;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Loads a root by id, scoped to the collection AND the active plugin scope
|
|
1271
|
+
* (e.g. multi-tenant's `tenant_slug` predicate). Throws when the root does not
|
|
1272
|
+
* exist or lies outside the caller's scope.
|
|
1273
|
+
*
|
|
1274
|
+
* This is the single choke point that closes IDOR on by-id endpoints: a caller in one scope
|
|
1275
|
+
* cannot read or mutate a root in another scope by guessing its id, because the
|
|
1276
|
+
* scope predicate is ANDed into the existence check. Pass the active
|
|
1277
|
+
* transaction (or `db`) as `exec` so the guard participates in the same tx.
|
|
1278
|
+
*
|
|
1279
|
+
* Soft-archived roots (`archivedAt` set) are treated as gone: they are excluded
|
|
1280
|
+
* here, so every by-id read/mutation 404s on an archived root. Physical removal
|
|
1281
|
+
* is the pruning layer's job; deleteRoot and pruning query roots directly.
|
|
1282
|
+
*/ async function requireRootInScope(exec, rootId, collection, rootScope, // A core error code (default ROOT_NOT_FOUND) OR a factory returning the error
|
|
1283
|
+
// to throw — the latter lets a plugin raise its OWN error (e.g. the i18n
|
|
1284
|
+
// plugin's TRANSLATION_SOURCE_NOT_FOUND) without core naming a plugin code.
|
|
1285
|
+
notFound = 'ROOT_NOT_FOUND') {
|
|
1286
|
+
const [row] = await exec.select({
|
|
1287
|
+
id: roots.id
|
|
1288
|
+
}).from(roots).where(drizzleOrm.and(drizzleOrm.eq(roots.id, rootId), drizzleOrm.eq(roots.collection, collection), drizzleOrm.isNull(roots.archivedAt), rootScope?.where)).limit(1);
|
|
1289
|
+
if (!row) {
|
|
1290
|
+
throw typeof notFound === 'function' ? notFound() : new CMSError(notFound);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const cmsContext = betterCall.createMiddleware(async ()=>{
|
|
1295
|
+
return {};
|
|
1296
|
+
});
|
|
1297
|
+
const createCMSEndpoint = betterCall.createEndpoint.create({
|
|
1298
|
+
use: [
|
|
1299
|
+
cmsContext
|
|
1300
|
+
]
|
|
1301
|
+
});
|
|
1302
|
+
function cmsMeta(base, cms) {
|
|
1303
|
+
return {
|
|
1304
|
+
...base,
|
|
1305
|
+
cms
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function normalizeSlug(raw) {
|
|
1310
|
+
return slugify__default.default(raw, {
|
|
1311
|
+
lower: true,
|
|
1312
|
+
strict: true,
|
|
1313
|
+
trim: true
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Build the full URL path for a root given its slug config and ancestor segments.
|
|
1318
|
+
* `segments` is ordered root-to-leaf (e.g. ['about', 'team']).
|
|
1319
|
+
*/ function buildFullPath(slugConfig, segments) {
|
|
1320
|
+
const root = slugConfig.root.replace(/\/+$/, '');
|
|
1321
|
+
const joined = segments.filter(Boolean).join('/');
|
|
1322
|
+
if (!joined) return root || '/';
|
|
1323
|
+
return root ? `${root}/${joined}` : `/${joined}`;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Validate that a slug segment is unique among siblings in the same collection.
|
|
1327
|
+
* Throws `SLUG_ALREADY_EXISTS` if a conflict is found.
|
|
1328
|
+
*/ const SAFE_SCOPE_COLUMN = /^[a-z_][a-z0-9_]*$/i;
|
|
1329
|
+
async function validateSlugUniqueness(db, collection, parentRootId, slug, excludeRootId, scopeColumns) {
|
|
1330
|
+
const parentCondition = parentRootId === null ? drizzleOrm.sql`r.parent_root_id IS NULL` : drizzleOrm.sql`r.parent_root_id = ${parentRootId}`;
|
|
1331
|
+
const excludeCondition = drizzleOrm.sql``;
|
|
1332
|
+
// Authoritative app-level uniqueness over ALL active scope dimensions. The core
|
|
1333
|
+
// slug index is non-unique, so THIS is the authority on every slug write. A
|
|
1334
|
+
// scoping plugin passes its per-row scope columns (e.g. `language`,
|
|
1335
|
+
// `tenant_slug` — the same values it stamps on insert), each ANDed in so the
|
|
1336
|
+
// effective key is (…scope, collection, parentRootId, slug). Single-tenant
|
|
1337
|
+
// installs pass nothing → global, identical to before. Plugin-owned columns are
|
|
1338
|
+
// referenced via raw SQL (they don't exist in the core Drizzle type); the column
|
|
1339
|
+
// name is validated as a safe identifier.
|
|
1340
|
+
const scopeConds = scopeColumns ? Object.entries(scopeColumns).flatMap(([col, val])=>{
|
|
1341
|
+
if (val === undefined || val === null) return [];
|
|
1342
|
+
if (!SAFE_SCOPE_COLUMN.test(col)) {
|
|
1343
|
+
throw new Error(`validateSlugUniqueness: unsafe scope column "${col}"`);
|
|
1344
|
+
}
|
|
1345
|
+
return [
|
|
1346
|
+
drizzleOrm.sql`AND r.${drizzleOrm.sql.raw(col)} = ${val}`
|
|
1347
|
+
];
|
|
1348
|
+
}) : [];
|
|
1349
|
+
const scopeCondition = scopeConds.length > 0 ? drizzleOrm.sql.join(scopeConds, drizzleOrm.sql` `) : drizzleOrm.sql``;
|
|
1350
|
+
const result = await db.execute(drizzleOrm.sql`
|
|
1351
|
+
SELECT 1 FROM cms.roots r
|
|
1352
|
+
WHERE r.collection = ${collection}
|
|
1353
|
+
AND ${parentCondition}
|
|
1354
|
+
AND r.slug = ${slug}
|
|
1355
|
+
${scopeCondition}
|
|
1356
|
+
${excludeCondition}
|
|
1357
|
+
LIMIT 1
|
|
1358
|
+
`);
|
|
1359
|
+
if (result.rows.length > 0) {
|
|
1360
|
+
throw new CMSError('SLUG_ALREADY_EXISTS');
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Walk up the parent chain from a given root, returning ancestor rows
|
|
1365
|
+
* ordered from the topmost ancestor down to (but not including) the given root.
|
|
1366
|
+
*
|
|
1367
|
+
* `scopeColumns` are plugin-owned per-scope columns (e.g. `tenant_slug`,
|
|
1368
|
+
* `language`) that the walk must stay within: each is SELECTed in the anchor and
|
|
1369
|
+
* the recursion is constrained to parents whose value MATCHES the starting root's
|
|
1370
|
+
* (`p.<col> = a.<col>`). Combined with the always-on `collection` match, the walk
|
|
1371
|
+
* can never cross a tenant/language/collection boundary on corrupted data — a
|
|
1372
|
+
* defensive complement to the write-time parent validation. Column names are
|
|
1373
|
+
* validated; pass `Object.keys(scope.roots.insertColumns)`.
|
|
1374
|
+
*/ async function resolveAncestors(db, rootId, scopeColumns) {
|
|
1375
|
+
const cols = ([]).filter((c)=>SAFE_SCOPE_COLUMN.test(c));
|
|
1376
|
+
const anchorSel = drizzleOrm.sql.join(cols.map((c)=>drizzleOrm.sql`, r.${drizzleOrm.sql.raw(c)}`), drizzleOrm.sql``);
|
|
1377
|
+
const recSel = drizzleOrm.sql.join(cols.map((c)=>drizzleOrm.sql`, p.${drizzleOrm.sql.raw(c)}`), drizzleOrm.sql``);
|
|
1378
|
+
const recMatch = drizzleOrm.sql.join(cols.map((c)=>drizzleOrm.sql`AND p.${drizzleOrm.sql.raw(c)} = a.${drizzleOrm.sql.raw(c)}`), drizzleOrm.sql` `);
|
|
1379
|
+
const result = await db.execute(drizzleOrm.sql`
|
|
1380
|
+
WITH RECURSIVE ancestors AS (
|
|
1381
|
+
SELECT r.id, r.slug, r.parent_root_id, r.collection${anchorSel}, 0 AS depth
|
|
1382
|
+
FROM cms.roots r
|
|
1383
|
+
WHERE r.id = ${rootId}
|
|
1384
|
+
|
|
1385
|
+
UNION ALL
|
|
1386
|
+
|
|
1387
|
+
SELECT p.id, p.slug, p.parent_root_id, p.collection${recSel}, a.depth + 1
|
|
1388
|
+
FROM cms.roots p
|
|
1389
|
+
JOIN ancestors a ON a.parent_root_id = p.id
|
|
1390
|
+
-- A root's parent is always same-collection (+ same tenant/language when
|
|
1391
|
+
-- those plugins are active); enforcing it stops the walk from ever crossing
|
|
1392
|
+
-- a scope boundary on corrupted data.
|
|
1393
|
+
WHERE p.collection = a.collection ${recMatch}
|
|
1394
|
+
)
|
|
1395
|
+
SELECT id, slug, parent_root_id
|
|
1396
|
+
FROM ancestors
|
|
1397
|
+
WHERE id != ${rootId}
|
|
1398
|
+
ORDER BY depth DESC
|
|
1399
|
+
`);
|
|
1400
|
+
return result.rows.map((row)=>({
|
|
1401
|
+
rootId: row.id,
|
|
1402
|
+
slug: row.slug,
|
|
1403
|
+
parentRootId: row.parent_root_id
|
|
1404
|
+
}));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Resolve a root to its CURRENT full path (from its live slug chain). Returns the
|
|
1409
|
+
* archived flag and parent so a caller can fall back when the target is archived.
|
|
1410
|
+
* `null` if the root no longer exists.
|
|
1411
|
+
*/ async function resolveRootPath(db, slugCfg, rootId, rootScope) {
|
|
1412
|
+
const [root] = await db.select({
|
|
1413
|
+
slug: roots.slug,
|
|
1414
|
+
parentRootId: roots.parentRootId,
|
|
1415
|
+
archivedAt: roots.archivedAt
|
|
1416
|
+
}).from(roots)// Gate the target by the active scope (tenant + language) so a page-target
|
|
1417
|
+
// pointing out of scope resolves to nothing rather than leaking its path —
|
|
1418
|
+
// symmetric with the scoped source resolution (resolveScopedRootId).
|
|
1419
|
+
.where(drizzleOrm.and(drizzleOrm.eq(roots.id, rootId), rootScope?.where)).limit(1);
|
|
1420
|
+
if (!root) return null;
|
|
1421
|
+
const ancestors = slugCfg.nested ? await resolveAncestors(db, rootId) : [];
|
|
1422
|
+
const segments = [
|
|
1423
|
+
...ancestors.map((a)=>a.slug ?? ''),
|
|
1424
|
+
root.slug ?? ''
|
|
1425
|
+
];
|
|
1426
|
+
return {
|
|
1427
|
+
path: buildFullPath(slugCfg, segments),
|
|
1428
|
+
archived: root.archivedAt != null,
|
|
1429
|
+
parentRootId: root.parentRootId
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
/** A page-reference's CURRENT path (for UI display), or `null` if the root is gone. */ async function resolveRootCurrentPath(db, slugCfg, rootId) {
|
|
1433
|
+
return (await resolveRootPath(db, slugCfg, rootId))?.path ?? null;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Error codes owned by the i18n plugin (typed into the API error union via
|
|
1438
|
+
* InferPluginErrorCodes when the plugin is installed). The LANGUAGE_* codes are
|
|
1439
|
+
* raised by the scope factory; the TRANSLATION_* codes by the per-collection
|
|
1440
|
+
* createTranslation/listTranslations endpoints.
|
|
1441
|
+
*
|
|
1442
|
+
* There is intentionally NO I18N_NOT_ENABLED code: createTranslation /
|
|
1443
|
+
* listTranslations only EXIST when this plugin is installed (they are
|
|
1444
|
+
* contributed via plugin.collectionEndpoints), so "i18n not enabled" is the
|
|
1445
|
+
* structural absence of the endpoint, not a runtime error.
|
|
1446
|
+
*/ const $ERROR_CODES = {
|
|
1447
|
+
LANGUAGE_REQUIRED: {
|
|
1448
|
+
status: 400,
|
|
1449
|
+
message: 'language is required -- authMiddleware must return { language } when the i18n plugin is active'
|
|
1450
|
+
},
|
|
1451
|
+
LANGUAGE_NOT_ENABLED: {
|
|
1452
|
+
status: 400,
|
|
1453
|
+
message: 'the resolved language is not one of the configured i18n languages'
|
|
1454
|
+
},
|
|
1455
|
+
TRANSLATION_SOURCE_NOT_FOUND: {
|
|
1456
|
+
status: 404,
|
|
1457
|
+
message: 'Translation source root not found in this collection / active language'
|
|
1458
|
+
},
|
|
1459
|
+
TRANSLATION_EXISTS: {
|
|
1460
|
+
status: 409,
|
|
1461
|
+
message: 'A translation in the target language already exists for this entry'
|
|
1462
|
+
},
|
|
1463
|
+
TRANSLATION_PARENT_NOT_TRANSLATED: {
|
|
1464
|
+
status: 409,
|
|
1465
|
+
message: 'The parent has no translation in the target language — translate the parent first'
|
|
1466
|
+
},
|
|
1467
|
+
TRANSLATION_LANGUAGE_NOT_ENABLED: {
|
|
1468
|
+
status: 400,
|
|
1469
|
+
message: 'targetLanguage is not one of the configured i18n languages'
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
/** Throw a typed i18n plugin error (mirrors ab-test's abTestError). */ function i18nError(code, message) {
|
|
1473
|
+
throw new betterCall.APIError($ERROR_CODES[code].status, {
|
|
1474
|
+
message: $ERROR_CODES[code].message,
|
|
1475
|
+
code
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* The i18n plugin's per-collection endpoints (Seam A): createTranslation +
|
|
1481
|
+
* listTranslations. Contributed to EVERY collection via plugin.collectionEndpoints,
|
|
1482
|
+
* so they surface at cms.api.<collection>.x ONLY when the i18n plugin is
|
|
1483
|
+
* installed (closing the leak where they appeared on every collection regardless).
|
|
1484
|
+
*
|
|
1485
|
+
* These were lifted from core/routes/blocks.ts verbatim, with two changes:
|
|
1486
|
+
* - i18n error codes (TRANSLATION_*) are thrown via i18nError (APIError + the
|
|
1487
|
+
* plugin's own $ERROR_CODES) instead of CMSError; core slug codes
|
|
1488
|
+
* (SLUG_EMPTY_NOT_ALLOWED) stay as CMSError (still a core code).
|
|
1489
|
+
* - the old I18N_NOT_ENABLED gate is gone: the endpoint only exists when this
|
|
1490
|
+
* plugin is installed, and the i18n scope factory rejects a request with no
|
|
1491
|
+
* active language (LANGUAGE_REQUIRED) before the handler runs.
|
|
1492
|
+
*/ function createI18nCollectionEndpoints(def, pluginCtx, languages) {
|
|
1493
|
+
const collectionName = def.name;
|
|
1494
|
+
const db = pluginCtx.db;
|
|
1495
|
+
return {
|
|
1496
|
+
// i18n: create the sibling-language version of an existing entry. The new
|
|
1497
|
+
// root INHERITS the source's translationKey (so it joins the group), takes
|
|
1498
|
+
// the TARGET language, and hangs under the target-language sibling of the
|
|
1499
|
+
// source's parent. Seeds from the source's `main` tree by default ('copy'),
|
|
1500
|
+
// or starts blank.
|
|
1501
|
+
/**
|
|
1502
|
+
* Creates a sibling-language version of an existing entry, inheriting its translation key and seeding from the source's main tree (or blank).
|
|
1503
|
+
* @param sourceRootId The root to translate from (must exist in the active language).
|
|
1504
|
+
* @param targetLanguage The language for the new root (must be configured in the plugin).
|
|
1505
|
+
* @param targetSlug Optional slug for the target root; defaults to the source slug if not provided.
|
|
1506
|
+
* @param seed How to initialize the target root's draft: 'copy' (default) copies the source's main tree, 'blank' starts empty.
|
|
1507
|
+
* @param message Optional commit message for the initial draft; defaults to 'Translation (language)'.
|
|
1508
|
+
* @returns The new root id, draft branch id, initial commit id, target language, and inherited translation key.
|
|
1509
|
+
* @throws TRANSLATION_LANGUAGE_NOT_ENABLED if targetLanguage is not in the configured language universe.
|
|
1510
|
+
* @throws TRANSLATION_SOURCE_NOT_FOUND if sourceRootId does not exist in the active language/tenant.
|
|
1511
|
+
* @throws TRANSLATION_EXISTS if a translation to targetLanguage already exists for this entry.
|
|
1512
|
+
* @throws TRANSLATION_PARENT_NOT_TRANSLATED if the source has a parent that has no translation in the target language.
|
|
1513
|
+
* @throws SLUG_EMPTY_NOT_ALLOWED if the target slug is empty and the collection disallows root slugs.
|
|
1514
|
+
* @example await cmsClient.pages.createTranslation({ sourceRootId: 'root_abc', targetLanguage: 'de', seed: 'copy' })
|
|
1515
|
+
*/ createTranslation: createCMSEndpoint(`/${collectionName}/createTranslation`, {
|
|
1516
|
+
method: 'POST',
|
|
1517
|
+
body: z__namespace.object({
|
|
1518
|
+
sourceRootId: z__namespace.string(),
|
|
1519
|
+
targetLanguage: z__namespace.string().min(1),
|
|
1520
|
+
targetSlug: z__namespace.string().optional(),
|
|
1521
|
+
seed: z__namespace.enum([
|
|
1522
|
+
'copy',
|
|
1523
|
+
'blank'
|
|
1524
|
+
]).optional(),
|
|
1525
|
+
message: z__namespace.string().optional()
|
|
1526
|
+
}),
|
|
1527
|
+
metadata: cmsMeta({
|
|
1528
|
+
$Infer: {
|
|
1529
|
+
body: {}
|
|
1530
|
+
}
|
|
1531
|
+
}, {
|
|
1532
|
+
permissionResource: 'root',
|
|
1533
|
+
operation: 'create',
|
|
1534
|
+
scope: 'collection',
|
|
1535
|
+
collection: collectionName
|
|
1536
|
+
})
|
|
1537
|
+
}, async (ctx)=>{
|
|
1538
|
+
const { userId, scope } = ctx.context;
|
|
1539
|
+
const actor = userId;
|
|
1540
|
+
const { sourceRootId, targetLanguage, message } = ctx.body;
|
|
1541
|
+
// The target language must be in the configured universe (the plugin's
|
|
1542
|
+
// own `languages`, closed in at assembly) — otherwise we'd stamp a root
|
|
1543
|
+
// with a language no routing could ever serve.
|
|
1544
|
+
if (!languages.includes(targetLanguage)) {
|
|
1545
|
+
i18nError('TRANSLATION_LANGUAGE_NOT_ENABLED');
|
|
1546
|
+
}
|
|
1547
|
+
const seed = ctx.body.seed ?? 'copy';
|
|
1548
|
+
const slugCfg = def.slug;
|
|
1549
|
+
return db.transaction(async (tx)=>{
|
|
1550
|
+
// Source must exist in the ACTIVE language — you translate FROM your
|
|
1551
|
+
// current language context.
|
|
1552
|
+
await requireRootInScope(tx, sourceRootId, collectionName, scope.roots, ()=>i18nError('TRANSLATION_SOURCE_NOT_FOUND'));
|
|
1553
|
+
// Source metadata incl. the plugin-owned translation_key (raw SQL).
|
|
1554
|
+
const srcRows = await tx.execute(drizzleOrm.sql`
|
|
1555
|
+
SELECT slug, parent_root_id, translation_key
|
|
1556
|
+
FROM cms.roots
|
|
1557
|
+
WHERE id = ${sourceRootId} AND collection = ${collectionName}
|
|
1558
|
+
`);
|
|
1559
|
+
const src = srcRows.rows[0];
|
|
1560
|
+
if (!src) i18nError('TRANSLATION_SOURCE_NOT_FOUND');
|
|
1561
|
+
// No existing sibling in the target language (also rejects translating
|
|
1562
|
+
// to the source's own language, since that sibling is the source). The
|
|
1563
|
+
// (translationKey, language) partial unique is the DB backstop for the
|
|
1564
|
+
// race this app check can't cover.
|
|
1565
|
+
const dup = await tx.execute(drizzleOrm.sql`
|
|
1566
|
+
SELECT 1 FROM cms.roots
|
|
1567
|
+
WHERE translation_key = ${src.translation_key}
|
|
1568
|
+
AND language = ${targetLanguage}
|
|
1569
|
+
AND collection = ${collectionName}
|
|
1570
|
+
AND archived_at IS NULL
|
|
1571
|
+
LIMIT 1
|
|
1572
|
+
`);
|
|
1573
|
+
if (dup.rows.length > 0) i18nError('TRANSLATION_EXISTS');
|
|
1574
|
+
// Target parent = the target-language sibling of the source's parent.
|
|
1575
|
+
// The collection filters are defense-in-depth (a root's parent chain is
|
|
1576
|
+
// always same-collection by write-time validation), mirroring createRoot.
|
|
1577
|
+
let targetParentRootId = null;
|
|
1578
|
+
if (src.parent_root_id !== null) {
|
|
1579
|
+
const parentRows = await tx.execute(drizzleOrm.sql`
|
|
1580
|
+
SELECT translation_key FROM cms.roots
|
|
1581
|
+
WHERE id = ${src.parent_root_id} AND collection = ${collectionName}
|
|
1582
|
+
`);
|
|
1583
|
+
const parentKey = parentRows.rows[0]?.translation_key;
|
|
1584
|
+
if (!parentKey) {
|
|
1585
|
+
i18nError('TRANSLATION_PARENT_NOT_TRANSLATED');
|
|
1586
|
+
}
|
|
1587
|
+
const sib = await tx.execute(drizzleOrm.sql`
|
|
1588
|
+
SELECT id FROM cms.roots
|
|
1589
|
+
WHERE translation_key = ${parentKey}
|
|
1590
|
+
AND language = ${targetLanguage}
|
|
1591
|
+
AND collection = ${collectionName}
|
|
1592
|
+
AND archived_at IS NULL
|
|
1593
|
+
LIMIT 1
|
|
1594
|
+
`);
|
|
1595
|
+
const sibRow = sib.rows[0];
|
|
1596
|
+
if (!sibRow) i18nError('TRANSLATION_PARENT_NOT_TRANSLATED');
|
|
1597
|
+
targetParentRootId = sibRow.id;
|
|
1598
|
+
}
|
|
1599
|
+
// Target slug (localized; defaults to the source slug), unique per
|
|
1600
|
+
// target language under the target parent.
|
|
1601
|
+
let targetSlug = null;
|
|
1602
|
+
if (slugCfg?.enabled) {
|
|
1603
|
+
const rawSlug = ctx.body.targetSlug ?? src.slug ?? '';
|
|
1604
|
+
targetSlug = slugCfg.normalize ? normalizeSlug(rawSlug) : rawSlug;
|
|
1605
|
+
if (!targetSlug && !slugCfg.allowRoot) {
|
|
1606
|
+
throw new CMSError('SLUG_EMPTY_NOT_ALLOWED');
|
|
1607
|
+
}
|
|
1608
|
+
await validateSlugUniqueness(tx, collectionName, targetParentRootId, targetSlug, undefined, // Uniqueness is checked in the TARGET language (not the active one),
|
|
1609
|
+
// within the active tenant — override language on the scope columns.
|
|
1610
|
+
{
|
|
1611
|
+
...scope.roots?.insertColumns ?? {},
|
|
1612
|
+
language: targetLanguage
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
// Create the sibling root: TARGET language (override the active-language
|
|
1616
|
+
// insert-scope), INHERITED translationKey, keep any other scope columns
|
|
1617
|
+
// (e.g. tenant_slug).
|
|
1618
|
+
const targetScope = {
|
|
1619
|
+
...scope.roots,
|
|
1620
|
+
insertColumns: {
|
|
1621
|
+
...scope.roots?.insertColumns ?? {},
|
|
1622
|
+
language: targetLanguage
|
|
1623
|
+
}
|
|
1624
|
+
};
|
|
1625
|
+
const newRoot = await scopedInsert(tx, 'cms.roots', {
|
|
1626
|
+
id: newId('root'),
|
|
1627
|
+
collection: collectionName,
|
|
1628
|
+
parent_root_id: targetParentRootId,
|
|
1629
|
+
slug: targetSlug,
|
|
1630
|
+
sort_order: 0,
|
|
1631
|
+
created_by: actor,
|
|
1632
|
+
translation_key: src.translation_key
|
|
1633
|
+
}, targetScope);
|
|
1634
|
+
// Seed the initial commit: copy the source's `main` tree as the starting
|
|
1635
|
+
// draft, or start blank.
|
|
1636
|
+
let versions;
|
|
1637
|
+
if (seed === 'copy') {
|
|
1638
|
+
const [mainBranch] = await tx.select({
|
|
1639
|
+
headCommitId: branches.headCommitId
|
|
1640
|
+
}).from(branches).where(drizzleOrm.and(drizzleOrm.eq(branches.rootId, sourceRootId), drizzleOrm.eq(branches.name, 'main'))).limit(1);
|
|
1641
|
+
if (mainBranch) {
|
|
1642
|
+
const snaps = await tx.select({
|
|
1643
|
+
blockVersionId: commitSnapshots.blockVersionId
|
|
1644
|
+
}).from(commitSnapshots).where(drizzleOrm.eq(commitSnapshots.commitId, mainBranch.headCommitId));
|
|
1645
|
+
const ids = snaps.map((s)=>s.blockVersionId);
|
|
1646
|
+
if (ids.length > 0) {
|
|
1647
|
+
const allV = await tx.select().from(blockVersions).where(drizzleOrm.inArray(blockVersions.id, ids));
|
|
1648
|
+
const byId = new Map(allV.map((v)=>[
|
|
1649
|
+
v.blockId,
|
|
1650
|
+
{
|
|
1651
|
+
blockId: v.blockId,
|
|
1652
|
+
type: v.type,
|
|
1653
|
+
properties: v.properties,
|
|
1654
|
+
children: v.children ?? [],
|
|
1655
|
+
deleted: v.deleted
|
|
1656
|
+
}
|
|
1657
|
+
]));
|
|
1658
|
+
const { copies } = deepCopySubtree(byId, sourceRootId);
|
|
1659
|
+
versions = copies.map((copy)=>{
|
|
1660
|
+
const isTop = copy.oldBlockId === sourceRootId;
|
|
1661
|
+
return {
|
|
1662
|
+
blockId: isTop ? newRoot.id : copy.newBlockId,
|
|
1663
|
+
type: isTop ? collectionName : copy.type,
|
|
1664
|
+
properties: copy.properties,
|
|
1665
|
+
children: copy.newChildren
|
|
1666
|
+
};
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
if (!versions) {
|
|
1672
|
+
versions = [
|
|
1673
|
+
{
|
|
1674
|
+
blockId: newRoot.id,
|
|
1675
|
+
type: collectionName,
|
|
1676
|
+
properties: {},
|
|
1677
|
+
children: []
|
|
1678
|
+
}
|
|
1679
|
+
];
|
|
1680
|
+
}
|
|
1681
|
+
const { commitId, branchId } = await createInitialCommit(tx, def, {
|
|
1682
|
+
rootId: newRoot.id,
|
|
1683
|
+
message: message ?? `Translation (${targetLanguage})`,
|
|
1684
|
+
createdBy: actor,
|
|
1685
|
+
versions
|
|
1686
|
+
});
|
|
1687
|
+
return {
|
|
1688
|
+
rootId: newRoot.id,
|
|
1689
|
+
branchId,
|
|
1690
|
+
commitId,
|
|
1691
|
+
language: targetLanguage,
|
|
1692
|
+
translationKey: src.translation_key
|
|
1693
|
+
};
|
|
1694
|
+
});
|
|
1695
|
+
}),
|
|
1696
|
+
// i18n: the language switcher / "which translations exist" for an entry.
|
|
1697
|
+
// Cross-language by design (queries the translation group), so it deliberately
|
|
1698
|
+
// bypasses the blanket per-language read scope — but the INPUT root is gated to
|
|
1699
|
+
// the active language + tenant, and a translationKey is a globally-unique
|
|
1700
|
+
// group id, so only this tenant's siblings are returned.
|
|
1701
|
+
/**
|
|
1702
|
+
* Retrieves all language variants (siblings) of a given entry, bypassing per-language read scope.
|
|
1703
|
+
* @param rootId The root id (must exist in the active language and tenant).
|
|
1704
|
+
* @returns The translation key (group id) and an array of all siblings with their language, root id, slug, and resolved path.
|
|
1705
|
+
* @throws TRANSLATION_SOURCE_NOT_FOUND if rootId does not exist or has no translation key.
|
|
1706
|
+
* @example await cmsClient.pages.listTranslations({ rootId: 'root_abc' })
|
|
1707
|
+
*/ listTranslations: createCMSEndpoint(`/${collectionName}/listTranslations`, {
|
|
1708
|
+
method: 'GET',
|
|
1709
|
+
query: z__namespace.object({
|
|
1710
|
+
rootId: z__namespace.string()
|
|
1711
|
+
}),
|
|
1712
|
+
metadata: cmsMeta({
|
|
1713
|
+
$Infer: {
|
|
1714
|
+
query: {}
|
|
1715
|
+
}
|
|
1716
|
+
}, {
|
|
1717
|
+
permissionResource: 'root',
|
|
1718
|
+
operation: 'read',
|
|
1719
|
+
scope: 'collection',
|
|
1720
|
+
collection: collectionName
|
|
1721
|
+
})
|
|
1722
|
+
}, async (ctx)=>{
|
|
1723
|
+
const { scope } = ctx.context;
|
|
1724
|
+
const slugCfg = def.slug;
|
|
1725
|
+
// The input root must be in the active language + tenant.
|
|
1726
|
+
await requireRootInScope(db, ctx.query.rootId, collectionName, scope.roots);
|
|
1727
|
+
const keyRows = await db.execute(drizzleOrm.sql`
|
|
1728
|
+
SELECT translation_key FROM cms.roots
|
|
1729
|
+
WHERE id = ${ctx.query.rootId} AND collection = ${collectionName}
|
|
1730
|
+
`);
|
|
1731
|
+
const translationKey = keyRows.rows[0]?.translation_key;
|
|
1732
|
+
if (!translationKey) {
|
|
1733
|
+
i18nError('TRANSLATION_SOURCE_NOT_FOUND');
|
|
1734
|
+
}
|
|
1735
|
+
const sibRows = await db.execute(drizzleOrm.sql`
|
|
1736
|
+
SELECT id, language, slug FROM cms.roots
|
|
1737
|
+
WHERE translation_key = ${translationKey}
|
|
1738
|
+
AND collection = ${collectionName}
|
|
1739
|
+
AND archived_at IS NULL
|
|
1740
|
+
ORDER BY language
|
|
1741
|
+
`);
|
|
1742
|
+
const translations = await Promise.all(sibRows.rows.map(async (r)=>({
|
|
1743
|
+
language: r.language,
|
|
1744
|
+
rootId: r.id,
|
|
1745
|
+
slug: r.slug,
|
|
1746
|
+
path: slugCfg?.enabled && slugCfg ? await resolveRootCurrentPath(db, slugCfg, r.id) : null
|
|
1747
|
+
})));
|
|
1748
|
+
return {
|
|
1749
|
+
translationKey,
|
|
1750
|
+
translations
|
|
1751
|
+
};
|
|
1752
|
+
})
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const cms = pgCore.pgSchema('cms');
|
|
1757
|
+
/**
|
|
1758
|
+
* Typed Drizzle handle for the `cms.roots` columns the i18n resolver queries,
|
|
1759
|
+
* INCLUDING the plugin-owned `language` + `translation_key` — which the core
|
|
1760
|
+
* generated `roots` object does NOT carry (they are contributed only by
|
|
1761
|
+
* `i18nSchema`'s add-only merge). Drizzle permits multiple table objects over
|
|
1762
|
+
* one physical table; this is the i18n plugin's typed VIEW for resolution
|
|
1763
|
+
* queries. It is NOT a schema source — migrations come from `i18nSchema`.
|
|
1764
|
+
*/ const i18nRoots = cms.table('roots', {
|
|
1765
|
+
id: pgCore.text('id').primaryKey(),
|
|
1766
|
+
collection: pgCore.text('collection').notNull(),
|
|
1767
|
+
archivedAt: pgCore.timestamp('archived_at'),
|
|
1768
|
+
language: pgCore.text('language').notNull(),
|
|
1769
|
+
translationKey: pgCore.text('translation_key').notNull()
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* The i18n plugin's reference resolver (Seam B). Owns ALL translation-group
|
|
1774
|
+
* resolution: a stored reference value (`rot_` rootId or `tgr_` group key) is
|
|
1775
|
+
* resolved to the rootId(s) it relates to, honouring the active language + its
|
|
1776
|
+
* fallback chain. Core carries this on the resolved scope and rides it from the
|
|
1777
|
+
* read path and the A/B co-render walk; without the i18n plugin core uses its
|
|
1778
|
+
* own identity default and none of this code runs.
|
|
1779
|
+
*
|
|
1780
|
+
* Because the i18n factory builds this ONLY when a language is active, the impl
|
|
1781
|
+
* assumes i18n is on (no `'language' in scopeColumns` self-gate) and queries the
|
|
1782
|
+
* plugin-owned `language` / `translation_key` columns through a TYPED roots view
|
|
1783
|
+
* (D5) instead of raw SQL — tenant scoping reuses the generic
|
|
1784
|
+
* `rootScopeConditions` (language excluded), so there is no raw tenant predicate.
|
|
1785
|
+
*
|
|
1786
|
+
* Closes over the active `language` + `fallback` chain (the resolution policy);
|
|
1787
|
+
* `db` + `scopeColumns` (the merged tenant predicate) arrive per call.
|
|
1788
|
+
* - resolveRenderTargets: tgr_ → best sibling along [language, ...fallback];
|
|
1789
|
+
* rot_ → active-language sibling of its group, else the stored anchor;
|
|
1790
|
+
* other values → themselves.
|
|
1791
|
+
* - resolveConflictTargets: the whole-group SUPERSET a key could render as
|
|
1792
|
+
* (the A/B co-render conflict set).
|
|
1793
|
+
* - expandGroup / groupKeysFor: translation-group expansion / its `tgr_` keys.
|
|
1794
|
+
*/ function buildI18nReferenceResolver(language, fallback) {
|
|
1795
|
+
const languageChain = [
|
|
1796
|
+
language,
|
|
1797
|
+
...fallback
|
|
1798
|
+
];
|
|
1799
|
+
return {
|
|
1800
|
+
async resolveRenderTargets (db, scopeColumns, collection, storedValues) {
|
|
1801
|
+
const tenantConds = rootScopeConditions(scopeColumns, [
|
|
1802
|
+
'language'
|
|
1803
|
+
]);
|
|
1804
|
+
const valueToRootId = new Map();
|
|
1805
|
+
const tgrValues = [];
|
|
1806
|
+
const rotValues = [];
|
|
1807
|
+
for (const value of storedValues){
|
|
1808
|
+
if (value.startsWith('tgr_')) tgrValues.push(value);
|
|
1809
|
+
else if (value.startsWith('rot_')) rotValues.push(value);
|
|
1810
|
+
else valueToRootId.set(value, value); // unknown prefix — used as-is
|
|
1811
|
+
}
|
|
1812
|
+
if (tgrValues.length > 0) {
|
|
1813
|
+
// One query for all keys across all chain languages; pick the sibling
|
|
1814
|
+
// whose language is highest in the chain.
|
|
1815
|
+
const rows = await db.select({
|
|
1816
|
+
id: i18nRoots.id,
|
|
1817
|
+
translationKey: i18nRoots.translationKey,
|
|
1818
|
+
language: i18nRoots.language
|
|
1819
|
+
}).from(i18nRoots).where(drizzleOrm.and(drizzleOrm.inArray(i18nRoots.translationKey, tgrValues), drizzleOrm.inArray(i18nRoots.language, languageChain), drizzleOrm.eq(i18nRoots.collection, collection), drizzleOrm.isNull(i18nRoots.archivedAt), ...tenantConds));
|
|
1820
|
+
const rank = new Map(languageChain.map((l, i)=>[
|
|
1821
|
+
l,
|
|
1822
|
+
i
|
|
1823
|
+
]));
|
|
1824
|
+
const best = new Map();
|
|
1825
|
+
for (const r of rows){
|
|
1826
|
+
const rk = rank.get(r.language) ?? Number.POSITIVE_INFINITY;
|
|
1827
|
+
const cur = best.get(r.translationKey);
|
|
1828
|
+
if (!cur || rk < cur.rank) best.set(r.translationKey, {
|
|
1829
|
+
id: r.id,
|
|
1830
|
+
rank: rk
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
for (const value of tgrValues){
|
|
1834
|
+
const b = best.get(value);
|
|
1835
|
+
if (b) valueToRootId.set(value, b.id); // missing in all chain langs → unresolved
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
if (rotValues.length > 0) {
|
|
1839
|
+
// 1) stored anchor → its translation group key (tenant-scoped; NOT
|
|
1840
|
+
// archived-filtered — the anchor itself may be archived).
|
|
1841
|
+
const groupRows = await db.select({
|
|
1842
|
+
id: i18nRoots.id,
|
|
1843
|
+
translationKey: i18nRoots.translationKey
|
|
1844
|
+
}).from(i18nRoots).where(drizzleOrm.and(drizzleOrm.inArray(i18nRoots.id, rotValues), drizzleOrm.eq(i18nRoots.collection, collection), ...tenantConds));
|
|
1845
|
+
const groupByRot = new Map();
|
|
1846
|
+
for (const r of groupRows)groupByRot.set(r.id, r.translationKey);
|
|
1847
|
+
// 2) each group → its ACTIVE-language sibling (only).
|
|
1848
|
+
const groupKeys = [
|
|
1849
|
+
...new Set(groupByRot.values())
|
|
1850
|
+
];
|
|
1851
|
+
const siblingByGroup = new Map();
|
|
1852
|
+
if (groupKeys.length > 0) {
|
|
1853
|
+
const sibRows = await db.select({
|
|
1854
|
+
id: i18nRoots.id,
|
|
1855
|
+
translationKey: i18nRoots.translationKey
|
|
1856
|
+
}).from(i18nRoots).where(drizzleOrm.and(drizzleOrm.inArray(i18nRoots.translationKey, groupKeys), drizzleOrm.eq(i18nRoots.language, language), drizzleOrm.eq(i18nRoots.collection, collection), drizzleOrm.isNull(i18nRoots.archivedAt), ...tenantConds));
|
|
1857
|
+
for (const r of sibRows)siblingByGroup.set(r.translationKey, r.id);
|
|
1858
|
+
}
|
|
1859
|
+
// 3) anchor → active-language sibling, else the stored anchor itself.
|
|
1860
|
+
for (const value of rotValues){
|
|
1861
|
+
const group = groupByRot.get(value);
|
|
1862
|
+
const sibling = group ? siblingByGroup.get(group) : undefined;
|
|
1863
|
+
valueToRootId.set(value, sibling ?? value);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
return valueToRootId;
|
|
1867
|
+
},
|
|
1868
|
+
async resolveConflictTargets (db, scopeColumns, storedKeys) {
|
|
1869
|
+
return resolveReferenceTargets(db, storedKeys, scopeColumns);
|
|
1870
|
+
},
|
|
1871
|
+
async expandGroup (db, _scopeColumns, rootIds) {
|
|
1872
|
+
return [
|
|
1873
|
+
...await expandTranslationGroups(db, rootIds)
|
|
1874
|
+
];
|
|
1875
|
+
},
|
|
1876
|
+
async groupKeysFor (db, _scopeColumns, rootIds) {
|
|
1877
|
+
return groupTranslationKeys(db, rootIds);
|
|
1878
|
+
}
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Expand a set of rootIds to ALL their translation-group siblings. A reference
|
|
1883
|
+
* stores a `rot_` anchor but the read-time auto-upgrade renders the active-
|
|
1884
|
+
* language sibling, so the co-render check must treat the whole group as one
|
|
1885
|
+
* logical block. `translation_key` is a globally-unique, tenant-bound group id
|
|
1886
|
+
* → no tenant predicate needed.
|
|
1887
|
+
*/ async function expandTranslationGroups(db, rootIds) {
|
|
1888
|
+
const out = new Set(rootIds);
|
|
1889
|
+
if (rootIds.length === 0) return out;
|
|
1890
|
+
const groupKeys = db.select({
|
|
1891
|
+
translationKey: i18nRoots.translationKey
|
|
1892
|
+
}).from(i18nRoots).where(drizzleOrm.inArray(i18nRoots.id, rootIds));
|
|
1893
|
+
const rows = await db.select({
|
|
1894
|
+
id: i18nRoots.id
|
|
1895
|
+
}).from(i18nRoots).where(drizzleOrm.inArray(i18nRoots.translationKey, groupKeys));
|
|
1896
|
+
for (const r of rows)out.add(r.id);
|
|
1897
|
+
return out;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* The translation-group key(s) (`tgr_`) for a set of roots. A host may embed the
|
|
1901
|
+
* group via a `tgr_` key rather than a `rot_` rootId, so the co-render up-walk
|
|
1902
|
+
* match set must include these.
|
|
1903
|
+
*/ async function groupTranslationKeys(db, rootIds) {
|
|
1904
|
+
if (rootIds.length === 0) return [];
|
|
1905
|
+
const rows = await db.selectDistinct({
|
|
1906
|
+
translationKey: i18nRoots.translationKey
|
|
1907
|
+
}).from(i18nRoots).where(drizzleOrm.and(drizzleOrm.inArray(i18nRoots.id, rootIds), drizzleOrm.isNotNull(i18nRoots.translationKey)));
|
|
1908
|
+
return rows.map((r)=>r.translationKey);
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Resolve reference targetKeys (`rot_` rootIds OR `tgr_` group keys) to the
|
|
1912
|
+
* rootIds they actually RENDER — expanding a `tgr_` to its whole group (a
|
|
1913
|
+
* conservative superset; the read path picks one sibling, we keep all).
|
|
1914
|
+
* Tenant-scoped: an author-typed foreign rootId resolves to nothing → the
|
|
1915
|
+
* co-render set never crosses tenants.
|
|
1916
|
+
*/ async function resolveReferenceTargets(db, keys, scopeColumns) {
|
|
1917
|
+
if (keys.length === 0) return [];
|
|
1918
|
+
const rows = await db.select({
|
|
1919
|
+
id: i18nRoots.id
|
|
1920
|
+
}).from(i18nRoots).where(drizzleOrm.and(drizzleOrm.or(drizzleOrm.inArray(i18nRoots.id, keys), drizzleOrm.inArray(i18nRoots.translationKey, keys)), drizzleOrm.isNull(i18nRoots.archivedAt), ...rootScopeConditions(scopeColumns, [
|
|
1921
|
+
'language'
|
|
1922
|
+
])));
|
|
1923
|
+
return rows.map((r)=>r.id);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function definePluginSchema(schema) {
|
|
1927
|
+
return {
|
|
1928
|
+
...schema
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* Plugin schema for i18n: adds the plugin-owned `language` column to `roots`
|
|
1934
|
+
* (one root per language — each language reuses the full engine) plus the
|
|
1935
|
+
* per-language slug uniqueness the core can no longer provide.
|
|
1936
|
+
*
|
|
1937
|
+
* The core `slugUniqueIdx` was demoted to a non-unique lookup index (phase I1)
|
|
1938
|
+
* precisely BECAUSE a core GLOBAL unique on (collection, parentRootId, slug)
|
|
1939
|
+
* cannot be loosened by a plugin and would forbid the same slug across
|
|
1940
|
+
* languages. So the real DB guarantee for same-slug-per-language lives here.
|
|
1941
|
+
* Non-partial (archived rows still occupy the slug) to match the demoted core
|
|
1942
|
+
* index's behaviour; app-level validateSlugUniqueness is the authority and is
|
|
1943
|
+
* likewise non-archived-filtered.
|
|
1944
|
+
*
|
|
1945
|
+
* Caveat (unchanged from the old core unique): a NULL `parentRootId` is DISTINCT
|
|
1946
|
+
* in Postgres, so this index only backstops NESTED roots. Top-level
|
|
1947
|
+
* per-language uniqueness is enforced by validateSlugUniqueness alone (which
|
|
1948
|
+
* matches `parent_root_id IS NULL` explicitly) — exactly as it always was.
|
|
1949
|
+
*/ const i18nSchema = definePluginSchema({
|
|
1950
|
+
extend: {
|
|
1951
|
+
roots: {
|
|
1952
|
+
columns: {
|
|
1953
|
+
language: {
|
|
1954
|
+
type: 'text',
|
|
1955
|
+
notNull: true
|
|
1956
|
+
},
|
|
1957
|
+
// Stable group id tying sibling-language roots into one logical entry.
|
|
1958
|
+
// A NEW entry (createRoot / root duplication) mints a fresh `tgr_` id;
|
|
1959
|
+
// createTranslation (I3b) inherits it. Indexed with language to resolve
|
|
1960
|
+
// "the sibling of this entry in language L" / "which languages exist" in
|
|
1961
|
+
// one hop.
|
|
1962
|
+
translationKey: {
|
|
1963
|
+
type: 'text',
|
|
1964
|
+
notNull: true
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
indexes: {
|
|
1968
|
+
languageSlugUnique: {
|
|
1969
|
+
columns: [
|
|
1970
|
+
'language',
|
|
1971
|
+
'collection',
|
|
1972
|
+
'parentRootId',
|
|
1973
|
+
'slug'
|
|
1974
|
+
],
|
|
1975
|
+
unique: true
|
|
1976
|
+
},
|
|
1977
|
+
languageCollectionIdx: {
|
|
1978
|
+
columns: [
|
|
1979
|
+
'language',
|
|
1980
|
+
'collection'
|
|
1981
|
+
]
|
|
1982
|
+
},
|
|
1983
|
+
// At most ONE active root per (group, language) — the DB backstop for
|
|
1984
|
+
// the "one sibling per language" invariant that createTranslation's
|
|
1985
|
+
// app-level check enforces (and the race it can't). PARTIAL (archived
|
|
1986
|
+
// rows excluded) so archiving a translation frees the slot, matching the
|
|
1987
|
+
// app check. Doubles as the lookup index for "the sibling in language L".
|
|
1988
|
+
// translationKey is a globally-unique group id, so no tenant column is
|
|
1989
|
+
// needed even under multi-tenant.
|
|
1990
|
+
translationLanguageUnique: {
|
|
1991
|
+
columns: [
|
|
1992
|
+
'translationKey',
|
|
1993
|
+
'language'
|
|
1994
|
+
],
|
|
1995
|
+
unique: true,
|
|
1996
|
+
where: 'archived_at IS NULL'
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
},
|
|
2000
|
+
// Redirects are per-language. No DB-unique is added here: path-source
|
|
2001
|
+
// uniqueness can't be DB-enforced when BOTH multi-tenant and i18n are on
|
|
2002
|
+
// (the correct key (tenant_slug, language, collection, sourcePath) can't be
|
|
2003
|
+
// expressed by either plugin alone), so it is the app-level authority
|
|
2004
|
+
// (assertSourceUnique + the auto-create pre-check, both scope.redirects-aware).
|
|
2005
|
+
redirects: {
|
|
2006
|
+
columns: {
|
|
2007
|
+
language: {
|
|
2008
|
+
type: 'text',
|
|
2009
|
+
notNull: true
|
|
2010
|
+
}
|
|
2011
|
+
},
|
|
2012
|
+
indexes: {
|
|
2013
|
+
languageCollectionIdx: {
|
|
2014
|
+
columns: [
|
|
2015
|
+
'language',
|
|
2016
|
+
'collection'
|
|
2017
|
+
]
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
// The translation-group id prefix is owned by this plugin (core no longer
|
|
2025
|
+
// declares it). Registered at import so newId('translationGroup') works in
|
|
2026
|
+
// the per-new-entry mint below.
|
|
2027
|
+
registerIdPrefix('translationGroup', 'tgr');
|
|
2028
|
+
/**
|
|
2029
|
+
* Resolves the active language from the incoming request context.
|
|
2030
|
+
* Priority: body.language -> query.language -> fallback.
|
|
2031
|
+
*
|
|
2032
|
+
* The CMS is routing-agnostic: HOW you derive the language (URL prefix `/de`,
|
|
2033
|
+
* domain, Accept-Language header, cookie) is the consumer's middleware concern.
|
|
2034
|
+
* This helper only reads an explicit per-request override; pass the negotiated
|
|
2035
|
+
* default as `fallback`.
|
|
2036
|
+
*/ function resolveLanguage(ctx, fallback) {
|
|
2037
|
+
return ctx.request?.body?.language ?? ctx.request?.query?.language ?? fallback;
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Read the resolved i18n context (active language + fallback chain + configured
|
|
2041
|
+
* universe) from a ResolvedScope. The plugin's own accessor for the OPAQUE
|
|
2042
|
+
* `pluginContext.i18n` slot it stashes per request (Seam C); core never names
|
|
2043
|
+
* i18n. Undefined when the i18n plugin did not scope the request.
|
|
2044
|
+
*/ function getI18nContext(scope) {
|
|
2045
|
+
return scope?.pluginContext?.['i18n'];
|
|
2046
|
+
}
|
|
2047
|
+
const PLUGIN_ID = 'i18n';
|
|
2048
|
+
/**
|
|
2049
|
+
* The ordered fallback chain for `activeLang` — languages to try AFTER it, with
|
|
2050
|
+
* the active language removed and duplicates dropped.
|
|
2051
|
+
*/ function resolveFallbackChain(config, activeLang) {
|
|
2052
|
+
const fb = config.fallback;
|
|
2053
|
+
const base = fb?.[activeLang] ?? fb?.default ?? [
|
|
2054
|
+
config.defaultLanguage
|
|
2055
|
+
];
|
|
2056
|
+
const seen = new Set([
|
|
2057
|
+
activeLang
|
|
2058
|
+
]);
|
|
2059
|
+
const chain = [];
|
|
2060
|
+
for (const l of base){
|
|
2061
|
+
if (!seen.has(l)) {
|
|
2062
|
+
seen.add(l);
|
|
2063
|
+
chain.push(l);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
return chain;
|
|
2067
|
+
}
|
|
2068
|
+
function i18n(config) {
|
|
2069
|
+
const universe = new Set(config.languages);
|
|
2070
|
+
if (!universe.has(config.defaultLanguage)) {
|
|
2071
|
+
throw new Error(`i18n: defaultLanguage "${String(config.defaultLanguage)}" must be one of languages [${config.languages.join(', ')}]`);
|
|
2072
|
+
}
|
|
2073
|
+
// Catch fallback-config typos at construction (like defaultLanguage): keys must
|
|
2074
|
+
// be a configured language or 'default'; every chain entry must be a language.
|
|
2075
|
+
if (config.fallback) {
|
|
2076
|
+
for (const [key, chain] of Object.entries(config.fallback)){
|
|
2077
|
+
if (key !== 'default' && !universe.has(key)) {
|
|
2078
|
+
throw new Error(`i18n: fallback key "${key}" is not one of languages [${config.languages.join(', ')}]`);
|
|
2079
|
+
}
|
|
2080
|
+
for (const lang of chain ?? []){
|
|
2081
|
+
if (!universe.has(lang)) {
|
|
2082
|
+
throw new Error(`i18n: fallback for "${key}" references unknown language "${lang}"`);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return {
|
|
2088
|
+
id: PLUGIN_ID,
|
|
2089
|
+
schema: i18nSchema,
|
|
2090
|
+
$ERROR_CODES,
|
|
2091
|
+
// Per-collection endpoints (Seam A): createTranslation / listTranslations
|
|
2092
|
+
// surface at cms.api.<collection>.x only because this plugin is installed.
|
|
2093
|
+
// The configured language universe is closed in here so the endpoints
|
|
2094
|
+
// validate a target language without reading per-request scope.
|
|
2095
|
+
collectionEndpoints: (def, ctx)=>createI18nCollectionEndpoints(def, ctx, config.languages),
|
|
2096
|
+
init (_ctx) {
|
|
2097
|
+
const factory = (mwResult)=>{
|
|
2098
|
+
const language = mwResult.language;
|
|
2099
|
+
if (typeof language !== 'string' || language.length === 0) {
|
|
2100
|
+
throw new betterCall.APIError(400, {
|
|
2101
|
+
message: $ERROR_CODES.LANGUAGE_REQUIRED.message,
|
|
2102
|
+
code: 'LANGUAGE_REQUIRED'
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
if (!universe.has(language)) {
|
|
2106
|
+
throw new betterCall.APIError(400, {
|
|
2107
|
+
message: $ERROR_CODES.LANGUAGE_NOT_ENABLED.message,
|
|
2108
|
+
code: 'LANGUAGE_NOT_ENABLED'
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
const fallback = resolveFallbackChain(config, language);
|
|
2112
|
+
// Blanket per-language scoping on `roots`, exactly like multi-tenant's
|
|
2113
|
+
// tenant_slug (Strapi-style per-locale context): `where` scopes every
|
|
2114
|
+
// roots read/guard to the active language, `insertColumns` stamps it on
|
|
2115
|
+
// every create. Cross-language operations (the language switcher /
|
|
2116
|
+
// translation status) are served by dedicated translationKey endpoints
|
|
2117
|
+
// later — they query by group id, not the blanket scope.
|
|
2118
|
+
return {
|
|
2119
|
+
roots: {
|
|
2120
|
+
where: drizzleOrm.sql`"cms"."roots"."language" = ${language}`,
|
|
2121
|
+
insertColumns: {
|
|
2122
|
+
language
|
|
2123
|
+
},
|
|
2124
|
+
// A new logical entry mints a FRESH translation group id (Seam D);
|
|
2125
|
+
// sibling-language roots inherit it later via createTranslation.
|
|
2126
|
+
newEntryColumns: ()=>({
|
|
2127
|
+
translation_key: newId('translationGroup')
|
|
2128
|
+
}),
|
|
2129
|
+
// `language` is stamped on insert but is a CROSS-SCOPE column for
|
|
2130
|
+
// reads: a reference/host/usage in any sibling language still counts,
|
|
2131
|
+
// so cross-scope read queries must NOT filter by it (Seam D6).
|
|
2132
|
+
crossScopeExclude: [
|
|
2133
|
+
'language'
|
|
2134
|
+
]
|
|
2135
|
+
},
|
|
2136
|
+
// Redirects are per-language too: a redirect created for `en` must not
|
|
2137
|
+
// fire for a `de` visitor (and the two languages can have different
|
|
2138
|
+
// redirects for the same path). The resolver / CRUD / auto-create all
|
|
2139
|
+
// consume scope.redirects, so this is the whole wiring.
|
|
2140
|
+
redirects: {
|
|
2141
|
+
where: drizzleOrm.sql`"cms"."redirects"."language" = ${language}`,
|
|
2142
|
+
insertColumns: {
|
|
2143
|
+
language
|
|
2144
|
+
}
|
|
2145
|
+
},
|
|
2146
|
+
// The reference resolver core's read path + co-render walk ride through
|
|
2147
|
+
// the Seam B handle (translation-group aware: tgr_ -> best fallback
|
|
2148
|
+
// sibling; rot_ -> active-language sibling, else anchor). P1 still
|
|
2149
|
+
// imports the impl from core; it MOVES into this plugin in P2.
|
|
2150
|
+
referenceResolver: buildI18nReferenceResolver(language, fallback),
|
|
2151
|
+
// Per-request i18n context (active language + fallback chain + the
|
|
2152
|
+
// configured universe), stashed in the OPAQUE pluginContext slot (Seam
|
|
2153
|
+
// C) keyed by this plugin's id. Core never reads it; consumers read it
|
|
2154
|
+
// via the exported getI18nContext(scope) accessor.
|
|
2155
|
+
pluginContext: {
|
|
2156
|
+
i18n: {
|
|
2157
|
+
language,
|
|
2158
|
+
fallback,
|
|
2159
|
+
languages: config.languages
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
};
|
|
2164
|
+
return {
|
|
2165
|
+
context: {
|
|
2166
|
+
scopeConditions: [
|
|
2167
|
+
factory
|
|
2168
|
+
]
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
exports.getI18nContext = getI18nContext;
|
|
2176
|
+
exports.i18n = i18n;
|
|
2177
|
+
exports.resolveLanguage = resolveLanguage;
|