@createcms/core 0.1.1

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