@spfn/cms 0.2.0-beta.5 → 0.2.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -5
- package/dist/index.d.ts +61 -1
- package/dist/index.js +2 -12
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +54 -0
- package/dist/server.js +229 -9
- package/dist/server.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ pnpm spfn add @spfn/cms
|
|
|
26
26
|
```typescript
|
|
27
27
|
// labels.ts
|
|
28
28
|
import { defineLabelConfig, defineLabels, createCmsClient } from '@spfn/cms';
|
|
29
|
+
import { getLocale } from '@spfn/cms/actions';
|
|
29
30
|
|
|
30
31
|
// Configure locales
|
|
31
32
|
export const labelConfig = defineLabelConfig({
|
|
@@ -52,7 +53,11 @@ export const labelsDefinition = defineLabels({
|
|
|
52
53
|
// Create client with API, getLabel, getLabels, and format
|
|
53
54
|
export const { api, getLabel, getLabels, format } = createCmsClient(
|
|
54
55
|
labelsDefinition,
|
|
55
|
-
|
|
56
|
+
{
|
|
57
|
+
defaultLocale: labelConfig.defaultLocale,
|
|
58
|
+
fallbackLocale: labelConfig.fallbackLocale,
|
|
59
|
+
getLocale: () => getLocale(labelConfig.defaultLocale),
|
|
60
|
+
}
|
|
56
61
|
);
|
|
57
62
|
```
|
|
58
63
|
|
|
@@ -281,7 +286,11 @@ Labels synchronize automatically on server startup:
|
|
|
281
286
|
Factory function to create CMS client with API, getLabel, getLabels, and format utilities.
|
|
282
287
|
|
|
283
288
|
```typescript
|
|
284
|
-
const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition,
|
|
289
|
+
const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, {
|
|
290
|
+
defaultLocale: labelConfig.defaultLocale,
|
|
291
|
+
fallbackLocale: labelConfig.fallbackLocale,
|
|
292
|
+
getLocale: () => getLocale(labelConfig.defaultLocale),
|
|
293
|
+
});
|
|
285
294
|
```
|
|
286
295
|
|
|
287
296
|
**Returns:**
|
|
@@ -383,6 +392,133 @@ await syncLabels(labelsDefinition, {
|
|
|
383
392
|
|
|
384
393
|
**Returns:** `{ added, updated, removed, unchanged }`
|
|
385
394
|
|
|
395
|
+
## Admin API
|
|
396
|
+
|
|
397
|
+
CMS 라벨을 관리하기 위한 Admin API를 제공합니다. 섹션별 테이블 뷰로 라벨을 조회/수정/발행할 수 있습니다.
|
|
398
|
+
|
|
399
|
+
### Admin Routes
|
|
400
|
+
|
|
401
|
+
`cmsAppRouter`에 포함된 Admin 라우트들:
|
|
402
|
+
|
|
403
|
+
| Route | Method | Description |
|
|
404
|
+
|-------|--------|-------------|
|
|
405
|
+
| `getSectionLabels` | GET | 섹션의 모든 라벨 조회 (Draft/Published 상태 포함) |
|
|
406
|
+
| `saveSectionDraft` | POST | 섹션 라벨 일괄 Draft 저장 |
|
|
407
|
+
| `publishSection` | POST | 섹션 전체 발행 (Draft → Published) |
|
|
408
|
+
| `resetSectionDraft` | DELETE | 섹션 Draft 초기화 |
|
|
409
|
+
|
|
410
|
+
### Admin UI 구현 예제
|
|
411
|
+
|
|
412
|
+
**1. API Client 설정:**
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
// labels.ts
|
|
416
|
+
import { createCmsClient } from '@spfn/cms';
|
|
417
|
+
|
|
418
|
+
export const { api: cmsApi } = createCmsClient(labelsDefinition, {
|
|
419
|
+
defaultLocale: labelConfig.defaultLocale,
|
|
420
|
+
fallbackLocale: labelConfig.fallbackLocale,
|
|
421
|
+
getLocale: () => getLocale(labelConfig.defaultLocale),
|
|
422
|
+
});
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
**2. 섹션 라벨 조회:**
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// 섹션의 모든 라벨을 Draft/Published 상태와 함께 조회
|
|
429
|
+
const data = await cmsApi.getSectionLabels.call({
|
|
430
|
+
params: { section: 'home' },
|
|
431
|
+
query: { locales: 'en,ko' }, // 콤마로 구분
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// 반환값
|
|
435
|
+
{
|
|
436
|
+
section: 'home',
|
|
437
|
+
locales: ['en', 'ko'],
|
|
438
|
+
labels: [
|
|
439
|
+
{
|
|
440
|
+
id: 1,
|
|
441
|
+
key: 'home.hero.title',
|
|
442
|
+
defaultValue: { en: 'Welcome', ko: '환영합니다' },
|
|
443
|
+
draft: { en: 'Welcome!', ko: '환영합니다!' } | null,
|
|
444
|
+
published: { en: 'Welcome', ko: '환영합니다' } | null,
|
|
445
|
+
hasDraft: true
|
|
446
|
+
},
|
|
447
|
+
// ...
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
**3. Draft 저장:**
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
// 수정된 라벨들을 Draft로 저장
|
|
456
|
+
await cmsApi.saveSectionDraft.call({
|
|
457
|
+
params: { section: 'home' },
|
|
458
|
+
body: {
|
|
459
|
+
labels: [
|
|
460
|
+
{ id: 1, values: { en: 'Welcome!', ko: '환영합니다!' } },
|
|
461
|
+
{ id: 2, values: { ko: '새로운 부제목' } }, // 특정 locale만 수정 가능
|
|
462
|
+
]
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
**4. 섹션 발행:**
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
// Draft가 있는 모든 라벨을 Published로 발행
|
|
471
|
+
const result = await cmsApi.publishSection.call({
|
|
472
|
+
params: { section: 'home' },
|
|
473
|
+
body: { locales: ['en', 'ko'] },
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// 반환값
|
|
477
|
+
{
|
|
478
|
+
published: 2, // 발행된 라벨 수
|
|
479
|
+
version: 3, // 최대 버전 번호
|
|
480
|
+
labels: ['home.hero.title', 'home.hero.subtitle'] // 발행된 라벨 키
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**5. Draft 초기화:**
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
// 섹션의 모든 Draft 삭제 (Published 값으로 복원)
|
|
488
|
+
await cmsApi.resetSectionDraft.call({
|
|
489
|
+
params: { section: 'home' },
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Workflow
|
|
494
|
+
|
|
495
|
+
```
|
|
496
|
+
┌─────────────┐ saveSectionDraft ┌─────────────┐
|
|
497
|
+
│ Default │ ──────────────────────► │ Draft │
|
|
498
|
+
│ (코드 정의) │ │ (version:null)│
|
|
499
|
+
└─────────────┘ └──────┬──────┘
|
|
500
|
+
│
|
|
501
|
+
publishSection │
|
|
502
|
+
◄───────────────────────────────┘
|
|
503
|
+
│
|
|
504
|
+
▼
|
|
505
|
+
┌─────────────┐
|
|
506
|
+
│ Published │
|
|
507
|
+
│ (version:N) │
|
|
508
|
+
└─────────────┘
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**상태 우선순위:** Draft > Published > Default
|
|
512
|
+
|
|
513
|
+
### 상태 표시
|
|
514
|
+
|
|
515
|
+
| 상태 | 의미 | UI 표시 예 |
|
|
516
|
+
|------|------|-----------|
|
|
517
|
+
| Default | 코드에 정의된 기본값만 존재 | 회색 |
|
|
518
|
+
| Draft | 저장되었으나 미발행 | 노란색 |
|
|
519
|
+
| Published | 발행되어 실제 서비스에 반영 | 초록색 |
|
|
520
|
+
| Edited | UI에서 수정 중 (미저장) | 파란색 |
|
|
521
|
+
|
|
386
522
|
## Architecture
|
|
387
523
|
|
|
388
524
|
### Database Schema
|
|
@@ -399,9 +535,6 @@ cms_label_values (actual content)
|
|
|
399
535
|
cms_published_cache (performance)
|
|
400
536
|
├─ section, locale, content (JSONB)
|
|
401
537
|
└─ version (for cache invalidation)
|
|
402
|
-
|
|
403
|
-
cms_audit_logs (tracking)
|
|
404
|
-
└─ action, userId, changes, metadata
|
|
405
538
|
```
|
|
406
539
|
|
|
407
540
|
### Query Flow
|
package/dist/index.d.ts
CHANGED
|
@@ -102,7 +102,12 @@ declare function format(template: string, vars: Record<string, string | number>)
|
|
|
102
102
|
* @example
|
|
103
103
|
* ```typescript
|
|
104
104
|
* // labels.ts - Setup once
|
|
105
|
-
*
|
|
105
|
+
* import { getLocale } from '@spfn/cms/actions';
|
|
106
|
+
*
|
|
107
|
+
* export const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, {
|
|
108
|
+
* defaultLocale: 'ko',
|
|
109
|
+
* getLocale: () => getLocale('ko'),
|
|
110
|
+
* });
|
|
106
111
|
*
|
|
107
112
|
* // Single section - direct access
|
|
108
113
|
* const label = await getLabel('home');
|
|
@@ -121,6 +126,7 @@ declare function format(template: string, vars: Record<string, string | number>)
|
|
|
121
126
|
declare function createCmsClient<T>(labelsDefinition: T, config: {
|
|
122
127
|
defaultLocale: string;
|
|
123
128
|
fallbackLocale?: string;
|
|
129
|
+
getLocale: () => Promise<string>;
|
|
124
130
|
}): {
|
|
125
131
|
api: _spfn_core_nextjs.Client<_spfn_core_route.Router<{
|
|
126
132
|
getLabelCache: _spfn_core_route.RouteDef<{
|
|
@@ -129,6 +135,60 @@ declare function createCmsClient<T>(labelsDefinition: T, config: {
|
|
|
129
135
|
locale: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
130
136
|
}>;
|
|
131
137
|
}, {}, Record<string, any>>;
|
|
138
|
+
getSectionLabels: _spfn_core_route.RouteDef<{
|
|
139
|
+
params: _sinclair_typebox.TObject<{
|
|
140
|
+
section: _sinclair_typebox.TString;
|
|
141
|
+
}>;
|
|
142
|
+
query: _sinclair_typebox.TObject<{
|
|
143
|
+
locales: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
144
|
+
}>;
|
|
145
|
+
}, {}, {
|
|
146
|
+
section: string;
|
|
147
|
+
locales: string[];
|
|
148
|
+
labels: Array<{
|
|
149
|
+
id: number;
|
|
150
|
+
key: string;
|
|
151
|
+
defaultValue: Record<string, string>;
|
|
152
|
+
draft: Record<string, string> | null;
|
|
153
|
+
published: Record<string, string> | null;
|
|
154
|
+
hasDraft: boolean;
|
|
155
|
+
}>;
|
|
156
|
+
}>;
|
|
157
|
+
saveSectionDraft: _spfn_core_route.RouteDef<{
|
|
158
|
+
params: _sinclair_typebox.TObject<{
|
|
159
|
+
section: _sinclair_typebox.TString;
|
|
160
|
+
}>;
|
|
161
|
+
body: _sinclair_typebox.TObject<{
|
|
162
|
+
labels: _sinclair_typebox.TArray<_sinclair_typebox.TObject<{
|
|
163
|
+
id: _sinclair_typebox.TNumber;
|
|
164
|
+
values: _sinclair_typebox.TRecord<_sinclair_typebox.TString, _sinclair_typebox.TString>;
|
|
165
|
+
}>>;
|
|
166
|
+
}>;
|
|
167
|
+
}, {}, {
|
|
168
|
+
updated: number;
|
|
169
|
+
success: boolean;
|
|
170
|
+
}>;
|
|
171
|
+
publishSection: _spfn_core_route.RouteDef<{
|
|
172
|
+
params: _sinclair_typebox.TObject<{
|
|
173
|
+
section: _sinclair_typebox.TString;
|
|
174
|
+
}>;
|
|
175
|
+
body: _sinclair_typebox.TObject<{
|
|
176
|
+
locales: _sinclair_typebox.TArray<_sinclair_typebox.TString>;
|
|
177
|
+
}>;
|
|
178
|
+
}, {}, {
|
|
179
|
+
published: number;
|
|
180
|
+
version: number;
|
|
181
|
+
labels: string[];
|
|
182
|
+
success: boolean;
|
|
183
|
+
}>;
|
|
184
|
+
resetSectionDraft: _spfn_core_route.RouteDef<{
|
|
185
|
+
params: _sinclair_typebox.TObject<{
|
|
186
|
+
section: _sinclair_typebox.TString;
|
|
187
|
+
}>;
|
|
188
|
+
}, {}, {
|
|
189
|
+
reset: number;
|
|
190
|
+
success: boolean;
|
|
191
|
+
}>;
|
|
132
192
|
}>>;
|
|
133
193
|
getLabel: <K extends SectionKeys<T>>(section: K) => Promise<BoundLabelSection<T, K>>;
|
|
134
194
|
getLabels: <K extends SectionKeys<T>>(sections: readonly K[]) => Promise<BoundLabelsSections<T, K>>;
|
package/dist/index.js
CHANGED
|
@@ -41,16 +41,6 @@ function createProxy(obj, locale, fallbackLocale) {
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// src/actions.ts
|
|
45
|
-
import { cookies } from "next/headers";
|
|
46
|
-
var LOCALE_COOKIE_NAME = "cms-locale";
|
|
47
|
-
var LOCALE_MAX_AGE = 365 * 24 * 60 * 60;
|
|
48
|
-
async function getLocale(defaultLocale) {
|
|
49
|
-
const cookieStore = await cookies();
|
|
50
|
-
const localeCookie = cookieStore.get(LOCALE_COOKIE_NAME);
|
|
51
|
-
return localeCookie?.value ?? defaultLocale ?? "en";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
44
|
// src/lib/helpers.ts
|
|
55
45
|
function setNestedValue(target, path, value) {
|
|
56
46
|
const parts = path.split(".");
|
|
@@ -85,7 +75,7 @@ var cmsLogger = logger.child("@spfn/cms");
|
|
|
85
75
|
var api = createApi();
|
|
86
76
|
function createCmsClient(labelsDefinition, config) {
|
|
87
77
|
async function getLabel(section) {
|
|
88
|
-
const locale = await getLocale(
|
|
78
|
+
const locale = await config.getLocale();
|
|
89
79
|
cmsLogger.debug("getLabel called", {
|
|
90
80
|
section,
|
|
91
81
|
locale,
|
|
@@ -107,7 +97,7 @@ function createCmsClient(labelsDefinition, config) {
|
|
|
107
97
|
return merged[section];
|
|
108
98
|
}
|
|
109
99
|
async function getLabels(sections) {
|
|
110
|
-
const locale = await getLocale(
|
|
100
|
+
const locale = await config.getLocale();
|
|
111
101
|
cmsLogger.debug("getLabels called", {
|
|
112
102
|
sections,
|
|
113
103
|
locale,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/lib/bind-locale.ts","../src/actions.ts","../src/lib/helpers.ts","../src/lib/define-labels.ts"],"sourcesContent":["import { createApi } from \"@spfn/core/nextjs\";\nimport { logger } from \"@spfn/core/logger\";\nimport { type AppRouter } from './server/routes/index';\nimport { bindLocale, type SectionKeys, type BoundLabelSection, type BoundLabelsSections } from './lib/bind-locale';\nimport { getLocale } from './actions';\nimport { setNestedValue } from './lib/helpers';\nimport { format, defineLabelConfig, defineLabels } from './lib/define-labels';\n\nconst cmsLogger = logger.child('@spfn/cms');\n\n/**\n * Default API client (for backward compatibility or when not using labels)\n */\nconst api = createApi<AppRouter>();\n\n/**\n * Create CMS client with API, label getters, and format utility\n *\n * @param labelsDefinition - Labels defined using defineLabels()\n * @param config - Label config from defineLabelConfig()\n * @returns API client, getLabel (single), getLabels (multiple), and format utility\n *\n * @example\n * ```typescript\n * // labels.ts - Setup once\n * export const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, labelConfig);\n *\n * // Single section - direct access\n * const label = await getLabel('home');\n * label.hero.title // \"Hello\" (no section name!)\n *\n * // Multiple sections - with section names\n * const labels = await getLabels(['home', 'about']);\n * labels.home.hero.title // \"Hello\"\n * labels.about.title // \"About Us\"\n *\n * // With template variables\n * const greeting = label.hero.greeting; // \"Hello {name}\"\n * format(greeting, { name: \"John\" }); // \"Hello John\"\n * ```\n */\nexport function createCmsClient<T>(\n labelsDefinition: T,\n config: { defaultLocale: string; fallbackLocale?: string }\n)\n{\n /**\n * Get a single section's labels (without section name wrapper)\n *\n * @param section - Section name to fetch\n * @returns Labels for the section, directly accessible\n *\n * @example\n * ```typescript\n * const label = await getLabel('signup');\n * label.title // Direct access\n * label.userName\n * ```\n */\n async function getLabel<K extends SectionKeys<T>>(section: K): Promise<BoundLabelSection<T, K>>\n {\n // Auto-detect locale from cookie, fallback to config.defaultLocale\n const locale = await getLocale(config.defaultLocale);\n\n cmsLogger.debug('getLabel called', {\n section,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [section as string],\n locale\n }\n });\n\n // 2. Filter only requested section\n const filteredLabels: any = {};\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n\n // 3. Generate defaults with locale binding\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n // 5. Return only the section content (without section name)\n return merged[section] as BoundLabelSection<T, K>;\n }\n\n /**\n * Get multiple sections' labels (with section names as keys)\n *\n * @param sections - Array of section names to fetch\n * @returns Object with section names as keys\n *\n * @example\n * ```typescript\n * const labels = await getLabels(['home', 'about']);\n * labels.home.title\n * labels.about.description\n * ```\n */\n async function getLabels<K extends SectionKeys<T>>(sections: readonly K[]): Promise<BoundLabelsSections<T, K>>\n {\n // Auto-detect locale from cookie, fallback to config.defaultLocale\n const locale = await getLocale(config.defaultLocale);\n\n cmsLogger.debug('getLabels called', {\n sections,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n availableDefinitionKeys: Object.keys(labelsDefinition as any),\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [...sections] as unknown as string[],\n locale\n }\n });\n\n cmsLogger.debug('Fetched from cache', {\n cacheKeys: Object.keys(cache),\n cacheEntryCount: Object.keys(cache).length,\n cacheStructure: Object.entries(cache).map(([key, value]) => ({\n section: key,\n isObject: typeof value === 'object',\n isNull: value === null,\n contentKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 2. Filter only requested sections (performance optimization)\n const filteredLabels: any = {};\n for (const section of sections)\n {\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n }\n\n cmsLogger.debug('Filtered sections', {\n requestedSections: sections,\n filteredSections: Object.keys(filteredLabels),\n filteredLabelsStructure: Object.entries(filteredLabels).map(([key, value]) => ({\n section: key,\n hasValue: !!value,\n isObject: typeof value === 'object',\n nestedKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 3. Generate defaults with locale binding (only for requested sections)\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n cmsLogger.debug('Generated defaults with locale binding', {\n defaultsKeys: Object.keys(defaults),\n });\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n cmsLogger.debug('Merged cache and defaults', {\n mergedKeys: Object.keys(merged),\n });\n\n return merged as BoundLabelsSections<T, K>;\n }\n\n return { api, getLabel, getLabels, format };\n}\n\n/**\n * Deep merge cache into defaults\n */\nfunction deepMergeCache(defaults: any, cache: Record<string, any>, locale: string): any\n{\n const result = { ...defaults };\n\n cmsLogger.debug('deepMergeCache: Starting merge', {\n cacheEntries: Object.keys(cache).length,\n locale,\n });\n\n for (const [section, content] of Object.entries(cache))\n {\n if (!content || typeof content !== 'object')\n {\n cmsLogger.debug('deepMergeCache: Skipping invalid content', { section });\n continue;\n }\n\n const contentKeys = Object.keys(content);\n cmsLogger.debug('deepMergeCache: Processing section', {\n section,\n labelCount: contentKeys.length,\n });\n\n for (const [flatKey, value] of Object.entries(content))\n {\n // Extract locale-specific value from LabelValue format\n let extractedValue: any;\n\n if (value && typeof value === 'object' && 'content' in value)\n {\n extractedValue = (value as any).content;\n cmsLogger.debug('deepMergeCache: Extracted from content field', {\n flatKey,\n hasContent: true,\n });\n }\n else if (value && typeof value === 'object' && locale in value)\n {\n extractedValue = (value as any)[locale];\n cmsLogger.debug('deepMergeCache: Extracted from locale field', {\n flatKey,\n locale,\n });\n }\n else\n {\n extractedValue = value;\n cmsLogger.debug('deepMergeCache: Using raw value', {\n flatKey,\n valueType: typeof value,\n });\n }\n\n // Set value using helper function\n setNestedValue(result, flatKey, extractedValue);\n }\n }\n\n cmsLogger.debug('deepMergeCache: Merge completed', {\n resultKeys: Object.keys(result),\n });\n\n return result;\n}\n\n/**\n * Re-export format utility for standalone use\n *\n * @example\n * ```typescript\n * import { format } from '@spfn/cms/api-client';\n *\n * const text = \"Hello {name}, you have {count} messages\";\n * format(text, { name: \"John\", count: 5 });\n * // \"Hello John, you have 5 messages\"\n * ```\n */\nexport { format, defineLabelConfig, defineLabels };\n\n/**\n * Re-export types for external use\n */\nexport type { BoundLabels, SectionKeys, BoundLabelSection, BoundLabelsSections } from './lib/bind-locale';","/**\n * Bind locale to labels, returning locale-specific values\n *\n * @example\n * ```ts\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * ```\n */\n\n/**\n * Type that converts locale records to strings\n */\nexport type BoundLabels<T> = {\n [K in keyof T]: T[K] extends Record<string, any>\n ? IsLocaleRecord<T[K]> extends true\n ? string\n : BoundLabels<T[K]>\n : T[K];\n};\n\n/**\n * Extract section keys from label definition\n */\nexport type SectionKeys<T> = Extract<keyof T, string>;\n\n/**\n * Get content of a single section (without section name wrapper)\n */\nexport type BoundLabelSection<T, K extends SectionKeys<T>> = BoundLabels<T>[K];\n\n/**\n * Pick specific sections from bound labels (for multiple sections)\n */\nexport type BoundLabelsSections<T, K extends SectionKeys<T>> = Pick<BoundLabels<T>, K>;\n\n/**\n * Check if object is a locale record (has string values only)\n */\ntype IsLocaleRecord<T> = T extends Record<string, string> ? true : false;\n\n/**\n * Check if an object is a locale record at runtime\n */\nfunction isLocaleRecord(obj: any): boolean\n{\n if (!obj || typeof obj !== 'object')\n {\n return false;\n }\n\n const values = Object.values(obj);\n\n // Empty object is not a locale record\n if (values.length === 0)\n {\n return false;\n }\n\n // All values must be strings\n return values.every(v => typeof v === 'string');\n}\n\n/**\n * Bind a locale to label definitions, returning locale-specific values\n *\n * @param labels - Label definitions with locale records\n * @param locale - Locale to bind (e.g., 'en', 'ko')\n * @param fallbackLocale - Optional fallback locale if value not found\n * @returns Labels with locale-specific string values\n *\n * @example\n * ```typescript\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" },\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * labels.home.hero.title // \"환영합니다\"\n *\n * // With fallback\n * const labelsEn = bindLocale(labelsDefinition, 'en', 'ko');\n * ```\n */\nexport function bindLocale<T>(\n labels: T,\n locale: string,\n fallbackLocale?: string\n): BoundLabels<T>\n{\n return createProxy(labels, locale, fallbackLocale) as BoundLabels<T>;\n}\n\n/**\n * Create a proxy that intercepts property access and returns locale-specific values\n */\nfunction createProxy(obj: any, locale: string, fallbackLocale?: string): any\n{\n return new Proxy(obj, {\n get(target, prop)\n {\n const value = target[prop];\n\n // If value doesn't exist, return undefined\n if (value === undefined)\n {\n return undefined;\n }\n\n // If this is a locale record, return the locale value\n if (isLocaleRecord(value))\n {\n // Try to get the requested locale\n if (value[locale] !== undefined)\n {\n return value[locale];\n }\n\n // Fallback to fallbackLocale if specified\n if (fallbackLocale && value[fallbackLocale] !== undefined)\n {\n return value[fallbackLocale];\n }\n\n // If locale not found, return first available locale\n const firstLocale = Object.keys(value)[0];\n return value[firstLocale];\n }\n\n // If this is a nested object, wrap it in a proxy\n if (typeof value === 'object' && value !== null)\n {\n return createProxy(value, locale, fallbackLocale);\n }\n\n // Otherwise return as-is\n return value;\n },\n });\n}","\"use server\"\n\nimport { cookies } from 'next/headers';\n\nconst LOCALE_COOKIE_NAME = 'cms-locale';\nconst LOCALE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year\n\n/**\n * Set user's preferred locale in cookie\n *\n * @param locale - Language code (e.g., 'ko', 'en', 'ja')\n */\nexport async function setLocale(locale: string): Promise<void>\n{\n const cookieStore = await cookies();\n\n cookieStore.set(LOCALE_COOKIE_NAME, locale, {\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n maxAge: LOCALE_MAX_AGE,\n path: '/',\n });\n}\n\n/**\n * Get user's preferred locale from cookie\n *\n * @param defaultLocale - Default locale from labelConfig.defaultLocale\n * @returns Language code (from cookie, or defaultLocale, or 'en')\n */\nexport async function getLocale(defaultLocale?: string): Promise<string>\n{\n const cookieStore = await cookies();\n const localeCookie = cookieStore.get(LOCALE_COOKIE_NAME);\n\n return localeCookie?.value ?? defaultLocale ?? 'en';\n}","/**\n * CMS Helper Functions\n */\n\nexport type FlatLabel = Record<string, Record<string, string>>;\n\n/**\n * Flatten nested label structure into dot notation\n *\n * @param labels - Nested label object\n * @param prefix - Key prefix for recursion\n * @returns Flattened label structure\n *\n * @example\n * ```typescript\n * const nested = {\n * home: {\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * };\n *\n * const flat = flattenLabels(nested);\n * // { \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" } }\n * ```\n */\nexport function flattenLabels<T extends Record<string, any>>(labels: T, prefix = ''): FlatLabel\n{\n const result: FlatLabel = {};\n\n if (!labels || typeof labels !== 'object')\n {\n return result;\n }\n\n const obj = labels as Record<string, unknown>;\n\n for (const [key, value] of Object.entries(obj))\n {\n const newKey = prefix ? `${prefix}.${key}` : key;\n\n if (!value || typeof value !== 'object')\n {\n continue;\n }\n\n const valueObj = value as Record<string, unknown>;\n\n // Check if this is a leaf node (locale values: { en: \"...\", ko: \"...\" })\n const isLeaf = Object.values(valueObj).every(v => typeof v === 'string');\n\n if (isLeaf)\n {\n result[newKey] = valueObj as Record<string, string>;\n }\n else\n {\n // Recursively flatten nested structure\n Object.assign(result, flattenLabels(value, newKey));\n }\n }\n\n return result;\n}\n\n/**\n * Set a value in nested object using dot notation path\n *\n * @param target - Target object to modify\n * @param path - Dot notation path (e.g., \"home.hero.title\")\n * @param value - Value to set\n *\n * @example\n * ```typescript\n * const obj = {};\n * setNestedValue(obj, \"home.hero.title\", \"Welcome\");\n * // obj = { home: { hero: { title: \"Welcome\" } } }\n * ```\n */\nexport function setNestedValue(target: any, path: string, value: any): void\n{\n const parts = path.split('.');\n let current = target;\n\n for (let i = 0; i < parts.length - 1; i++)\n {\n const part = parts[i];\n if (!current[part])\n {\n current[part] = {};\n }\n current = current[part];\n }\n\n // Set the leaf value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Unflatten dot notation keys back to nested structure\n *\n * @param flat - Flattened label object\n * @returns Nested label structure\n *\n * @example\n * ```typescript\n * const flat = {\n * \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" },\n * \"home.hero.subtitle\": { en: \"Subtitle\", ko: \"부제목\" }\n * };\n *\n * const nested = unflattenLabels(flat);\n * // {\n * // home: {\n * // hero: {\n * // title: { en: \"Welcome\", ko: \"환영합니다\" },\n * // subtitle: { en: \"Subtitle\", ko: \"부제목\" }\n * // }\n * // }\n * // }\n * ```\n */\nexport function unflattenLabels(flat: FlatLabel): Record<string, any>\n{\n const result: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(flat))\n {\n setNestedValue(result, key, value);\n }\n\n return result;\n}","/**\n * Defines a type-safe label configuration.\n *\n * @example\n * ```ts\n * export const labelConfig = defineLabelConfig({\n * locales: ['en', 'ar'] as const,\n * defaultLocale: 'en',\n * fallbackLocale: 'en', // Optional\n * });\n *\n * export type LabelConfig = typeof labelConfig;\n * export type AppLocale = typeof labelConfig.locales[number]; // 'en' | 'ar'\n * ```\n */\nexport function defineLabelConfig<const TLocales extends readonly string[]>(config: {\n locales: TLocales;\n defaultLocale: TLocales[number];\n fallbackLocale?: TLocales[number];\n useBrowserLanguage?: boolean;\n})\n{\n return config;\n}\n\n/**\n * Define nested label structure (tRPC-style)\n *\n * @example\n * ```ts\n * export const labels = defineLabels({\n * home: {\n * slogan: { en: \"Welcome\", ko: \"환영합니다\" },\n * hero: {\n * title: { en: \"Hello\", ko: \"안녕하세요\" }\n * }\n * },\n * about: {\n * title: { en: \"About Us\", ko: \"회사 소개\" }\n * }\n * });\n *\n * // Usage\n * labels.home.slogan;\n * labels.home.hero.title;\n * labels.about.title;\n * ```\n */\nexport function defineLabels<const T>(labels: T)\n{\n return labels;\n}\n\nexport function format(template: string, vars: Record<string, string | number>): string\n{\n return template.replace(/\\{(\\w+)}/g, (match, key) =>\n {\n const value = vars[key];\n return value !== undefined ? String(value) : match;\n });\n}"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/lib/bind-locale.ts","../src/lib/helpers.ts","../src/lib/define-labels.ts"],"sourcesContent":["import { createApi } from \"@spfn/core/nextjs\";\nimport { logger } from \"@spfn/core/logger\";\nimport { type AppRouter } from './server/routes/index';\nimport { bindLocale, type SectionKeys, type BoundLabelSection, type BoundLabelsSections } from './lib/bind-locale';\nimport { setNestedValue } from './lib/helpers';\nimport { format, defineLabelConfig, defineLabels } from './lib/define-labels';\n\nconst cmsLogger = logger.child('@spfn/cms');\n\n/**\n * Default API client (for backward compatibility or when not using labels)\n */\nconst api = createApi<AppRouter>();\n\n/**\n * Create CMS client with API, label getters, and format utility\n *\n * @param labelsDefinition - Labels defined using defineLabels()\n * @param config - Label config from defineLabelConfig()\n * @returns API client, getLabel (single), getLabels (multiple), and format utility\n *\n * @example\n * ```typescript\n * // labels.ts - Setup once\n * import { getLocale } from '@spfn/cms/actions';\n *\n * export const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, {\n * defaultLocale: 'ko',\n * getLocale: () => getLocale('ko'),\n * });\n *\n * // Single section - direct access\n * const label = await getLabel('home');\n * label.hero.title // \"Hello\" (no section name!)\n *\n * // Multiple sections - with section names\n * const labels = await getLabels(['home', 'about']);\n * labels.home.hero.title // \"Hello\"\n * labels.about.title // \"About Us\"\n *\n * // With template variables\n * const greeting = label.hero.greeting; // \"Hello {name}\"\n * format(greeting, { name: \"John\" }); // \"Hello John\"\n * ```\n */\nexport function createCmsClient<T>(\n labelsDefinition: T,\n config: {\n defaultLocale: string;\n fallbackLocale?: string;\n getLocale: () => Promise<string>;\n }\n)\n{\n /**\n * Get a single section's labels (without section name wrapper)\n *\n * @param section - Section name to fetch\n * @returns Labels for the section, directly accessible\n *\n * @example\n * ```typescript\n * const label = await getLabel('signup');\n * label.title // Direct access\n * label.userName\n * ```\n */\n async function getLabel<K extends SectionKeys<T>>(section: K): Promise<BoundLabelSection<T, K>>\n {\n // Auto-detect locale from cookie via injected getLocale\n const locale = await config.getLocale();\n\n cmsLogger.debug('getLabel called', {\n section,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [section as string],\n locale\n }\n });\n\n // 2. Filter only requested section\n const filteredLabels: any = {};\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n\n // 3. Generate defaults with locale binding\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n // 5. Return only the section content (without section name)\n return merged[section] as BoundLabelSection<T, K>;\n }\n\n /**\n * Get multiple sections' labels (with section names as keys)\n *\n * @param sections - Array of section names to fetch\n * @returns Object with section names as keys\n *\n * @example\n * ```typescript\n * const labels = await getLabels(['home', 'about']);\n * labels.home.title\n * labels.about.description\n * ```\n */\n async function getLabels<K extends SectionKeys<T>>(sections: readonly K[]): Promise<BoundLabelsSections<T, K>>\n {\n // Auto-detect locale from cookie via injected getLocale\n const locale = await config.getLocale();\n\n cmsLogger.debug('getLabels called', {\n sections,\n locale,\n defaultLocale: config.defaultLocale,\n fallbackLocale: config.fallbackLocale,\n availableDefinitionKeys: Object.keys(labelsDefinition as any),\n });\n\n // 1. Fetch from published_cache\n const cache = await api.getLabelCache.call({\n query: {\n sections: [...sections] as unknown as string[],\n locale\n }\n });\n\n cmsLogger.debug('Fetched from cache', {\n cacheKeys: Object.keys(cache),\n cacheEntryCount: Object.keys(cache).length,\n cacheStructure: Object.entries(cache).map(([key, value]) => ({\n section: key,\n isObject: typeof value === 'object',\n isNull: value === null,\n contentKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 2. Filter only requested sections (performance optimization)\n const filteredLabels: any = {};\n for (const section of sections)\n {\n if (section in (labelsDefinition as any))\n {\n filteredLabels[section] = (labelsDefinition as any)[section];\n }\n }\n\n cmsLogger.debug('Filtered sections', {\n requestedSections: sections,\n filteredSections: Object.keys(filteredLabels),\n filteredLabelsStructure: Object.entries(filteredLabels).map(([key, value]) => ({\n section: key,\n hasValue: !!value,\n isObject: typeof value === 'object',\n nestedKeys: value && typeof value === 'object' ? Object.keys(value) : [],\n })),\n });\n\n // 3. Generate defaults with locale binding (only for requested sections)\n const defaults = bindLocale(filteredLabels, locale, config.fallbackLocale);\n\n cmsLogger.debug('Generated defaults with locale binding', {\n defaultsKeys: Object.keys(defaults),\n });\n\n // 4. Merge: cache takes priority, fallback to defaults\n const merged = deepMergeCache(defaults, cache, locale);\n\n cmsLogger.debug('Merged cache and defaults', {\n mergedKeys: Object.keys(merged),\n });\n\n return merged as BoundLabelsSections<T, K>;\n }\n\n return { api, getLabel, getLabels, format };\n}\n\n/**\n * Deep merge cache into defaults\n */\nfunction deepMergeCache(defaults: any, cache: Record<string, any>, locale: string): any\n{\n const result = { ...defaults };\n\n cmsLogger.debug('deepMergeCache: Starting merge', {\n cacheEntries: Object.keys(cache).length,\n locale,\n });\n\n for (const [section, content] of Object.entries(cache))\n {\n if (!content || typeof content !== 'object')\n {\n cmsLogger.debug('deepMergeCache: Skipping invalid content', { section });\n continue;\n }\n\n const contentKeys = Object.keys(content);\n cmsLogger.debug('deepMergeCache: Processing section', {\n section,\n labelCount: contentKeys.length,\n });\n\n for (const [flatKey, value] of Object.entries(content))\n {\n // Extract locale-specific value from LabelValue format\n let extractedValue: any;\n\n if (value && typeof value === 'object' && 'content' in value)\n {\n extractedValue = (value as any).content;\n cmsLogger.debug('deepMergeCache: Extracted from content field', {\n flatKey,\n hasContent: true,\n });\n }\n else if (value && typeof value === 'object' && locale in value)\n {\n extractedValue = (value as any)[locale];\n cmsLogger.debug('deepMergeCache: Extracted from locale field', {\n flatKey,\n locale,\n });\n }\n else\n {\n extractedValue = value;\n cmsLogger.debug('deepMergeCache: Using raw value', {\n flatKey,\n valueType: typeof value,\n });\n }\n\n // Set value using helper function\n setNestedValue(result, flatKey, extractedValue);\n }\n }\n\n cmsLogger.debug('deepMergeCache: Merge completed', {\n resultKeys: Object.keys(result),\n });\n\n return result;\n}\n\n/**\n * Re-export format utility for standalone use\n *\n * @example\n * ```typescript\n * import { format } from '@spfn/cms/api-client';\n *\n * const text = \"Hello {name}, you have {count} messages\";\n * format(text, { name: \"John\", count: 5 });\n * // \"Hello John, you have 5 messages\"\n * ```\n */\nexport { format, defineLabelConfig, defineLabels };\n\n/**\n * Re-export types for external use\n */\nexport type { BoundLabels, SectionKeys, BoundLabelSection, BoundLabelsSections } from './lib/bind-locale';","/**\n * Bind locale to labels, returning locale-specific values\n *\n * @example\n * ```ts\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * ```\n */\n\n/**\n * Type that converts locale records to strings\n */\nexport type BoundLabels<T> = {\n [K in keyof T]: T[K] extends Record<string, any>\n ? IsLocaleRecord<T[K]> extends true\n ? string\n : BoundLabels<T[K]>\n : T[K];\n};\n\n/**\n * Extract section keys from label definition\n */\nexport type SectionKeys<T> = Extract<keyof T, string>;\n\n/**\n * Get content of a single section (without section name wrapper)\n */\nexport type BoundLabelSection<T, K extends SectionKeys<T>> = BoundLabels<T>[K];\n\n/**\n * Pick specific sections from bound labels (for multiple sections)\n */\nexport type BoundLabelsSections<T, K extends SectionKeys<T>> = Pick<BoundLabels<T>, K>;\n\n/**\n * Check if object is a locale record (has string values only)\n */\ntype IsLocaleRecord<T> = T extends Record<string, string> ? true : false;\n\n/**\n * Check if an object is a locale record at runtime\n */\nfunction isLocaleRecord(obj: any): boolean\n{\n if (!obj || typeof obj !== 'object')\n {\n return false;\n }\n\n const values = Object.values(obj);\n\n // Empty object is not a locale record\n if (values.length === 0)\n {\n return false;\n }\n\n // All values must be strings\n return values.every(v => typeof v === 'string');\n}\n\n/**\n * Bind a locale to label definitions, returning locale-specific values\n *\n * @param labels - Label definitions with locale records\n * @param locale - Locale to bind (e.g., 'en', 'ko')\n * @param fallbackLocale - Optional fallback locale if value not found\n * @returns Labels with locale-specific string values\n *\n * @example\n * ```typescript\n * const labelsDefinition = defineLabels({\n * home: {\n * title: { en: \"Home\", ko: \"홈\" },\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * });\n *\n * const labels = bindLocale(labelsDefinition, 'ko');\n * labels.home.title // \"홈\"\n * labels.home.hero.title // \"환영합니다\"\n *\n * // With fallback\n * const labelsEn = bindLocale(labelsDefinition, 'en', 'ko');\n * ```\n */\nexport function bindLocale<T>(\n labels: T,\n locale: string,\n fallbackLocale?: string\n): BoundLabels<T>\n{\n return createProxy(labels, locale, fallbackLocale) as BoundLabels<T>;\n}\n\n/**\n * Create a proxy that intercepts property access and returns locale-specific values\n */\nfunction createProxy(obj: any, locale: string, fallbackLocale?: string): any\n{\n return new Proxy(obj, {\n get(target, prop)\n {\n const value = target[prop];\n\n // If value doesn't exist, return undefined\n if (value === undefined)\n {\n return undefined;\n }\n\n // If this is a locale record, return the locale value\n if (isLocaleRecord(value))\n {\n // Try to get the requested locale\n if (value[locale] !== undefined)\n {\n return value[locale];\n }\n\n // Fallback to fallbackLocale if specified\n if (fallbackLocale && value[fallbackLocale] !== undefined)\n {\n return value[fallbackLocale];\n }\n\n // If locale not found, return first available locale\n const firstLocale = Object.keys(value)[0];\n return value[firstLocale];\n }\n\n // If this is a nested object, wrap it in a proxy\n if (typeof value === 'object' && value !== null)\n {\n return createProxy(value, locale, fallbackLocale);\n }\n\n // Otherwise return as-is\n return value;\n },\n });\n}","/**\n * CMS Helper Functions\n */\n\nexport type FlatLabel = Record<string, Record<string, string>>;\n\n/**\n * Flatten nested label structure into dot notation\n *\n * @param labels - Nested label object\n * @param prefix - Key prefix for recursion\n * @returns Flattened label structure\n *\n * @example\n * ```typescript\n * const nested = {\n * home: {\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * };\n *\n * const flat = flattenLabels(nested);\n * // { \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" } }\n * ```\n */\nexport function flattenLabels<T extends Record<string, any>>(labels: T, prefix = ''): FlatLabel\n{\n const result: FlatLabel = {};\n\n if (!labels || typeof labels !== 'object')\n {\n return result;\n }\n\n const obj = labels as Record<string, unknown>;\n\n for (const [key, value] of Object.entries(obj))\n {\n const newKey = prefix ? `${prefix}.${key}` : key;\n\n if (!value || typeof value !== 'object')\n {\n continue;\n }\n\n const valueObj = value as Record<string, unknown>;\n\n // Check if this is a leaf node (locale values: { en: \"...\", ko: \"...\" })\n const isLeaf = Object.values(valueObj).every(v => typeof v === 'string');\n\n if (isLeaf)\n {\n result[newKey] = valueObj as Record<string, string>;\n }\n else\n {\n // Recursively flatten nested structure\n Object.assign(result, flattenLabels(value, newKey));\n }\n }\n\n return result;\n}\n\n/**\n * Set a value in nested object using dot notation path\n *\n * @param target - Target object to modify\n * @param path - Dot notation path (e.g., \"home.hero.title\")\n * @param value - Value to set\n *\n * @example\n * ```typescript\n * const obj = {};\n * setNestedValue(obj, \"home.hero.title\", \"Welcome\");\n * // obj = { home: { hero: { title: \"Welcome\" } } }\n * ```\n */\nexport function setNestedValue(target: any, path: string, value: any): void\n{\n const parts = path.split('.');\n let current = target;\n\n for (let i = 0; i < parts.length - 1; i++)\n {\n const part = parts[i];\n if (!current[part])\n {\n current[part] = {};\n }\n current = current[part];\n }\n\n // Set the leaf value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Unflatten dot notation keys back to nested structure\n *\n * @param flat - Flattened label object\n * @returns Nested label structure\n *\n * @example\n * ```typescript\n * const flat = {\n * \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" },\n * \"home.hero.subtitle\": { en: \"Subtitle\", ko: \"부제목\" }\n * };\n *\n * const nested = unflattenLabels(flat);\n * // {\n * // home: {\n * // hero: {\n * // title: { en: \"Welcome\", ko: \"환영합니다\" },\n * // subtitle: { en: \"Subtitle\", ko: \"부제목\" }\n * // }\n * // }\n * // }\n * ```\n */\nexport function unflattenLabels(flat: FlatLabel): Record<string, any>\n{\n const result: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(flat))\n {\n setNestedValue(result, key, value);\n }\n\n return result;\n}","/**\n * Defines a type-safe label configuration.\n *\n * @example\n * ```ts\n * export const labelConfig = defineLabelConfig({\n * locales: ['en', 'ar'] as const,\n * defaultLocale: 'en',\n * fallbackLocale: 'en', // Optional\n * });\n *\n * export type LabelConfig = typeof labelConfig;\n * export type AppLocale = typeof labelConfig.locales[number]; // 'en' | 'ar'\n * ```\n */\nexport function defineLabelConfig<const TLocales extends readonly string[]>(config: {\n locales: TLocales;\n defaultLocale: TLocales[number];\n fallbackLocale?: TLocales[number];\n useBrowserLanguage?: boolean;\n})\n{\n return config;\n}\n\n/**\n * Define nested label structure (tRPC-style)\n *\n * @example\n * ```ts\n * export const labels = defineLabels({\n * home: {\n * slogan: { en: \"Welcome\", ko: \"환영합니다\" },\n * hero: {\n * title: { en: \"Hello\", ko: \"안녕하세요\" }\n * }\n * },\n * about: {\n * title: { en: \"About Us\", ko: \"회사 소개\" }\n * }\n * });\n *\n * // Usage\n * labels.home.slogan;\n * labels.home.hero.title;\n * labels.about.title;\n * ```\n */\nexport function defineLabels<const T>(labels: T)\n{\n return labels;\n}\n\nexport function format(template: string, vars: Record<string, string | number>): string\n{\n return template.replace(/\\{(\\w+)}/g, (match, key) =>\n {\n const value = vars[key];\n return value !== undefined ? String(value) : match;\n });\n}"],"mappings":"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","names":[]}
|
package/dist/server.d.ts
CHANGED
|
@@ -8,6 +8,60 @@ declare const cmsAppRouter: _spfn_core_route.Router<{
|
|
|
8
8
|
locale: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
9
9
|
}>;
|
|
10
10
|
}, {}, Record<string, any>>;
|
|
11
|
+
getSectionLabels: _spfn_core_route.RouteDef<{
|
|
12
|
+
params: _sinclair_typebox.TObject<{
|
|
13
|
+
section: _sinclair_typebox.TString;
|
|
14
|
+
}>;
|
|
15
|
+
query: _sinclair_typebox.TObject<{
|
|
16
|
+
locales: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
17
|
+
}>;
|
|
18
|
+
}, {}, {
|
|
19
|
+
section: string;
|
|
20
|
+
locales: string[];
|
|
21
|
+
labels: Array<{
|
|
22
|
+
id: number;
|
|
23
|
+
key: string;
|
|
24
|
+
defaultValue: Record<string, string>;
|
|
25
|
+
draft: Record<string, string> | null;
|
|
26
|
+
published: Record<string, string> | null;
|
|
27
|
+
hasDraft: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
}>;
|
|
30
|
+
saveSectionDraft: _spfn_core_route.RouteDef<{
|
|
31
|
+
params: _sinclair_typebox.TObject<{
|
|
32
|
+
section: _sinclair_typebox.TString;
|
|
33
|
+
}>;
|
|
34
|
+
body: _sinclair_typebox.TObject<{
|
|
35
|
+
labels: _sinclair_typebox.TArray<_sinclair_typebox.TObject<{
|
|
36
|
+
id: _sinclair_typebox.TNumber;
|
|
37
|
+
values: _sinclair_typebox.TRecord<_sinclair_typebox.TString, _sinclair_typebox.TString>;
|
|
38
|
+
}>>;
|
|
39
|
+
}>;
|
|
40
|
+
}, {}, {
|
|
41
|
+
updated: number;
|
|
42
|
+
success: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
publishSection: _spfn_core_route.RouteDef<{
|
|
45
|
+
params: _sinclair_typebox.TObject<{
|
|
46
|
+
section: _sinclair_typebox.TString;
|
|
47
|
+
}>;
|
|
48
|
+
body: _sinclair_typebox.TObject<{
|
|
49
|
+
locales: _sinclair_typebox.TArray<_sinclair_typebox.TString>;
|
|
50
|
+
}>;
|
|
51
|
+
}, {}, {
|
|
52
|
+
published: number;
|
|
53
|
+
version: number;
|
|
54
|
+
labels: string[];
|
|
55
|
+
success: boolean;
|
|
56
|
+
}>;
|
|
57
|
+
resetSectionDraft: _spfn_core_route.RouteDef<{
|
|
58
|
+
params: _sinclair_typebox.TObject<{
|
|
59
|
+
section: _sinclair_typebox.TString;
|
|
60
|
+
}>;
|
|
61
|
+
}, {}, {
|
|
62
|
+
reset: number;
|
|
63
|
+
success: boolean;
|
|
64
|
+
}>;
|
|
11
65
|
}>;
|
|
12
66
|
|
|
13
67
|
/**
|
package/dist/server.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import "@spfn/cms/config";
|
|
3
3
|
|
|
4
4
|
// src/server/routes/index.ts
|
|
5
|
-
import { Type } from "@sinclair/typebox";
|
|
6
|
-
import { defineRouter, route } from "@spfn/core/route";
|
|
5
|
+
import { Type as Type2 } from "@sinclair/typebox";
|
|
6
|
+
import { defineRouter as defineRouter2, route as route2 } from "@spfn/core/route";
|
|
7
7
|
|
|
8
8
|
// src/server/repositories/cms-labels.repository.ts
|
|
9
9
|
import { BaseRepository } from "@spfn/core/db";
|
|
@@ -319,10 +319,11 @@ var CmsLabelValuesRepository = class extends BaseRepository2 {
|
|
|
319
319
|
* Write primary 사용
|
|
320
320
|
*/
|
|
321
321
|
async deleteByVersion(labelId, version) {
|
|
322
|
+
const versionCondition = version === null ? isNull(cmsLabelValues.version) : eq2(cmsLabelValues.version, version);
|
|
322
323
|
return this.db.delete(cmsLabelValues).where(
|
|
323
324
|
and(
|
|
324
325
|
eq2(cmsLabelValues.labelId, labelId),
|
|
325
|
-
|
|
326
|
+
versionCondition
|
|
326
327
|
)
|
|
327
328
|
).returning();
|
|
328
329
|
}
|
|
@@ -491,11 +492,225 @@ var CmsPublishedCacheRepository = class extends BaseRepository3 {
|
|
|
491
492
|
};
|
|
492
493
|
var cmsPublishedCacheRepository = new CmsPublishedCacheRepository();
|
|
493
494
|
|
|
494
|
-
// src/server/routes/
|
|
495
|
-
|
|
495
|
+
// src/server/routes/admin.routes.ts
|
|
496
|
+
import { Type } from "@sinclair/typebox";
|
|
497
|
+
import { defineRouter, route } from "@spfn/core/route";
|
|
498
|
+
|
|
499
|
+
// src/server/services/publish.service.ts
|
|
500
|
+
import { logger } from "@spfn/core/logger";
|
|
501
|
+
var publishLogger = logger.child("@spfn/cms:publish");
|
|
502
|
+
async function getSectionLabels(section, locales) {
|
|
503
|
+
publishLogger.debug("getSectionLabels", { section, locales });
|
|
504
|
+
const labels = await cmsLabelsRepository.findBySection(section);
|
|
505
|
+
if (labels.length === 0) {
|
|
506
|
+
return { section, locales, labels: [] };
|
|
507
|
+
}
|
|
508
|
+
const labelIds = labels.map((l) => l.id);
|
|
509
|
+
const draftValues = await Promise.all(
|
|
510
|
+
labelIds.map((id4) => cmsLabelValuesRepository.findDraftsByLabelId(id4))
|
|
511
|
+
);
|
|
512
|
+
const labelVersions = labels.filter((l) => l.publishedVersion !== null).map((l) => ({ labelId: l.id, version: l.publishedVersion }));
|
|
513
|
+
const publishedValuesMap = labelVersions.length > 0 ? await cmsLabelValuesRepository.findByLabelVersions(labelVersions) : /* @__PURE__ */ new Map();
|
|
514
|
+
const result = labels.map((label, index4) => {
|
|
515
|
+
const drafts = draftValues[index4];
|
|
516
|
+
const published = publishedValuesMap.get(label.id) || [];
|
|
517
|
+
const draftRecord = drafts.length > 0 ? drafts.reduce((acc, d) => {
|
|
518
|
+
const value = d.value;
|
|
519
|
+
acc[d.locale] = value?.content ?? value;
|
|
520
|
+
return acc;
|
|
521
|
+
}, {}) : null;
|
|
522
|
+
let publishedRecord = null;
|
|
523
|
+
if (published.length > 0) {
|
|
524
|
+
publishedRecord = {};
|
|
525
|
+
for (const p of published) {
|
|
526
|
+
const value = p.value;
|
|
527
|
+
publishedRecord[p.locale] = value?.content ?? value;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
id: label.id,
|
|
532
|
+
key: label.key,
|
|
533
|
+
defaultValue: label.defaultValue || {},
|
|
534
|
+
draft: draftRecord,
|
|
535
|
+
published: publishedRecord,
|
|
536
|
+
hasDraft: drafts.length > 0
|
|
537
|
+
};
|
|
538
|
+
});
|
|
539
|
+
return { section, locales, labels: result };
|
|
540
|
+
}
|
|
541
|
+
async function saveSectionDraft(section, labels) {
|
|
542
|
+
publishLogger.debug("saveSectionDraft", { section, labelCount: labels.length });
|
|
543
|
+
let updated = 0;
|
|
544
|
+
for (const { id: id4, values } of labels) {
|
|
545
|
+
for (const [locale, value] of Object.entries(values)) {
|
|
546
|
+
await cmsLabelValuesRepository.upsert({
|
|
547
|
+
labelId: id4,
|
|
548
|
+
version: null,
|
|
549
|
+
locale,
|
|
550
|
+
value: { type: "text", content: value }
|
|
551
|
+
});
|
|
552
|
+
updated++;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
publishLogger.info("Draft saved", { section, updated });
|
|
556
|
+
return { updated };
|
|
557
|
+
}
|
|
558
|
+
async function publishSection(section, locales) {
|
|
559
|
+
publishLogger.debug("publishSection", { section, locales });
|
|
560
|
+
const labels = await cmsLabelsRepository.findBySection(section);
|
|
561
|
+
if (labels.length === 0) {
|
|
562
|
+
return { published: 0, version: 0, labels: [] };
|
|
563
|
+
}
|
|
564
|
+
const publishedLabels = [];
|
|
565
|
+
let maxVersion = 0;
|
|
566
|
+
for (const label of labels) {
|
|
567
|
+
const drafts = await cmsLabelValuesRepository.findDraftsByLabelId(label.id);
|
|
568
|
+
if (drafts.length === 0) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const newVersion = (label.publishedVersion || 0) + 1;
|
|
572
|
+
maxVersion = Math.max(maxVersion, newVersion);
|
|
573
|
+
for (const draft of drafts) {
|
|
574
|
+
await cmsLabelValuesRepository.upsert({
|
|
575
|
+
labelId: label.id,
|
|
576
|
+
version: newVersion,
|
|
577
|
+
locale: draft.locale,
|
|
578
|
+
breakpoint: draft.breakpoint,
|
|
579
|
+
value: draft.value
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
await cmsLabelValuesRepository.deleteByVersion(label.id, null);
|
|
583
|
+
await cmsLabelsRepository.updateById(label.id, {
|
|
584
|
+
publishedVersion: newVersion
|
|
585
|
+
});
|
|
586
|
+
publishedLabels.push(label.key);
|
|
587
|
+
}
|
|
588
|
+
if (publishedLabels.length > 0) {
|
|
589
|
+
await rebuildSectionCache(section, locales);
|
|
590
|
+
}
|
|
591
|
+
publishLogger.info("Section published", {
|
|
592
|
+
section,
|
|
593
|
+
published: publishedLabels.length,
|
|
594
|
+
version: maxVersion
|
|
595
|
+
});
|
|
596
|
+
return {
|
|
597
|
+
published: publishedLabels.length,
|
|
598
|
+
version: maxVersion,
|
|
599
|
+
labels: publishedLabels
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
async function resetSectionDraft(section) {
|
|
603
|
+
publishLogger.debug("resetSectionDraft", { section });
|
|
604
|
+
const labels = await cmsLabelsRepository.findBySection(section);
|
|
605
|
+
let reset = 0;
|
|
606
|
+
for (const label of labels) {
|
|
607
|
+
const drafts = await cmsLabelValuesRepository.findDraftsByLabelId(label.id);
|
|
608
|
+
if (drafts.length > 0) {
|
|
609
|
+
await cmsLabelValuesRepository.deleteByVersion(label.id, null);
|
|
610
|
+
reset += drafts.length;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
publishLogger.info("Draft reset", { section, reset });
|
|
614
|
+
return { reset };
|
|
615
|
+
}
|
|
616
|
+
async function rebuildSectionCache(section, locales) {
|
|
617
|
+
publishLogger.debug("rebuildSectionCache", { section, locales });
|
|
618
|
+
const labels = await cmsLabelsRepository.findBySection(section);
|
|
619
|
+
const labelVersions = labels.filter((l) => l.publishedVersion !== null).map((l) => ({ labelId: l.id, version: l.publishedVersion }));
|
|
620
|
+
if (labelVersions.length === 0) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const publishedValuesMap = await cmsLabelValuesRepository.findByLabelVersions(labelVersions);
|
|
624
|
+
for (const locale of locales) {
|
|
625
|
+
const content = {};
|
|
626
|
+
for (const label of labels) {
|
|
627
|
+
const values = publishedValuesMap.get(label.id) || [];
|
|
628
|
+
const localeValue = values.find((v) => v.locale === locale);
|
|
629
|
+
if (localeValue) {
|
|
630
|
+
content[label.key] = localeValue.value;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
await cmsPublishedCacheRepository.upsert({
|
|
634
|
+
section,
|
|
635
|
+
locale,
|
|
636
|
+
content,
|
|
637
|
+
publishedAt: /* @__PURE__ */ new Date(),
|
|
638
|
+
publishedBy: "system"
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
publishLogger.debug("Cache rebuilt", { section, locales });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/server/routes/admin.routes.ts
|
|
645
|
+
var getSectionLabelsRoute = route.get("/_cms/admin/sections/:section/labels").input({
|
|
646
|
+
params: Type.Object({
|
|
647
|
+
section: Type.String()
|
|
648
|
+
}),
|
|
496
649
|
query: Type.Object({
|
|
497
|
-
|
|
498
|
-
|
|
650
|
+
locales: Type.Optional(Type.String())
|
|
651
|
+
// comma-separated: "en,ko"
|
|
652
|
+
})
|
|
653
|
+
}).handler(async (c) => {
|
|
654
|
+
const { params, query } = await c.data();
|
|
655
|
+
const { section } = params;
|
|
656
|
+
const locales = query.locales?.split(",") || ["en"];
|
|
657
|
+
return getSectionLabels(section, locales);
|
|
658
|
+
});
|
|
659
|
+
var saveSectionDraftRoute = route.put("/_cms/admin/sections/:section/draft").input({
|
|
660
|
+
params: Type.Object({
|
|
661
|
+
section: Type.String()
|
|
662
|
+
}),
|
|
663
|
+
body: Type.Object({
|
|
664
|
+
labels: Type.Array(
|
|
665
|
+
Type.Object({
|
|
666
|
+
id: Type.Number(),
|
|
667
|
+
values: Type.Record(Type.String(), Type.String())
|
|
668
|
+
})
|
|
669
|
+
)
|
|
670
|
+
})
|
|
671
|
+
}).handler(async (c) => {
|
|
672
|
+
const { params, body } = await c.data();
|
|
673
|
+
const { section } = params;
|
|
674
|
+
const { labels } = body;
|
|
675
|
+
const result = await saveSectionDraft(section, labels);
|
|
676
|
+
return { success: true, ...result };
|
|
677
|
+
});
|
|
678
|
+
var publishSectionRoute = route.post("/_cms/admin/sections/:section/publish").input({
|
|
679
|
+
params: Type.Object({
|
|
680
|
+
section: Type.String()
|
|
681
|
+
}),
|
|
682
|
+
body: Type.Object({
|
|
683
|
+
locales: Type.Array(Type.String())
|
|
684
|
+
})
|
|
685
|
+
}).handler(async (c) => {
|
|
686
|
+
const { params, body } = await c.data();
|
|
687
|
+
const { section } = params;
|
|
688
|
+
const { locales } = body;
|
|
689
|
+
const result = await publishSection(section, locales);
|
|
690
|
+
return { success: true, ...result };
|
|
691
|
+
});
|
|
692
|
+
var resetSectionDraftRoute = route.delete("/_cms/admin/sections/:section/draft").input({
|
|
693
|
+
params: Type.Object({
|
|
694
|
+
section: Type.String()
|
|
695
|
+
})
|
|
696
|
+
}).handler(async (c) => {
|
|
697
|
+
const { params } = await c.data();
|
|
698
|
+
const { section } = params;
|
|
699
|
+
const result = await resetSectionDraft(section);
|
|
700
|
+
return { success: true, ...result };
|
|
701
|
+
});
|
|
702
|
+
var cmsAdminRouter = defineRouter({
|
|
703
|
+
getSectionLabels: getSectionLabelsRoute,
|
|
704
|
+
saveSectionDraft: saveSectionDraftRoute,
|
|
705
|
+
publishSection: publishSectionRoute,
|
|
706
|
+
resetSectionDraft: resetSectionDraftRoute
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// src/server/routes/index.ts
|
|
710
|
+
var getLabelCache = route2.get("/_cms/labels/cache").skip(["auth"]).input({
|
|
711
|
+
query: Type2.Object({
|
|
712
|
+
sections: Type2.Array(Type2.String()),
|
|
713
|
+
locale: Type2.Optional(Type2.String())
|
|
499
714
|
})
|
|
500
715
|
}).handler(async (c) => {
|
|
501
716
|
const { query } = await c.data();
|
|
@@ -506,8 +721,13 @@ var getLabelCache = route.get("/_cms/labels/cache").skip(["auth"]).input({
|
|
|
506
721
|
return acc;
|
|
507
722
|
}, {});
|
|
508
723
|
});
|
|
509
|
-
var cmsAppRouter =
|
|
510
|
-
getLabelCache
|
|
724
|
+
var cmsAppRouter = defineRouter2({
|
|
725
|
+
getLabelCache,
|
|
726
|
+
// Admin routes
|
|
727
|
+
getSectionLabels: getSectionLabelsRoute,
|
|
728
|
+
saveSectionDraft: saveSectionDraftRoute,
|
|
729
|
+
publishSection: publishSectionRoute,
|
|
730
|
+
resetSectionDraft: resetSectionDraftRoute
|
|
511
731
|
});
|
|
512
732
|
|
|
513
733
|
// src/server/services/sync.service.ts
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/server/routes/index.ts","../src/server/repositories/cms-labels.repository.ts","../src/server/entities/cms-labels.ts","../src/server/entities/cms-schema.ts","../src/server/entities/cms-label-values.ts","../src/server/entities/cms-published-cache.ts","../src/server/repositories/cms-label-values.repository.ts","../src/server/repositories/cms-published-cache.repository.ts","../src/server/services/sync.service.ts","../src/lib/helpers.ts"],"sourcesContent":["import '@spfn/cms/config';\n\nexport { cmsAppRouter } from './server/routes';\nexport { syncLabels } from './server/services';","/**\n * CMS App Router\n *\n * 모든 CMS 라우트를 통합하는 메인 라우터\n */\n\nimport { Type } from '@sinclair/typebox';\nimport { defineRouter, route } from '@spfn/core/route';\nimport { cmsPublishedCacheRepository } from '../repositories';\n\nexport const getLabelCache = route.get('/_cms/labels/cache')\n .skip(['auth'])\n .input({\n query: Type.Object({\n sections: Type.Array(Type.String()),\n locale: Type.Optional(Type.String())\n })\n })\n .handler(async (c) =>\n {\n const { query } = await c.data();\n const { sections, locale = 'en' } = query;\n\n // 단일 쿼리로 모든 섹션 조회 (N+1 방지)\n const results = await cmsPublishedCacheRepository.findBySections(sections, locale);\n\n // Record<section, content> 형태로 변환\n return results.reduce((acc, item) => {\n acc[item.section] = item.content;\n return acc;\n }, {} as Record<string, any>);\n });\n\nexport const cmsAppRouter = defineRouter({\n getLabelCache\n});\n\nexport type AppRouter = typeof cmsAppRouter;","/**\n * CMS Labels Repository\n *\n * 라벨 메타데이터 관리를 위한 Repository\n * BaseRepository를 상속받아 자동 트랜잭션 컨텍스트 지원 및 Read/Write 분리\n */\n\nimport { BaseRepository } from '@spfn/core/db';\nimport { asc, count as drizzleCount, eq, inArray } from 'drizzle-orm';\nimport { type CmsLabel, cmsLabels, type NewCmsLabel } from '../entities';\n\n/**\n * CMS Labels Repository 클래스\n *\n * BaseRepository를 상속받아 다음 기능을 제공:\n * - 자동 트랜잭션 컨텍스트 감지 및 사용\n * - Read/Write 연결 분리 (replica 활용)\n * - 타입 안전성\n */\nexport class CmsLabelsRepository extends BaseRepository\n{\n /**\n * 라벨 목록 조회\n * Read replica 사용\n */\n async findMany(options?: {\n section?: string;\n }): Promise<CmsLabel[]>\n {\n const { section } = options || {};\n\n let query = this.readDb\n .select()\n .from(cmsLabels)\n .orderBy(asc(cmsLabels.key)); // key 오름차순 정렬 (JSON 파일의 순서 유지)\n\n if (section)\n {\n query = query.where(eq(cmsLabels.section, section)) as typeof query;\n }\n\n return query;\n }\n\n /**\n * 전체 라벨 수 조회\n * Read replica 사용\n */\n async count(section?: string): Promise<number>\n {\n const query = this.readDb\n .select({ count: drizzleCount() })\n .from(cmsLabels);\n\n const result = section\n ? await query.where(eq(cmsLabels.section, section))\n : await query;\n\n return result[0]?.count ?? 0;\n }\n\n /**\n * ID로 라벨 조회\n * Read replica 사용\n */\n async findById(id: number): Promise<CmsLabel | null>\n {\n const result = await this.readDb\n .select()\n .from(cmsLabels)\n .where(eq(cmsLabels.id, id))\n .limit(1);\n\n return result[0] ?? null;\n }\n\n /**\n * Key로 라벨 조회\n * Read replica 사용\n */\n async findByKey(key: string): Promise<CmsLabel | null>\n {\n const result = await this.readDb\n .select()\n .from(cmsLabels)\n .where(eq(cmsLabels.key, key))\n .limit(1);\n\n return result[0] ?? null;\n }\n\n /**\n * 섹션으로 모든 라벨 조회\n * Read replica 사용\n */\n async findBySection(section: string): Promise<CmsLabel[]>\n {\n return this.readDb\n .select()\n .from(cmsLabels)\n .where(eq(cmsLabels.section, section))\n .orderBy(asc(cmsLabels.key)); // key 오름차순 정렬 (JSON 파일의 순서 유지)\n }\n\n /**\n * 라벨 생성\n * Write primary 사용\n */\n async create(data: NewCmsLabel): Promise<CmsLabel>\n {\n const result = await this.db\n .insert(cmsLabels)\n .values(data)\n .returning();\n\n return result[0];\n }\n\n /**\n * 라벨 수정\n * Write primary 사용\n */\n async updateById(id: number, data: Partial<NewCmsLabel>): Promise<CmsLabel | null>\n {\n const result = await this.db\n .update(cmsLabels)\n .set({ ...data, updatedAt: new Date() })\n .where(eq(cmsLabels.id, id))\n .returning();\n\n return result[0] ?? null;\n }\n\n /**\n * 라벨 삭제\n * Write primary 사용\n */\n async deleteById(id: number): Promise<CmsLabel | null>\n {\n const result = await this.db\n .delete(cmsLabels)\n .where(eq(cmsLabels.id, id))\n .returning();\n\n return result[0] ?? null;\n }\n\n /**\n * 여러 key로 라벨 조회\n * Read replica 사용\n */\n async findByKeys(keys: string[]): Promise<CmsLabel[]>\n {\n if (keys.length === 0)\n {\n return [];\n }\n\n return this.readDb\n .select()\n .from(cmsLabels)\n .where(inArray(cmsLabels.key, keys));\n }\n\n /**\n * 여러 라벨 한번에 생성\n * Write primary 사용\n */\n async bulkCreate(data: NewCmsLabel[]): Promise<CmsLabel[]>\n {\n if (data.length === 0)\n {\n return [];\n }\n\n return this.db\n .insert(cmsLabels)\n .values(data)\n .returning();\n }\n\n /**\n * 여러 라벨 한번에 수정 (key 기준)\n * Write primary 사용\n *\n * @param updates - Array of { key, data } objects\n */\n async bulkUpdateByKeys(updates: Array<{ key: string; data: Partial<NewCmsLabel> }>): Promise<void>\n {\n if (updates.length === 0)\n {\n return;\n }\n\n // Drizzle doesn't support bulk update directly, so we need to do it one by one\n // But we can do it in a single transaction context (handled by BaseRepository)\n for (const { key, data } of updates)\n {\n await this.db\n .update(cmsLabels)\n .set({ ...data, updatedAt: new Date() })\n .where(eq(cmsLabels.key, key));\n }\n }\n\n /**\n * 여러 라벨 한번에 삭제 (key 기준)\n * Write primary 사용\n */\n async bulkDeleteByKeys(keys: string[]): Promise<CmsLabel[]>\n {\n if (keys.length === 0)\n {\n return [];\n }\n\n return this.db\n .delete(cmsLabels)\n .where(inArray(cmsLabels.key, keys))\n .returning();\n }\n}\n\n// Default instance export\nexport const cmsLabelsRepository = new CmsLabelsRepository();\n\n","/**\n * CMS Labels Entity\n *\n * 라벨의 메타데이터와 현재 발행 상태를 관리합니다.\n * - 라벨 식별 (id, key)\n * - 섹션 분류 (section)\n * - 타입 정의 (type)\n * - 발행 상태 (publishedVersion)\n */\n\nimport { index, integer, text } from 'drizzle-orm/pg-core';\nimport { id, timestamps, typedJsonb } from '@spfn/core/db';\nimport { cmsSchema } from './cms-schema';\n\nexport const cmsLabels = cmsSchema.table('labels', {\n // Primary Key\n id: id(),\n\n // 라벨 식별자\n key: text('key').notNull().unique(),\n // 예: \"home.hero.title\", \"why-futureplay.hero.subtitle\"\n // 구조: {section}.{component}.{property}\n\n // 섹션 분류 (페이지 단위)\n section: text('section').notNull(),\n // 예: \"home\", \"why-futureplay\", \"team\"\n\n // 값 타입\n type: text('type').notNull(),\n // \"text\" | \"image\" | \"video\" | \"file\" | \"object\"\n\n // 기본값\n defaultValue: typedJsonb<Record<string, any>>('default_value'),\n // 라벨의 기본값 (sync 시 설정)\n // 예: { en: \"Welcome\", ko: \"환영합니다\" } 또는 단일 값\n\n // 설명\n description: text('description'),\n // 라벨에 대한 설명 (optional)\n\n // 현재 발행된 버전 번호\n publishedVersion: integer('published_version'),\n // null = 미발행 상태\n // 1, 2, 3... = 발행된 버전 번호\n\n // 생성자 추적\n createdBy: text('created_by'),\n\n // 타임스탬프\n ...timestamps(),\n}, (table) => [\n // 인덱스: 섹션별 조회 최적화\n index('cms_labels_section_idx').on(table.section),\n\n // 인덱스: key로 조회 최적화 (unique 제약으로 자동 생성되지만 명시)\n index('cms_labels_key_idx').on(table.key),\n]);\n\n// 타입 추론\nexport type CmsLabel = typeof cmsLabels.$inferSelect;\nexport type NewCmsLabel = typeof cmsLabels.$inferInsert;","/**\n * CMS Schema Definition\n *\n * Creates isolated 'spfn_cms' PostgreSQL schema for CMS tables.\n * Export this schema so drizzle-kit can generate CREATE SCHEMA statement.\n */\nimport { createSchema } from '@spfn/core/db';\n\nexport const cmsSchema = createSchema('@spfn/cms');","/**\n * CMS Label Values Entity\n *\n * 라벨의 실제 값을 저장합니다.\n * - 다국어 지원 (locale)\n * - 반응형 지원 (breakpoint)\n * - 버전 관리 (version)\n * - JSONB로 유연한 값 저장\n */\n\nimport { integer, text, index, unique } from 'drizzle-orm/pg-core';\nimport { id, utcTimestamp, typedJsonb, foreignKey } from '@spfn/core/db';\nimport { cmsSchema } from './cms-schema';\nimport { cmsLabels } from './cms-labels';\n\n// Create isolated schema for @spfn/cms\n// Schema imported from cms-schema.ts\n\nexport const cmsLabelValues = cmsSchema.table('label_values', {\n // Primary Key\n id: id(),\n\n // Foreign Key: cms_labels\n labelId: foreignKey('label', () => cmsLabels.id, { onDelete: 'cascade' }),\n\n // 버전 번호 (null = draft, number = published version)\n version: integer('version'),\n\n // 언어 코드\n locale: text('locale').notNull().default('en'),\n // \"ko\" | \"en\" | \"ja\"\n\n // 반응형 브레이크포인트\n breakpoint: text('breakpoint'),\n // null = 기본값 (모든 화면 크기)\n // \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\"\n\n // 실제 값 (JSONB)\n value: typedJsonb<Record<string, any>>('value').notNull(),\n // LabelValue 타입:\n // - TextValue: { type: \"text\", content: string }\n // - ImageValue: { type: \"image\", url: string, alt?: string, width?: number, height?: number }\n // - VideoValue: { type: \"video\", url: string, thumbnail?: string, duration?: number }\n // - FileValue: { type: \"file\", url: string, filename: string, size?: number }\n // - ObjectValue: { type: \"object\", fields: Record<string, LabelValue> }\n\n // 생성 시각\n createdAt: utcTimestamp('created_at').defaultNow().notNull(),\n}, (table) => [\n // UNIQUE 제약: 같은 버전에서 locale + breakpoint 조합은 유일\n unique('cms_label_values_locale_breakpoint_unique')\n .on(table.labelId, table.version, table.locale, table.breakpoint),\n\n // 인덱스: labelId + version 복합 조회 최적화\n index('cms_label_values_label_version_idx')\n .on(table.labelId, table.version),\n\n // 인덱스: locale 필터링 최적화\n index('cms_label_values_locale_idx').on(table.locale),\n]);\n\n// 타입 추론\nexport type CmsLabelValue = typeof cmsLabelValues.$inferSelect;\nexport type NewCmsLabelValue = typeof cmsLabelValues.$inferInsert;\n\n/**\n * 사용 예시:\n *\n * // 텍스트 값 저장\n * await db.insert(cmsLabelValues).values({\n * labelId: 1,\n * version: 1,\n * locale: 'ko',\n * breakpoint: null,\n * value: {\n * type: 'text',\n * content: '미래를 만드는 기업'\n * }\n * });\n *\n * // 반응형 이미지 저장 (모바일용)\n * await db.insert(cmsLabelValues).values({\n * labelId: 2,\n * version: 1,\n * locale: 'ko',\n * breakpoint: 'sm',\n * value: {\n * type: 'image',\n * url: '/uploads/hero-mobile.jpg',\n * alt: 'Hero Image',\n * width: 640,\n * height: 480\n * }\n * });\n *\n * // 특정 버전의 한국어 값 조회\n * const values = await db.select()\n * .from(cmsLabelValues)\n * .where(and(\n * eq(cmsLabelValues.labelId, 1),\n * eq(cmsLabelValues.version, 2),\n * eq(cmsLabelValues.locale, 'ko')\n * ));\n *\n * // Object 타입 값 저장 (재귀 구조)\n * await db.insert(cmsLabelValues).values({\n * labelId: 3,\n * version: 1,\n * locale: 'ko',\n * value: {\n * type: 'object',\n * fields: {\n * title: { type: 'text', content: '특징 1' },\n * icon: { type: 'image', url: '/icons/feature1.svg', alt: 'Icon' },\n * description: { type: 'text', content: '상세 설명...' }\n * }\n * }\n * });\n */","/**\n * CMS Published Cache Entity\n *\n * 발행된 콘텐츠를 섹션+언어 단위로 캐싱합니다.\n * - 초고속 읽기 성능 (5ms)\n * - 단일 쿼리로 섹션 전체 로드\n * - JSONB로 즉시 사용 가능한 데이터\n *\n * 성능 비교:\n * - 정규화 테이블 JOIN: 87ms\n * - 캐시 테이블: 5ms (17배 빠름!)\n */\n\nimport { text, integer, index, unique } from 'drizzle-orm/pg-core';\nimport { id, publishingFields, typedJsonb } from \"@spfn/core/db\";\nimport { cmsSchema } from './cms-schema';\n\n// Create isolated schema for @spfn/cms\n// Schema imported from cms-schema.ts\nexport const cmsPublishedCache = cmsSchema.table('published_cache', {\n // Primary Key\n id: id(),\n\n // 섹션 (페이지 단위)\n section: text('section').notNull(),\n // \"home\" | \"why-futureplay\" | \"team\" | \"our-companies\" | \"apply\"\n\n // 언어\n locale: text('locale').notNull(),\n // \"ko\" | \"en\" | \"ja\"\n\n // 캐시된 콘텐츠 (JSONB)\n content: typedJsonb<Record<string, any>>('content').notNull(),\n // Record<string, LabelValue>\n // {\n // \"home.hero.title\": { type: \"text\", content: \"...\" },\n // \"home.hero.image\": { type: \"image\", url: \"...\", alt: \"...\" },\n // ...\n // }\n\n // 발행 정보\n ...publishingFields(),\n\n // 캐시 버전 (클라이언트 캐싱용)\n version: integer('version').notNull().default(1),\n}, (table) => [\n // UNIQUE 제약: section + locale 조합은 유일\n unique('cms_published_cache_unique').on(table.section, table.locale),\n\n // 인덱스: section으로 조회 최적화\n index('cms_published_cache_section_idx').on(table.section),\n]);\n\n// 타입 추론\nexport type CmsPublishedCache = typeof cmsPublishedCache.$inferSelect;\nexport type NewCmsPublishedCache = typeof cmsPublishedCache.$inferInsert;\n\n/**\n * 사용 예시:\n *\n * // 캐시 생성/업데이트 (UPSERT)\n * await db.insert(cmsPublishedCache)\n * .values({\n * section: 'home',\n * locale: 'ko',\n * content: {\n * 'home.hero.title': {\n * type: 'text',\n * content: '미래를 만드는 기업'\n * },\n * 'home.hero.image': {\n * type: 'image',\n * url: '/uploads/hero.jpg',\n * alt: 'Hero',\n * width: 1920,\n * height: 1080\n * }\n * },\n * publishedAt: new Date(),\n * publishedBy: 'admin@futureplay.com'\n * })\n * .onConflictDoUpdate({\n * target: [cmsPublishedCache.section, cmsPublishedCache.locale],\n * set: {\n * content: sql`EXCLUDED.content`,\n * publishedAt: sql`EXCLUDED.published_at`,\n * publishedBy: sql`EXCLUDED.published_by`,\n * version: sql`${cmsPublishedCache.version} + 1`\n * }\n * });\n *\n * // 캐시 조회 (초고속!)\n * const cache = await db.select()\n * .from(cmsPublishedCache)\n * .where(and(\n * eq(cmsPublishedCache.section, 'home'),\n * eq(cmsPublishedCache.locale, 'ko')\n * ))\n * .limit(1);\n *\n * const labels = cache[0].content; // 즉시 사용 가능!\n *\n * // 섹션의 모든 언어 캐시 조회\n * const allLocales = await db.select()\n * .from(cmsPublishedCache)\n * .where(eq(cmsPublishedCache.section, 'home'));\n *\n * // 오래된 캐시 감지\n * const stale = await db.select()\n * .from(cmsPublishedCache)\n * .where(lt(\n * cmsPublishedCache.publishedAt,\n * new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)\n * ));\n */","/**\n * CMS Label Values Repository\n *\n * 라벨 값 관리를 위한 Repository\n * BaseRepository를 상속받아 자동 트랜잭션 컨텍스트 지원 및 Read/Write 분리\n */\n\nimport { BaseRepository } from '@spfn/core/db';\nimport { eq, and, SQL, isNull, gte, lte, inArray } from 'drizzle-orm';\nimport { cmsLabelValues, type CmsLabelValue, type NewCmsLabelValue } from '../entities';\n\n/**\n * 버전 히스토리 타입\n */\nexport interface VersionHistory\n{\n version: number;\n publishedAt: string;\n publishedBy: null;\n notes: null;\n values: Array<{\n id: number;\n locale: string;\n breakpoint: string | null;\n value: any;\n createdAt: string;\n }>;\n}\n\n/**\n * CMS Label Values Repository 클래스\n */\nexport class CmsLabelValuesRepository extends BaseRepository\n{\n /**\n * 특정 라벨의 특정 버전 값들 조회\n * Read replica 사용\n */\n async findByLabelIdAndVersion(\n labelId: number,\n version: number,\n options?: {\n locale?: string;\n breakpoint?: string | null;\n }\n ): Promise<CmsLabelValue[]>\n {\n const { locale, breakpoint } = options || {};\n\n const conditions: SQL[] = [\n eq(cmsLabelValues.labelId, labelId),\n eq(cmsLabelValues.version, version)\n ];\n\n if (locale)\n {\n conditions.push(eq(cmsLabelValues.locale, locale));\n }\n\n if (breakpoint !== undefined)\n {\n conditions.push(\n breakpoint === null\n ? isNull(cmsLabelValues.breakpoint)\n : eq(cmsLabelValues.breakpoint, breakpoint)\n );\n }\n\n return this.readDb\n .select()\n .from(cmsLabelValues)\n .where(and(...conditions));\n }\n\n /**\n * 값 저장 (upsert)\n * - version: null → Draft 저장 (덮어쓰기)\n * - version: number → Published 버전 생성 (불변)\n * Write primary 사용\n */\n async upsert(data: NewCmsLabelValue & { labelId: number }): Promise<CmsLabelValue>\n {\n // 기존 값이 있는지 확인\n const versionCondition = data.version === null || data.version === undefined\n ? isNull(cmsLabelValues.version)\n : eq(cmsLabelValues.version, data.version as number);\n\n const existingResult = await this.db\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, data.labelId),\n versionCondition,\n eq(cmsLabelValues.locale, data.locale || 'ko'),\n data.breakpoint\n ? eq(cmsLabelValues.breakpoint, data.breakpoint)\n : isNull(cmsLabelValues.breakpoint)\n )\n )\n .limit(1);\n\n const existing = existingResult[0];\n\n if (existing)\n {\n // UPDATE (only for drafts with version: null)\n if (data.version === null || data.version === undefined)\n {\n const updated = await this.db\n .update(cmsLabelValues)\n .set({ value: data.value })\n .where(eq(cmsLabelValues.id, existing.id))\n .returning();\n\n return updated[0];\n }\n else\n {\n // Published versions are immutable - this shouldn't happen\n throw new Error(`Published version ${data.version} already exists and cannot be overwritten`);\n }\n }\n else\n {\n // INSERT (both draft and new published versions)\n const inserted = await this.db\n .insert(cmsLabelValues)\n .values(data)\n .returning();\n\n return inserted[0];\n }\n }\n\n /**\n * Draft 값들 조회 (version = null)\n * Read replica 사용\n */\n async findDraftsByLabelId(labelId: number): Promise<CmsLabelValue[]>\n {\n return this.readDb\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, labelId),\n isNull(cmsLabelValues.version)\n )\n );\n }\n\n /**\n * 여러 값 일괄 저장\n * Write primary 사용\n */\n async upsertMany(values: (NewCmsLabelValue & { labelId: number })[]): Promise<CmsLabelValue[]>\n {\n const results = [];\n for (const value of values)\n {\n const result = await this.upsert(value);\n results.push(result);\n }\n return results;\n }\n\n /**\n * 특정 버전의 모든 값 삭제\n * Write primary 사용\n */\n async deleteByVersion(labelId: number, version: number): Promise<CmsLabelValue[]>\n {\n return this.db\n .delete(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, labelId),\n eq(cmsLabelValues.version, version)\n )\n )\n .returning();\n }\n\n /**\n * 여러 라벨의 publishedVersion 값들을 한 번에 조회 (N+1 문제 해결)\n * Read replica 사용\n *\n * @param labelVersions - { labelId, version } 배열\n * @returns labelId를 키로 하는 Map<labelId, CmsLabelValue[]>\n *\n * @example\n * ```typescript\n * const result = await findByLabelVersions([\n * { labelId: 1, version: 5 },\n * { labelId: 2, version: 3 }\n * ]);\n * // result.get(1) -> label 1의 version 5 값들\n * // result.get(2) -> label 2의 version 3 값들\n * ```\n */\n async findByLabelVersions(\n labelVersions: Array<{ labelId: number; version: number }>\n ): Promise<Map<number, CmsLabelValue[]>>\n {\n if (labelVersions.length === 0)\n {\n return new Map();\n }\n\n // 모든 label의 publishedVersion 값들을 한 번에 조회\n const allValues = await this.readDb\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n inArray(\n cmsLabelValues.labelId,\n labelVersions.map(lv => lv.labelId)\n )\n )\n );\n\n // labelId와 version으로 필터링하여 Map 생성\n const versionMap = new Map(labelVersions.map(lv => [lv.labelId, lv.version]));\n const resultMap = new Map<number, CmsLabelValue[]>();\n\n for (const value of allValues)\n {\n const expectedVersion = versionMap.get(value.labelId);\n\n // 해당 labelId의 version이 일치하는 경우만 포함\n if (expectedVersion !== undefined && value.version === expectedVersion)\n {\n if (!resultMap.has(value.labelId))\n {\n resultMap.set(value.labelId, []);\n }\n resultMap.get(value.labelId)!.push(value);\n }\n }\n\n return resultMap;\n }\n\n /**\n * 라벨의 버전 히스토리 조회 (1 ~ maxVersion)\n * 한 번의 쿼리로 모든 버전을 조회하고 version별로 그룹화\n * Read replica 사용\n */\n async findVersionHistoryByLabelId(\n labelId: number,\n maxVersion: number\n ): Promise<VersionHistory[]>\n {\n // 모든 버전의 값을 한 번에 조회\n const allValues = await this.readDb\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, labelId),\n gte(cmsLabelValues.version, 1),\n lte(cmsLabelValues.version, maxVersion)\n )\n )\n .orderBy(cmsLabelValues.version, cmsLabelValues.locale);\n\n // version별로 그룹화\n const versionMap = new Map<number, CmsLabelValue[]>();\n\n for (const value of allValues)\n {\n if (value.version === null) continue; // null 버전은 제외\n\n if (!versionMap.has(value.version))\n {\n versionMap.set(value.version, []);\n }\n versionMap.get(value.version)!.push(value);\n }\n\n // VersionHistory 형식으로 변환\n const versions: VersionHistory[] = [];\n\n for (let version = 1; version <= maxVersion; version++)\n {\n const values = versionMap.get(version);\n\n if (values && values.length > 0)\n {\n versions.push({\n version,\n publishedAt: values[0].createdAt.toISOString(),\n publishedBy: null, // label_values에는 publishedBy 정보가 없음\n notes: null, // label_values에는 notes 정보가 없음\n values: values.map(v => ({\n id: v.id,\n locale: v.locale,\n breakpoint: v.breakpoint,\n value: v.value,\n createdAt: v.createdAt.toISOString()\n }))\n });\n }\n }\n\n // 버전 내림차순 정렬 (최신 버전이 먼저)\n versions.sort((a, b) => b.version - a.version);\n\n return versions;\n }\n}\n\n// Default instance export\nexport const cmsLabelValuesRepository = new CmsLabelValuesRepository();","/**\n * CMS Published Cache Repository\n *\n * 발행된 콘텐츠 캐시 관리 (초고속 조회)\n * BaseRepository를 상속받아 자동 트랜잭션 컨텍스트 지원 및 Read/Write 분리\n */\n\nimport { BaseRepository } from '@spfn/core/db';\nimport { eq, and, sql, inArray } from 'drizzle-orm';\nimport { cmsPublishedCache, type CmsPublishedCache, type NewCmsPublishedCache } from '../entities';\n\n/**\n * CMS Published Cache Repository 클래스\n */\nexport class CmsPublishedCacheRepository extends BaseRepository\n{\n /**\n * 섹션 + 언어로 발행된 캐시 조회\n * Read replica 사용\n */\n async findBySection(section: string, locale: string = 'en'): Promise<CmsPublishedCache | null>\n {\n const result = await this.readDb\n .select()\n .from(cmsPublishedCache)\n .where(\n and(\n eq(cmsPublishedCache.section, section),\n eq(cmsPublishedCache.locale, locale)\n )\n )\n .limit(1);\n\n return result[0] ?? null;\n }\n\n /**\n * 캐시 생성 또는 업데이트 (UPSERT)\n * Write primary 사용\n */\n async upsert(data: NewCmsPublishedCache): Promise<CmsPublishedCache>\n {\n const result = await this.db\n .insert(cmsPublishedCache)\n .values(data)\n .onConflictDoUpdate({\n target: [cmsPublishedCache.section, cmsPublishedCache.locale],\n set: {\n content: data.content,\n publishedAt: data.publishedAt,\n publishedBy: data.publishedBy,\n version: sql`${cmsPublishedCache.version} + 1`, // 버전 증가로 클라이언트 캐시 무효화\n }\n })\n .returning();\n\n return result[0];\n }\n\n /**\n * 여러 섹션의 캐시를 한 번에 조회 (N+1 방지)\n * Read replica 사용\n */\n async findBySections(sections: string[], locale: string = 'en'): Promise<CmsPublishedCache[]>\n {\n if (sections.length === 0)\n {\n return [];\n }\n\n return this.readDb\n .select()\n .from(cmsPublishedCache)\n .where(\n and(\n inArray(cmsPublishedCache.section, sections),\n eq(cmsPublishedCache.locale, locale)\n )\n );\n }\n\n /**\n * 섹션별 모든 언어 캐시 조회\n * Read replica 사용\n */\n async findAllLanguages(section: string): Promise<CmsPublishedCache[]>\n {\n return this.readDb\n .select()\n .from(cmsPublishedCache)\n .where(eq(cmsPublishedCache.section, section));\n }\n\n /**\n * 캐시 삭제\n * Write primary 사용\n */\n async deleteBySection(section: string, locale?: string): Promise<void>\n {\n if (locale)\n {\n await this.db\n .delete(cmsPublishedCache)\n .where(\n and(\n eq(cmsPublishedCache.section, section),\n eq(cmsPublishedCache.locale, locale)\n )\n );\n }\n else\n {\n await this.db\n .delete(cmsPublishedCache)\n .where(eq(cmsPublishedCache.section, section));\n }\n }\n}\n\n// Default instance export\nexport const cmsPublishedCacheRepository = new CmsPublishedCacheRepository();","/**\n * CMS Label Synchronization Service\n *\n * Synchronizes labels defined in code with database\n */\n\nimport { isEqual } from 'lodash-es';\nimport { type SyncOptions, type SyncResult } from '../../lib/types';\nimport { type FlatLabel, flattenLabels } from '../../lib/helpers';\nimport { cmsLabelsRepository } from '../repositories';\nimport { type NewCmsLabel } from '../entities';\n\n/**\n * Compare current DB labels with new labels\n *\n * @param dbLabels - Labels currently in database\n * @param codeLabels - Labels from code (flattened)\n * @returns Comparison result with added/removed/updated labels\n */\nfunction compareLabels(dbLabels: FlatLabel, codeLabels: FlatLabel): SyncResult\n{\n const added: string[] = [];\n const removed: string[] = [];\n const updated: string[] = [];\n const unchanged: string[] = [];\n\n const dbKeys = Object.keys(dbLabels);\n const codeKeys = Object.keys(codeLabels);\n\n // Check for added and updated labels\n for (const key of codeKeys)\n {\n if (!(key in dbLabels))\n {\n // New label\n added.push(key);\n }\n else\n {\n // Check if values changed (deep equality check for nested objects)\n const dbValue = dbLabels[key];\n const codeValue = codeLabels[key];\n\n if (!isEqual(dbValue, codeValue))\n {\n updated.push(key);\n }\n else\n {\n unchanged.push(key);\n }\n }\n }\n\n // Check for removed labels\n for (const key of dbKeys)\n {\n if (!(key in codeLabels))\n {\n removed.push(key);\n }\n }\n\n return {\n added,\n removed,\n updated,\n unchanged,\n };\n}\n\n/**\n * Sync labels with database\n *\n * @param labels - Single label definition or array of label definitions\n * @param options - Sync options\n * @returns Sync result\n *\n * @example\n * ```typescript\n * // Single definition\n * await syncLabels(labelsDefinition);\n *\n * // Multiple definitions\n * await syncLabels([homeLabels, aboutLabels, commonLabels]);\n * ```\n */\nexport async function syncLabels<T extends Record<string, any>>(\n labels: T | T[],\n options?: SyncOptions\n): Promise<SyncResult>\n{\n const { removeOrphaned = false, dryRun = false } = options || {};\n\n // 1. Merge multiple label definitions into one (if array provided)\n const mergedLabels = Array.isArray(labels)\n ? Object.assign({}, ...labels)\n : labels;\n\n // 2. Flatten code labels\n const codeLabels = flattenLabels(mergedLabels);\n\n // 3. Fetch current labels from DB\n const dbLabels = await cmsLabelsRepository.findMany();\n const dbLabelMap: FlatLabel = {};\n\n for (const label of dbLabels)\n {\n if (label.defaultValue)\n {\n dbLabelMap[label.key] = label.defaultValue as Record<string, string>;\n }\n }\n\n // 4. Compare changes\n const result = compareLabels(dbLabelMap, codeLabels);\n\n // 5. Return result if dry run\n if (dryRun)\n {\n return result;\n }\n\n // 6. Create new labels\n if (result.added.length > 0)\n {\n const toCreate: NewCmsLabel[] = result.added.map(key => ({\n key,\n section: extractSection(key),\n type: 'text',\n defaultValue: codeLabels[key],\n }));\n\n await cmsLabelsRepository.bulkCreate(toCreate);\n }\n\n // 7. Update changed labels\n if (result.updated.length > 0)\n {\n const updates = result.updated.map(key => ({\n key,\n data: {\n defaultValue: codeLabels[key],\n },\n }));\n\n await cmsLabelsRepository.bulkUpdateByKeys(updates);\n }\n\n // 8. Remove deleted labels (only if option is true)\n if (removeOrphaned && result.removed.length > 0)\n {\n await cmsLabelsRepository.bulkDeleteByKeys(result.removed);\n }\n\n return result;\n}\n\n/**\n * Extract section from key\n * Example: \"home.hero.title\" -> \"home\"\n */\nfunction extractSection(key: string): string\n{\n const parts = key.split('.');\n return parts[0] || key;\n}","/**\n * CMS Helper Functions\n */\n\nexport type FlatLabel = Record<string, Record<string, string>>;\n\n/**\n * Flatten nested label structure into dot notation\n *\n * @param labels - Nested label object\n * @param prefix - Key prefix for recursion\n * @returns Flattened label structure\n *\n * @example\n * ```typescript\n * const nested = {\n * home: {\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * };\n *\n * const flat = flattenLabels(nested);\n * // { \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" } }\n * ```\n */\nexport function flattenLabels<T extends Record<string, any>>(labels: T, prefix = ''): FlatLabel\n{\n const result: FlatLabel = {};\n\n if (!labels || typeof labels !== 'object')\n {\n return result;\n }\n\n const obj = labels as Record<string, unknown>;\n\n for (const [key, value] of Object.entries(obj))\n {\n const newKey = prefix ? `${prefix}.${key}` : key;\n\n if (!value || typeof value !== 'object')\n {\n continue;\n }\n\n const valueObj = value as Record<string, unknown>;\n\n // Check if this is a leaf node (locale values: { en: \"...\", ko: \"...\" })\n const isLeaf = Object.values(valueObj).every(v => typeof v === 'string');\n\n if (isLeaf)\n {\n result[newKey] = valueObj as Record<string, string>;\n }\n else\n {\n // Recursively flatten nested structure\n Object.assign(result, flattenLabels(value, newKey));\n }\n }\n\n return result;\n}\n\n/**\n * Set a value in nested object using dot notation path\n *\n * @param target - Target object to modify\n * @param path - Dot notation path (e.g., \"home.hero.title\")\n * @param value - Value to set\n *\n * @example\n * ```typescript\n * const obj = {};\n * setNestedValue(obj, \"home.hero.title\", \"Welcome\");\n * // obj = { home: { hero: { title: \"Welcome\" } } }\n * ```\n */\nexport function setNestedValue(target: any, path: string, value: any): void\n{\n const parts = path.split('.');\n let current = target;\n\n for (let i = 0; i < parts.length - 1; i++)\n {\n const part = parts[i];\n if (!current[part])\n {\n current[part] = {};\n }\n current = current[part];\n }\n\n // Set the leaf value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Unflatten dot notation keys back to nested structure\n *\n * @param flat - Flattened label object\n * @returns Nested label structure\n *\n * @example\n * ```typescript\n * const flat = {\n * \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" },\n * \"home.hero.subtitle\": { en: \"Subtitle\", ko: \"부제목\" }\n * };\n *\n * const nested = unflattenLabels(flat);\n * // {\n * // home: {\n * // hero: {\n * // title: { en: \"Welcome\", ko: \"환영합니다\" },\n * // subtitle: { en: \"Subtitle\", ko: \"부제목\" }\n * // }\n * // }\n * // }\n * ```\n */\nexport function unflattenLabels(flat: FlatLabel): Record<string, any>\n{\n const result: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(flat))\n {\n setNestedValue(result, key, value);\n }\n\n return result;\n}"],"mappings":";AAAA,OAAO;;;ACMP,SAAS,YAAY;AACrB,SAAS,cAAc,aAAa;;;ACApC,SAAS,sBAAsB;AAC/B,SAAS,KAAK,SAAS,cAAc,IAAI,eAAe;;;ACExD,SAAS,OAAO,SAAS,YAAY;AACrC,SAAS,IAAI,YAAY,kBAAkB;;;ACL3C,SAAS,oBAAoB;AAEtB,IAAM,YAAY,aAAa,WAAW;;;ADM1C,IAAM,YAAY,UAAU,MAAM,UAAU;AAAA;AAAA,EAE/C,IAAI,GAAG;AAAA;AAAA,EAGP,KAAK,KAAK,KAAK,EAAE,QAAQ,EAAE,OAAO;AAAA;AAAA;AAAA;AAAA,EAKlC,SAAS,KAAK,SAAS,EAAE,QAAQ;AAAA;AAAA;AAAA,EAIjC,MAAM,KAAK,MAAM,EAAE,QAAQ;AAAA;AAAA;AAAA,EAI3B,cAAc,WAAgC,eAAe;AAAA;AAAA;AAAA;AAAA,EAK7D,aAAa,KAAK,aAAa;AAAA;AAAA;AAAA,EAI/B,kBAAkB,QAAQ,mBAAmB;AAAA;AAAA;AAAA;AAAA,EAK7C,WAAW,KAAK,YAAY;AAAA;AAAA,EAG5B,GAAG,WAAW;AAClB,GAAG,CAAC,UAAU;AAAA;AAAA,EAEV,MAAM,wBAAwB,EAAE,GAAG,MAAM,OAAO;AAAA;AAAA,EAGhD,MAAM,oBAAoB,EAAE,GAAG,MAAM,GAAG;AAC5C,CAAC;;;AE9CD,SAAS,WAAAA,UAAS,QAAAC,OAAM,SAAAC,QAAO,cAAc;AAC7C,SAAS,MAAAC,KAAI,cAAc,cAAAC,aAAY,kBAAkB;AAOlD,IAAM,iBAAiB,UAAU,MAAM,gBAAgB;AAAA;AAAA,EAE1D,IAAIC,IAAG;AAAA;AAAA,EAGP,SAAS,WAAW,SAAS,MAAM,UAAU,IAAI,EAAE,UAAU,UAAU,CAAC;AAAA;AAAA,EAGxE,SAASC,SAAQ,SAAS;AAAA;AAAA,EAG1B,QAAQC,MAAK,QAAQ,EAAE,QAAQ,EAAE,QAAQ,IAAI;AAAA;AAAA;AAAA,EAI7C,YAAYA,MAAK,YAAY;AAAA;AAAA;AAAA;AAAA,EAK7B,OAAOC,YAAgC,OAAO,EAAE,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASxD,WAAW,aAAa,YAAY,EAAE,WAAW,EAAE,QAAQ;AAC/D,GAAG,CAAC,UAAU;AAAA;AAAA,EAEV,OAAO,2CAA2C,EAC7C,GAAG,MAAM,SAAS,MAAM,SAAS,MAAM,QAAQ,MAAM,UAAU;AAAA;AAAA,EAGpEC,OAAM,oCAAoC,EACrC,GAAG,MAAM,SAAS,MAAM,OAAO;AAAA;AAAA,EAGpCA,OAAM,6BAA6B,EAAE,GAAG,MAAM,MAAM;AACxD,CAAC;;;AC9CD,SAAS,QAAAC,OAAM,WAAAC,UAAS,SAAAC,QAAO,UAAAC,eAAc;AAC7C,SAAS,MAAAC,KAAI,kBAAkB,cAAAC,mBAAkB;AAK1C,IAAM,oBAAoB,UAAU,MAAM,mBAAmB;AAAA;AAAA,EAEhE,IAAIC,IAAG;AAAA;AAAA,EAGP,SAASC,MAAK,SAAS,EAAE,QAAQ;AAAA;AAAA;AAAA,EAIjC,QAAQA,MAAK,QAAQ,EAAE,QAAQ;AAAA;AAAA;AAAA,EAI/B,SAASC,YAAgC,SAAS,EAAE,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAS5D,GAAG,iBAAiB;AAAA;AAAA,EAGpB,SAASC,SAAQ,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC;AACnD,GAAG,CAAC,UAAU;AAAA;AAAA,EAEVC,QAAO,4BAA4B,EAAE,GAAG,MAAM,SAAS,MAAM,MAAM;AAAA;AAAA,EAGnEC,OAAM,iCAAiC,EAAE,GAAG,MAAM,OAAO;AAC7D,CAAC;;;AJhCM,IAAM,sBAAN,cAAkC,eACzC;AAAA;AAAA;AAAA;AAAA;AAAA,EAKI,MAAM,SAAS,SAGf;AACI,UAAM,EAAE,QAAQ,IAAI,WAAW,CAAC;AAEhC,QAAI,QAAQ,KAAK,OACZ,OAAO,EACP,KAAK,SAAS,EACd,QAAQ,IAAI,UAAU,GAAG,CAAC;AAE/B,QAAI,SACJ;AACI,cAAQ,MAAM,MAAM,GAAG,UAAU,SAAS,OAAO,CAAC;AAAA,IACtD;AAEA,WAAO;AAAA,EACX;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,MAAM,SACZ;AACI,UAAM,QAAQ,KAAK,OACd,OAAO,EAAE,OAAO,aAAa,EAAE,CAAC,EAChC,KAAK,SAAS;AAEnB,UAAM,SAAS,UACT,MAAM,MAAM,MAAM,GAAG,UAAU,SAAS,OAAO,CAAC,IAChD,MAAM;AAEZ,WAAO,OAAO,CAAC,GAAG,SAAS;AAAA,EAC/B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,SAASC,KACf;AACI,UAAM,SAAS,MAAM,KAAK,OACrB,OAAO,EACP,KAAK,SAAS,EACd,MAAM,GAAG,UAAU,IAAIA,GAAE,CAAC,EAC1B,MAAM,CAAC;AAEZ,WAAO,OAAO,CAAC,KAAK;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,UAAU,KAChB;AACI,UAAM,SAAS,MAAM,KAAK,OACrB,OAAO,EACP,KAAK,SAAS,EACd,MAAM,GAAG,UAAU,KAAK,GAAG,CAAC,EAC5B,MAAM,CAAC;AAEZ,WAAO,OAAO,CAAC,KAAK;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,cAAc,SACpB;AACI,WAAO,KAAK,OACP,OAAO,EACP,KAAK,SAAS,EACd,MAAM,GAAG,UAAU,SAAS,OAAO,CAAC,EACpC,QAAQ,IAAI,UAAU,GAAG,CAAC;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAO,MACb;AACI,UAAM,SAAS,MAAM,KAAK,GACrB,OAAO,SAAS,EAChB,OAAO,IAAI,EACX,UAAU;AAEf,WAAO,OAAO,CAAC;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WAAWA,KAAY,MAC7B;AACI,UAAM,SAAS,MAAM,KAAK,GACrB,OAAO,SAAS,EAChB,IAAI,EAAE,GAAG,MAAM,WAAW,oBAAI,KAAK,EAAE,CAAC,EACtC,MAAM,GAAG,UAAU,IAAIA,GAAE,CAAC,EAC1B,UAAU;AAEf,WAAO,OAAO,CAAC,KAAK;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WAAWA,KACjB;AACI,UAAM,SAAS,MAAM,KAAK,GACrB,OAAO,SAAS,EAChB,MAAM,GAAG,UAAU,IAAIA,GAAE,CAAC,EAC1B,UAAU;AAEf,WAAO,OAAO,CAAC,KAAK;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WAAW,MACjB;AACI,QAAI,KAAK,WAAW,GACpB;AACI,aAAO,CAAC;AAAA,IACZ;AAEA,WAAO,KAAK,OACP,OAAO,EACP,KAAK,SAAS,EACd,MAAM,QAAQ,UAAU,KAAK,IAAI,CAAC;AAAA,EAC3C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WAAW,MACjB;AACI,QAAI,KAAK,WAAW,GACpB;AACI,aAAO,CAAC;AAAA,IACZ;AAEA,WAAO,KAAK,GACP,OAAO,SAAS,EAChB,OAAO,IAAI,EACX,UAAU;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,iBAAiB,SACvB;AACI,QAAI,QAAQ,WAAW,GACvB;AACI;AAAA,IACJ;AAIA,eAAW,EAAE,KAAK,KAAK,KAAK,SAC5B;AACI,YAAM,KAAK,GACN,OAAO,SAAS,EAChB,IAAI,EAAE,GAAG,MAAM,WAAW,oBAAI,KAAK,EAAE,CAAC,EACtC,MAAM,GAAG,UAAU,KAAK,GAAG,CAAC;AAAA,IACrC;AAAA,EACJ;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,iBAAiB,MACvB;AACI,QAAI,KAAK,WAAW,GACpB;AACI,aAAO,CAAC;AAAA,IACZ;AAEA,WAAO,KAAK,GACP,OAAO,SAAS,EAChB,MAAM,QAAQ,UAAU,KAAK,IAAI,CAAC,EAClC,UAAU;AAAA,EACnB;AACJ;AAGO,IAAM,sBAAsB,IAAI,oBAAoB;;;AKzN3D,SAAS,kBAAAC,uBAAsB;AAC/B,SAAS,MAAAC,KAAI,KAAU,QAAQ,KAAK,KAAK,WAAAC,gBAAe;AAwBjD,IAAM,2BAAN,cAAuCC,gBAC9C;AAAA;AAAA;AAAA;AAAA;AAAA,EAKI,MAAM,wBACF,SACA,SACA,SAKJ;AACI,UAAM,EAAE,QAAQ,WAAW,IAAI,WAAW,CAAC;AAE3C,UAAM,aAAoB;AAAA,MACtBC,IAAG,eAAe,SAAS,OAAO;AAAA,MAClCA,IAAG,eAAe,SAAS,OAAO;AAAA,IACtC;AAEA,QAAI,QACJ;AACI,iBAAW,KAAKA,IAAG,eAAe,QAAQ,MAAM,CAAC;AAAA,IACrD;AAEA,QAAI,eAAe,QACnB;AACI,iBAAW;AAAA,QACP,eAAe,OACT,OAAO,eAAe,UAAU,IAChCA,IAAG,eAAe,YAAY,UAAU;AAAA,MAClD;AAAA,IACJ;AAEA,WAAO,KAAK,OACP,OAAO,EACP,KAAK,cAAc,EACnB,MAAM,IAAI,GAAG,UAAU,CAAC;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,OAAO,MACb;AAEI,UAAM,mBAAmB,KAAK,YAAY,QAAQ,KAAK,YAAY,SAC7D,OAAO,eAAe,OAAO,IAC7BA,IAAG,eAAe,SAAS,KAAK,OAAiB;AAEvD,UAAM,iBAAiB,MAAM,KAAK,GAC7B,OAAO,EACP,KAAK,cAAc,EACnB;AAAA,MACG;AAAA,QACIA,IAAG,eAAe,SAAS,KAAK,OAAO;AAAA,QACvC;AAAA,QACAA,IAAG,eAAe,QAAQ,KAAK,UAAU,IAAI;AAAA,QAC7C,KAAK,aACCA,IAAG,eAAe,YAAY,KAAK,UAAU,IAC7C,OAAO,eAAe,UAAU;AAAA,MAC1C;AAAA,IACJ,EACC,MAAM,CAAC;AAEZ,UAAM,WAAW,eAAe,CAAC;AAEjC,QAAI,UACJ;AAEI,UAAI,KAAK,YAAY,QAAQ,KAAK,YAAY,QAC9C;AACI,cAAM,UAAU,MAAM,KAAK,GACtB,OAAO,cAAc,EACrB,IAAI,EAAE,OAAO,KAAK,MAAM,CAAC,EACzB,MAAMA,IAAG,eAAe,IAAI,SAAS,EAAE,CAAC,EACxC,UAAU;AAEf,eAAO,QAAQ,CAAC;AAAA,MACpB,OAEA;AAEI,cAAM,IAAI,MAAM,qBAAqB,KAAK,OAAO,2CAA2C;AAAA,MAChG;AAAA,IACJ,OAEA;AAEI,YAAM,WAAW,MAAM,KAAK,GACvB,OAAO,cAAc,EACrB,OAAO,IAAI,EACX,UAAU;AAEf,aAAO,SAAS,CAAC;AAAA,IACrB;AAAA,EACJ;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,oBAAoB,SAC1B;AACI,WAAO,KAAK,OACP,OAAO,EACP,KAAK,cAAc,EACnB;AAAA,MACG;AAAA,QACIA,IAAG,eAAe,SAAS,OAAO;AAAA,QAClC,OAAO,eAAe,OAAO;AAAA,MACjC;AAAA,IACJ;AAAA,EACR;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WAAW,QACjB;AACI,UAAM,UAAU,CAAC;AACjB,eAAW,SAAS,QACpB;AACI,YAAM,SAAS,MAAM,KAAK,OAAO,KAAK;AACtC,cAAQ,KAAK,MAAM;AAAA,IACvB;AACA,WAAO;AAAA,EACX;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,gBAAgB,SAAiB,SACvC;AACI,WAAO,KAAK,GACP,OAAO,cAAc,EACrB;AAAA,MACG;AAAA,QACIA,IAAG,eAAe,SAAS,OAAO;AAAA,QAClCA,IAAG,eAAe,SAAS,OAAO;AAAA,MACtC;AAAA,IACJ,EACC,UAAU;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmBA,MAAM,oBACF,eAEJ;AACI,QAAI,cAAc,WAAW,GAC7B;AACI,aAAO,oBAAI,IAAI;AAAA,IACnB;AAGA,UAAM,YAAY,MAAM,KAAK,OACxB,OAAO,EACP,KAAK,cAAc,EACnB;AAAA,MACG;AAAA,QACIC;AAAA,UACI,eAAe;AAAA,UACf,cAAc,IAAI,QAAM,GAAG,OAAO;AAAA,QACtC;AAAA,MACJ;AAAA,IACJ;AAGJ,UAAM,aAAa,IAAI,IAAI,cAAc,IAAI,QAAM,CAAC,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC;AAC5E,UAAM,YAAY,oBAAI,IAA6B;AAEnD,eAAW,SAAS,WACpB;AACI,YAAM,kBAAkB,WAAW,IAAI,MAAM,OAAO;AAGpD,UAAI,oBAAoB,UAAa,MAAM,YAAY,iBACvD;AACI,YAAI,CAAC,UAAU,IAAI,MAAM,OAAO,GAChC;AACI,oBAAU,IAAI,MAAM,SAAS,CAAC,CAAC;AAAA,QACnC;AACA,kBAAU,IAAI,MAAM,OAAO,EAAG,KAAK,KAAK;AAAA,MAC5C;AAAA,IACJ;AAEA,WAAO;AAAA,EACX;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,4BACF,SACA,YAEJ;AAEI,UAAM,YAAY,MAAM,KAAK,OACxB,OAAO,EACP,KAAK,cAAc,EACnB;AAAA,MACG;AAAA,QACID,IAAG,eAAe,SAAS,OAAO;AAAA,QAClC,IAAI,eAAe,SAAS,CAAC;AAAA,QAC7B,IAAI,eAAe,SAAS,UAAU;AAAA,MAC1C;AAAA,IACJ,EACC,QAAQ,eAAe,SAAS,eAAe,MAAM;AAG1D,UAAM,aAAa,oBAAI,IAA6B;AAEpD,eAAW,SAAS,WACpB;AACI,UAAI,MAAM,YAAY,KAAM;AAE5B,UAAI,CAAC,WAAW,IAAI,MAAM,OAAO,GACjC;AACI,mBAAW,IAAI,MAAM,SAAS,CAAC,CAAC;AAAA,MACpC;AACA,iBAAW,IAAI,MAAM,OAAO,EAAG,KAAK,KAAK;AAAA,IAC7C;AAGA,UAAM,WAA6B,CAAC;AAEpC,aAAS,UAAU,GAAG,WAAW,YAAY,WAC7C;AACI,YAAM,SAAS,WAAW,IAAI,OAAO;AAErC,UAAI,UAAU,OAAO,SAAS,GAC9B;AACI,iBAAS,KAAK;AAAA,UACV;AAAA,UACA,aAAa,OAAO,CAAC,EAAE,UAAU,YAAY;AAAA,UAC7C,aAAa;AAAA;AAAA,UACb,OAAO;AAAA;AAAA,UACP,QAAQ,OAAO,IAAI,QAAM;AAAA,YACrB,IAAI,EAAE;AAAA,YACN,QAAQ,EAAE;AAAA,YACV,YAAY,EAAE;AAAA,YACd,OAAO,EAAE;AAAA,YACT,WAAW,EAAE,UAAU,YAAY;AAAA,UACvC,EAAE;AAAA,QACN,CAAC;AAAA,MACL;AAAA,IACJ;AAGA,aAAS,KAAK,CAAC,GAAG,MAAM,EAAE,UAAU,EAAE,OAAO;AAE7C,WAAO;AAAA,EACX;AACJ;AAGO,IAAM,2BAA2B,IAAI,yBAAyB;;;ACpTrE,SAAS,kBAAAE,uBAAsB;AAC/B,SAAS,MAAAC,KAAI,OAAAC,MAAK,KAAK,WAAAC,gBAAe;AAM/B,IAAM,8BAAN,cAA0CC,gBACjD;AAAA;AAAA;AAAA;AAAA;AAAA,EAKI,MAAM,cAAc,SAAiB,SAAiB,MACtD;AACI,UAAM,SAAS,MAAM,KAAK,OACrB,OAAO,EACP,KAAK,iBAAiB,EACtB;AAAA,MACGC;AAAA,QACIC,IAAG,kBAAkB,SAAS,OAAO;AAAA,QACrCA,IAAG,kBAAkB,QAAQ,MAAM;AAAA,MACvC;AAAA,IACJ,EACC,MAAM,CAAC;AAEZ,WAAO,OAAO,CAAC,KAAK;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAO,MACb;AACI,UAAM,SAAS,MAAM,KAAK,GACrB,OAAO,iBAAiB,EACxB,OAAO,IAAI,EACX,mBAAmB;AAAA,MAChB,QAAQ,CAAC,kBAAkB,SAAS,kBAAkB,MAAM;AAAA,MAC5D,KAAK;AAAA,QACD,SAAS,KAAK;AAAA,QACd,aAAa,KAAK;AAAA,QAClB,aAAa,KAAK;AAAA,QAClB,SAAS,MAAM,kBAAkB,OAAO;AAAA;AAAA,MAC5C;AAAA,IACJ,CAAC,EACA,UAAU;AAEf,WAAO,OAAO,CAAC;AAAA,EACnB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,eAAe,UAAoB,SAAiB,MAC1D;AACI,QAAI,SAAS,WAAW,GACxB;AACI,aAAO,CAAC;AAAA,IACZ;AAEA,WAAO,KAAK,OACP,OAAO,EACP,KAAK,iBAAiB,EACtB;AAAA,MACGD;AAAA,QACIE,SAAQ,kBAAkB,SAAS,QAAQ;AAAA,QAC3CD,IAAG,kBAAkB,QAAQ,MAAM;AAAA,MACvC;AAAA,IACJ;AAAA,EACR;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,iBAAiB,SACvB;AACI,WAAO,KAAK,OACP,OAAO,EACP,KAAK,iBAAiB,EACtB,MAAMA,IAAG,kBAAkB,SAAS,OAAO,CAAC;AAAA,EACrD;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,gBAAgB,SAAiB,QACvC;AACI,QAAI,QACJ;AACI,YAAM,KAAK,GACN,OAAO,iBAAiB,EACxB;AAAA,QACGD;AAAA,UACIC,IAAG,kBAAkB,SAAS,OAAO;AAAA,UACrCA,IAAG,kBAAkB,QAAQ,MAAM;AAAA,QACvC;AAAA,MACJ;AAAA,IACR,OAEA;AACI,YAAM,KAAK,GACN,OAAO,iBAAiB,EACxB,MAAMA,IAAG,kBAAkB,SAAS,OAAO,CAAC;AAAA,IACrD;AAAA,EACJ;AACJ;AAGO,IAAM,8BAA8B,IAAI,4BAA4B;;;AP9GpE,IAAM,gBAAgB,MAAM,IAAI,oBAAoB,EACtD,KAAK,CAAC,MAAM,CAAC,EACb,MAAM;AAAA,EACH,OAAO,KAAK,OAAO;AAAA,IACf,UAAU,KAAK,MAAM,KAAK,OAAO,CAAC;AAAA,IAClC,QAAQ,KAAK,SAAS,KAAK,OAAO,CAAC;AAAA,EACvC,CAAC;AACL,CAAC,EACA,QAAQ,OAAO,MAChB;AACI,QAAM,EAAE,MAAM,IAAI,MAAM,EAAE,KAAK;AAC/B,QAAM,EAAE,UAAU,SAAS,KAAK,IAAI;AAGpC,QAAM,UAAU,MAAM,4BAA4B,eAAe,UAAU,MAAM;AAGjF,SAAO,QAAQ,OAAO,CAAC,KAAK,SAAS;AACjC,QAAI,KAAK,OAAO,IAAI,KAAK;AACzB,WAAO;AAAA,EACX,GAAG,CAAC,CAAwB;AAChC,CAAC;AAEE,IAAM,eAAe,aAAa;AAAA,EACrC;AACJ,CAAC;;;AQ7BD,SAAS,eAAe;;;ACqBjB,SAAS,cAA6C,QAAW,SAAS,IACjF;AACI,QAAM,SAAoB,CAAC;AAE3B,MAAI,CAAC,UAAU,OAAO,WAAW,UACjC;AACI,WAAO;AAAA,EACX;AAEA,QAAM,MAAM;AAEZ,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,GAAG,GAC7C;AACI,UAAM,SAAS,SAAS,GAAG,MAAM,IAAI,GAAG,KAAK;AAE7C,QAAI,CAAC,SAAS,OAAO,UAAU,UAC/B;AACI;AAAA,IACJ;AAEA,UAAM,WAAW;AAGjB,UAAM,SAAS,OAAO,OAAO,QAAQ,EAAE,MAAM,OAAK,OAAO,MAAM,QAAQ;AAEvE,QAAI,QACJ;AACI,aAAO,MAAM,IAAI;AAAA,IACrB,OAEA;AAEI,aAAO,OAAO,QAAQ,cAAc,OAAO,MAAM,CAAC;AAAA,IACtD;AAAA,EACJ;AAEA,SAAO;AACX;;;AD7CA,SAAS,cAAc,UAAqB,YAC5C;AACI,QAAM,QAAkB,CAAC;AACzB,QAAM,UAAoB,CAAC;AAC3B,QAAM,UAAoB,CAAC;AAC3B,QAAM,YAAsB,CAAC;AAE7B,QAAM,SAAS,OAAO,KAAK,QAAQ;AACnC,QAAM,WAAW,OAAO,KAAK,UAAU;AAGvC,aAAW,OAAO,UAClB;AACI,QAAI,EAAE,OAAO,WACb;AAEI,YAAM,KAAK,GAAG;AAAA,IAClB,OAEA;AAEI,YAAM,UAAU,SAAS,GAAG;AAC5B,YAAM,YAAY,WAAW,GAAG;AAEhC,UAAI,CAAC,QAAQ,SAAS,SAAS,GAC/B;AACI,gBAAQ,KAAK,GAAG;AAAA,MACpB,OAEA;AACI,kBAAU,KAAK,GAAG;AAAA,MACtB;AAAA,IACJ;AAAA,EACJ;AAGA,aAAW,OAAO,QAClB;AACI,QAAI,EAAE,OAAO,aACb;AACI,cAAQ,KAAK,GAAG;AAAA,IACpB;AAAA,EACJ;AAEA,SAAO;AAAA,IACH;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACJ;AACJ;AAkBA,eAAsB,WAClB,QACA,SAEJ;AACI,QAAM,EAAE,iBAAiB,OAAO,SAAS,MAAM,IAAI,WAAW,CAAC;AAG/D,QAAM,eAAe,MAAM,QAAQ,MAAM,IACnC,OAAO,OAAO,CAAC,GAAG,GAAG,MAAM,IAC3B;AAGN,QAAM,aAAa,cAAc,YAAY;AAG7C,QAAM,WAAW,MAAM,oBAAoB,SAAS;AACpD,QAAM,aAAwB,CAAC;AAE/B,aAAW,SAAS,UACpB;AACI,QAAI,MAAM,cACV;AACI,iBAAW,MAAM,GAAG,IAAI,MAAM;AAAA,IAClC;AAAA,EACJ;AAGA,QAAM,SAAS,cAAc,YAAY,UAAU;AAGnD,MAAI,QACJ;AACI,WAAO;AAAA,EACX;AAGA,MAAI,OAAO,MAAM,SAAS,GAC1B;AACI,UAAM,WAA0B,OAAO,MAAM,IAAI,UAAQ;AAAA,MACrD;AAAA,MACA,SAAS,eAAe,GAAG;AAAA,MAC3B,MAAM;AAAA,MACN,cAAc,WAAW,GAAG;AAAA,IAChC,EAAE;AAEF,UAAM,oBAAoB,WAAW,QAAQ;AAAA,EACjD;AAGA,MAAI,OAAO,QAAQ,SAAS,GAC5B;AACI,UAAM,UAAU,OAAO,QAAQ,IAAI,UAAQ;AAAA,MACvC;AAAA,MACA,MAAM;AAAA,QACF,cAAc,WAAW,GAAG;AAAA,MAChC;AAAA,IACJ,EAAE;AAEF,UAAM,oBAAoB,iBAAiB,OAAO;AAAA,EACtD;AAGA,MAAI,kBAAkB,OAAO,QAAQ,SAAS,GAC9C;AACI,UAAM,oBAAoB,iBAAiB,OAAO,OAAO;AAAA,EAC7D;AAEA,SAAO;AACX;AAMA,SAAS,eAAe,KACxB;AACI,QAAM,QAAQ,IAAI,MAAM,GAAG;AAC3B,SAAO,MAAM,CAAC,KAAK;AACvB;","names":["integer","text","index","id","typedJsonb","id","integer","text","typedJsonb","index","text","integer","index","unique","id","typedJsonb","id","text","typedJsonb","integer","unique","index","id","BaseRepository","eq","inArray","BaseRepository","eq","inArray","BaseRepository","eq","and","inArray","BaseRepository","and","eq","inArray"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/server/routes/index.ts","../src/server/repositories/cms-labels.repository.ts","../src/server/entities/cms-labels.ts","../src/server/entities/cms-schema.ts","../src/server/entities/cms-label-values.ts","../src/server/entities/cms-published-cache.ts","../src/server/repositories/cms-label-values.repository.ts","../src/server/repositories/cms-published-cache.repository.ts","../src/server/routes/admin.routes.ts","../src/server/services/publish.service.ts","../src/server/services/sync.service.ts","../src/lib/helpers.ts"],"sourcesContent":["import '@spfn/cms/config';\n\nexport { cmsAppRouter } from './server/routes';\nexport { syncLabels } from './server/services';","/**\n * CMS App Router\n *\n * 모든 CMS 라우트를 통합하는 메인 라우터\n */\n\nimport { Type } from '@sinclair/typebox';\nimport { defineRouter, route } from '@spfn/core/route';\nimport { cmsPublishedCacheRepository } from '../repositories';\nimport {\n getSectionLabelsRoute,\n saveSectionDraftRoute,\n publishSectionRoute,\n resetSectionDraftRoute,\n} from './admin.routes';\n\nexport const getLabelCache = route.get('/_cms/labels/cache')\n .skip(['auth'])\n .input({\n query: Type.Object({\n sections: Type.Array(Type.String()),\n locale: Type.Optional(Type.String())\n })\n })\n .handler(async (c) =>\n {\n const { query } = await c.data();\n const { sections, locale = 'en' } = query;\n\n // 단일 쿼리로 모든 섹션 조회 (N+1 방지)\n const results = await cmsPublishedCacheRepository.findBySections(sections, locale);\n\n // Record<section, content> 형태로 변환\n return results.reduce((acc, item) => {\n acc[item.section] = item.content;\n return acc;\n }, {} as Record<string, any>);\n });\n\nexport const cmsAppRouter = defineRouter({\n getLabelCache,\n // Admin routes\n getSectionLabels: getSectionLabelsRoute,\n saveSectionDraft: saveSectionDraftRoute,\n publishSection: publishSectionRoute,\n resetSectionDraft: resetSectionDraftRoute,\n});\n\nexport type AppRouter = typeof cmsAppRouter;\n\n// Re-export admin router for standalone use\nexport { cmsAdminRouter } from './admin.routes';\nexport type { CmsAdminRouter } from './admin.routes';","/**\n * CMS Labels Repository\n *\n * 라벨 메타데이터 관리를 위한 Repository\n * BaseRepository를 상속받아 자동 트랜잭션 컨텍스트 지원 및 Read/Write 분리\n */\n\nimport { BaseRepository } from '@spfn/core/db';\nimport { asc, count as drizzleCount, eq, inArray } from 'drizzle-orm';\nimport { type CmsLabel, cmsLabels, type NewCmsLabel } from '../entities';\n\n/**\n * CMS Labels Repository 클래스\n *\n * BaseRepository를 상속받아 다음 기능을 제공:\n * - 자동 트랜잭션 컨텍스트 감지 및 사용\n * - Read/Write 연결 분리 (replica 활용)\n * - 타입 안전성\n */\nexport class CmsLabelsRepository extends BaseRepository\n{\n /**\n * 라벨 목록 조회\n * Read replica 사용\n */\n async findMany(options?: {\n section?: string;\n }): Promise<CmsLabel[]>\n {\n const { section } = options || {};\n\n let query = this.readDb\n .select()\n .from(cmsLabels)\n .orderBy(asc(cmsLabels.key)); // key 오름차순 정렬 (JSON 파일의 순서 유지)\n\n if (section)\n {\n query = query.where(eq(cmsLabels.section, section)) as typeof query;\n }\n\n return query;\n }\n\n /**\n * 전체 라벨 수 조회\n * Read replica 사용\n */\n async count(section?: string): Promise<number>\n {\n const query = this.readDb\n .select({ count: drizzleCount() })\n .from(cmsLabels);\n\n const result = section\n ? await query.where(eq(cmsLabels.section, section))\n : await query;\n\n return result[0]?.count ?? 0;\n }\n\n /**\n * ID로 라벨 조회\n * Read replica 사용\n */\n async findById(id: number): Promise<CmsLabel | null>\n {\n const result = await this.readDb\n .select()\n .from(cmsLabels)\n .where(eq(cmsLabels.id, id))\n .limit(1);\n\n return result[0] ?? null;\n }\n\n /**\n * Key로 라벨 조회\n * Read replica 사용\n */\n async findByKey(key: string): Promise<CmsLabel | null>\n {\n const result = await this.readDb\n .select()\n .from(cmsLabels)\n .where(eq(cmsLabels.key, key))\n .limit(1);\n\n return result[0] ?? null;\n }\n\n /**\n * 섹션으로 모든 라벨 조회\n * Read replica 사용\n */\n async findBySection(section: string): Promise<CmsLabel[]>\n {\n return this.readDb\n .select()\n .from(cmsLabels)\n .where(eq(cmsLabels.section, section))\n .orderBy(asc(cmsLabels.key)); // key 오름차순 정렬 (JSON 파일의 순서 유지)\n }\n\n /**\n * 라벨 생성\n * Write primary 사용\n */\n async create(data: NewCmsLabel): Promise<CmsLabel>\n {\n const result = await this.db\n .insert(cmsLabels)\n .values(data)\n .returning();\n\n return result[0];\n }\n\n /**\n * 라벨 수정\n * Write primary 사용\n */\n async updateById(id: number, data: Partial<NewCmsLabel>): Promise<CmsLabel | null>\n {\n const result = await this.db\n .update(cmsLabels)\n .set({ ...data, updatedAt: new Date() })\n .where(eq(cmsLabels.id, id))\n .returning();\n\n return result[0] ?? null;\n }\n\n /**\n * 라벨 삭제\n * Write primary 사용\n */\n async deleteById(id: number): Promise<CmsLabel | null>\n {\n const result = await this.db\n .delete(cmsLabels)\n .where(eq(cmsLabels.id, id))\n .returning();\n\n return result[0] ?? null;\n }\n\n /**\n * 여러 key로 라벨 조회\n * Read replica 사용\n */\n async findByKeys(keys: string[]): Promise<CmsLabel[]>\n {\n if (keys.length === 0)\n {\n return [];\n }\n\n return this.readDb\n .select()\n .from(cmsLabels)\n .where(inArray(cmsLabels.key, keys));\n }\n\n /**\n * 여러 라벨 한번에 생성\n * Write primary 사용\n */\n async bulkCreate(data: NewCmsLabel[]): Promise<CmsLabel[]>\n {\n if (data.length === 0)\n {\n return [];\n }\n\n return this.db\n .insert(cmsLabels)\n .values(data)\n .returning();\n }\n\n /**\n * 여러 라벨 한번에 수정 (key 기준)\n * Write primary 사용\n *\n * @param updates - Array of { key, data } objects\n */\n async bulkUpdateByKeys(updates: Array<{ key: string; data: Partial<NewCmsLabel> }>): Promise<void>\n {\n if (updates.length === 0)\n {\n return;\n }\n\n // Drizzle doesn't support bulk update directly, so we need to do it one by one\n // But we can do it in a single transaction context (handled by BaseRepository)\n for (const { key, data } of updates)\n {\n await this.db\n .update(cmsLabels)\n .set({ ...data, updatedAt: new Date() })\n .where(eq(cmsLabels.key, key));\n }\n }\n\n /**\n * 여러 라벨 한번에 삭제 (key 기준)\n * Write primary 사용\n */\n async bulkDeleteByKeys(keys: string[]): Promise<CmsLabel[]>\n {\n if (keys.length === 0)\n {\n return [];\n }\n\n return this.db\n .delete(cmsLabels)\n .where(inArray(cmsLabels.key, keys))\n .returning();\n }\n}\n\n// Default instance export\nexport const cmsLabelsRepository = new CmsLabelsRepository();\n\n","/**\n * CMS Labels Entity\n *\n * 라벨의 메타데이터와 현재 발행 상태를 관리합니다.\n * - 라벨 식별 (id, key)\n * - 섹션 분류 (section)\n * - 타입 정의 (type)\n * - 발행 상태 (publishedVersion)\n */\n\nimport { index, integer, text } from 'drizzle-orm/pg-core';\nimport { id, timestamps, typedJsonb } from '@spfn/core/db';\nimport { cmsSchema } from './cms-schema';\n\nexport const cmsLabels = cmsSchema.table('labels', {\n // Primary Key\n id: id(),\n\n // 라벨 식별자\n key: text('key').notNull().unique(),\n // 예: \"home.hero.title\", \"why-futureplay.hero.subtitle\"\n // 구조: {section}.{component}.{property}\n\n // 섹션 분류 (페이지 단위)\n section: text('section').notNull(),\n // 예: \"home\", \"why-futureplay\", \"team\"\n\n // 값 타입\n type: text('type').notNull(),\n // \"text\" | \"image\" | \"video\" | \"file\" | \"object\"\n\n // 기본값\n defaultValue: typedJsonb<Record<string, any>>('default_value'),\n // 라벨의 기본값 (sync 시 설정)\n // 예: { en: \"Welcome\", ko: \"환영합니다\" } 또는 단일 값\n\n // 설명\n description: text('description'),\n // 라벨에 대한 설명 (optional)\n\n // 현재 발행된 버전 번호\n publishedVersion: integer('published_version'),\n // null = 미발행 상태\n // 1, 2, 3... = 발행된 버전 번호\n\n // 생성자 추적\n createdBy: text('created_by'),\n\n // 타임스탬프\n ...timestamps(),\n}, (table) => [\n // 인덱스: 섹션별 조회 최적화\n index('cms_labels_section_idx').on(table.section),\n\n // 인덱스: key로 조회 최적화 (unique 제약으로 자동 생성되지만 명시)\n index('cms_labels_key_idx').on(table.key),\n]);\n\n// 타입 추론\nexport type CmsLabel = typeof cmsLabels.$inferSelect;\nexport type NewCmsLabel = typeof cmsLabels.$inferInsert;","/**\n * CMS Schema Definition\n *\n * Creates isolated 'spfn_cms' PostgreSQL schema for CMS tables.\n * Export this schema so drizzle-kit can generate CREATE SCHEMA statement.\n */\nimport { createSchema } from '@spfn/core/db';\n\nexport const cmsSchema = createSchema('@spfn/cms');","/**\n * CMS Label Values Entity\n *\n * 라벨의 실제 값을 저장합니다.\n * - 다국어 지원 (locale)\n * - 반응형 지원 (breakpoint)\n * - 버전 관리 (version)\n * - JSONB로 유연한 값 저장\n */\n\nimport { integer, text, index, unique } from 'drizzle-orm/pg-core';\nimport { id, utcTimestamp, typedJsonb, foreignKey } from '@spfn/core/db';\nimport { cmsSchema } from './cms-schema';\nimport { cmsLabels } from './cms-labels';\n\n// Create isolated schema for @spfn/cms\n// Schema imported from cms-schema.ts\n\nexport const cmsLabelValues = cmsSchema.table('label_values', {\n // Primary Key\n id: id(),\n\n // Foreign Key: cms_labels\n labelId: foreignKey('label', () => cmsLabels.id, { onDelete: 'cascade' }),\n\n // 버전 번호 (null = draft, number = published version)\n version: integer('version'),\n\n // 언어 코드\n locale: text('locale').notNull().default('en'),\n // \"ko\" | \"en\" | \"ja\"\n\n // 반응형 브레이크포인트\n breakpoint: text('breakpoint'),\n // null = 기본값 (모든 화면 크기)\n // \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\"\n\n // 실제 값 (JSONB)\n value: typedJsonb<Record<string, any>>('value').notNull(),\n // LabelValue 타입:\n // - TextValue: { type: \"text\", content: string }\n // - ImageValue: { type: \"image\", url: string, alt?: string, width?: number, height?: number }\n // - VideoValue: { type: \"video\", url: string, thumbnail?: string, duration?: number }\n // - FileValue: { type: \"file\", url: string, filename: string, size?: number }\n // - ObjectValue: { type: \"object\", fields: Record<string, LabelValue> }\n\n // 생성 시각\n createdAt: utcTimestamp('created_at').defaultNow().notNull(),\n}, (table) => [\n // UNIQUE 제약: 같은 버전에서 locale + breakpoint 조합은 유일\n unique('cms_label_values_locale_breakpoint_unique')\n .on(table.labelId, table.version, table.locale, table.breakpoint),\n\n // 인덱스: labelId + version 복합 조회 최적화\n index('cms_label_values_label_version_idx')\n .on(table.labelId, table.version),\n\n // 인덱스: locale 필터링 최적화\n index('cms_label_values_locale_idx').on(table.locale),\n]);\n\n// 타입 추론\nexport type CmsLabelValue = typeof cmsLabelValues.$inferSelect;\nexport type NewCmsLabelValue = typeof cmsLabelValues.$inferInsert;\n\n/**\n * 사용 예시:\n *\n * // 텍스트 값 저장\n * await db.insert(cmsLabelValues).values({\n * labelId: 1,\n * version: 1,\n * locale: 'ko',\n * breakpoint: null,\n * value: {\n * type: 'text',\n * content: '미래를 만드는 기업'\n * }\n * });\n *\n * // 반응형 이미지 저장 (모바일용)\n * await db.insert(cmsLabelValues).values({\n * labelId: 2,\n * version: 1,\n * locale: 'ko',\n * breakpoint: 'sm',\n * value: {\n * type: 'image',\n * url: '/uploads/hero-mobile.jpg',\n * alt: 'Hero Image',\n * width: 640,\n * height: 480\n * }\n * });\n *\n * // 특정 버전의 한국어 값 조회\n * const values = await db.select()\n * .from(cmsLabelValues)\n * .where(and(\n * eq(cmsLabelValues.labelId, 1),\n * eq(cmsLabelValues.version, 2),\n * eq(cmsLabelValues.locale, 'ko')\n * ));\n *\n * // Object 타입 값 저장 (재귀 구조)\n * await db.insert(cmsLabelValues).values({\n * labelId: 3,\n * version: 1,\n * locale: 'ko',\n * value: {\n * type: 'object',\n * fields: {\n * title: { type: 'text', content: '특징 1' },\n * icon: { type: 'image', url: '/icons/feature1.svg', alt: 'Icon' },\n * description: { type: 'text', content: '상세 설명...' }\n * }\n * }\n * });\n */","/**\n * CMS Published Cache Entity\n *\n * 발행된 콘텐츠를 섹션+언어 단위로 캐싱합니다.\n * - 초고속 읽기 성능 (5ms)\n * - 단일 쿼리로 섹션 전체 로드\n * - JSONB로 즉시 사용 가능한 데이터\n *\n * 성능 비교:\n * - 정규화 테이블 JOIN: 87ms\n * - 캐시 테이블: 5ms (17배 빠름!)\n */\n\nimport { text, integer, index, unique } from 'drizzle-orm/pg-core';\nimport { id, publishingFields, typedJsonb } from \"@spfn/core/db\";\nimport { cmsSchema } from './cms-schema';\n\n// Create isolated schema for @spfn/cms\n// Schema imported from cms-schema.ts\nexport const cmsPublishedCache = cmsSchema.table('published_cache', {\n // Primary Key\n id: id(),\n\n // 섹션 (페이지 단위)\n section: text('section').notNull(),\n // \"home\" | \"why-futureplay\" | \"team\" | \"our-companies\" | \"apply\"\n\n // 언어\n locale: text('locale').notNull(),\n // \"ko\" | \"en\" | \"ja\"\n\n // 캐시된 콘텐츠 (JSONB)\n content: typedJsonb<Record<string, any>>('content').notNull(),\n // Record<string, LabelValue>\n // {\n // \"home.hero.title\": { type: \"text\", content: \"...\" },\n // \"home.hero.image\": { type: \"image\", url: \"...\", alt: \"...\" },\n // ...\n // }\n\n // 발행 정보\n ...publishingFields(),\n\n // 캐시 버전 (클라이언트 캐싱용)\n version: integer('version').notNull().default(1),\n}, (table) => [\n // UNIQUE 제약: section + locale 조합은 유일\n unique('cms_published_cache_unique').on(table.section, table.locale),\n\n // 인덱스: section으로 조회 최적화\n index('cms_published_cache_section_idx').on(table.section),\n]);\n\n// 타입 추론\nexport type CmsPublishedCache = typeof cmsPublishedCache.$inferSelect;\nexport type NewCmsPublishedCache = typeof cmsPublishedCache.$inferInsert;\n\n/**\n * 사용 예시:\n *\n * // 캐시 생성/업데이트 (UPSERT)\n * await db.insert(cmsPublishedCache)\n * .values({\n * section: 'home',\n * locale: 'ko',\n * content: {\n * 'home.hero.title': {\n * type: 'text',\n * content: '미래를 만드는 기업'\n * },\n * 'home.hero.image': {\n * type: 'image',\n * url: '/uploads/hero.jpg',\n * alt: 'Hero',\n * width: 1920,\n * height: 1080\n * }\n * },\n * publishedAt: new Date(),\n * publishedBy: 'admin@futureplay.com'\n * })\n * .onConflictDoUpdate({\n * target: [cmsPublishedCache.section, cmsPublishedCache.locale],\n * set: {\n * content: sql`EXCLUDED.content`,\n * publishedAt: sql`EXCLUDED.published_at`,\n * publishedBy: sql`EXCLUDED.published_by`,\n * version: sql`${cmsPublishedCache.version} + 1`\n * }\n * });\n *\n * // 캐시 조회 (초고속!)\n * const cache = await db.select()\n * .from(cmsPublishedCache)\n * .where(and(\n * eq(cmsPublishedCache.section, 'home'),\n * eq(cmsPublishedCache.locale, 'ko')\n * ))\n * .limit(1);\n *\n * const labels = cache[0].content; // 즉시 사용 가능!\n *\n * // 섹션의 모든 언어 캐시 조회\n * const allLocales = await db.select()\n * .from(cmsPublishedCache)\n * .where(eq(cmsPublishedCache.section, 'home'));\n *\n * // 오래된 캐시 감지\n * const stale = await db.select()\n * .from(cmsPublishedCache)\n * .where(lt(\n * cmsPublishedCache.publishedAt,\n * new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)\n * ));\n */","/**\n * CMS Label Values Repository\n *\n * 라벨 값 관리를 위한 Repository\n * BaseRepository를 상속받아 자동 트랜잭션 컨텍스트 지원 및 Read/Write 분리\n */\n\nimport { BaseRepository } from '@spfn/core/db';\nimport { eq, and, SQL, isNull, gte, lte, inArray } from 'drizzle-orm';\nimport { cmsLabelValues, type CmsLabelValue, type NewCmsLabelValue } from '../entities';\n\n/**\n * 버전 히스토리 타입\n */\nexport interface VersionHistory\n{\n version: number;\n publishedAt: string;\n publishedBy: null;\n notes: null;\n values: Array<{\n id: number;\n locale: string;\n breakpoint: string | null;\n value: any;\n createdAt: string;\n }>;\n}\n\n/**\n * CMS Label Values Repository 클래스\n */\nexport class CmsLabelValuesRepository extends BaseRepository\n{\n /**\n * 특정 라벨의 특정 버전 값들 조회\n * Read replica 사용\n */\n async findByLabelIdAndVersion(\n labelId: number,\n version: number,\n options?: {\n locale?: string;\n breakpoint?: string | null;\n }\n ): Promise<CmsLabelValue[]>\n {\n const { locale, breakpoint } = options || {};\n\n const conditions: SQL[] = [\n eq(cmsLabelValues.labelId, labelId),\n eq(cmsLabelValues.version, version)\n ];\n\n if (locale)\n {\n conditions.push(eq(cmsLabelValues.locale, locale));\n }\n\n if (breakpoint !== undefined)\n {\n conditions.push(\n breakpoint === null\n ? isNull(cmsLabelValues.breakpoint)\n : eq(cmsLabelValues.breakpoint, breakpoint)\n );\n }\n\n return this.readDb\n .select()\n .from(cmsLabelValues)\n .where(and(...conditions));\n }\n\n /**\n * 값 저장 (upsert)\n * - version: null → Draft 저장 (덮어쓰기)\n * - version: number → Published 버전 생성 (불변)\n * Write primary 사용\n */\n async upsert(data: NewCmsLabelValue & { labelId: number }): Promise<CmsLabelValue>\n {\n // 기존 값이 있는지 확인\n const versionCondition = data.version === null || data.version === undefined\n ? isNull(cmsLabelValues.version)\n : eq(cmsLabelValues.version, data.version as number);\n\n const existingResult = await this.db\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, data.labelId),\n versionCondition,\n eq(cmsLabelValues.locale, data.locale || 'ko'),\n data.breakpoint\n ? eq(cmsLabelValues.breakpoint, data.breakpoint)\n : isNull(cmsLabelValues.breakpoint)\n )\n )\n .limit(1);\n\n const existing = existingResult[0];\n\n if (existing)\n {\n // UPDATE (only for drafts with version: null)\n if (data.version === null || data.version === undefined)\n {\n const updated = await this.db\n .update(cmsLabelValues)\n .set({ value: data.value })\n .where(eq(cmsLabelValues.id, existing.id))\n .returning();\n\n return updated[0];\n }\n else\n {\n // Published versions are immutable - this shouldn't happen\n throw new Error(`Published version ${data.version} already exists and cannot be overwritten`);\n }\n }\n else\n {\n // INSERT (both draft and new published versions)\n const inserted = await this.db\n .insert(cmsLabelValues)\n .values(data)\n .returning();\n\n return inserted[0];\n }\n }\n\n /**\n * Draft 값들 조회 (version = null)\n * Read replica 사용\n */\n async findDraftsByLabelId(labelId: number): Promise<CmsLabelValue[]>\n {\n return this.readDb\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, labelId),\n isNull(cmsLabelValues.version)\n )\n );\n }\n\n /**\n * 여러 값 일괄 저장\n * Write primary 사용\n */\n async upsertMany(values: (NewCmsLabelValue & { labelId: number })[]): Promise<CmsLabelValue[]>\n {\n const results = [];\n for (const value of values)\n {\n const result = await this.upsert(value);\n results.push(result);\n }\n return results;\n }\n\n /**\n * 특정 버전의 모든 값 삭제\n * Write primary 사용\n */\n async deleteByVersion(labelId: number, version: number | null): Promise<CmsLabelValue[]>\n {\n const versionCondition = version === null\n ? isNull(cmsLabelValues.version)\n : eq(cmsLabelValues.version, version);\n\n return this.db\n .delete(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, labelId),\n versionCondition\n )\n )\n .returning();\n }\n\n /**\n * 여러 라벨의 publishedVersion 값들을 한 번에 조회 (N+1 문제 해결)\n * Read replica 사용\n *\n * @param labelVersions - { labelId, version } 배열\n * @returns labelId를 키로 하는 Map<labelId, CmsLabelValue[]>\n *\n * @example\n * ```typescript\n * const result = await findByLabelVersions([\n * { labelId: 1, version: 5 },\n * { labelId: 2, version: 3 }\n * ]);\n * // result.get(1) -> label 1의 version 5 값들\n * // result.get(2) -> label 2의 version 3 값들\n * ```\n */\n async findByLabelVersions(\n labelVersions: Array<{ labelId: number; version: number }>\n ): Promise<Map<number, CmsLabelValue[]>>\n {\n if (labelVersions.length === 0)\n {\n return new Map();\n }\n\n // 모든 label의 publishedVersion 값들을 한 번에 조회\n const allValues = await this.readDb\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n inArray(\n cmsLabelValues.labelId,\n labelVersions.map(lv => lv.labelId)\n )\n )\n );\n\n // labelId와 version으로 필터링하여 Map 생성\n const versionMap = new Map(labelVersions.map(lv => [lv.labelId, lv.version]));\n const resultMap = new Map<number, CmsLabelValue[]>();\n\n for (const value of allValues)\n {\n const expectedVersion = versionMap.get(value.labelId);\n\n // 해당 labelId의 version이 일치하는 경우만 포함\n if (expectedVersion !== undefined && value.version === expectedVersion)\n {\n if (!resultMap.has(value.labelId))\n {\n resultMap.set(value.labelId, []);\n }\n resultMap.get(value.labelId)!.push(value);\n }\n }\n\n return resultMap;\n }\n\n /**\n * 라벨의 버전 히스토리 조회 (1 ~ maxVersion)\n * 한 번의 쿼리로 모든 버전을 조회하고 version별로 그룹화\n * Read replica 사용\n */\n async findVersionHistoryByLabelId(\n labelId: number,\n maxVersion: number\n ): Promise<VersionHistory[]>\n {\n // 모든 버전의 값을 한 번에 조회\n const allValues = await this.readDb\n .select()\n .from(cmsLabelValues)\n .where(\n and(\n eq(cmsLabelValues.labelId, labelId),\n gte(cmsLabelValues.version, 1),\n lte(cmsLabelValues.version, maxVersion)\n )\n )\n .orderBy(cmsLabelValues.version, cmsLabelValues.locale);\n\n // version별로 그룹화\n const versionMap = new Map<number, CmsLabelValue[]>();\n\n for (const value of allValues)\n {\n if (value.version === null) continue; // null 버전은 제외\n\n if (!versionMap.has(value.version))\n {\n versionMap.set(value.version, []);\n }\n versionMap.get(value.version)!.push(value);\n }\n\n // VersionHistory 형식으로 변환\n const versions: VersionHistory[] = [];\n\n for (let version = 1; version <= maxVersion; version++)\n {\n const values = versionMap.get(version);\n\n if (values && values.length > 0)\n {\n versions.push({\n version,\n publishedAt: values[0].createdAt.toISOString(),\n publishedBy: null, // label_values에는 publishedBy 정보가 없음\n notes: null, // label_values에는 notes 정보가 없음\n values: values.map(v => ({\n id: v.id,\n locale: v.locale,\n breakpoint: v.breakpoint,\n value: v.value,\n createdAt: v.createdAt.toISOString()\n }))\n });\n }\n }\n\n // 버전 내림차순 정렬 (최신 버전이 먼저)\n versions.sort((a, b) => b.version - a.version);\n\n return versions;\n }\n}\n\n// Default instance export\nexport const cmsLabelValuesRepository = new CmsLabelValuesRepository();","/**\n * CMS Published Cache Repository\n *\n * 발행된 콘텐츠 캐시 관리 (초고속 조회)\n * BaseRepository를 상속받아 자동 트랜잭션 컨텍스트 지원 및 Read/Write 분리\n */\n\nimport { BaseRepository } from '@spfn/core/db';\nimport { eq, and, sql, inArray } from 'drizzle-orm';\nimport { cmsPublishedCache, type CmsPublishedCache, type NewCmsPublishedCache } from '../entities';\n\n/**\n * CMS Published Cache Repository 클래스\n */\nexport class CmsPublishedCacheRepository extends BaseRepository\n{\n /**\n * 섹션 + 언어로 발행된 캐시 조회\n * Read replica 사용\n */\n async findBySection(section: string, locale: string = 'en'): Promise<CmsPublishedCache | null>\n {\n const result = await this.readDb\n .select()\n .from(cmsPublishedCache)\n .where(\n and(\n eq(cmsPublishedCache.section, section),\n eq(cmsPublishedCache.locale, locale)\n )\n )\n .limit(1);\n\n return result[0] ?? null;\n }\n\n /**\n * 캐시 생성 또는 업데이트 (UPSERT)\n * Write primary 사용\n */\n async upsert(data: NewCmsPublishedCache): Promise<CmsPublishedCache>\n {\n const result = await this.db\n .insert(cmsPublishedCache)\n .values(data)\n .onConflictDoUpdate({\n target: [cmsPublishedCache.section, cmsPublishedCache.locale],\n set: {\n content: data.content,\n publishedAt: data.publishedAt,\n publishedBy: data.publishedBy,\n version: sql`${cmsPublishedCache.version} + 1`, // 버전 증가로 클라이언트 캐시 무효화\n }\n })\n .returning();\n\n return result[0];\n }\n\n /**\n * 여러 섹션의 캐시를 한 번에 조회 (N+1 방지)\n * Read replica 사용\n */\n async findBySections(sections: string[], locale: string = 'en'): Promise<CmsPublishedCache[]>\n {\n if (sections.length === 0)\n {\n return [];\n }\n\n return this.readDb\n .select()\n .from(cmsPublishedCache)\n .where(\n and(\n inArray(cmsPublishedCache.section, sections),\n eq(cmsPublishedCache.locale, locale)\n )\n );\n }\n\n /**\n * 섹션별 모든 언어 캐시 조회\n * Read replica 사용\n */\n async findAllLanguages(section: string): Promise<CmsPublishedCache[]>\n {\n return this.readDb\n .select()\n .from(cmsPublishedCache)\n .where(eq(cmsPublishedCache.section, section));\n }\n\n /**\n * 캐시 삭제\n * Write primary 사용\n */\n async deleteBySection(section: string, locale?: string): Promise<void>\n {\n if (locale)\n {\n await this.db\n .delete(cmsPublishedCache)\n .where(\n and(\n eq(cmsPublishedCache.section, section),\n eq(cmsPublishedCache.locale, locale)\n )\n );\n }\n else\n {\n await this.db\n .delete(cmsPublishedCache)\n .where(eq(cmsPublishedCache.section, section));\n }\n }\n}\n\n// Default instance export\nexport const cmsPublishedCacheRepository = new CmsPublishedCacheRepository();","/**\n * CMS Admin Routes\n *\n * 관리자용 라벨 관리 API\n */\n\nimport { Type } from '@sinclair/typebox';\nimport { defineRouter, route } from '@spfn/core/route';\nimport {\n getSectionLabels,\n saveSectionDraft,\n publishSection,\n resetSectionDraft,\n} from '../services/publish.service';\n\n/**\n * 섹션의 모든 라벨 조회 (테이블 뷰용)\n *\n * GET /_cms/admin/sections/:section/labels?locales=en,ko\n */\nexport const getSectionLabelsRoute = route.get('/_cms/admin/sections/:section/labels')\n .input({\n params: Type.Object({\n section: Type.String(),\n }),\n query: Type.Object({\n locales: Type.Optional(Type.String()), // comma-separated: \"en,ko\"\n }),\n })\n .handler(async (c) =>\n {\n const { params, query } = await c.data();\n const { section } = params;\n const locales = query.locales?.split(',') || ['en'];\n\n return getSectionLabels(section, locales);\n });\n\n/**\n * 섹션 라벨 일괄 Draft 저장\n *\n * PUT /_cms/admin/sections/:section/draft\n */\nexport const saveSectionDraftRoute = route.put('/_cms/admin/sections/:section/draft')\n .input({\n params: Type.Object({\n section: Type.String(),\n }),\n body: Type.Object({\n labels: Type.Array(\n Type.Object({\n id: Type.Number(),\n values: Type.Record(Type.String(), Type.String()),\n })\n ),\n }),\n })\n .handler(async (c) =>\n {\n const { params, body } = await c.data();\n const { section } = params;\n const { labels } = body;\n\n const result = await saveSectionDraft(section, labels);\n\n return { success: true, ...result };\n });\n\n/**\n * 섹션 전체 발행\n *\n * POST /_cms/admin/sections/:section/publish\n */\nexport const publishSectionRoute = route.post('/_cms/admin/sections/:section/publish')\n .input({\n params: Type.Object({\n section: Type.String(),\n }),\n body: Type.Object({\n locales: Type.Array(Type.String()),\n }),\n })\n .handler(async (c) =>\n {\n const { params, body } = await c.data();\n const { section } = params;\n const { locales } = body;\n\n const result = await publishSection(section, locales);\n\n return { success: true, ...result };\n });\n\n/**\n * 섹션 Draft 초기화\n *\n * DELETE /_cms/admin/sections/:section/draft\n */\nexport const resetSectionDraftRoute = route.delete('/_cms/admin/sections/:section/draft')\n .input({\n params: Type.Object({\n section: Type.String(),\n }),\n })\n .handler(async (c) =>\n {\n const { params } = await c.data();\n const { section } = params;\n\n const result = await resetSectionDraft(section);\n\n return { success: true, ...result };\n });\n\n/**\n * Admin Router\n */\nexport const cmsAdminRouter = defineRouter({\n getSectionLabels: getSectionLabelsRoute,\n saveSectionDraft: saveSectionDraftRoute,\n publishSection: publishSectionRoute,\n resetSectionDraft: resetSectionDraftRoute,\n});\n\nexport type CmsAdminRouter = typeof cmsAdminRouter;\n","/**\n * CMS Publish Service\n *\n * 라벨 Draft 저장 및 발행 관리\n */\n\nimport { logger } from '@spfn/core/logger';\nimport {\n cmsLabelsRepository,\n cmsLabelValuesRepository,\n cmsPublishedCacheRepository\n} from '../repositories';\nimport { type CmsLabelValue } from '../entities';\n\nconst publishLogger = logger.child('@spfn/cms:publish');\n\n/**\n * 섹션의 모든 라벨 조회 (Admin 테이블 뷰용)\n */\nexport async function getSectionLabels(\n section: string,\n locales: string[]\n): Promise<{\n section: string;\n locales: string[];\n labels: Array<{\n id: number;\n key: string;\n defaultValue: Record<string, string>;\n draft: Record<string, string> | null;\n published: Record<string, string> | null;\n hasDraft: boolean;\n }>;\n}>\n{\n publishLogger.debug('getSectionLabels', { section, locales });\n\n // 1. 섹션의 모든 라벨 메타데이터 조회\n const labels = await cmsLabelsRepository.findBySection(section);\n\n if (labels.length === 0)\n {\n return { section, locales, labels: [] };\n }\n\n // 2. 각 라벨의 Draft 값 조회 (version: null)\n const labelIds = labels.map(l => l.id);\n const draftValues = await Promise.all(\n labelIds.map(id => cmsLabelValuesRepository.findDraftsByLabelId(id))\n );\n\n // 3. 각 라벨의 Published 값 조회\n const labelVersions = labels\n .filter(l => l.publishedVersion !== null)\n .map(l => ({ labelId: l.id, version: l.publishedVersion! }));\n\n const publishedValuesMap = labelVersions.length > 0\n ? await cmsLabelValuesRepository.findByLabelVersions(labelVersions)\n : new Map();\n\n // 4. 결과 조합\n const result = labels.map((label, index) =>\n {\n const drafts = draftValues[index];\n const published = publishedValuesMap.get(label.id) || [];\n\n // Draft를 locale별 Record로 변환\n const draftRecord: Record<string, string> | null = drafts.length > 0\n ? drafts.reduce((acc, d) =>\n {\n const value = d.value as any;\n acc[d.locale] = value?.content ?? value;\n return acc;\n }, {} as Record<string, string>)\n : null;\n\n // Published를 locale별 Record로 변환\n let publishedRecord: Record<string, string> | null = null;\n if (published.length > 0)\n {\n publishedRecord = {};\n for (const p of published as CmsLabelValue[])\n {\n const value = p.value as any;\n publishedRecord[p.locale] = value?.content ?? value;\n }\n }\n\n return {\n id: label.id,\n key: label.key,\n defaultValue: (label.defaultValue as Record<string, string>) || {},\n draft: draftRecord,\n published: publishedRecord,\n hasDraft: drafts.length > 0,\n };\n });\n\n return { section, locales, labels: result };\n}\n\n/**\n * 섹션 라벨 일괄 Draft 저장\n */\nexport async function saveSectionDraft(\n section: string,\n labels: Array<{\n id: number;\n values: Record<string, string>;\n }>\n): Promise<{ updated: number }>\n{\n publishLogger.debug('saveSectionDraft', { section, labelCount: labels.length });\n\n let updated = 0;\n\n for (const { id, values } of labels)\n {\n // 각 locale별로 Draft 저장 (version: null)\n for (const [locale, value] of Object.entries(values))\n {\n await cmsLabelValuesRepository.upsert({\n labelId: id,\n version: null,\n locale,\n value: { type: 'text', content: value },\n });\n updated++;\n }\n }\n\n publishLogger.info('Draft saved', { section, updated });\n\n return { updated };\n}\n\n/**\n * 섹션 전체 발행\n *\n * 1. Draft가 있는 라벨들의 Draft 값을 새 버전으로 복사\n * 2. cms_labels.publishedVersion 업데이트\n * 3. cms_published_cache 갱신\n */\nexport async function publishSection(\n section: string,\n locales: string[]\n): Promise<{\n published: number;\n version: number;\n labels: string[];\n}>\n{\n publishLogger.debug('publishSection', { section, locales });\n\n // 1. 섹션의 모든 라벨 조회\n const labels = await cmsLabelsRepository.findBySection(section);\n\n if (labels.length === 0)\n {\n return { published: 0, version: 0, labels: [] };\n }\n\n const publishedLabels: string[] = [];\n let maxVersion = 0;\n\n for (const label of labels)\n {\n // 2. Draft 값 조회\n const drafts = await cmsLabelValuesRepository.findDraftsByLabelId(label.id);\n\n if (drafts.length === 0)\n {\n continue;\n }\n\n // 3. 새 버전 번호 생성\n const newVersion = (label.publishedVersion || 0) + 1;\n maxVersion = Math.max(maxVersion, newVersion);\n\n // 4. Draft 값을 새 버전으로 복사\n for (const draft of drafts)\n {\n await cmsLabelValuesRepository.upsert({\n labelId: label.id,\n version: newVersion,\n locale: draft.locale,\n breakpoint: draft.breakpoint,\n value: draft.value,\n });\n }\n\n // 4.5. Draft 삭제 (version: null)\n await cmsLabelValuesRepository.deleteByVersion(label.id, null);\n\n // 5. cms_labels.publishedVersion 업데이트\n await cmsLabelsRepository.updateById(label.id, {\n publishedVersion: newVersion,\n });\n\n publishedLabels.push(label.key);\n }\n\n // 6. cms_published_cache 갱신\n if (publishedLabels.length > 0)\n {\n await rebuildSectionCache(section, locales);\n }\n\n publishLogger.info('Section published', {\n section,\n published: publishedLabels.length,\n version: maxVersion,\n });\n\n return {\n published: publishedLabels.length,\n version: maxVersion,\n labels: publishedLabels,\n };\n}\n\n/**\n * 섹션 Draft 초기화 (삭제)\n */\nexport async function resetSectionDraft(section: string): Promise<{ reset: number }>\n{\n publishLogger.debug('resetSectionDraft', { section });\n\n // 1. 섹션의 모든 라벨 조회\n const labels = await cmsLabelsRepository.findBySection(section);\n\n let reset = 0;\n\n for (const label of labels)\n {\n // 2. Draft 값 삭제 (version: null)\n const drafts = await cmsLabelValuesRepository.findDraftsByLabelId(label.id);\n\n if (drafts.length > 0)\n {\n await cmsLabelValuesRepository.deleteByVersion(label.id, null as any);\n reset += drafts.length;\n }\n }\n\n publishLogger.info('Draft reset', { section, reset });\n\n return { reset };\n}\n\n/**\n * 섹션의 Published Cache 재구축\n */\nasync function rebuildSectionCache(section: string, locales: string[]): Promise<void>\n{\n publishLogger.debug('rebuildSectionCache', { section, locales });\n\n // 1. 섹션의 모든 라벨 조회\n const labels = await cmsLabelsRepository.findBySection(section);\n\n // 2. Published 값이 있는 라벨들 조회\n const labelVersions = labels\n .filter(l => l.publishedVersion !== null)\n .map(l => ({ labelId: l.id, version: l.publishedVersion! }));\n\n if (labelVersions.length === 0)\n {\n return;\n }\n\n const publishedValuesMap = await cmsLabelValuesRepository.findByLabelVersions(labelVersions);\n\n // 3. locale별로 cache 구축\n for (const locale of locales)\n {\n const content: Record<string, any> = {};\n\n for (const label of labels)\n {\n const values = publishedValuesMap.get(label.id) || [];\n const localeValue = values.find(v => v.locale === locale);\n\n if (localeValue)\n {\n // key에서 section prefix 제거하여 저장\n // 예: \"home.hero.title\" → content[\"home.hero.title\"] = value\n content[label.key] = localeValue.value;\n }\n }\n\n // 4. cache upsert\n await cmsPublishedCacheRepository.upsert({\n section,\n locale,\n content,\n publishedAt: new Date(),\n publishedBy: 'system',\n });\n }\n\n publishLogger.debug('Cache rebuilt', { section, locales });\n}\n","/**\n * CMS Label Synchronization Service\n *\n * Synchronizes labels defined in code with database\n */\n\nimport { isEqual } from 'lodash-es';\nimport { type SyncOptions, type SyncResult } from '../../lib/types';\nimport { type FlatLabel, flattenLabels } from '../../lib/helpers';\nimport { cmsLabelsRepository } from '../repositories';\nimport { type NewCmsLabel } from '../entities';\n\n/**\n * Compare current DB labels with new labels\n *\n * @param dbLabels - Labels currently in database\n * @param codeLabels - Labels from code (flattened)\n * @returns Comparison result with added/removed/updated labels\n */\nfunction compareLabels(dbLabels: FlatLabel, codeLabels: FlatLabel): SyncResult\n{\n const added: string[] = [];\n const removed: string[] = [];\n const updated: string[] = [];\n const unchanged: string[] = [];\n\n const dbKeys = Object.keys(dbLabels);\n const codeKeys = Object.keys(codeLabels);\n\n // Check for added and updated labels\n for (const key of codeKeys)\n {\n if (!(key in dbLabels))\n {\n // New label\n added.push(key);\n }\n else\n {\n // Check if values changed (deep equality check for nested objects)\n const dbValue = dbLabels[key];\n const codeValue = codeLabels[key];\n\n if (!isEqual(dbValue, codeValue))\n {\n updated.push(key);\n }\n else\n {\n unchanged.push(key);\n }\n }\n }\n\n // Check for removed labels\n for (const key of dbKeys)\n {\n if (!(key in codeLabels))\n {\n removed.push(key);\n }\n }\n\n return {\n added,\n removed,\n updated,\n unchanged,\n };\n}\n\n/**\n * Sync labels with database\n *\n * @param labels - Single label definition or array of label definitions\n * @param options - Sync options\n * @returns Sync result\n *\n * @example\n * ```typescript\n * // Single definition\n * await syncLabels(labelsDefinition);\n *\n * // Multiple definitions\n * await syncLabels([homeLabels, aboutLabels, commonLabels]);\n * ```\n */\nexport async function syncLabels<T extends Record<string, any>>(\n labels: T | T[],\n options?: SyncOptions\n): Promise<SyncResult>\n{\n const { removeOrphaned = false, dryRun = false } = options || {};\n\n // 1. Merge multiple label definitions into one (if array provided)\n const mergedLabels = Array.isArray(labels)\n ? Object.assign({}, ...labels)\n : labels;\n\n // 2. Flatten code labels\n const codeLabels = flattenLabels(mergedLabels);\n\n // 3. Fetch current labels from DB\n const dbLabels = await cmsLabelsRepository.findMany();\n const dbLabelMap: FlatLabel = {};\n\n for (const label of dbLabels)\n {\n if (label.defaultValue)\n {\n dbLabelMap[label.key] = label.defaultValue as Record<string, string>;\n }\n }\n\n // 4. Compare changes\n const result = compareLabels(dbLabelMap, codeLabels);\n\n // 5. Return result if dry run\n if (dryRun)\n {\n return result;\n }\n\n // 6. Create new labels\n if (result.added.length > 0)\n {\n const toCreate: NewCmsLabel[] = result.added.map(key => ({\n key,\n section: extractSection(key),\n type: 'text',\n defaultValue: codeLabels[key],\n }));\n\n await cmsLabelsRepository.bulkCreate(toCreate);\n }\n\n // 7. Update changed labels\n if (result.updated.length > 0)\n {\n const updates = result.updated.map(key => ({\n key,\n data: {\n defaultValue: codeLabels[key],\n },\n }));\n\n await cmsLabelsRepository.bulkUpdateByKeys(updates);\n }\n\n // 8. Remove deleted labels (only if option is true)\n if (removeOrphaned && result.removed.length > 0)\n {\n await cmsLabelsRepository.bulkDeleteByKeys(result.removed);\n }\n\n return result;\n}\n\n/**\n * Extract section from key\n * Example: \"home.hero.title\" -> \"home\"\n */\nfunction extractSection(key: string): string\n{\n const parts = key.split('.');\n return parts[0] || key;\n}","/**\n * CMS Helper Functions\n */\n\nexport type FlatLabel = Record<string, Record<string, string>>;\n\n/**\n * Flatten nested label structure into dot notation\n *\n * @param labels - Nested label object\n * @param prefix - Key prefix for recursion\n * @returns Flattened label structure\n *\n * @example\n * ```typescript\n * const nested = {\n * home: {\n * hero: {\n * title: { en: \"Welcome\", ko: \"환영합니다\" }\n * }\n * }\n * };\n *\n * const flat = flattenLabels(nested);\n * // { \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" } }\n * ```\n */\nexport function flattenLabels<T extends Record<string, any>>(labels: T, prefix = ''): FlatLabel\n{\n const result: FlatLabel = {};\n\n if (!labels || typeof labels !== 'object')\n {\n return result;\n }\n\n const obj = labels as Record<string, unknown>;\n\n for (const [key, value] of Object.entries(obj))\n {\n const newKey = prefix ? `${prefix}.${key}` : key;\n\n if (!value || typeof value !== 'object')\n {\n continue;\n }\n\n const valueObj = value as Record<string, unknown>;\n\n // Check if this is a leaf node (locale values: { en: \"...\", ko: \"...\" })\n const isLeaf = Object.values(valueObj).every(v => typeof v === 'string');\n\n if (isLeaf)\n {\n result[newKey] = valueObj as Record<string, string>;\n }\n else\n {\n // Recursively flatten nested structure\n Object.assign(result, flattenLabels(value, newKey));\n }\n }\n\n return result;\n}\n\n/**\n * Set a value in nested object using dot notation path\n *\n * @param target - Target object to modify\n * @param path - Dot notation path (e.g., \"home.hero.title\")\n * @param value - Value to set\n *\n * @example\n * ```typescript\n * const obj = {};\n * setNestedValue(obj, \"home.hero.title\", \"Welcome\");\n * // obj = { home: { hero: { title: \"Welcome\" } } }\n * ```\n */\nexport function setNestedValue(target: any, path: string, value: any): void\n{\n const parts = path.split('.');\n let current = target;\n\n for (let i = 0; i < parts.length - 1; i++)\n {\n const part = parts[i];\n if (!current[part])\n {\n current[part] = {};\n }\n current = current[part];\n }\n\n // Set the leaf value\n const lastPart = parts[parts.length - 1];\n current[lastPart] = value;\n}\n\n/**\n * Unflatten dot notation keys back to nested structure\n *\n * @param flat - Flattened label object\n * @returns Nested label structure\n *\n * @example\n * ```typescript\n * const flat = {\n * \"home.hero.title\": { en: \"Welcome\", ko: \"환영합니다\" },\n * \"home.hero.subtitle\": { en: \"Subtitle\", ko: \"부제목\" }\n * };\n *\n * const nested = unflattenLabels(flat);\n * // {\n * // home: {\n * // hero: {\n * // title: { en: \"Welcome\", ko: \"환영합니다\" },\n * // subtitle: { en: \"Subtitle\", ko: \"부제목\" }\n * // }\n * // }\n * // }\n * ```\n */\nexport function unflattenLabels(flat: FlatLabel): Record<string, any>\n{\n const result: Record<string, any> = {};\n\n for (const [key, value] of Object.entries(flat))\n {\n setNestedValue(result, key, value);\n }\n\n return result;\n}"],"mappings":"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","names":["Type","defineRouter","route","integer","text","index","id","typedJsonb","id","integer","text","typedJsonb","index","text","integer","index","unique","id","typedJsonb","id","text","typedJsonb","integer","unique","index","id","BaseRepository","eq","inArray","BaseRepository","eq","inArray","BaseRepository","eq","and","inArray","BaseRepository","and","eq","inArray","id","index","route","Type","defineRouter"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spfn/cms",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.7",
|
|
4
4
|
"description": "SPFN CMS - Content Management System with type-safe labels and Next.js integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"jiti": "^2.6.1",
|
|
76
76
|
"lodash-es": "^4.17.21",
|
|
77
77
|
"zustand": "^5.0.8",
|
|
78
|
-
"@spfn/core": "0.2.0-beta.
|
|
78
|
+
"@spfn/core": "0.2.0-beta.6"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
81
|
"@types/lodash-es": "^4.17.12",
|
|
@@ -84,14 +84,14 @@
|
|
|
84
84
|
"@vitest/coverage-v8": "^4.0.6",
|
|
85
85
|
"drizzle-kit": "^0.31.6",
|
|
86
86
|
"drizzle-typebox": "^0.1.0",
|
|
87
|
-
"glob": "
|
|
87
|
+
"glob": "11.1.0",
|
|
88
88
|
"madge": "^8.0.0",
|
|
89
89
|
"postgres": "^3.4.0",
|
|
90
90
|
"tsup": "^8.5.0",
|
|
91
91
|
"tsx": "^4.20.6",
|
|
92
92
|
"typescript": "^5.3.3",
|
|
93
93
|
"vitest": "^4.0.6",
|
|
94
|
-
"spfn": "0.2.0-beta.
|
|
94
|
+
"spfn": "0.2.0-beta.5"
|
|
95
95
|
},
|
|
96
96
|
"scripts": {
|
|
97
97
|
"build": "pnpm check:circular && tsup",
|