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