@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,3082 @@
1
+ #!/usr/bin/env node
2
+ import { cac } from 'cac';
3
+ import kleur from 'kleur';
4
+ import path from 'node:path';
5
+ import { mkdir, writeFile, access, readFile } from 'node:fs/promises';
6
+ import { createJiti } from 'jiti';
7
+ import { readFileSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
8
+ import Module from 'node:module';
9
+ import { tmpdir } from 'node:os';
10
+ import { pathToFileURL } from 'node:url';
11
+ import ora from 'ora';
12
+ import prompts from 'prompts';
13
+
14
+ function toSnakeCase(value) {
15
+ return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase();
16
+ }
17
+ function cloneColumns(columns) {
18
+ return Object.fromEntries(Object.entries(columns).map(([key, value])=>[
19
+ key,
20
+ {
21
+ ...value
22
+ }
23
+ ]));
24
+ }
25
+ function cloneIndexes(indexes) {
26
+ return Object.fromEntries(Object.entries(indexes).map(([key, value])=>[
27
+ key,
28
+ {
29
+ ...value,
30
+ columns: [
31
+ ...value.columns
32
+ ]
33
+ }
34
+ ]));
35
+ }
36
+ function cloneEnum(enumDef) {
37
+ return {
38
+ ...enumDef,
39
+ key: '',
40
+ dbName: '',
41
+ values: [
42
+ ...enumDef.values
43
+ ]
44
+ };
45
+ }
46
+ function mergeSchemaSources(sources) {
47
+ const enums = {};
48
+ const tables = {};
49
+ for (const source of sources){
50
+ const sourceEnums = source.schema.enums ?? {};
51
+ for (const [enumKey, enumDef] of Object.entries(sourceEnums)){
52
+ if (enums[enumKey]) {
53
+ throw new Error(`Duplicate enum "${enumKey}" declared by "${source.name}".`);
54
+ }
55
+ const resolved = cloneEnum(enumDef);
56
+ resolved.key = enumKey;
57
+ resolved.dbName = enumDef.enumName ?? toSnakeCase(enumKey);
58
+ enums[enumKey] = resolved;
59
+ }
60
+ const sourceTables = source.schema.tables ?? {};
61
+ for (const [tableKey, tableDef] of Object.entries(sourceTables)){
62
+ if (tables[tableKey]) {
63
+ throw new Error(`Duplicate table "${tableKey}" declared by "${source.name}".`);
64
+ }
65
+ const resolved = {
66
+ key: tableKey,
67
+ dbName: tableDef.tableName ?? toSnakeCase(tableKey),
68
+ indexPrefix: tableDef.indexPrefix,
69
+ columns: cloneColumns(tableDef.columns),
70
+ indexes: cloneIndexes(Object.fromEntries(Object.entries(tableDef.indexes ?? {}).map(([indexKey, indexDef])=>[
71
+ indexKey,
72
+ {
73
+ ...indexDef,
74
+ columns: [
75
+ ...indexDef.columns
76
+ ]
77
+ }
78
+ ]))),
79
+ compositePrimaryKey: tableDef.compositePrimaryKey ? {
80
+ columns: [
81
+ ...tableDef.compositePrimaryKey.columns
82
+ ]
83
+ } : undefined,
84
+ foreignKeys: tableDef.foreignKeys ? tableDef.foreignKeys.map((fk)=>({
85
+ ...fk,
86
+ columns: [
87
+ ...fk.columns
88
+ ],
89
+ foreignColumns: [
90
+ ...fk.foreignColumns
91
+ ]
92
+ })) : undefined
93
+ };
94
+ tables[tableKey] = resolved;
95
+ }
96
+ }
97
+ // Phase 2: Apply extensions
98
+ for (const source of sources){
99
+ const sourceExtensions = source.schema.extend ?? {};
100
+ for (const [targetTableKey, extension] of Object.entries(sourceExtensions)){
101
+ const targetTable = tables[targetTableKey];
102
+ if (!targetTable) {
103
+ throw new Error(`Schema source "${source.name}" extends unknown table "${targetTableKey}".`);
104
+ }
105
+ for (const [columnKey, columnDef] of Object.entries(extension.columns)){
106
+ if (targetTable.columns[columnKey]) {
107
+ throw new Error(`Schema source "${source.name}" tried to add duplicate column "${columnKey}" to "${targetTableKey}".`);
108
+ }
109
+ targetTable.columns[columnKey] = {
110
+ ...columnDef
111
+ };
112
+ }
113
+ for (const [indexKey, indexDef] of Object.entries(extension.indexes ?? {})){
114
+ if (targetTable.indexes[indexKey]) {
115
+ throw new Error(`Schema source "${source.name}" tried to add duplicate index "${indexKey}" to "${targetTableKey}".`);
116
+ }
117
+ targetTable.indexes[indexKey] = {
118
+ ...indexDef,
119
+ columns: [
120
+ ...indexDef.columns
121
+ ]
122
+ };
123
+ }
124
+ }
125
+ }
126
+ // Phase 3: Validation
127
+ const enumDbNames = new Set();
128
+ for (const enumDef of Object.values(enums)){
129
+ if (enumDbNames.has(enumDef.dbName)) {
130
+ throw new Error(`Duplicate enum database name "${enumDef.dbName}".`);
131
+ }
132
+ enumDbNames.add(enumDef.dbName);
133
+ }
134
+ const tableDbNames = new Set();
135
+ for (const table of Object.values(tables)){
136
+ if (tableDbNames.has(table.dbName)) {
137
+ throw new Error(`Duplicate table database name "${table.dbName}".`);
138
+ }
139
+ tableDbNames.add(table.dbName);
140
+ for (const [columnKey, columnDef] of Object.entries(table.columns)){
141
+ if (typeof columnDef.type === 'object') {
142
+ const enumName = columnDef.type.enum;
143
+ if (!enums[enumName]) {
144
+ throw new Error(`Column "${table.key}.${columnKey}" references unknown enum "${enumName}".`);
145
+ }
146
+ }
147
+ if (columnDef.references) {
148
+ const referencedTable = tables[columnDef.references.table];
149
+ if (!referencedTable) {
150
+ throw new Error(`Column "${table.key}.${columnKey}" references unknown table "${columnDef.references.table}".`);
151
+ }
152
+ if (!referencedTable.columns[columnDef.references.column]) {
153
+ throw new Error(`Column "${table.key}.${columnKey}" references unknown column "${columnDef.references.table}.${columnDef.references.column}".`);
154
+ }
155
+ }
156
+ }
157
+ for (const [indexKey, indexDef] of Object.entries(table.indexes)){
158
+ for (const columnName of indexDef.columns){
159
+ if (!table.columns[columnName]) {
160
+ throw new Error(`Index "${table.key}.${indexKey}" references unknown column "${columnName}".`);
161
+ }
162
+ }
163
+ }
164
+ if (table.foreignKeys) {
165
+ for (const fk of table.foreignKeys){
166
+ for (const col of fk.columns){
167
+ if (!table.columns[col]) {
168
+ throw new Error(`Foreign key in "${table.key}" references unknown local column "${col}".`);
169
+ }
170
+ }
171
+ const refTable = tables[fk.foreignTable];
172
+ if (!refTable) {
173
+ throw new Error(`Foreign key in "${table.key}" references unknown table "${fk.foreignTable}".`);
174
+ }
175
+ for (const col of fk.foreignColumns){
176
+ if (!refTable.columns[col]) {
177
+ throw new Error(`Foreign key in "${table.key}" references unknown column "${fk.foreignTable}.${col}".`);
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return {
184
+ enums,
185
+ tables
186
+ };
187
+ }
188
+
189
+ function quote(value) {
190
+ return JSON.stringify(value);
191
+ }
192
+ function toPascalCase(value) {
193
+ return value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_\-\s]+/g, ' ').split(' ').filter(Boolean).map((part)=>part.charAt(0).toUpperCase() + part.slice(1)).join('');
194
+ }
195
+ function singularize(value) {
196
+ if (value.endsWith('ies')) {
197
+ return `${value.slice(0, -3)}y`;
198
+ }
199
+ if (value.endsWith('ches') || value.endsWith('shes') || value.endsWith('xes') || value.endsWith('zes')) {
200
+ return value.slice(0, -2);
201
+ }
202
+ if (value.endsWith('s') && !value.endsWith('ss')) {
203
+ return value.slice(0, -1);
204
+ }
205
+ return value;
206
+ }
207
+ function emitLiteral(value) {
208
+ return JSON.stringify(value.value);
209
+ }
210
+ function emitDefault(defaultValue) {
211
+ switch(defaultValue.kind){
212
+ case 'literal':
213
+ return {
214
+ code: `.default(${emitLiteral(defaultValue)})`,
215
+ needsSql: false
216
+ };
217
+ case 'sql':
218
+ return {
219
+ code: `.default(sql.raw(${quote(defaultValue.value)}))`,
220
+ needsSql: true
221
+ };
222
+ }
223
+ }
224
+ function emitEnum(enumDef) {
225
+ const values = `[${enumDef.values.map(quote).join(', ')}]`;
226
+ return `export const ${enumDef.key}Enum = cms.enum(${quote(enumDef.dbName)}, ${values});`;
227
+ }
228
+ function emitColumn(table, columnKey, columnDef) {
229
+ const columnName = columnDef.columnName ?? toSnakeCase(columnKey);
230
+ let builder = '';
231
+ let needsTsvector = false;
232
+ if (typeof columnDef.type === 'string') {
233
+ if (columnDef.type === 'tsvector') {
234
+ builder = `tsvectorColumn(${quote(columnName)})`;
235
+ needsTsvector = true;
236
+ } else {
237
+ builder = `${columnDef.type}(${quote(columnName)})`;
238
+ }
239
+ } else {
240
+ builder = `${columnDef.type.enum}Enum(${quote(columnName)})`;
241
+ }
242
+ let needsSql = false;
243
+ let needsAnyPgColumn = false;
244
+ if (columnDef.jsonType && typeof columnDef.type === 'string' && columnDef.type === 'jsonb') {
245
+ builder += `.$type<${columnDef.jsonType}>()`;
246
+ }
247
+ if (columnDef.primaryKey) {
248
+ builder += '.primaryKey()';
249
+ }
250
+ if (columnDef.notNull) {
251
+ builder += '.notNull()';
252
+ }
253
+ if (columnDef.unique) {
254
+ builder += '.unique()';
255
+ }
256
+ if (columnDef.defaultNow) {
257
+ builder += '.defaultNow()';
258
+ }
259
+ if (columnDef.defaultId) {
260
+ if (!columnDef.defaultIdPrefix) {
261
+ throw new Error(`Column "${columnKey}" has defaultId but no defaultIdPrefix`);
262
+ }
263
+ builder += `.$defaultFn(() => newId(${quote(columnDef.defaultIdPrefix)}))`;
264
+ }
265
+ if (columnDef.default) {
266
+ const emitted = emitDefault(columnDef.default);
267
+ builder += emitted.code;
268
+ needsSql ||= emitted.needsSql;
269
+ }
270
+ if (columnDef.references) {
271
+ const options = [];
272
+ if (columnDef.references.onDelete) {
273
+ options.push(`onDelete: ${quote(columnDef.references.onDelete)}`);
274
+ }
275
+ if (columnDef.references.onUpdate) {
276
+ options.push(`onUpdate: ${quote(columnDef.references.onUpdate)}`);
277
+ }
278
+ const suffix = options.length > 0 ? `, { ${options.join(', ')} }` : '';
279
+ const isSelfReference = columnDef.references.table === table.key;
280
+ const refCallback = isSelfReference ? `(): AnyPgColumn => ${columnDef.references.table}.${columnDef.references.column}` : `() => ${columnDef.references.table}.${columnDef.references.column}`;
281
+ needsAnyPgColumn ||= isSelfReference;
282
+ builder += `.references(${refCallback}${suffix})`;
283
+ }
284
+ return {
285
+ code: ` ${columnKey}: ${builder},`,
286
+ needsSql,
287
+ needsAnyPgColumn,
288
+ needsTsvector
289
+ };
290
+ }
291
+ function emitIndexes(table) {
292
+ const entries = Object.entries(table.indexes);
293
+ if (entries.length === 0) {
294
+ return [];
295
+ }
296
+ const prefix = table.indexPrefix ?? table.dbName;
297
+ return entries.map(([indexKey, indexDef])=>{
298
+ const factory = indexDef.unique ? 'uniqueIndex' : 'index';
299
+ const columns = indexDef.columns.map((column)=>`table.${column}`).join(', ');
300
+ let line;
301
+ if (indexDef.using && indexDef.using !== 'btree') {
302
+ line = ` ${factory}(${quote(`${prefix}_${toSnakeCase(indexKey)}`)}).using(${quote(indexDef.using)}, ${columns})`;
303
+ } else {
304
+ line = ` ${factory}(${quote(`${prefix}_${toSnakeCase(indexKey)}`)}).on(${columns})`;
305
+ }
306
+ if (indexDef.where) {
307
+ line += `.where(sql\`${indexDef.where}\`)`;
308
+ }
309
+ return `${line},`;
310
+ });
311
+ }
312
+ function emitCompositePrimaryKey(table) {
313
+ if (!table.compositePrimaryKey) return null;
314
+ const columns = table.compositePrimaryKey.columns.map((col)=>`table.${col}`).join(', ');
315
+ return ` primaryKey({ columns: [${columns}] }),`;
316
+ }
317
+ function emitTableLevelForeignKeys(table) {
318
+ if (!table.foreignKeys || table.foreignKeys.length === 0) {
319
+ return {
320
+ lines: [],
321
+ needsForeignKeyImport: false
322
+ };
323
+ }
324
+ const lines = table.foreignKeys.map((fk)=>{
325
+ const localCols = fk.columns.map((c)=>`table.${c}`).join(', ');
326
+ const isSelfReference = fk.foreignTable === table.key;
327
+ const foreignCols = fk.foreignColumns.map((c)=>`${isSelfReference ? 'table' : fk.foreignTable}.${c}`).join(', ');
328
+ const parts = [
329
+ ` columns: [${localCols}],`,
330
+ ` foreignColumns: [${foreignCols}],`
331
+ ];
332
+ if (fk.name) {
333
+ parts.push(` name: ${quote(fk.name)},`);
334
+ }
335
+ let chain = '';
336
+ if (fk.onDelete) {
337
+ chain += `.onDelete(${quote(fk.onDelete)})`;
338
+ }
339
+ if (fk.onUpdate) {
340
+ chain += `.onUpdate(${quote(fk.onUpdate)})`;
341
+ }
342
+ return ` foreignKey({\n${parts.join('\n')}\n })${chain},`;
343
+ });
344
+ return {
345
+ lines,
346
+ needsForeignKeyImport: true
347
+ };
348
+ }
349
+ function emitTableTypeAliases(table) {
350
+ const baseName = toPascalCase(singularize(table.key));
351
+ return `export type ${baseName} = typeof ${table.key}.$inferSelect;
352
+ export type New${baseName} = typeof ${table.key}.$inferInsert;`;
353
+ }
354
+ function emitTable(table) {
355
+ let needsSql = false;
356
+ let needsAnyPgColumn = false;
357
+ let needsForeignKeyImport = false;
358
+ let needsPrimaryKeyImport = false;
359
+ let needsTsvector = false;
360
+ const emittedColumns = Object.entries(table.columns).map(([columnKey, columnDef])=>{
361
+ const emitted = emitColumn(table, columnKey, columnDef);
362
+ needsSql ||= emitted.needsSql;
363
+ needsAnyPgColumn ||= emitted.needsAnyPgColumn;
364
+ needsTsvector ||= emitted.needsTsvector;
365
+ return emitted.code;
366
+ });
367
+ const indexes = emitIndexes(table);
368
+ for (const idx of Object.values(table.indexes)){
369
+ if (idx.where) needsSql = true;
370
+ }
371
+ const compositePK = emitCompositePrimaryKey(table);
372
+ if (compositePK) needsPrimaryKeyImport = true;
373
+ const { lines: fkLines, needsForeignKeyImport: needsFK } = emitTableLevelForeignKeys(table);
374
+ needsForeignKeyImport = needsFK;
375
+ const tableFactory = 'cms.table';
376
+ const callbackLines = [
377
+ ...compositePK ? [
378
+ compositePK
379
+ ] : [],
380
+ ...fkLines,
381
+ ...indexes
382
+ ];
383
+ const callback = callbackLines.length > 0 ? `,
384
+ (table) => [
385
+ ${callbackLines.join('\n')}
386
+ ]` : '';
387
+ return {
388
+ code: `export const ${table.key} = ${tableFactory}(
389
+ ${quote(table.dbName)},
390
+ {
391
+ ${emittedColumns.join('\n')}
392
+ }${callback},
393
+ );`,
394
+ needsSql,
395
+ needsAnyPgColumn,
396
+ needsForeignKeyImport,
397
+ needsPrimaryKeyImport,
398
+ needsTsvector
399
+ };
400
+ }
401
+ function emitDrizzleSchema(schema, options) {
402
+ const pgCoreImports = new Set([
403
+ 'pgSchema'
404
+ ]);
405
+ let needsSql = false;
406
+ let needsAnyPgColumn = false;
407
+ let needsForeignKeyImport = false;
408
+ let needsPrimaryKeyImport = false;
409
+ let needsTsvector = false;
410
+ const enums = Object.values(schema.enums).sort((a, b)=>a.key.localeCompare(b.key));
411
+ const tables = Object.values(schema.tables).sort((a, b)=>a.key.localeCompare(b.key));
412
+ for (const table of tables){
413
+ for (const column of Object.values(table.columns)){
414
+ if (typeof column.type === 'string') {
415
+ if (column.type !== 'tsvector') {
416
+ pgCoreImports.add(column.type);
417
+ }
418
+ }
419
+ if (column.default?.kind === 'sql') {
420
+ needsSql = true;
421
+ }
422
+ }
423
+ if (Object.keys(table.indexes).length > 0) {
424
+ pgCoreImports.add('index');
425
+ }
426
+ if (Object.values(table.indexes).some((indexDef)=>indexDef.unique)) {
427
+ pgCoreImports.add('uniqueIndex');
428
+ }
429
+ if (Object.values(table.indexes).some((indexDef)=>indexDef.where)) {
430
+ needsSql = true;
431
+ }
432
+ }
433
+ const enumBlocks = enums.map(emitEnum);
434
+ const tableBlocks = tables.map((table)=>{
435
+ const emitted = emitTable(table);
436
+ needsSql ||= emitted.needsSql;
437
+ needsAnyPgColumn ||= emitted.needsAnyPgColumn;
438
+ needsForeignKeyImport ||= emitted.needsForeignKeyImport;
439
+ needsPrimaryKeyImport ||= emitted.needsPrimaryKeyImport;
440
+ needsTsvector ||= emitted.needsTsvector;
441
+ return emitted.code;
442
+ });
443
+ const typeBlocks = tables.map(emitTableTypeAliases);
444
+ if (needsForeignKeyImport) pgCoreImports.add('foreignKey');
445
+ if (needsPrimaryKeyImport) pgCoreImports.add('primaryKey');
446
+ if (needsTsvector) pgCoreImports.add('customType');
447
+ const imports = [];
448
+ if (needsSql) {
449
+ imports.push(`import { sql } from 'drizzle-orm';`);
450
+ }
451
+ imports.push(`import { ${Array.from(pgCoreImports).sort().join(', ')} } from 'drizzle-orm/pg-core';`);
452
+ if (needsAnyPgColumn) {
453
+ imports.push(`import type { AnyPgColumn } from 'drizzle-orm/pg-core';`);
454
+ }
455
+ const needsNewId = tables.some((table)=>Object.values(table.columns).some((col)=>col.defaultId));
456
+ if (needsNewId) {
457
+ imports.push(options?.nanoidImport ?? `import { newId } from '@createcms/core/nanoid';`);
458
+ }
459
+ const tsvectorDef = needsTsvector ? `\nconst tsvectorColumn = customType<{ data: string }>({
460
+ dataType() { return 'tsvector'; },
461
+ });\n` : '';
462
+ const schemaExport = `export const schema = {
463
+ ${tables.map((table)=>` ${table.key},`).join('\n')}
464
+ ${enums.map((enumDef)=>` ${enumDef.key}Enum,`).join('\n')}
465
+ };`;
466
+ return `/* eslint-disable */
467
+ /**
468
+ * This file is generated by createcms.
469
+ * Do not edit manually.
470
+ */
471
+
472
+ ${imports.join('\n')}
473
+
474
+ export const cms = pgSchema('cms');
475
+ ${tsvectorDef}${enumBlocks.join('\n\n')}
476
+ ${enumBlocks.length > 0 && tableBlocks.length > 0 ? '\n' : ''}${tableBlocks.join('\n\n')}
477
+
478
+ ${schemaExport}
479
+
480
+ ${typeBlocks.join('\n\n')}
481
+ `;
482
+ }
483
+
484
+ async function generateSchema(config) {
485
+ const merged = mergeSchemaSources(config.sources);
486
+ const output = emitDrizzleSchema(merged, config.emit);
487
+ const outputPath = path.resolve(config.outputPath);
488
+ await mkdir(path.dirname(outputPath), {
489
+ recursive: true
490
+ });
491
+ await writeFile(outputPath, output, 'utf8');
492
+ return {
493
+ outputPath,
494
+ schema: merged,
495
+ output
496
+ };
497
+ }
498
+
499
+ function defineCoreSchema(schema) {
500
+ return {
501
+ ...schema
502
+ };
503
+ }
504
+
505
+ const coreSchema = defineCoreSchema({
506
+ enums: {
507
+ approvalStatus: {
508
+ enumName: 'approval_status',
509
+ values: [
510
+ 'pending',
511
+ 'approved',
512
+ 'rejected'
513
+ ]
514
+ },
515
+ assetStatus: {
516
+ enumName: 'asset_status',
517
+ values: [
518
+ 'private',
519
+ 'public'
520
+ ]
521
+ },
522
+ mergeRequestStatus: {
523
+ enumName: 'merge_request_status',
524
+ values: [
525
+ 'open',
526
+ 'merged',
527
+ 'closed'
528
+ ]
529
+ },
530
+ conflictResolution: {
531
+ enumName: 'conflict_resolution',
532
+ values: [
533
+ 'source',
534
+ 'target',
535
+ 'manual'
536
+ ]
537
+ },
538
+ commentThreadTarget: {
539
+ enumName: 'comment_thread_target',
540
+ values: [
541
+ 'mergeRequest',
542
+ 'block'
543
+ ]
544
+ },
545
+ commentThreadStatus: {
546
+ enumName: 'comment_thread_status',
547
+ values: [
548
+ 'open',
549
+ 'resolved'
550
+ ]
551
+ },
552
+ commentMessageType: {
553
+ enumName: 'comment_message_type',
554
+ values: [
555
+ 'comment',
556
+ 'system'
557
+ ]
558
+ },
559
+ commentSystemType: {
560
+ enumName: 'comment_system_type',
561
+ values: [
562
+ 'threadResolved',
563
+ 'threadReopened'
564
+ ]
565
+ },
566
+ notificationType: {
567
+ enumName: 'notification_type',
568
+ values: [
569
+ 'mention',
570
+ 'comment',
571
+ 'threadResolved',
572
+ 'approvalRequested',
573
+ 'approvalApproved',
574
+ 'approvalRejected',
575
+ 'mergeRequestOpened',
576
+ 'mergeRequestMerged',
577
+ 'mergeRequestClosed',
578
+ 'mergeRequestReopened',
579
+ 'published',
580
+ 'custom'
581
+ ]
582
+ },
583
+ // A redirect endpoint (source or target) is either a page REFERENCE (rootId,
584
+ // resolves to the page's current path — follows moves) or a literal PATH.
585
+ // 'regex' is reserved for a later version (unindexed ordered scan).
586
+ redirectEndpointType: {
587
+ enumName: 'redirect_endpoint_type',
588
+ values: [
589
+ 'page',
590
+ 'path'
591
+ ]
592
+ },
593
+ // The kind of content a `content_usages` row indexes. One generalist index
594
+ // (version-keyed, insert-only, branch-head liveness) backs all three: assets
595
+ // (GC reclaim + media-library UI), variables (in-use guard + revalidation),
596
+ // and reusable-block references (delete guard + usage UI). 'reference' is
597
+ // populated from RB1 on; the enum carries it now so RB1 needs no enum change.
598
+ contentUsageTarget: {
599
+ enumName: 'content_usage_target',
600
+ values: [
601
+ 'asset',
602
+ 'variable',
603
+ 'reference'
604
+ ]
605
+ }
606
+ },
607
+ tables: {
608
+ // ========================================================================
609
+ // ROOTS
610
+ // ========================================================================
611
+ roots: {
612
+ columns: {
613
+ id: {
614
+ type: 'text',
615
+ primaryKey: true,
616
+ defaultId: true,
617
+ defaultIdPrefix: 'root'
618
+ },
619
+ collection: {
620
+ type: 'text',
621
+ notNull: true
622
+ },
623
+ parentRootId: {
624
+ type: 'text'
625
+ },
626
+ slug: {
627
+ type: 'text'
628
+ },
629
+ sortOrder: {
630
+ type: 'integer',
631
+ notNull: true,
632
+ default: {
633
+ kind: 'literal',
634
+ value: 0
635
+ }
636
+ },
637
+ createdBy: {
638
+ type: 'text'
639
+ },
640
+ createdAt: {
641
+ type: 'timestamp',
642
+ notNull: true,
643
+ defaultNow: true
644
+ },
645
+ archivedAt: {
646
+ type: 'timestamp'
647
+ },
648
+ lastPrunedAt: {
649
+ type: 'timestamp'
650
+ }
651
+ },
652
+ foreignKeys: [
653
+ {
654
+ columns: [
655
+ 'parentRootId'
656
+ ],
657
+ foreignTable: 'roots',
658
+ foreignColumns: [
659
+ 'id'
660
+ ],
661
+ name: 'roots_parent_fk',
662
+ onDelete: 'cascade'
663
+ }
664
+ ],
665
+ indexes: {
666
+ collectionIdx: {
667
+ columns: [
668
+ 'collection'
669
+ ]
670
+ },
671
+ parentRootIdx: {
672
+ columns: [
673
+ 'parentRootId'
674
+ ]
675
+ },
676
+ // Lookup index for resolvePathToRootId's slug-chain CTE. NON-unique on
677
+ // purpose: a core GLOBAL unique on (collection, parentRootId, slug) cannot
678
+ // be loosened by a plugin (merge.ts only ADDS), so it would forbid the
679
+ // i18n plugin from allowing the SAME slug across languages (en/blog +
680
+ // de/blog). Uniqueness is the app-level authority (validateSlugUniqueness,
681
+ // called on every slug write) plus, under a scoping plugin, a per-scope
682
+ // partial unique. See I18N_DESIGN.md §3 (the redirects "Option B" move).
683
+ slugIdx: {
684
+ columns: [
685
+ 'collection',
686
+ 'parentRootId',
687
+ 'slug'
688
+ ]
689
+ },
690
+ // Pruning GC round-robin: pick the least-recently-pruned live roots.
691
+ archivedAtIdx: {
692
+ columns: [
693
+ 'archivedAt'
694
+ ]
695
+ },
696
+ lastPrunedAtIdx: {
697
+ columns: [
698
+ 'lastPrunedAt'
699
+ ]
700
+ }
701
+ }
702
+ },
703
+ // ========================================================================
704
+ // COMMITS
705
+ // ========================================================================
706
+ commits: {
707
+ columns: {
708
+ id: {
709
+ type: 'text',
710
+ primaryKey: true,
711
+ defaultId: true,
712
+ defaultIdPrefix: 'commit'
713
+ },
714
+ rootId: {
715
+ type: 'text',
716
+ notNull: true,
717
+ references: {
718
+ table: 'roots',
719
+ column: 'id'
720
+ }
721
+ },
722
+ parentCommitId: {
723
+ type: 'text'
724
+ },
725
+ mergeSourceCommitId: {
726
+ type: 'text'
727
+ },
728
+ message: {
729
+ type: 'text'
730
+ },
731
+ createdBy: {
732
+ type: 'text'
733
+ },
734
+ createdAt: {
735
+ type: 'timestamp',
736
+ notNull: true,
737
+ defaultNow: true
738
+ }
739
+ },
740
+ foreignKeys: [
741
+ {
742
+ columns: [
743
+ 'parentCommitId'
744
+ ],
745
+ foreignTable: 'commits',
746
+ foreignColumns: [
747
+ 'id'
748
+ ],
749
+ name: 'commits_parent_fk'
750
+ },
751
+ {
752
+ columns: [
753
+ 'mergeSourceCommitId'
754
+ ],
755
+ foreignTable: 'commits',
756
+ foreignColumns: [
757
+ 'id'
758
+ ],
759
+ name: 'commits_merge_source_fk'
760
+ }
761
+ ],
762
+ indexes: {
763
+ parentIdx: {
764
+ columns: [
765
+ 'parentCommitId'
766
+ ]
767
+ },
768
+ mergeSourceIdx: {
769
+ columns: [
770
+ 'mergeSourceCommitId'
771
+ ]
772
+ },
773
+ rootCreatedIdx: {
774
+ columns: [
775
+ 'rootId',
776
+ 'createdAt'
777
+ ]
778
+ }
779
+ }
780
+ },
781
+ // ========================================================================
782
+ // BRANCHES
783
+ // ========================================================================
784
+ branches: {
785
+ columns: {
786
+ id: {
787
+ type: 'text',
788
+ primaryKey: true,
789
+ defaultId: true,
790
+ defaultIdPrefix: 'branch'
791
+ },
792
+ rootId: {
793
+ type: 'text',
794
+ notNull: true,
795
+ references: {
796
+ table: 'roots',
797
+ column: 'id'
798
+ }
799
+ },
800
+ name: {
801
+ type: 'text',
802
+ notNull: true
803
+ },
804
+ headCommitId: {
805
+ type: 'text',
806
+ notNull: true,
807
+ references: {
808
+ table: 'commits',
809
+ column: 'id'
810
+ }
811
+ },
812
+ createdBy: {
813
+ type: 'text'
814
+ },
815
+ createdAt: {
816
+ type: 'timestamp',
817
+ notNull: true,
818
+ defaultNow: true
819
+ },
820
+ updatedAt: {
821
+ type: 'timestamp',
822
+ notNull: true,
823
+ defaultNow: true
824
+ }
825
+ },
826
+ indexes: {
827
+ rootIdIdx: {
828
+ columns: [
829
+ 'rootId'
830
+ ]
831
+ },
832
+ rootNameUnique: {
833
+ columns: [
834
+ 'rootId',
835
+ 'name'
836
+ ],
837
+ unique: true
838
+ }
839
+ }
840
+ },
841
+ // ========================================================================
842
+ // BLOCK_VERSIONS
843
+ // ========================================================================
844
+ blockVersions: {
845
+ tableName: 'block_versions',
846
+ indexPrefix: 'bv',
847
+ columns: {
848
+ id: {
849
+ type: 'text',
850
+ primaryKey: true,
851
+ defaultId: true,
852
+ defaultIdPrefix: 'blockVersion'
853
+ },
854
+ blockId: {
855
+ type: 'text',
856
+ notNull: true
857
+ },
858
+ rootId: {
859
+ type: 'text',
860
+ notNull: true,
861
+ references: {
862
+ table: 'roots',
863
+ column: 'id'
864
+ }
865
+ },
866
+ commitId: {
867
+ type: 'text',
868
+ notNull: true,
869
+ references: {
870
+ table: 'commits',
871
+ column: 'id'
872
+ }
873
+ },
874
+ type: {
875
+ type: 'text',
876
+ notNull: true
877
+ },
878
+ properties: {
879
+ type: 'jsonb',
880
+ notNull: true,
881
+ jsonType: 'Record<string, unknown>'
882
+ },
883
+ children: {
884
+ type: 'jsonb',
885
+ notNull: true,
886
+ jsonType: 'string[]',
887
+ default: {
888
+ kind: 'literal',
889
+ value: []
890
+ }
891
+ },
892
+ deleted: {
893
+ type: 'boolean',
894
+ notNull: true,
895
+ default: {
896
+ kind: 'literal',
897
+ value: false
898
+ }
899
+ },
900
+ createdAt: {
901
+ type: 'timestamp',
902
+ notNull: true,
903
+ defaultNow: true
904
+ }
905
+ },
906
+ indexes: {
907
+ blockIdIdx: {
908
+ columns: [
909
+ 'blockId'
910
+ ]
911
+ },
912
+ commitIdIdx: {
913
+ columns: [
914
+ 'commitId'
915
+ ]
916
+ },
917
+ rootIdIdx: {
918
+ columns: [
919
+ 'rootId'
920
+ ]
921
+ },
922
+ blockCommitUnique: {
923
+ columns: [
924
+ 'blockId',
925
+ 'commitId'
926
+ ],
927
+ unique: true
928
+ },
929
+ propertiesGin: {
930
+ columns: [
931
+ 'properties'
932
+ ],
933
+ using: 'gin'
934
+ }
935
+ }
936
+ },
937
+ // ========================================================================
938
+ // COMMIT_SNAPSHOTS
939
+ // ========================================================================
940
+ commitSnapshots: {
941
+ tableName: 'commit_snapshots',
942
+ indexPrefix: 'cs',
943
+ columns: {
944
+ commitId: {
945
+ type: 'text',
946
+ notNull: true,
947
+ references: {
948
+ table: 'commits',
949
+ column: 'id',
950
+ onDelete: 'cascade'
951
+ }
952
+ },
953
+ blockId: {
954
+ type: 'text',
955
+ notNull: true
956
+ },
957
+ blockVersionId: {
958
+ type: 'text',
959
+ notNull: true,
960
+ references: {
961
+ table: 'blockVersions',
962
+ column: 'id',
963
+ onDelete: 'cascade'
964
+ }
965
+ }
966
+ },
967
+ compositePrimaryKey: {
968
+ columns: [
969
+ 'commitId',
970
+ 'blockId'
971
+ ]
972
+ },
973
+ indexes: {
974
+ blockVersionIdx: {
975
+ columns: [
976
+ 'blockVersionId'
977
+ ]
978
+ }
979
+ }
980
+ },
981
+ // ========================================================================
982
+ // PUBLICATIONS
983
+ // ========================================================================
984
+ publications: {
985
+ columns: {
986
+ rootId: {
987
+ type: 'text',
988
+ notNull: true,
989
+ references: {
990
+ table: 'roots',
991
+ column: 'id'
992
+ }
993
+ },
994
+ branchId: {
995
+ type: 'text',
996
+ notNull: true,
997
+ references: {
998
+ table: 'branches',
999
+ column: 'id'
1000
+ }
1001
+ },
1002
+ commitId: {
1003
+ type: 'text',
1004
+ notNull: true,
1005
+ references: {
1006
+ table: 'commits',
1007
+ column: 'id'
1008
+ }
1009
+ },
1010
+ publishedBy: {
1011
+ type: 'text',
1012
+ notNull: true
1013
+ },
1014
+ publishedAt: {
1015
+ type: 'timestamp',
1016
+ notNull: true,
1017
+ defaultNow: true
1018
+ }
1019
+ },
1020
+ compositePrimaryKey: {
1021
+ columns: [
1022
+ 'rootId',
1023
+ 'branchId'
1024
+ ]
1025
+ },
1026
+ indexes: {
1027
+ branchIdx: {
1028
+ columns: [
1029
+ 'branchId'
1030
+ ]
1031
+ }
1032
+ }
1033
+ },
1034
+ // ========================================================================
1035
+ // MERGE_REQUESTS
1036
+ // ========================================================================
1037
+ mergeRequests: {
1038
+ tableName: 'merge_requests',
1039
+ indexPrefix: 'mr',
1040
+ columns: {
1041
+ id: {
1042
+ type: 'text',
1043
+ primaryKey: true,
1044
+ defaultId: true,
1045
+ defaultIdPrefix: 'mergeRequest'
1046
+ },
1047
+ rootId: {
1048
+ type: 'text',
1049
+ notNull: true,
1050
+ references: {
1051
+ table: 'roots',
1052
+ column: 'id'
1053
+ }
1054
+ },
1055
+ sourceBranchId: {
1056
+ type: 'text',
1057
+ notNull: true,
1058
+ references: {
1059
+ table: 'branches',
1060
+ column: 'id'
1061
+ }
1062
+ },
1063
+ targetBranchId: {
1064
+ type: 'text',
1065
+ notNull: true,
1066
+ references: {
1067
+ table: 'branches',
1068
+ column: 'id'
1069
+ }
1070
+ },
1071
+ sourceCommitId: {
1072
+ type: 'text',
1073
+ notNull: true,
1074
+ references: {
1075
+ table: 'commits',
1076
+ column: 'id'
1077
+ }
1078
+ },
1079
+ baseCommitId: {
1080
+ type: 'text',
1081
+ references: {
1082
+ table: 'commits',
1083
+ column: 'id'
1084
+ }
1085
+ },
1086
+ mergeCommitId: {
1087
+ type: 'text',
1088
+ references: {
1089
+ table: 'commits',
1090
+ column: 'id'
1091
+ }
1092
+ },
1093
+ status: {
1094
+ type: {
1095
+ enum: 'mergeRequestStatus'
1096
+ },
1097
+ notNull: true,
1098
+ default: {
1099
+ kind: 'literal',
1100
+ value: 'open'
1101
+ }
1102
+ },
1103
+ title: {
1104
+ type: 'text'
1105
+ },
1106
+ description: {
1107
+ type: 'text'
1108
+ },
1109
+ createdBy: {
1110
+ type: 'text',
1111
+ notNull: true
1112
+ },
1113
+ createdAt: {
1114
+ type: 'timestamp',
1115
+ notNull: true,
1116
+ defaultNow: true
1117
+ },
1118
+ updatedAt: {
1119
+ type: 'timestamp',
1120
+ notNull: true,
1121
+ defaultNow: true
1122
+ }
1123
+ },
1124
+ indexes: {
1125
+ rootIdx: {
1126
+ columns: [
1127
+ 'rootId'
1128
+ ]
1129
+ },
1130
+ sourceBranchIdx: {
1131
+ columns: [
1132
+ 'sourceBranchId'
1133
+ ]
1134
+ },
1135
+ targetBranchIdx: {
1136
+ columns: [
1137
+ 'targetBranchId'
1138
+ ]
1139
+ },
1140
+ statusIdx: {
1141
+ columns: [
1142
+ 'status'
1143
+ ]
1144
+ },
1145
+ openSourceTargetUnique: {
1146
+ columns: [
1147
+ 'sourceBranchId',
1148
+ 'targetBranchId'
1149
+ ],
1150
+ unique: true,
1151
+ where: "status = 'open'"
1152
+ }
1153
+ }
1154
+ },
1155
+ // ========================================================================
1156
+ // MERGE_CONFLICTS
1157
+ // ========================================================================
1158
+ mergeConflicts: {
1159
+ tableName: 'merge_conflicts',
1160
+ indexPrefix: 'mc',
1161
+ columns: {
1162
+ id: {
1163
+ type: 'text',
1164
+ primaryKey: true,
1165
+ defaultId: true,
1166
+ defaultIdPrefix: 'mergeConflict'
1167
+ },
1168
+ mergeRequestId: {
1169
+ type: 'text',
1170
+ notNull: true,
1171
+ references: {
1172
+ table: 'mergeRequests',
1173
+ column: 'id',
1174
+ onDelete: 'cascade'
1175
+ }
1176
+ },
1177
+ blockId: {
1178
+ type: 'text',
1179
+ notNull: true
1180
+ },
1181
+ sourceVersionId: {
1182
+ type: 'text',
1183
+ references: {
1184
+ table: 'blockVersions',
1185
+ column: 'id'
1186
+ }
1187
+ },
1188
+ targetVersionId: {
1189
+ type: 'text',
1190
+ references: {
1191
+ table: 'blockVersions',
1192
+ column: 'id'
1193
+ }
1194
+ },
1195
+ baseVersionId: {
1196
+ type: 'text',
1197
+ references: {
1198
+ table: 'blockVersions',
1199
+ column: 'id'
1200
+ }
1201
+ },
1202
+ resolution: {
1203
+ type: {
1204
+ enum: 'conflictResolution'
1205
+ }
1206
+ },
1207
+ resolvedVersionId: {
1208
+ type: 'text',
1209
+ references: {
1210
+ table: 'blockVersions',
1211
+ column: 'id'
1212
+ }
1213
+ },
1214
+ resolvedBy: {
1215
+ type: 'text'
1216
+ },
1217
+ resolvedAt: {
1218
+ type: 'timestamp'
1219
+ },
1220
+ createdAt: {
1221
+ type: 'timestamp',
1222
+ notNull: true,
1223
+ defaultNow: true
1224
+ }
1225
+ },
1226
+ indexes: {
1227
+ mergeRequestIdx: {
1228
+ columns: [
1229
+ 'mergeRequestId'
1230
+ ]
1231
+ },
1232
+ mergeBlockUnique: {
1233
+ columns: [
1234
+ 'mergeRequestId',
1235
+ 'blockId'
1236
+ ],
1237
+ unique: true
1238
+ }
1239
+ }
1240
+ },
1241
+ // ========================================================================
1242
+ // APPROVALS
1243
+ // ========================================================================
1244
+ approvals: {
1245
+ columns: {
1246
+ id: {
1247
+ type: 'text',
1248
+ primaryKey: true,
1249
+ defaultId: true,
1250
+ defaultIdPrefix: 'approval'
1251
+ },
1252
+ mergeRequestId: {
1253
+ type: 'text',
1254
+ references: {
1255
+ table: 'mergeRequests',
1256
+ column: 'id',
1257
+ onDelete: 'cascade'
1258
+ }
1259
+ },
1260
+ branchId: {
1261
+ type: 'text',
1262
+ notNull: true,
1263
+ references: {
1264
+ table: 'branches',
1265
+ column: 'id'
1266
+ }
1267
+ },
1268
+ commitId: {
1269
+ type: 'text',
1270
+ notNull: true,
1271
+ references: {
1272
+ table: 'commits',
1273
+ column: 'id'
1274
+ }
1275
+ },
1276
+ status: {
1277
+ type: {
1278
+ enum: 'approvalStatus'
1279
+ },
1280
+ notNull: true,
1281
+ default: {
1282
+ kind: 'literal',
1283
+ value: 'pending'
1284
+ }
1285
+ },
1286
+ requestedBy: {
1287
+ type: 'text',
1288
+ notNull: true
1289
+ },
1290
+ requestedReviewer: {
1291
+ type: 'text',
1292
+ notNull: true
1293
+ },
1294
+ reviewedBy: {
1295
+ type: 'text'
1296
+ },
1297
+ message: {
1298
+ type: 'text'
1299
+ },
1300
+ rejectionReason: {
1301
+ type: 'text'
1302
+ },
1303
+ reviewedAt: {
1304
+ type: 'timestamp'
1305
+ },
1306
+ createdAt: {
1307
+ type: 'timestamp',
1308
+ notNull: true,
1309
+ defaultNow: true
1310
+ },
1311
+ updatedAt: {
1312
+ type: 'timestamp',
1313
+ notNull: true,
1314
+ defaultNow: true
1315
+ }
1316
+ },
1317
+ indexes: {
1318
+ mrIdx: {
1319
+ columns: [
1320
+ 'mergeRequestId'
1321
+ ]
1322
+ },
1323
+ branchIdx: {
1324
+ columns: [
1325
+ 'branchId'
1326
+ ]
1327
+ },
1328
+ branchCommitIdx: {
1329
+ columns: [
1330
+ 'branchId',
1331
+ 'commitId'
1332
+ ]
1333
+ },
1334
+ statusIdx: {
1335
+ columns: [
1336
+ 'status'
1337
+ ]
1338
+ },
1339
+ requestedReviewerIdx: {
1340
+ columns: [
1341
+ 'requestedReviewer'
1342
+ ]
1343
+ },
1344
+ targetReviewerUnique: {
1345
+ columns: [
1346
+ 'mergeRequestId',
1347
+ 'branchId',
1348
+ 'commitId',
1349
+ 'requestedReviewer'
1350
+ ],
1351
+ unique: true
1352
+ }
1353
+ }
1354
+ },
1355
+ // ========================================================================
1356
+ // ASSET_FOLDERS
1357
+ // ========================================================================
1358
+ assetFolders: {
1359
+ tableName: 'asset_folders',
1360
+ columns: {
1361
+ id: {
1362
+ type: 'text',
1363
+ primaryKey: true,
1364
+ defaultId: true,
1365
+ defaultIdPrefix: 'assetFolder'
1366
+ },
1367
+ name: {
1368
+ type: 'text',
1369
+ notNull: true
1370
+ },
1371
+ parentId: {
1372
+ type: 'text'
1373
+ },
1374
+ createdBy: {
1375
+ type: 'text'
1376
+ },
1377
+ createdAt: {
1378
+ type: 'timestamp',
1379
+ notNull: true,
1380
+ defaultNow: true
1381
+ }
1382
+ },
1383
+ foreignKeys: [
1384
+ {
1385
+ columns: [
1386
+ 'parentId'
1387
+ ],
1388
+ foreignTable: 'assetFolders',
1389
+ foreignColumns: [
1390
+ 'id'
1391
+ ],
1392
+ name: 'asset_folders_parent_fk',
1393
+ onDelete: 'cascade'
1394
+ }
1395
+ ],
1396
+ indexes: {
1397
+ parentIdx: {
1398
+ columns: [
1399
+ 'parentId'
1400
+ ]
1401
+ },
1402
+ nameUnique: {
1403
+ columns: [
1404
+ 'parentId',
1405
+ 'name'
1406
+ ],
1407
+ unique: true
1408
+ }
1409
+ }
1410
+ },
1411
+ // ========================================================================
1412
+ // ASSETS
1413
+ // ========================================================================
1414
+ assets: {
1415
+ columns: {
1416
+ id: {
1417
+ type: 'text',
1418
+ primaryKey: true,
1419
+ defaultId: true,
1420
+ defaultIdPrefix: 'asset'
1421
+ },
1422
+ slug: {
1423
+ type: 'text',
1424
+ notNull: true
1425
+ },
1426
+ mimeType: {
1427
+ type: 'text',
1428
+ notNull: true
1429
+ },
1430
+ size: {
1431
+ type: 'integer',
1432
+ notNull: true
1433
+ },
1434
+ objectKey: {
1435
+ type: 'text',
1436
+ notNull: true
1437
+ },
1438
+ status: {
1439
+ type: {
1440
+ enum: 'assetStatus'
1441
+ },
1442
+ notNull: true,
1443
+ default: {
1444
+ kind: 'literal',
1445
+ value: 'private'
1446
+ }
1447
+ },
1448
+ folderId: {
1449
+ type: 'text',
1450
+ references: {
1451
+ table: 'assetFolders',
1452
+ column: 'id',
1453
+ onDelete: 'set null'
1454
+ }
1455
+ },
1456
+ variantOf: {
1457
+ type: 'text',
1458
+ references: {
1459
+ table: 'assets',
1460
+ column: 'id',
1461
+ onDelete: 'set null'
1462
+ }
1463
+ },
1464
+ uploadedBy: {
1465
+ type: 'text'
1466
+ },
1467
+ createdAt: {
1468
+ type: 'timestamp',
1469
+ notNull: true,
1470
+ defaultNow: true
1471
+ },
1472
+ updatedAt: {
1473
+ type: 'timestamp',
1474
+ notNull: true,
1475
+ defaultNow: true
1476
+ },
1477
+ archivedAt: {
1478
+ type: 'timestamp'
1479
+ }
1480
+ },
1481
+ indexes: {
1482
+ folderIdx: {
1483
+ columns: [
1484
+ 'folderId'
1485
+ ]
1486
+ },
1487
+ statusIdx: {
1488
+ columns: [
1489
+ 'status'
1490
+ ]
1491
+ },
1492
+ variantOfIdx: {
1493
+ columns: [
1494
+ 'variantOf'
1495
+ ]
1496
+ },
1497
+ objectKeyUnique: {
1498
+ columns: [
1499
+ 'objectKey'
1500
+ ],
1501
+ unique: true
1502
+ },
1503
+ slugUnique: {
1504
+ columns: [
1505
+ 'slug'
1506
+ ],
1507
+ unique: true
1508
+ }
1509
+ }
1510
+ },
1511
+ // ========================================================================
1512
+ // COMMENT_THREADS
1513
+ // ========================================================================
1514
+ commentThreads: {
1515
+ tableName: 'comment_threads',
1516
+ indexPrefix: 'ct',
1517
+ columns: {
1518
+ id: {
1519
+ type: 'text',
1520
+ primaryKey: true,
1521
+ defaultId: true,
1522
+ defaultIdPrefix: 'commentThread'
1523
+ },
1524
+ rootId: {
1525
+ type: 'text',
1526
+ references: {
1527
+ table: 'roots',
1528
+ column: 'id',
1529
+ onDelete: 'cascade'
1530
+ }
1531
+ },
1532
+ collection: {
1533
+ type: 'text',
1534
+ notNull: true
1535
+ },
1536
+ targetType: {
1537
+ type: {
1538
+ enum: 'commentThreadTarget'
1539
+ },
1540
+ notNull: true
1541
+ },
1542
+ mergeRequestId: {
1543
+ type: 'text',
1544
+ references: {
1545
+ table: 'mergeRequests',
1546
+ column: 'id',
1547
+ onDelete: 'cascade'
1548
+ }
1549
+ },
1550
+ blockId: {
1551
+ type: 'text'
1552
+ },
1553
+ commitId: {
1554
+ type: 'text',
1555
+ references: {
1556
+ table: 'commits',
1557
+ column: 'id',
1558
+ onDelete: 'set null'
1559
+ }
1560
+ },
1561
+ status: {
1562
+ type: {
1563
+ enum: 'commentThreadStatus'
1564
+ },
1565
+ notNull: true,
1566
+ default: {
1567
+ kind: 'literal',
1568
+ value: 'open'
1569
+ }
1570
+ },
1571
+ resolvedBy: {
1572
+ type: 'text'
1573
+ },
1574
+ resolvedAt: {
1575
+ type: 'timestamp'
1576
+ },
1577
+ createdBy: {
1578
+ type: 'text',
1579
+ notNull: true
1580
+ },
1581
+ createdAt: {
1582
+ type: 'timestamp',
1583
+ notNull: true,
1584
+ defaultNow: true
1585
+ },
1586
+ updatedAt: {
1587
+ type: 'timestamp',
1588
+ notNull: true,
1589
+ defaultNow: true
1590
+ },
1591
+ deletedAt: {
1592
+ type: 'timestamp'
1593
+ }
1594
+ },
1595
+ indexes: {
1596
+ collectionIdx: {
1597
+ columns: [
1598
+ 'collection',
1599
+ 'createdAt'
1600
+ ]
1601
+ },
1602
+ mrIdx: {
1603
+ columns: [
1604
+ 'mergeRequestId',
1605
+ 'createdAt'
1606
+ ]
1607
+ },
1608
+ blockIdx: {
1609
+ columns: [
1610
+ 'blockId',
1611
+ 'createdAt'
1612
+ ]
1613
+ },
1614
+ commitIdx: {
1615
+ columns: [
1616
+ 'commitId',
1617
+ 'createdAt'
1618
+ ]
1619
+ },
1620
+ rootIdx: {
1621
+ columns: [
1622
+ 'rootId',
1623
+ 'createdAt'
1624
+ ]
1625
+ },
1626
+ statusIdx: {
1627
+ columns: [
1628
+ 'status'
1629
+ ]
1630
+ }
1631
+ }
1632
+ },
1633
+ // ========================================================================
1634
+ // COMMENT_MESSAGES
1635
+ // ========================================================================
1636
+ commentMessages: {
1637
+ tableName: 'comment_messages',
1638
+ indexPrefix: 'cm',
1639
+ columns: {
1640
+ id: {
1641
+ type: 'text',
1642
+ primaryKey: true,
1643
+ defaultId: true,
1644
+ defaultIdPrefix: 'commentMessage'
1645
+ },
1646
+ threadId: {
1647
+ type: 'text',
1648
+ notNull: true,
1649
+ references: {
1650
+ table: 'commentThreads',
1651
+ column: 'id',
1652
+ onDelete: 'cascade'
1653
+ }
1654
+ },
1655
+ parentMessageId: {
1656
+ type: 'text'
1657
+ },
1658
+ authorId: {
1659
+ type: 'text'
1660
+ },
1661
+ messageType: {
1662
+ type: {
1663
+ enum: 'commentMessageType'
1664
+ },
1665
+ notNull: true,
1666
+ default: {
1667
+ kind: 'literal',
1668
+ value: 'comment'
1669
+ }
1670
+ },
1671
+ systemType: {
1672
+ type: {
1673
+ enum: 'commentSystemType'
1674
+ }
1675
+ },
1676
+ body: {
1677
+ type: 'text'
1678
+ },
1679
+ meta: {
1680
+ type: 'jsonb',
1681
+ jsonType: 'Record<string, unknown>'
1682
+ },
1683
+ editedAt: {
1684
+ type: 'timestamp'
1685
+ },
1686
+ deletedAt: {
1687
+ type: 'timestamp'
1688
+ },
1689
+ createdAt: {
1690
+ type: 'timestamp',
1691
+ notNull: true,
1692
+ defaultNow: true
1693
+ },
1694
+ updatedAt: {
1695
+ type: 'timestamp',
1696
+ notNull: true,
1697
+ defaultNow: true
1698
+ }
1699
+ },
1700
+ foreignKeys: [
1701
+ {
1702
+ columns: [
1703
+ 'parentMessageId'
1704
+ ],
1705
+ foreignTable: 'commentMessages',
1706
+ foreignColumns: [
1707
+ 'id'
1708
+ ],
1709
+ name: 'comment_messages_parent_fk',
1710
+ onDelete: 'set null'
1711
+ }
1712
+ ],
1713
+ indexes: {
1714
+ threadIdx: {
1715
+ columns: [
1716
+ 'threadId',
1717
+ 'createdAt'
1718
+ ]
1719
+ },
1720
+ parentIdx: {
1721
+ columns: [
1722
+ 'parentMessageId'
1723
+ ]
1724
+ },
1725
+ typeIdx: {
1726
+ columns: [
1727
+ 'messageType',
1728
+ 'systemType'
1729
+ ]
1730
+ },
1731
+ authorIdx: {
1732
+ columns: [
1733
+ 'authorId',
1734
+ 'createdAt'
1735
+ ]
1736
+ }
1737
+ }
1738
+ },
1739
+ // ========================================================================
1740
+ // COMMENT_MENTIONS
1741
+ // ========================================================================
1742
+ commentMentions: {
1743
+ tableName: 'comment_mentions',
1744
+ indexPrefix: 'cmn',
1745
+ columns: {
1746
+ id: {
1747
+ type: 'text',
1748
+ primaryKey: true,
1749
+ defaultId: true,
1750
+ defaultIdPrefix: 'commentMention'
1751
+ },
1752
+ messageId: {
1753
+ type: 'text',
1754
+ notNull: true,
1755
+ references: {
1756
+ table: 'commentMessages',
1757
+ column: 'id',
1758
+ onDelete: 'cascade'
1759
+ }
1760
+ },
1761
+ threadId: {
1762
+ type: 'text',
1763
+ notNull: true,
1764
+ references: {
1765
+ table: 'commentThreads',
1766
+ column: 'id',
1767
+ onDelete: 'cascade'
1768
+ }
1769
+ },
1770
+ mentionedUserId: {
1771
+ type: 'text',
1772
+ notNull: true
1773
+ },
1774
+ mentionedBy: {
1775
+ type: 'text',
1776
+ notNull: true
1777
+ },
1778
+ createdAt: {
1779
+ type: 'timestamp',
1780
+ notNull: true,
1781
+ defaultNow: true
1782
+ }
1783
+ },
1784
+ indexes: {
1785
+ userIdx: {
1786
+ columns: [
1787
+ 'mentionedUserId',
1788
+ 'createdAt'
1789
+ ]
1790
+ },
1791
+ messageIdx: {
1792
+ columns: [
1793
+ 'messageId'
1794
+ ]
1795
+ },
1796
+ threadUserIdx: {
1797
+ columns: [
1798
+ 'threadId',
1799
+ 'mentionedUserId'
1800
+ ]
1801
+ },
1802
+ messageUserUnique: {
1803
+ columns: [
1804
+ 'messageId',
1805
+ 'mentionedUserId'
1806
+ ],
1807
+ unique: true
1808
+ }
1809
+ }
1810
+ },
1811
+ // ========================================================================
1812
+ // ASSET_REFERENCES
1813
+ // ========================================================================
1814
+ // ========================================================================
1815
+ // CONTENT_USAGES — One generalist materialized index for ALL content-derived
1816
+ // usage: assets, variables, and reusable-block references. Replaces the old
1817
+ // per-domain asset_references + variable_usages tables (structurally one
1818
+ // pattern). Keyed by the IMMUTABLE blockVersionId: a block diverges per
1819
+ // branch into distinct, append-only versions, so a version-keyed row never
1820
+ // drifts. Rows are inserted ONCE when a version is created (never re-synced)
1821
+ // and removed only by FK cascade when the version (or its root) is pruned.
1822
+ // LIVENESS is decided by joining to branch-HEAD snapshots, never a stored
1823
+ // flag — a superseded version simply stops counting. `targetKey` is
1824
+ // POLYMORPHIC (assetId | variableKey | referencedRootId) and intentionally
1825
+ // carries NO FK: correctness comes from the liveness join, not a per-target
1826
+ // cascade (verified — asset GC reclaim + hardDeleteRoot depend on the
1827
+ // blockVersionId/rootId keys + the join). This is the single source of truth
1828
+ // for the destructive GC/guards AND the usage UIs.
1829
+ contentUsages: {
1830
+ tableName: 'content_usages',
1831
+ indexPrefix: 'cu',
1832
+ columns: {
1833
+ id: {
1834
+ type: 'text',
1835
+ primaryKey: true,
1836
+ defaultId: true,
1837
+ defaultIdPrefix: 'contentUsage'
1838
+ },
1839
+ targetKind: {
1840
+ type: {
1841
+ enum: 'contentUsageTarget'
1842
+ },
1843
+ notNull: true
1844
+ },
1845
+ // assetId | variableKey | referencedRootId — discriminated by targetKind.
1846
+ targetKey: {
1847
+ type: 'text',
1848
+ notNull: true
1849
+ },
1850
+ blockVersionId: {
1851
+ type: 'text',
1852
+ notNull: true,
1853
+ references: {
1854
+ table: 'blockVersions',
1855
+ column: 'id',
1856
+ onDelete: 'cascade'
1857
+ }
1858
+ },
1859
+ // rootId/blockId are the denormalized HOST root/block for fast UI
1860
+ // grouping and for the prune-by-rootId path; rootId also cascades.
1861
+ rootId: {
1862
+ type: 'text',
1863
+ notNull: true,
1864
+ references: {
1865
+ table: 'roots',
1866
+ column: 'id',
1867
+ onDelete: 'cascade'
1868
+ }
1869
+ },
1870
+ blockId: {
1871
+ type: 'text',
1872
+ notNull: true
1873
+ },
1874
+ propertyKey: {
1875
+ type: 'text',
1876
+ notNull: true
1877
+ }
1878
+ },
1879
+ indexes: {
1880
+ versionTargetPropUnique: {
1881
+ columns: [
1882
+ 'blockVersionId',
1883
+ 'targetKind',
1884
+ 'targetKey',
1885
+ 'propertyKey'
1886
+ ],
1887
+ unique: true
1888
+ },
1889
+ targetIdx: {
1890
+ columns: [
1891
+ 'targetKind',
1892
+ 'targetKey'
1893
+ ]
1894
+ },
1895
+ blockVersionIdx: {
1896
+ columns: [
1897
+ 'blockVersionId'
1898
+ ]
1899
+ },
1900
+ rootIdx: {
1901
+ columns: [
1902
+ 'rootId'
1903
+ ]
1904
+ }
1905
+ }
1906
+ },
1907
+ // ========================================================================
1908
+ // VARIABLES — User-editable key-value pairs for content substitution
1909
+ // ========================================================================
1910
+ variables: {
1911
+ columns: {
1912
+ id: {
1913
+ type: 'text',
1914
+ primaryKey: true,
1915
+ defaultId: true,
1916
+ defaultIdPrefix: 'variable'
1917
+ },
1918
+ key: {
1919
+ type: 'text',
1920
+ notNull: true
1921
+ },
1922
+ value: {
1923
+ type: 'text',
1924
+ notNull: true
1925
+ },
1926
+ description: {
1927
+ type: 'text'
1928
+ },
1929
+ createdBy: {
1930
+ type: 'text'
1931
+ },
1932
+ updatedBy: {
1933
+ type: 'text'
1934
+ },
1935
+ createdAt: {
1936
+ type: 'timestamp',
1937
+ notNull: true,
1938
+ defaultNow: true
1939
+ },
1940
+ updatedAt: {
1941
+ type: 'timestamp',
1942
+ notNull: true,
1943
+ defaultNow: true
1944
+ }
1945
+ },
1946
+ indexes: {
1947
+ keyUnique: {
1948
+ columns: [
1949
+ 'key'
1950
+ ],
1951
+ unique: true
1952
+ }
1953
+ }
1954
+ },
1955
+ // ========================================================================
1956
+ // TEMPLATES — Default-value formulas per collection/block/property
1957
+ // ========================================================================
1958
+ templates: {
1959
+ columns: {
1960
+ id: {
1961
+ type: 'text',
1962
+ primaryKey: true,
1963
+ defaultId: true,
1964
+ defaultIdPrefix: 'template'
1965
+ },
1966
+ collection: {
1967
+ type: 'text',
1968
+ notNull: true
1969
+ },
1970
+ blockType: {
1971
+ type: 'text',
1972
+ notNull: true
1973
+ },
1974
+ propertyKey: {
1975
+ type: 'text',
1976
+ notNull: true
1977
+ },
1978
+ template: {
1979
+ type: 'text',
1980
+ notNull: true
1981
+ },
1982
+ description: {
1983
+ type: 'text'
1984
+ },
1985
+ createdBy: {
1986
+ type: 'text'
1987
+ },
1988
+ updatedBy: {
1989
+ type: 'text'
1990
+ },
1991
+ createdAt: {
1992
+ type: 'timestamp',
1993
+ notNull: true,
1994
+ defaultNow: true
1995
+ },
1996
+ updatedAt: {
1997
+ type: 'timestamp',
1998
+ notNull: true,
1999
+ defaultNow: true
2000
+ }
2001
+ },
2002
+ indexes: {
2003
+ collectionBlockPropUnique: {
2004
+ columns: [
2005
+ 'collection',
2006
+ 'blockType',
2007
+ 'propertyKey'
2008
+ ],
2009
+ unique: true
2010
+ },
2011
+ collectionIdx: {
2012
+ columns: [
2013
+ 'collection'
2014
+ ]
2015
+ },
2016
+ collectionBlockIdx: {
2017
+ columns: [
2018
+ 'collection',
2019
+ 'blockType'
2020
+ ]
2021
+ }
2022
+ }
2023
+ },
2024
+ // ========================================================================
2025
+ // TEMPLATE_VARIABLE_USAGES — Tracks which variables are used in templates
2026
+ // ========================================================================
2027
+ templateVariableUsages: {
2028
+ tableName: 'template_variable_usages',
2029
+ indexPrefix: 'tvu',
2030
+ columns: {
2031
+ id: {
2032
+ type: 'text',
2033
+ primaryKey: true,
2034
+ defaultId: true,
2035
+ defaultIdPrefix: 'tplVarUsage'
2036
+ },
2037
+ variableKey: {
2038
+ type: 'text',
2039
+ notNull: true
2040
+ },
2041
+ templateId: {
2042
+ type: 'text',
2043
+ notNull: true,
2044
+ references: {
2045
+ table: 'templates',
2046
+ column: 'id',
2047
+ onDelete: 'cascade'
2048
+ }
2049
+ }
2050
+ },
2051
+ indexes: {
2052
+ keyTemplateUnique: {
2053
+ columns: [
2054
+ 'variableKey',
2055
+ 'templateId'
2056
+ ],
2057
+ unique: true
2058
+ },
2059
+ variableKeyIdx: {
2060
+ columns: [
2061
+ 'variableKey'
2062
+ ]
2063
+ },
2064
+ templateIdIdx: {
2065
+ columns: [
2066
+ 'templateId'
2067
+ ]
2068
+ }
2069
+ }
2070
+ },
2071
+ // ========================================================================
2072
+ // SEARCH_INDEX — Materialized full-text search index across all entities
2073
+ // ========================================================================
2074
+ searchIndex: {
2075
+ tableName: 'search_index',
2076
+ indexPrefix: 'si',
2077
+ columns: {
2078
+ id: {
2079
+ type: 'text',
2080
+ primaryKey: true,
2081
+ defaultId: true,
2082
+ defaultIdPrefix: 'si'
2083
+ },
2084
+ entityType: {
2085
+ type: 'text',
2086
+ notNull: true
2087
+ },
2088
+ entityId: {
2089
+ type: 'text',
2090
+ notNull: true
2091
+ },
2092
+ collection: {
2093
+ type: 'text'
2094
+ },
2095
+ rootId: {
2096
+ type: 'text'
2097
+ },
2098
+ contentVector: {
2099
+ type: 'tsvector',
2100
+ notNull: true
2101
+ },
2102
+ title: {
2103
+ type: 'text'
2104
+ },
2105
+ snippet: {
2106
+ type: 'text'
2107
+ },
2108
+ meta: {
2109
+ type: 'jsonb',
2110
+ jsonType: 'Record<string, unknown>'
2111
+ },
2112
+ updatedAt: {
2113
+ type: 'timestamp',
2114
+ notNull: true,
2115
+ defaultNow: true
2116
+ }
2117
+ },
2118
+ indexes: {
2119
+ vectorGin: {
2120
+ columns: [
2121
+ 'contentVector'
2122
+ ],
2123
+ using: 'gin'
2124
+ },
2125
+ entityTypeIdx: {
2126
+ columns: [
2127
+ 'entityType'
2128
+ ]
2129
+ },
2130
+ collectionIdx: {
2131
+ columns: [
2132
+ 'collection'
2133
+ ]
2134
+ },
2135
+ rootIdx: {
2136
+ columns: [
2137
+ 'rootId'
2138
+ ]
2139
+ },
2140
+ entityUnique: {
2141
+ columns: [
2142
+ 'entityType',
2143
+ 'entityId'
2144
+ ],
2145
+ unique: true
2146
+ }
2147
+ }
2148
+ },
2149
+ // ========================================================================
2150
+ // NOTIFICATIONS — In-app notification inbox per user
2151
+ // ========================================================================
2152
+ notifications: {
2153
+ tableName: 'notifications',
2154
+ indexPrefix: 'ntf',
2155
+ columns: {
2156
+ id: {
2157
+ type: 'text',
2158
+ primaryKey: true,
2159
+ defaultId: true,
2160
+ defaultIdPrefix: 'notification'
2161
+ },
2162
+ recipientId: {
2163
+ type: 'text',
2164
+ notNull: true
2165
+ },
2166
+ actorId: {
2167
+ type: 'text'
2168
+ },
2169
+ type: {
2170
+ type: {
2171
+ enum: 'notificationType'
2172
+ },
2173
+ notNull: true
2174
+ },
2175
+ title: {
2176
+ type: 'text',
2177
+ notNull: true
2178
+ },
2179
+ body: {
2180
+ type: 'text'
2181
+ },
2182
+ resourceType: {
2183
+ type: 'text'
2184
+ },
2185
+ resourceId: {
2186
+ type: 'text'
2187
+ },
2188
+ collection: {
2189
+ type: 'text'
2190
+ },
2191
+ meta: {
2192
+ type: 'jsonb',
2193
+ jsonType: 'Record<string, unknown>'
2194
+ },
2195
+ readAt: {
2196
+ type: 'timestamp'
2197
+ },
2198
+ archivedAt: {
2199
+ type: 'timestamp'
2200
+ },
2201
+ createdAt: {
2202
+ type: 'timestamp',
2203
+ notNull: true,
2204
+ defaultNow: true
2205
+ }
2206
+ },
2207
+ indexes: {
2208
+ recipientCreatedIdx: {
2209
+ columns: [
2210
+ 'recipientId',
2211
+ 'createdAt'
2212
+ ]
2213
+ },
2214
+ recipientUnreadIdx: {
2215
+ columns: [
2216
+ 'recipientId',
2217
+ 'readAt'
2218
+ ]
2219
+ },
2220
+ resourceIdx: {
2221
+ columns: [
2222
+ 'resourceType',
2223
+ 'resourceId'
2224
+ ]
2225
+ },
2226
+ typeIdx: {
2227
+ columns: [
2228
+ 'type'
2229
+ ]
2230
+ }
2231
+ }
2232
+ },
2233
+ // ========================================================================
2234
+ // REDIRECTS — SEO 301/302 mapping (see REDIRECTS_DESIGN.md)
2235
+ // ========================================================================
2236
+ // Source and target are each a page REFERENCE (rootId → current path,
2237
+ // follows moves) or a literal PATH. Auto-created on rename/move/archive,
2238
+ // or managed manually. Resolved by the consumer before serving content.
2239
+ redirects: {
2240
+ tableName: 'redirects',
2241
+ indexPrefix: 'rdr',
2242
+ columns: {
2243
+ id: {
2244
+ type: 'text',
2245
+ primaryKey: true,
2246
+ defaultId: true,
2247
+ defaultIdPrefix: 'redirect'
2248
+ },
2249
+ collection: {
2250
+ type: 'text',
2251
+ notNull: true
2252
+ },
2253
+ sourceType: {
2254
+ type: {
2255
+ enum: 'redirectEndpointType'
2256
+ },
2257
+ notNull: true
2258
+ },
2259
+ // 'page' source: redirect away from this live page (follows its slug).
2260
+ sourceRootId: {
2261
+ type: 'text',
2262
+ references: {
2263
+ table: 'roots',
2264
+ column: 'id',
2265
+ onDelete: 'cascade'
2266
+ }
2267
+ },
2268
+ // 'path' source: the exact (normalized) old/dead path.
2269
+ sourcePath: {
2270
+ type: 'text'
2271
+ },
2272
+ targetType: {
2273
+ type: {
2274
+ enum: 'redirectEndpointType'
2275
+ },
2276
+ notNull: true
2277
+ },
2278
+ // 'page' target: resolves to the root's CURRENT published path.
2279
+ targetRootId: {
2280
+ type: 'text',
2281
+ references: {
2282
+ table: 'roots',
2283
+ column: 'id',
2284
+ onDelete: 'cascade'
2285
+ }
2286
+ },
2287
+ // 'path' target: literal / external destination.
2288
+ targetPath: {
2289
+ type: 'text'
2290
+ },
2291
+ statusCode: {
2292
+ type: 'integer',
2293
+ notNull: true,
2294
+ default: {
2295
+ kind: 'literal',
2296
+ value: 301
2297
+ }
2298
+ },
2299
+ createdBy: {
2300
+ type: 'text'
2301
+ },
2302
+ createdAt: {
2303
+ type: 'timestamp',
2304
+ notNull: true,
2305
+ defaultNow: true
2306
+ },
2307
+ updatedAt: {
2308
+ type: 'timestamp',
2309
+ notNull: true,
2310
+ defaultNow: true
2311
+ },
2312
+ archivedAt: {
2313
+ type: 'timestamp'
2314
+ }
2315
+ },
2316
+ indexes: {
2317
+ // Exact path-source lookup (collection + path). NON-unique on purpose:
2318
+ // uniqueness of a source is enforced at the APPLICATION level
2319
+ // (assertSourceUnique + the auto-create pre-check), scope-filtered where a
2320
+ // scoping plugin is active. A core DB-unique on (collection, sourcePath)
2321
+ // would be GLOBAL — it cannot be loosened by a plugin (merge.ts only ADDS
2322
+ // indexes), so two scopes could never share a path. A scoping
2323
+ // plugin instead adds its own PARTIAL UNIQUE on its scope column(s) + (collection,
2324
+ // sourcePath), which is the real DB guarantee when scoping is on.
2325
+ collectionSourcePathIdx: {
2326
+ columns: [
2327
+ 'collection',
2328
+ 'sourcePath'
2329
+ ]
2330
+ },
2331
+ // Page-source lookup: does this live root redirect away? NON-unique for
2332
+ // the same reason (per-scope uniqueness is the plugin's job).
2333
+ sourceRootIdx: {
2334
+ columns: [
2335
+ 'sourceRootId'
2336
+ ]
2337
+ },
2338
+ collectionIdx: {
2339
+ columns: [
2340
+ 'collection'
2341
+ ]
2342
+ },
2343
+ archivedAtIdx: {
2344
+ columns: [
2345
+ 'archivedAt'
2346
+ ]
2347
+ }
2348
+ }
2349
+ }
2350
+ }
2351
+ });
2352
+
2353
+ /** Returns true if a file/path exists and is accessible. */ async function fileExists(filePath) {
2354
+ try {
2355
+ await access(filePath);
2356
+ return true;
2357
+ } catch {
2358
+ return false;
2359
+ }
2360
+ }
2361
+
2362
+ const CONFIG_CANDIDATES = [
2363
+ 'cms.ts',
2364
+ 'cms.js',
2365
+ 'src/cms.ts',
2366
+ 'src/cms.js',
2367
+ 'src/lib/cms.ts',
2368
+ 'src/lib/cms.js',
2369
+ 'lib/cms.ts',
2370
+ 'lib/cms.js'
2371
+ ];
2372
+ async function discoverConfig(cwd) {
2373
+ for (const candidate of CONFIG_CANDIDATES){
2374
+ const fullPath = path.resolve(cwd, candidate);
2375
+ if (await fileExists(fullPath)) {
2376
+ return fullPath;
2377
+ }
2378
+ }
2379
+ return undefined;
2380
+ }
2381
+
2382
+ const STUB_CODE = `
2383
+ const handler = {
2384
+ get(_, prop) {
2385
+ if (prop === Symbol.toPrimitive) return () => '';
2386
+ if (prop === 'then') return undefined;
2387
+ return stub;
2388
+ },
2389
+ apply() { return stub; },
2390
+ construct() { return stub; },
2391
+ };
2392
+ const stub = new Proxy(function(){}, handler);
2393
+ export default stub;
2394
+ export { stub as auth, stub as db, stub as headers, stub as cookies };
2395
+ `;
2396
+ const SHIM_CODE = `
2397
+ export const createCMS = (definition) => ({
2398
+ ...definition,
2399
+ router: {},
2400
+ api: {},
2401
+ collections: definition.collections || {},
2402
+ $plugins: definition.plugins || [],
2403
+ $schema: definition.schema,
2404
+ });
2405
+ // Config authoring helpers are pure identity functions at runtime (see
2406
+ // core/define.ts). The shim re-implements them so a config that imports the
2407
+ // idiomatic \`defineCollection\` / \`defineCollections\` / \`defineAuthMiddleware\`
2408
+ // API loads during \`createcms generate\` without pulling in the real package.
2409
+ export const defineCollection = (collection) => collection;
2410
+ export const defineCollections = (collections) => collections;
2411
+ export const defineAuthMiddleware = (middleware) => middleware;
2412
+ export default { createCMS, defineCollection, defineCollections, defineAuthMiddleware };
2413
+ `;
2414
+ function ensureFile(dir, name, code) {
2415
+ const filePath = path.join(dir, name);
2416
+ try {
2417
+ mkdirSync(dir, {
2418
+ recursive: true
2419
+ });
2420
+ writeFileSync(filePath, code, 'utf8');
2421
+ } catch {
2422
+ // best-effort
2423
+ }
2424
+ return filePath;
2425
+ }
2426
+ function readTsconfigPaths(cwd) {
2427
+ const alias = {};
2428
+ try {
2429
+ const raw = readFileSync(path.join(cwd, 'tsconfig.json'), 'utf8');
2430
+ const stripped = raw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '');
2431
+ const tsconfig = JSON.parse(stripped);
2432
+ const paths = tsconfig.compilerOptions?.paths;
2433
+ const baseUrl = tsconfig.compilerOptions?.baseUrl ?? '.';
2434
+ const base = path.resolve(cwd, baseUrl);
2435
+ if (paths) {
2436
+ for (const [pattern, targets] of Object.entries(paths)){
2437
+ if (targets.length > 0) {
2438
+ const key = pattern.replace(/\/\*$/, '');
2439
+ const target = targets[0].replace(/\/\*$/, '');
2440
+ alias[key] = path.resolve(base, target);
2441
+ }
2442
+ }
2443
+ }
2444
+ } catch {
2445
+ // No tsconfig or invalid — skip
2446
+ }
2447
+ return alias;
2448
+ }
2449
+ /**
2450
+ * Scans node_modules directories upward from cwd and collects all
2451
+ * installed package names. These will be aliased to the stub so
2452
+ * jiti never loads them (preventing side effects).
2453
+ */ function collectInstalledPackages(cwd) {
2454
+ const packages = new Set();
2455
+ let dir = cwd;
2456
+ while(true){
2457
+ const nmDir = path.join(dir, 'node_modules');
2458
+ try {
2459
+ for (const entry of readdirSync(nmDir)){
2460
+ if (entry.startsWith('.')) continue;
2461
+ if (entry.startsWith('@')) {
2462
+ const scopeDir = path.join(nmDir, entry);
2463
+ try {
2464
+ for (const pkg of readdirSync(scopeDir)){
2465
+ if (!pkg.startsWith('.')) packages.add(`${entry}/${pkg}`);
2466
+ }
2467
+ } catch {
2468
+ // not readable
2469
+ }
2470
+ } else {
2471
+ packages.add(entry);
2472
+ }
2473
+ }
2474
+ } catch {
2475
+ // no node_modules here
2476
+ }
2477
+ const parent = path.dirname(dir);
2478
+ if (parent === dir) break;
2479
+ dir = parent;
2480
+ }
2481
+ return [
2482
+ ...packages
2483
+ ];
2484
+ }
2485
+ /**
2486
+ * CJS fallback patch: catches any require() that still slips through
2487
+ * jiti's alias system and redirects to the stub.
2488
+ */ function patchModuleResolution(stubPath) {
2489
+ const original = Module._resolveFilename;
2490
+ Module._resolveFilename = function(request, parent, isMain, options) {
2491
+ try {
2492
+ return original.call(this, request, parent, isMain, options);
2493
+ } catch {
2494
+ return stubPath;
2495
+ }
2496
+ };
2497
+ return ()=>{
2498
+ Module._resolveFilename = original;
2499
+ };
2500
+ }
2501
+ /**
2502
+ * Resolves the real @createcms/core dist directory by locating one of
2503
+ * its exported subpaths and walking up to the dist root.
2504
+ */ function resolveRealCmsDistDir(cwd) {
2505
+ try {
2506
+ const req = Module.createRequire(path.join(cwd, '_'));
2507
+ // Resolve a known export — ./nanoid maps to dist/nanoid.js
2508
+ const nanoidPath = req.resolve('@createcms/core/nanoid');
2509
+ // nanoidPath = <pkg>/dist/nanoid.js → dirname = <pkg>/dist
2510
+ return path.dirname(nanoidPath);
2511
+ } catch {
2512
+ return null;
2513
+ }
2514
+ }
2515
+ async function loadCMSConfig(configPath) {
2516
+ const cwd = path.dirname(configPath);
2517
+ const tsconfigAlias = readTsconfigPaths(cwd);
2518
+ const tmpDir = path.join(tmpdir(), 'createcms-stubs');
2519
+ const stubPath = ensureFile(tmpDir, 'module-stub.mjs', STUB_CODE);
2520
+ const shimPath = ensureFile(tmpDir, 'cms-shim.mjs', SHIM_CODE);
2521
+ // Build alias map: stub every installed package, then override
2522
+ // @createcms/core with the lightweight shim
2523
+ const allPackages = collectInstalledPackages(cwd);
2524
+ const alias = {};
2525
+ for (const pkg of allPackages){
2526
+ alias[pkg] = stubPath;
2527
+ }
2528
+ alias['@createcms/core'] = shimPath;
2529
+ // Jiti treats aliases as prefix replacements, so @createcms/core/plugins/server
2530
+ // would resolve to <shimPath>/plugins/server (which doesn't exist).
2531
+ // We need explicit aliases for subpaths that must resolve to the real package
2532
+ // so the generator can load plugin schemas.
2533
+ const cmsDistDir = resolveRealCmsDistDir(cwd);
2534
+ if (cmsDistDir) {
2535
+ for (const key of Object.keys(alias)){
2536
+ if (key.startsWith('@createcms/core/')) {
2537
+ delete alias[key];
2538
+ }
2539
+ }
2540
+ // Map import specifiers → dist entry points
2541
+ const subpathMap = {
2542
+ 'plugins/server': 'plugins/server.js',
2543
+ 'plugins/client': 'plugins/client.js',
2544
+ 'plugins/multi-tenant': 'plugins/multi-tenant/index.js',
2545
+ 'plugins/ab-test': 'plugins/ab-test/index.js',
2546
+ 'plugins/ab-test/client': 'plugins/ab-test/client.js',
2547
+ 'plugins/media-optimize': 'plugins/media-optimize/index.js',
2548
+ plugins: 'plugins/index.js',
2549
+ nanoid: 'nanoid.js'
2550
+ };
2551
+ for (const [specifier, distRelative] of Object.entries(subpathMap)){
2552
+ alias[`@createcms/core/${specifier}`] = path.join(cmsDistDir, distRelative);
2553
+ }
2554
+ }
2555
+ // tsconfig paths override everything (e.g. @/* → ./src/*)
2556
+ Object.assign(alias, tsconfigAlias);
2557
+ const restore = patchModuleResolution(stubPath);
2558
+ try {
2559
+ const jiti = createJiti(pathToFileURL(configPath).href, {
2560
+ interopDefault: true,
2561
+ moduleCache: false,
2562
+ alias
2563
+ });
2564
+ const mod = await jiti.import(configPath);
2565
+ // jiti with interopDefault may nest the exports — try multiple paths
2566
+ const instance = mod.cms ?? mod.default ?? mod;
2567
+ if (!instance || typeof instance !== 'object') {
2568
+ throw new Error(`No CMS instance found. Export your createCMS() result as default or named "cms" from ${configPath}`);
2569
+ }
2570
+ return instance;
2571
+ } finally{
2572
+ restore();
2573
+ }
2574
+ }
2575
+
2576
+ function printMeta(label, value) {
2577
+ console.log(` ${kleur.bold().gray(label)} ${value}`);
2578
+ }
2579
+ function createSpinner(text) {
2580
+ return ora({
2581
+ text: ` ${text}`,
2582
+ color: 'cyan'
2583
+ });
2584
+ }
2585
+ async function confirmOverwrite(filePath) {
2586
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2587
+ console.error(`\n ${kleur.red('Error:')} Output file already exists and no interactive terminal is available.`);
2588
+ printMeta('file', filePath);
2589
+ printMeta('hint', 'Delete the file first or run the command in an interactive terminal.');
2590
+ return false;
2591
+ }
2592
+ console.log();
2593
+ console.log(` ${kleur.yellow('!')} ${kleur.bold('Output file already exists.')}`);
2594
+ printMeta('file', filePath);
2595
+ const response = await prompts({
2596
+ type: 'confirm',
2597
+ name: 'overwrite',
2598
+ message: 'Overwrite existing file?',
2599
+ initial: false
2600
+ }, {
2601
+ onCancel: ()=>false
2602
+ });
2603
+ return response.overwrite === true;
2604
+ }
2605
+
2606
+ function collectSchemaSources(config) {
2607
+ const sources = [
2608
+ {
2609
+ name: 'core',
2610
+ schema: coreSchema
2611
+ }
2612
+ ];
2613
+ if (config.$plugins) {
2614
+ for (const plugin of config.$plugins){
2615
+ if (plugin.schema) {
2616
+ sources.push({
2617
+ name: `plugin:${plugin.id}`,
2618
+ schema: plugin.schema
2619
+ });
2620
+ }
2621
+ }
2622
+ }
2623
+ return sources;
2624
+ }
2625
+ function registerGenerateCommand(cli) {
2626
+ cli.command('generate [config]', 'Generate the Drizzle schema from your CMS config').option('--output <path>', 'Override the output file path').action(async (configArg, options)=>{
2627
+ const cwd = process.cwd();
2628
+ const spinner = createSpinner('Looking for config');
2629
+ spinner.start();
2630
+ let configPath;
2631
+ if (configArg) {
2632
+ configPath = path.resolve(cwd, configArg);
2633
+ } else {
2634
+ const discovered = await discoverConfig(cwd);
2635
+ if (!discovered) {
2636
+ spinner.fail('No cms.ts found. Searched cms.ts, src/cms.ts, src/lib/cms.ts. Pass a path: createcms generate ./path/to/cms.ts');
2637
+ process.exit(1);
2638
+ }
2639
+ configPath = discovered;
2640
+ }
2641
+ spinner.succeed(` Found: ${configPath}`);
2642
+ const loadSpinner = createSpinner('Loading CMS config');
2643
+ loadSpinner.start();
2644
+ let config;
2645
+ try {
2646
+ config = await loadCMSConfig(configPath);
2647
+ } catch (err) {
2648
+ loadSpinner.fail(' Failed to load CMS config');
2649
+ throw err;
2650
+ }
2651
+ loadSpinner.succeed(' Config loaded');
2652
+ const outputPath = path.resolve(cwd, options?.output ?? config.$schema?.output ?? './cms-schema.ts');
2653
+ console.log();
2654
+ printMeta('config', kleur.dim(configPath));
2655
+ printMeta('output', kleur.dim(outputPath));
2656
+ const sources = collectSchemaSources(config);
2657
+ const pluginCount = sources.length - 1;
2658
+ if (pluginCount > 0) {
2659
+ printMeta('plugins', kleur.dim(sources.filter((s)=>s.name !== 'core').map((s)=>s.name.replace('plugin:', '')).join(', ')));
2660
+ }
2661
+ if (await fileExists(outputPath)) {
2662
+ const shouldOverwrite = await confirmOverwrite(outputPath);
2663
+ if (!shouldOverwrite) {
2664
+ console.log(`\n ${kleur.yellow('Generation cancelled.')}`);
2665
+ return;
2666
+ }
2667
+ }
2668
+ const genSpinner = createSpinner('Generating schema');
2669
+ genSpinner.start();
2670
+ try {
2671
+ const result = await generateSchema({
2672
+ sources,
2673
+ outputPath
2674
+ });
2675
+ genSpinner.succeed(' Schema generated');
2676
+ console.log();
2677
+ printMeta('file', kleur.green(result.outputPath));
2678
+ printMeta('tables', kleur.green(String(Object.keys(result.schema.tables).length)));
2679
+ printMeta('enums', kleur.green(String(Object.keys(result.schema.enums).length)));
2680
+ console.log();
2681
+ } catch (error) {
2682
+ genSpinner.fail(' Schema generation failed');
2683
+ throw error;
2684
+ }
2685
+ });
2686
+ }
2687
+
2688
+ // ============================================================================
2689
+ // `createcms init` scaffold templates + collection presets
2690
+ // ============================================================================
2691
+ //
2692
+ // Plain string templates (bundled into dist by bunchee — NOT read from disk at
2693
+ // runtime). They target a Next.js App Router project using the create-next-app
2694
+ // `--src-dir` layout (files under `src/`, the `@/*` → `./src/*` path alias).
2695
+ // db wiring is intentionally left to the consumer (`@/lib/db`) — `init` only
2696
+ // scaffolds the CMS side.
2697
+ /**
2698
+ * A ready-made collection the consumer can scaffold via `createcms init
2699
+ * --preset <name>`. The scaffolded collection file is editable code the
2700
+ * consumer OWNS (presets are a starting point, not a maintained import).
2701
+ */ const pagesCollection = ()=>`import type { CollectionDefinition } from '@createcms/core';
2702
+
2703
+ export const pagesCollection = {
2704
+ label: 'Pages',
2705
+ description: 'Marketing and content pages.',
2706
+ slug: { enabled: true, root: '/' },
2707
+ root: {
2708
+ properties: {
2709
+ title: {
2710
+ type: 'string',
2711
+ label: 'Title',
2712
+ required: true,
2713
+ defaultValue: 'Untitled page',
2714
+ },
2715
+ },
2716
+ },
2717
+ blocks: {
2718
+ richText: {
2719
+ label: 'Rich Text',
2720
+ description: 'A block of formatted text.',
2721
+ properties: {
2722
+ content: { type: 'richText', label: 'Content', required: true },
2723
+ },
2724
+ },
2725
+ },
2726
+ } as const satisfies CollectionDefinition;
2727
+
2728
+ export type PagesCollection = typeof pagesCollection;
2729
+ `;
2730
+ const postsCollection = ()=>`import type { CollectionDefinition } from '@createcms/core';
2731
+
2732
+ export const postsCollection = {
2733
+ label: 'Blog Posts',
2734
+ description: 'Articles and updates.',
2735
+ slug: { enabled: true, root: '/blog' },
2736
+ root: {
2737
+ properties: {
2738
+ title: {
2739
+ type: 'string',
2740
+ label: 'Title',
2741
+ required: true,
2742
+ defaultValue: 'Untitled post',
2743
+ },
2744
+ excerpt: {
2745
+ type: 'string',
2746
+ label: 'Excerpt',
2747
+ placeholder: 'A short summary shown in listings.',
2748
+ },
2749
+ publishedAt: { type: 'date', label: 'Published at' },
2750
+ draft: { type: 'boolean', label: 'Draft', defaultValue: false },
2751
+ },
2752
+ },
2753
+ blocks: {
2754
+ richText: {
2755
+ label: 'Rich Text',
2756
+ description: 'A block of formatted text.',
2757
+ properties: {
2758
+ content: { type: 'richText', label: 'Content', required: true },
2759
+ },
2760
+ },
2761
+ image: {
2762
+ label: 'Image',
2763
+ description: 'A figure with optional caption.',
2764
+ properties: {
2765
+ src: { type: 'image', label: 'Image', required: true },
2766
+ alt: { type: 'string', label: 'Alt text', required: true },
2767
+ caption: { type: 'string', label: 'Caption' },
2768
+ },
2769
+ },
2770
+ },
2771
+ } as const satisfies CollectionDefinition;
2772
+
2773
+ export type PostsCollection = typeof postsCollection;
2774
+ `;
2775
+ const docsCollection = ()=>`import type { CollectionDefinition } from '@createcms/core';
2776
+
2777
+ export const docsCollection = {
2778
+ label: 'Docs',
2779
+ description: 'Nested documentation pages.',
2780
+ slug: { enabled: true, root: '/docs', nested: true, normalize: true },
2781
+ root: {
2782
+ properties: {
2783
+ title: {
2784
+ type: 'string',
2785
+ label: 'Title',
2786
+ required: true,
2787
+ defaultValue: 'Untitled page',
2788
+ },
2789
+ },
2790
+ },
2791
+ blocks: {
2792
+ richText: {
2793
+ label: 'Rich Text',
2794
+ description: 'A block of formatted text.',
2795
+ properties: {
2796
+ content: { type: 'richText', label: 'Content', required: true },
2797
+ },
2798
+ },
2799
+ callout: {
2800
+ label: 'Callout',
2801
+ description: 'A highlighted note.',
2802
+ properties: {
2803
+ variant: {
2804
+ type: 'select',
2805
+ label: 'Variant',
2806
+ defaultValue: 'info',
2807
+ options: [
2808
+ { label: 'Info', value: 'info' },
2809
+ { label: 'Warning', value: 'warning' },
2810
+ { label: 'Tip', value: 'tip' },
2811
+ ],
2812
+ },
2813
+ content: { type: 'richText', label: 'Content', required: true },
2814
+ },
2815
+ },
2816
+ },
2817
+ } as const satisfies CollectionDefinition;
2818
+
2819
+ export type DocsCollection = typeof docsCollection;
2820
+ `;
2821
+ /** The shipped presets, keyed by `--preset <name>`. */ const PRESETS = {
2822
+ pages: {
2823
+ name: 'pages',
2824
+ description: 'Marketing & content pages (the default).',
2825
+ fileName: 'pages',
2826
+ exportName: 'pagesCollection',
2827
+ collectionKey: 'pages',
2828
+ collection: pagesCollection
2829
+ },
2830
+ blog: {
2831
+ name: 'blog',
2832
+ description: 'A blog: posts with excerpt, date, cover image + rich text.',
2833
+ fileName: 'posts',
2834
+ exportName: 'postsCollection',
2835
+ collectionKey: 'posts',
2836
+ collection: postsCollection
2837
+ },
2838
+ docs: {
2839
+ name: 'docs',
2840
+ description: 'Nested documentation pages with callouts.',
2841
+ fileName: 'docs',
2842
+ exportName: 'docsCollection',
2843
+ collectionKey: 'docs',
2844
+ collection: docsCollection
2845
+ }
2846
+ };
2847
+ const DEFAULT_PRESET = 'pages';
2848
+ /** `src/lib/cms.ts` — the createCMS config, wired to the chosen preset. */ const cmsConfigTemplate = (preset)=>`import { createCMS } from '@createcms/core';
2849
+
2850
+ import { ${preset.exportName} } from '@/cms/collections/${preset.fileName}';
2851
+ // TODO: point this at YOUR Drizzle client (a DrizzleInstance). createcms does
2852
+ // not scaffold the database client — wire your own (see https://orm.drizzle.team).
2853
+ import { db } from '@/lib/db';
2854
+
2855
+ export const cms = createCMS({
2856
+ db,
2857
+ // Where \`createcms generate\` writes the Drizzle schema for the CMS tables.
2858
+ schema: {
2859
+ output: './src/db/schema/cms.ts',
2860
+ },
2861
+ collections: {
2862
+ ${preset.collectionKey}: ${preset.exportName},
2863
+ },
2864
+ // Media uploads target any S3-compatible bucket. Fill from your environment.
2865
+ media: {
2866
+ provider: 'aws',
2867
+ region: process.env.S3_REGION!,
2868
+ accessKeyId: process.env.S3_ACCESS_KEY_ID!,
2869
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
2870
+ bucketName: process.env.S3_BUCKET!,
2871
+ publicUrl: process.env.S3_PUBLIC_URL!,
2872
+ },
2873
+ // Published content is public; everything else requires auth. Replace the
2874
+ // TODO with your real session/permission check (return {} to allow, throw to
2875
+ // deny).
2876
+ authMiddleware: async (ctx) => {
2877
+ if (ctx.permissionResource === 'publishedContent') return {};
2878
+ // TODO: resolve the signed-in user / permissions here.
2879
+ throw new Error('Unauthorized');
2880
+ },
2881
+ });
2882
+ `;
2883
+ /** `src/app/api/cms/[[...rest]]/route.ts` — mounts the CMS HTTP router. */ const routeHandlerTemplate = ()=>`import { cms } from '@/lib/cms';
2884
+
2885
+ const { handler } = cms.router;
2886
+
2887
+ export const GET = handler;
2888
+ export const POST = handler;
2889
+ `;
2890
+ /** `.env.example` — the env vars the scaffolded config reads. */ const envExampleTemplate = ()=>`# The CMS database — any Postgres connection string.
2891
+ DATABASE_URL=
2892
+
2893
+ # Media uploads — any S3-compatible bucket.
2894
+ S3_REGION=
2895
+ S3_ACCESS_KEY_ID=
2896
+ S3_SECRET_ACCESS_KEY=
2897
+ S3_BUCKET=
2898
+ S3_PUBLIC_URL=
2899
+ `;
2900
+ /** The npm script `init` adds to package.json. */ const GENERATE_SCRIPT = {
2901
+ name: 'cms:generate',
2902
+ command: 'createcms generate'
2903
+ };
2904
+ /**
2905
+ * The files `init` scaffolds for a chosen preset, relative to the project root.
2906
+ * Order is display order. Paths assume the `src/` layout (module header).
2907
+ */ function buildInitFiles(preset) {
2908
+ return [
2909
+ {
2910
+ path: 'src/lib/cms.ts',
2911
+ content: ()=>cmsConfigTemplate(preset)
2912
+ },
2913
+ {
2914
+ path: `src/cms/collections/${preset.fileName}.ts`,
2915
+ content: preset.collection
2916
+ },
2917
+ {
2918
+ path: 'src/app/api/cms/[[...rest]]/route.ts',
2919
+ content: routeHandlerTemplate
2920
+ },
2921
+ {
2922
+ path: '.env.example',
2923
+ content: envExampleTemplate
2924
+ }
2925
+ ];
2926
+ }
2927
+
2928
+ /**
2929
+ * Resolve which preset to scaffold. An explicit `--preset` wins (and a typo is
2930
+ * a hard error listing the valid names). Otherwise an interactive picker runs
2931
+ * when a TTY is available; non-interactively it falls back to the default.
2932
+ */ async function resolvePreset(flag) {
2933
+ if (flag) {
2934
+ // Object.hasOwn (not `in`) so prototype keys (constructor, __proto__, …) are
2935
+ // rejected as unknown instead of resolving to a bogus non-preset value.
2936
+ if (!Object.hasOwn(PRESETS, flag)) {
2937
+ throw new Error(`Unknown preset "${flag}". Available: ${Object.keys(PRESETS).join(', ')}.`);
2938
+ }
2939
+ return PRESETS[flag];
2940
+ }
2941
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2942
+ const response = await prompts({
2943
+ type: 'select',
2944
+ name: 'preset',
2945
+ message: 'Which collection preset?',
2946
+ choices: Object.values(PRESETS).map((p)=>({
2947
+ title: p.name,
2948
+ description: p.description,
2949
+ value: p.name
2950
+ })),
2951
+ initial: Object.keys(PRESETS).indexOf(DEFAULT_PRESET)
2952
+ });
2953
+ // Ctrl+C / escape leaves preset undefined → fall through to the default.
2954
+ if (typeof response.preset === 'string' && Object.hasOwn(PRESETS, response.preset)) {
2955
+ return PRESETS[response.preset];
2956
+ }
2957
+ }
2958
+ return PRESETS[DEFAULT_PRESET];
2959
+ }
2960
+ /**
2961
+ * Write the scaffold for `preset` into `cwd`. NON-DESTRUCTIVE: an existing file
2962
+ * is left untouched and reported as `skipped` (init never clobbers your code).
2963
+ * Returns the per-target outcome so the command can report it and tests can
2964
+ * assert it. Pure of any console/spinner UI — that lives in the command wrapper.
2965
+ */ async function scaffoldInit(opts) {
2966
+ const files = [];
2967
+ for (const file of buildInitFiles(opts.preset)){
2968
+ const target = path.join(opts.cwd, file.path);
2969
+ if (await fileExists(target)) {
2970
+ files.push({
2971
+ path: file.path,
2972
+ status: 'skipped'
2973
+ });
2974
+ continue;
2975
+ }
2976
+ await mkdir(path.dirname(target), {
2977
+ recursive: true
2978
+ });
2979
+ await writeFile(target, file.content(), 'utf8');
2980
+ files.push({
2981
+ path: file.path,
2982
+ status: 'created'
2983
+ });
2984
+ }
2985
+ return {
2986
+ files,
2987
+ pkg: await patchPackageJson(opts.cwd)
2988
+ };
2989
+ }
2990
+ /** Add the `cms:generate` script to package.json, if present and not already set. */ async function patchPackageJson(cwd) {
2991
+ const pkgPath = path.join(cwd, 'package.json');
2992
+ if (!await fileExists(pkgPath)) {
2993
+ return {
2994
+ status: 'skipped',
2995
+ reason: 'no package.json'
2996
+ };
2997
+ }
2998
+ const raw = await readFile(pkgPath, 'utf8');
2999
+ let parsed;
3000
+ try {
3001
+ parsed = JSON.parse(raw);
3002
+ } catch {
3003
+ return {
3004
+ status: 'skipped',
3005
+ reason: 'package.json is not valid JSON'
3006
+ };
3007
+ }
3008
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
3009
+ return {
3010
+ status: 'skipped',
3011
+ reason: 'package.json is not an object'
3012
+ };
3013
+ }
3014
+ const pkg = parsed;
3015
+ if (pkg.scripts?.[GENERATE_SCRIPT.name]) {
3016
+ return {
3017
+ status: 'skipped',
3018
+ reason: `script "${GENERATE_SCRIPT.name}" exists`
3019
+ };
3020
+ }
3021
+ pkg.scripts = {
3022
+ ...pkg.scripts,
3023
+ [GENERATE_SCRIPT.name]: GENERATE_SCRIPT.command
3024
+ };
3025
+ // Preserve the file's existing indentation (tabs / N spaces) to avoid
3026
+ // reformatting the user's whole package.json into a noisy git diff.
3027
+ const indent = raw.match(/\n([ \t]+)\S/)?.[1] ?? ' ';
3028
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, indent)}\n`, 'utf8');
3029
+ return {
3030
+ status: 'patched'
3031
+ };
3032
+ }
3033
+ function registerInitCommand(cli) {
3034
+ cli.command('init', 'Scaffold a CMS config, a collection preset, and the Next.js route handler').option('--cwd <dir>', 'Project root to scaffold into (default: cwd)').option('--preset <name>', `Collection preset to scaffold (${Object.keys(PRESETS).join(' | ')})`).action(async (options)=>{
3035
+ const cwd = path.resolve(process.cwd(), options?.cwd ?? '.');
3036
+ const preset = await resolvePreset(options?.preset);
3037
+ const spinner = createSpinner(`Scaffolding the "${preset.name}" preset`);
3038
+ spinner.start();
3039
+ let result;
3040
+ try {
3041
+ result = await scaffoldInit({
3042
+ cwd,
3043
+ preset
3044
+ });
3045
+ } catch (error) {
3046
+ spinner.fail(' Scaffolding failed');
3047
+ throw error;
3048
+ }
3049
+ const created = result.files.filter((f)=>f.status === 'created').length;
3050
+ spinner.succeed(` Scaffolded ${created} file${created === 1 ? '' : 's'}`);
3051
+ console.log();
3052
+ for (const f of result.files){
3053
+ const tag = f.status === 'created' ? kleur.green('created') : kleur.yellow('exists ');
3054
+ printMeta(tag, kleur.dim(f.path));
3055
+ }
3056
+ const pkgTag = result.pkg.status === 'patched' ? kleur.green('script ') : kleur.yellow('skipped');
3057
+ printMeta(pkgTag, kleur.dim(result.pkg.status === 'patched' ? `package.json → ${GENERATE_SCRIPT.name}` : `package.json (${result.pkg.reason})`));
3058
+ console.log();
3059
+ console.log(` ${kleur.bold('Next steps')}`);
3060
+ printMeta('1.', 'Provide your Drizzle client at src/lib/db.ts (@/lib/db).');
3061
+ printMeta('2.', 'Set DATABASE_URL + the S3_* vars (see .env.example).');
3062
+ printMeta('3.', `Run "${GENERATE_SCRIPT.command}" to emit the Drizzle schema.`);
3063
+ printMeta('4.', 'Generate + run your migrations (drizzle-kit), then start the app.');
3064
+ console.log();
3065
+ });
3066
+ }
3067
+
3068
+ const cli = cac('createcms');
3069
+ registerInitCommand(cli);
3070
+ registerGenerateCommand(cli);
3071
+ cli.help();
3072
+ cli.version('0.0.1');
3073
+ cli.parse(process.argv, {
3074
+ run: false
3075
+ });
3076
+ const result = cli.runMatchedCommand();
3077
+ if (result && typeof result === 'object' && 'then' in result) {
3078
+ result.catch((error)=>{
3079
+ console.error(error instanceof Error ? error.message : error);
3080
+ process.exit(1);
3081
+ });
3082
+ }