sonamu 0.8.13 → 0.8.14
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/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +2 -3
- package/dist/auth/auth-generator.d.ts +8 -0
- package/dist/auth/auth-generator.d.ts.map +1 -1
- package/dist/auth/auth-generator.js +33 -1
- package/dist/auth/better-auth-entities.d.ts.map +1 -1
- package/dist/auth/better-auth-entities.js +12 -2
- package/dist/bin/cli.js +18 -3
- package/dist/cone/cone-generator.js +10 -4
- package/dist/database/knex.d.ts.map +1 -1
- package/dist/database/knex.js +64 -2
- package/dist/database/puri.d.ts +9 -1
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +42 -1
- package/dist/database/puri.types.d.ts +2 -0
- package/dist/database/puri.types.d.ts.map +1 -1
- package/dist/database/puri.types.js +6 -2
- package/dist/entity/entity-manager.d.ts +149 -1
- package/dist/entity/entity-manager.d.ts.map +1 -1
- package/dist/entity/entity-manager.js +68 -4
- package/dist/migration/__tests__/code-generation.search-text.test.js +435 -0
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +696 -32
- package/dist/migration/migration-set.js +3 -1
- package/dist/migration/postgresql-schema-reader.d.ts +16 -2
- package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
- package/dist/migration/postgresql-schema-reader.js +281 -7
- package/dist/stream/sse.js +5 -3
- package/dist/template/__tests__/generated.template.search-text.test.js +99 -0
- package/dist/template/generated.template.test-d.js +24 -0
- package/dist/template/implementations/generated.template.d.ts.map +1 -1
- package/dist/template/implementations/generated.template.js +2 -2
- package/dist/template/implementations/init_types.template.d.ts.map +1 -1
- package/dist/template/implementations/init_types.template.js +11 -3
- package/dist/template/zod-converter.d.ts.map +1 -1
- package/dist/template/zod-converter.js +6 -2
- package/dist/testing/dev-test-routes.d.ts.map +1 -1
- package/dist/testing/dev-test-routes.js +5 -3
- package/dist/testing/fixture-generator.d.ts +13 -0
- package/dist/testing/fixture-generator.d.ts.map +1 -1
- package/dist/testing/fixture-generator.js +105 -8
- package/dist/testing/fixture-manager.d.ts.map +1 -1
- package/dist/testing/fixture-manager.js +19 -2
- package/dist/types/__tests__/entity-json-schema-search-text.test.js +256 -0
- package/dist/types/types.d.ts +494 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +117 -13
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +14 -2
- package/dist/ui/cdd-service.d.ts +16 -14
- package/dist/ui/cdd-service.d.ts.map +1 -1
- package/dist/ui/cdd-service.js +145 -37
- package/dist/ui/cdd-types.d.ts +60 -0
- package/dist/ui/cdd-types.d.ts.map +1 -0
- package/dist/ui/cdd-types.js +3 -0
- package/dist/ui-web/assets/index-D4XFBV-f.css +1 -0
- package/dist/ui-web/assets/{index-CQ_S40bD.js → index-D_19-Pi4.js} +87 -87
- package/dist/ui-web/index.html +2 -2
- package/package.json +7 -3
- package/src/api/sonamu.ts +1 -2
- package/src/auth/auth-generator.ts +38 -0
- package/src/auth/better-auth-entities.ts +18 -1
- package/src/bin/cli.ts +15 -1
- package/src/cone/cone-generator.ts +9 -3
- package/src/database/knex.ts +62 -4
- package/src/database/puri.ts +71 -0
- package/src/database/puri.types.ts +2 -0
- package/src/entity/entity-manager.ts +95 -3
- package/src/migration/__tests__/code-generation.search-text.test.ts +390 -0
- package/src/migration/code-generation.ts +848 -34
- package/src/migration/migration-set.ts +2 -0
- package/src/migration/postgresql-schema-reader.ts +366 -9
- package/src/skills/sonamu/auth-migration.md +80 -0
- package/src/skills/sonamu/cdd.md +148 -28
- package/src/skills/sonamu/cone.md +16 -0
- package/src/skills/sonamu/entity-relations.md +1 -1
- package/src/skills/sonamu/fixture-cli.md +4 -0
- package/src/skills/sonamu/frontend.md +65 -0
- package/src/skills/sonamu/migration.md +3 -1
- package/src/skills/sonamu/model.md +28 -0
- package/src/skills/sonamu/workflow.md +12 -5
- package/src/stream/sse.ts +4 -2
- package/src/template/__tests__/generated.template.search-text.test.ts +89 -0
- package/src/template/generated.template.test-d.ts +46 -0
- package/src/template/implementations/generated.template.ts +4 -1
- package/src/template/implementations/init_types.template.ts +20 -5
- package/src/template/zod-converter.ts +5 -0
- package/src/testing/dev-test-routes.ts +4 -2
- package/src/testing/fixture-generator.ts +157 -9
- package/src/testing/fixture-manager.ts +15 -1
- package/src/types/__tests__/entity-json-schema-search-text.test.ts +179 -0
- package/src/types/types.ts +168 -12
- package/src/ui/api.ts +24 -1
- package/src/ui/cdd-service.ts +195 -55
- package/src/ui/cdd-types.ts +73 -0
- package/dist/ui-web/assets/index-egkMxKos.css +0 -1
package/src/types/types.ts
CHANGED
|
@@ -17,6 +17,29 @@ export type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K
|
|
|
17
17
|
Model-Definition
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 부모 Entity fixture 생성 시 함께 생성할 companion Entity 설정
|
|
22
|
+
*
|
|
23
|
+
* 예: User fixture 생성 시 credentials Account를 함께 생성
|
|
24
|
+
*/
|
|
25
|
+
export type FixtureCompanion = {
|
|
26
|
+
/** 함께 생성할 Entity 이름 */
|
|
27
|
+
entity: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 고정 오버라이드 값.
|
|
31
|
+
* "{{fieldName}}" 형식으로 부모 fixture의 필드 값을 참조할 수 있다.
|
|
32
|
+
* 예: { "account_id": "{{email}}" } → 부모 User의 email 값 사용
|
|
33
|
+
*/
|
|
34
|
+
overrides?: Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 부모 1개당 생성할 companion 개수. 기본값 1.
|
|
38
|
+
* 예: count: 2 → User 1개당 companion 2개 생성
|
|
39
|
+
*/
|
|
40
|
+
count?: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
20
43
|
/**
|
|
21
44
|
* cone: 범용 메타데이터 시스템
|
|
22
45
|
*
|
|
@@ -31,6 +54,7 @@ export type Cone = {
|
|
|
31
54
|
fixtureGenerator?: string; // Faker.js 코드 또는 커스텀 함수
|
|
32
55
|
fixtureDefault?: unknown; // 기본값
|
|
33
56
|
fixtureStrategy?: "sequence"; // string 타입이지만 DB sequence로 관리되는 PK (better-auth 등)
|
|
57
|
+
fixtureCompanions?: FixtureCompanion[]; // 부모 fixture 생성 시 함께 생성할 companion Entity 목록
|
|
34
58
|
|
|
35
59
|
// 참조 데이터 관련
|
|
36
60
|
dataSource?: {
|
|
@@ -179,6 +203,14 @@ export type JsonProp = CommonProp & {
|
|
|
179
203
|
type: "json";
|
|
180
204
|
id: string;
|
|
181
205
|
}; // PG: json / TS: any(id) / JSON: any
|
|
206
|
+
export type SearchTextSourceColumn = {
|
|
207
|
+
name: string;
|
|
208
|
+
caseInsensitive?: boolean;
|
|
209
|
+
};
|
|
210
|
+
export type SearchTextProp = CommonProp & {
|
|
211
|
+
type: "searchText";
|
|
212
|
+
sourceColumns: SearchTextSourceColumn[];
|
|
213
|
+
}; // PG: text (generated) / TS: string / JSON: string
|
|
182
214
|
export type UuidProp = CommonProp & {
|
|
183
215
|
type: "uuid";
|
|
184
216
|
}; // PG: uuid / TS: string / JSON: string
|
|
@@ -271,6 +303,7 @@ export type EntityProp =
|
|
|
271
303
|
| UuidProp
|
|
272
304
|
| UuidArrayProp
|
|
273
305
|
| JsonProp
|
|
306
|
+
| SearchTextProp
|
|
274
307
|
| VirtualProp
|
|
275
308
|
| VectorProp
|
|
276
309
|
| VectorArrayProp
|
|
@@ -333,12 +366,26 @@ export type BuiltInTypeId = (typeof BUILT_IN_TYPE_IDS)[number];
|
|
|
333
366
|
*/
|
|
334
367
|
export type VectorOps = "vector_cosine_ops" | "vector_ip_ops" | "vector_l2_ops";
|
|
335
368
|
|
|
369
|
+
export const KnownOpclassValues = [
|
|
370
|
+
"gin_trgm_ops",
|
|
371
|
+
"gist_trgm_ops",
|
|
372
|
+
"gin_bigm_ops",
|
|
373
|
+
"vector_cosine_ops",
|
|
374
|
+
"vector_ip_ops",
|
|
375
|
+
"vector_l2_ops",
|
|
376
|
+
"pgroonga_varchar_full_text_search_ops_v2",
|
|
377
|
+
"pgroonga_jsonb_full_text_search_ops_v2",
|
|
378
|
+
] as const;
|
|
379
|
+
export type KnownOpclass = (typeof KnownOpclassValues)[number];
|
|
380
|
+
|
|
336
381
|
type EntityIndexColumn = {
|
|
337
382
|
name: string;
|
|
338
383
|
nullsFirst?: boolean;
|
|
339
384
|
sortOrder?: "ASC" | "DESC";
|
|
340
385
|
/** pgvector 인덱스에서 사용할 거리 연산자 (vector 컬럼에만 적용) */
|
|
341
386
|
vectorOps?: VectorOps;
|
|
387
|
+
/** generic 인덱스 opclass (vectorOps는 하위호환 목적으로 유지) */
|
|
388
|
+
opclass?: KnownOpclass | string;
|
|
342
389
|
};
|
|
343
390
|
export type EntityIndex = {
|
|
344
391
|
type: "index" | "unique" | "hnsw" | "ivfflat";
|
|
@@ -598,6 +645,9 @@ export function isUuidProp(p: unknown): p is UuidProp | UuidArrayProp {
|
|
|
598
645
|
export function isJsonProp(p: unknown): p is JsonProp {
|
|
599
646
|
return (p as JsonProp)?.type === "json";
|
|
600
647
|
}
|
|
648
|
+
export function isSearchTextProp(p: unknown): p is SearchTextProp {
|
|
649
|
+
return (p as SearchTextProp)?.type === "searchText";
|
|
650
|
+
}
|
|
601
651
|
export function isVirtualProp(p: unknown): p is VirtualProp {
|
|
602
652
|
return (p as VirtualProp)?.type === "virtual";
|
|
603
653
|
}
|
|
@@ -1041,6 +1091,12 @@ const GeneratedColumnSchema = z.object({
|
|
|
1041
1091
|
expression: z.string(),
|
|
1042
1092
|
});
|
|
1043
1093
|
|
|
1094
|
+
const FixtureCompanionSchema = z.object({
|
|
1095
|
+
entity: z.string(),
|
|
1096
|
+
overrides: z.record(z.string(), z.unknown()).optional(),
|
|
1097
|
+
count: z.number().int().positive().optional(),
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1044
1100
|
/**
|
|
1045
1101
|
* Cone 스키마 검증
|
|
1046
1102
|
*
|
|
@@ -1053,6 +1109,7 @@ const ConeSchema = z
|
|
|
1053
1109
|
fixtureGenerator: z.string().optional(),
|
|
1054
1110
|
fixtureDefault: z.unknown().optional(),
|
|
1055
1111
|
fixtureStrategy: z.literal("sequence").optional(),
|
|
1112
|
+
fixtureCompanions: z.array(FixtureCompanionSchema).optional(),
|
|
1056
1113
|
dataSource: z
|
|
1057
1114
|
.object({
|
|
1058
1115
|
strategy: z.enum(["sample", "ids", "query", "file", "recent", "random"]),
|
|
@@ -1186,6 +1243,21 @@ const JsonPropSchema = z
|
|
|
1186
1243
|
})
|
|
1187
1244
|
.strict();
|
|
1188
1245
|
|
|
1246
|
+
const SearchTextSourceColumnSchema = z
|
|
1247
|
+
.object({
|
|
1248
|
+
name: z.string(),
|
|
1249
|
+
caseInsensitive: z.boolean().optional(),
|
|
1250
|
+
})
|
|
1251
|
+
.strict();
|
|
1252
|
+
|
|
1253
|
+
const SearchTextPropSchema = z
|
|
1254
|
+
.object({
|
|
1255
|
+
...BasePropFields,
|
|
1256
|
+
type: z.literal("searchText"),
|
|
1257
|
+
sourceColumns: z.array(SearchTextSourceColumnSchema).min(1),
|
|
1258
|
+
})
|
|
1259
|
+
.strict();
|
|
1260
|
+
|
|
1189
1261
|
const VirtualPropSchema = z
|
|
1190
1262
|
.object({
|
|
1191
1263
|
...BasePropFields,
|
|
@@ -1297,6 +1369,7 @@ const NormalPropTypes = [
|
|
|
1297
1369
|
"uuid",
|
|
1298
1370
|
"uuid[]",
|
|
1299
1371
|
"json",
|
|
1372
|
+
"searchText",
|
|
1300
1373
|
"virtual",
|
|
1301
1374
|
"vector",
|
|
1302
1375
|
"vector[]",
|
|
@@ -1335,6 +1408,7 @@ export const NormalPropSchema = z
|
|
|
1335
1408
|
NumericPropSchema,
|
|
1336
1409
|
NumericArrayPropSchema,
|
|
1337
1410
|
JsonPropSchema,
|
|
1411
|
+
SearchTextPropSchema,
|
|
1338
1412
|
VirtualPropSchema,
|
|
1339
1413
|
VectorPropSchema,
|
|
1340
1414
|
VectorArrayPropSchema,
|
|
@@ -1391,6 +1465,7 @@ const EntityIndexColumnSchema = z.object({
|
|
|
1391
1465
|
nullsFirst: z.boolean().optional(),
|
|
1392
1466
|
sortOrder: z.enum(["ASC", "DESC"]).optional(),
|
|
1393
1467
|
vectorOps: z.enum(["vector_cosine_ops", "vector_ip_ops", "vector_l2_ops"]).optional(),
|
|
1468
|
+
opclass: z.union([z.enum(KnownOpclassValues), z.string().min(1)]).optional(),
|
|
1394
1469
|
});
|
|
1395
1470
|
|
|
1396
1471
|
// EntityIndex 스키마 정의
|
|
@@ -1439,7 +1514,75 @@ const EnumDefSchema = z.union([
|
|
|
1439
1514
|
}),
|
|
1440
1515
|
]);
|
|
1441
1516
|
|
|
1442
|
-
|
|
1517
|
+
function unwrapSearchTextJsonSourceType(zodType: z.ZodTypeAny): z.ZodTypeAny {
|
|
1518
|
+
let current = zodType;
|
|
1519
|
+
|
|
1520
|
+
while (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
|
|
1521
|
+
current = current.unwrap() as z.ZodTypeAny;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
return current;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
export function isSearchTextJsonSourceZodType(zodType: z.ZodTypeAny): boolean {
|
|
1528
|
+
const baseType = unwrapSearchTextJsonSourceType(zodType);
|
|
1529
|
+
if (!(baseType instanceof z.ZodArray)) {
|
|
1530
|
+
return false;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const elementType = baseType.def.element;
|
|
1534
|
+
return elementType instanceof z.ZodString;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function validateSearchTextSources(
|
|
1538
|
+
props: readonly z.infer<typeof EntityPropSchema>[],
|
|
1539
|
+
ctx: z.RefinementCtx,
|
|
1540
|
+
propsPath: (string | number)[] = ["props"],
|
|
1541
|
+
): void {
|
|
1542
|
+
const propsByName = new Map(
|
|
1543
|
+
props.map((prop) => {
|
|
1544
|
+
return [prop.name, prop];
|
|
1545
|
+
}),
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
props.forEach((prop, propIndex) => {
|
|
1549
|
+
if (prop.type !== "searchText") {
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
prop.sourceColumns.forEach((source, sourceIndex) => {
|
|
1554
|
+
const sourceProp = propsByName.get(source.name);
|
|
1555
|
+
const sourcePath = [...propsPath, propIndex, "sourceColumns", sourceIndex, "name"];
|
|
1556
|
+
|
|
1557
|
+
if (!sourceProp) {
|
|
1558
|
+
ctx.addIssue({
|
|
1559
|
+
code: "custom",
|
|
1560
|
+
message: `searchText source column "${source.name}"을(를) 찾을 수 없습니다.`,
|
|
1561
|
+
path: sourcePath,
|
|
1562
|
+
});
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
if (sourceProp.type === "string" || sourceProp.type === "string[]") {
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (sourceProp.type === "json") {
|
|
1571
|
+
// json source의 최종 타입 유효성은 EntityManager.register() 단계에서
|
|
1572
|
+
// 실제 Zod 타입을 해석하여 구조적으로 검증합니다.
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
ctx.addIssue({
|
|
1577
|
+
code: "custom",
|
|
1578
|
+
message: `searchText source column "${source.name}"의 타입 "${sourceProp.type}"은(는) 지원되지 않습니다.`,
|
|
1579
|
+
path: sourcePath,
|
|
1580
|
+
});
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const EntityJsonBaseSchema = z
|
|
1443
1586
|
.object({
|
|
1444
1587
|
id: z.string().describe("PascalCase로 된 Entity ID"),
|
|
1445
1588
|
title: z.string().describe("Entity 이름"),
|
|
@@ -1453,18 +1596,31 @@ export const EntityJsonSchema = z
|
|
|
1453
1596
|
})
|
|
1454
1597
|
.strict();
|
|
1455
1598
|
|
|
1599
|
+
export const EntityJsonSchema = EntityJsonBaseSchema.superRefine((entity, ctx) => {
|
|
1600
|
+
validateSearchTextSources(entity.props, ctx);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
const TemplateEntitySchema = EntityJsonBaseSchema.omit({ id: true })
|
|
1604
|
+
.extend({
|
|
1605
|
+
entityId: z.string(),
|
|
1606
|
+
})
|
|
1607
|
+
.partial({
|
|
1608
|
+
table: true,
|
|
1609
|
+
props: true,
|
|
1610
|
+
indexes: true,
|
|
1611
|
+
subsets: true,
|
|
1612
|
+
enums: true,
|
|
1613
|
+
})
|
|
1614
|
+
.superRefine((entity, ctx) => {
|
|
1615
|
+
if (!entity.props) {
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
validateSearchTextSources(entity.props, ctx);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1456
1622
|
export const TemplateOptions = z.object({
|
|
1457
|
-
entity:
|
|
1458
|
-
.extend({
|
|
1459
|
-
entityId: z.string(),
|
|
1460
|
-
})
|
|
1461
|
-
.partial({
|
|
1462
|
-
table: true,
|
|
1463
|
-
props: true,
|
|
1464
|
-
indexes: true,
|
|
1465
|
-
subsets: true,
|
|
1466
|
-
enums: true,
|
|
1467
|
-
}),
|
|
1623
|
+
entity: TemplateEntitySchema,
|
|
1468
1624
|
init_types: z.object({
|
|
1469
1625
|
entityId: z.string(),
|
|
1470
1626
|
}),
|
package/src/ui/api.ts
CHANGED
|
@@ -37,7 +37,15 @@ import {
|
|
|
37
37
|
} from "../types/types";
|
|
38
38
|
import { nonNullable } from "../utils/utils";
|
|
39
39
|
import { setAiApi } from "./ai-api";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
editContent,
|
|
42
|
+
editSchema,
|
|
43
|
+
getCddTree,
|
|
44
|
+
listSchemas,
|
|
45
|
+
openSourceFile,
|
|
46
|
+
readContent,
|
|
47
|
+
readSchema,
|
|
48
|
+
} from "./cdd-service";
|
|
41
49
|
|
|
42
50
|
export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
43
51
|
fastify.register(
|
|
@@ -1415,6 +1423,21 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1415
1423
|
return { success: true };
|
|
1416
1424
|
});
|
|
1417
1425
|
|
|
1426
|
+
// CDD Schema API
|
|
1427
|
+
server.get("/api/cdd/schemas", async () => {
|
|
1428
|
+
return listSchemas();
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
server.post<{ Body: { schemaKey: string } }>("/api/cdd/readSchema", async (request) => {
|
|
1432
|
+
const { schemaKey } = request.body;
|
|
1433
|
+
return readSchema(schemaKey);
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
server.post<{ Body: { schemaKey: string } }>("/api/cdd/editSchema", async (request) => {
|
|
1437
|
+
const { schemaKey } = request.body;
|
|
1438
|
+
return editSchema(schemaKey);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1418
1441
|
// ui-web 빌드 파일 서빙
|
|
1419
1442
|
const uiDistPath = path.resolve(import.meta.dirname, "../ui-web");
|
|
1420
1443
|
|
package/src/ui/cdd-service.ts
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import crypto from "crypto";
|
|
3
2
|
import fs from "fs";
|
|
4
3
|
import os from "os";
|
|
5
4
|
import path from "path";
|
|
6
5
|
import { Sonamu } from "../api/sonamu";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
6
|
+
import type {
|
|
7
|
+
CddContentEnvelope,
|
|
8
|
+
CddFileType,
|
|
9
|
+
CddSchema,
|
|
10
|
+
CddSchemaDetailEnvelope,
|
|
11
|
+
CddSchemaReference,
|
|
12
|
+
CddSchemaSummary,
|
|
13
|
+
CddTreeNode,
|
|
14
|
+
} from "./cdd-types";
|
|
15
|
+
|
|
16
|
+
export type {
|
|
17
|
+
CddContentEnvelope,
|
|
18
|
+
CddFileType,
|
|
19
|
+
CddSchema,
|
|
20
|
+
CddSchemaDetailEnvelope,
|
|
21
|
+
CddSchemaField,
|
|
22
|
+
CddSchemaFieldType,
|
|
23
|
+
CddSchemaReference,
|
|
24
|
+
CddSchemaSummary,
|
|
25
|
+
CddTreeNode,
|
|
26
|
+
} from "./cdd-types";
|
|
20
27
|
|
|
21
28
|
/** contract/ 디렉터리 절대 경로 반환 (apiRootPath 기준) */
|
|
22
29
|
function getContractDir(): string {
|
|
23
|
-
return path.join(Sonamu.apiRootPath, "contract");
|
|
30
|
+
return path.join(Sonamu.apiRootPath, "..", "..", "contract");
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/** 경로가 contract/ 디렉터리 내부인지 검증 */
|
|
@@ -82,15 +89,17 @@ export function getCddTree(): { exists: boolean; tree: CddTreeNode[] } {
|
|
|
82
89
|
return { exists: true, tree };
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
/**
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return
|
|
92
|
+
/** schema ID로 schema 파일을 찾아 반환 */
|
|
93
|
+
function resolveSchema(schemaId: string): CddSchema | null {
|
|
94
|
+
const contractDir = getContractDir();
|
|
95
|
+
const schemaPath = path.join(contractDir, "schemas", `${schemaId}.schema.json`);
|
|
96
|
+
if (!fs.existsSync(schemaPath)) return null;
|
|
97
|
+
const raw = fs.readFileSync(schemaPath, "utf-8");
|
|
98
|
+
return JSON.parse(raw) as CddSchema;
|
|
90
99
|
}
|
|
91
100
|
|
|
92
|
-
/** JSON 파일의 전체 내용을 읽어
|
|
93
|
-
export function readContent(filePath: string):
|
|
101
|
+
/** JSON 파일의 전체 내용을 읽어 schema와 함께 envelope로 반환 */
|
|
102
|
+
export function readContent(filePath: string): CddContentEnvelope {
|
|
94
103
|
assertInsideContractDir(filePath);
|
|
95
104
|
|
|
96
105
|
const contractDir = getContractDir();
|
|
@@ -101,11 +110,19 @@ export function readContent(filePath: string): Record<string, unknown> {
|
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
const raw = fs.readFileSync(absPath, "utf-8");
|
|
104
|
-
const
|
|
105
|
-
|
|
113
|
+
const document = JSON.parse(raw) as Record<string, unknown>;
|
|
114
|
+
const fileType = detectFileType(path.basename(filePath));
|
|
115
|
+
const schemaId = typeof document.schema === "string" ? document.schema : null;
|
|
116
|
+
const schema = schemaId ? resolveSchema(schemaId) : null;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
document,
|
|
120
|
+
schema,
|
|
121
|
+
fileType: fileType ?? "contract",
|
|
122
|
+
};
|
|
106
123
|
}
|
|
107
124
|
|
|
108
|
-
/** JSON
|
|
125
|
+
/** JSON 파일을 외부 에디터로 직접 편집 */
|
|
109
126
|
export async function editContent(
|
|
110
127
|
filePath: string,
|
|
111
128
|
): Promise<{ success: boolean; filePath: string }> {
|
|
@@ -119,37 +136,9 @@ export async function editContent(
|
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
const editor = resolveEditorCli();
|
|
139
|
+
await runEditor(editor, absPath);
|
|
122
140
|
|
|
123
|
-
|
|
124
|
-
const json: Record<string, unknown> = JSON.parse(raw);
|
|
125
|
-
|
|
126
|
-
const content = contentToString(json.content);
|
|
127
|
-
|
|
128
|
-
const tmpFileName = `cdd-edit-${crypto.randomUUID()}.md`;
|
|
129
|
-
const tmpFilePath = path.join(os.tmpdir(), tmpFileName);
|
|
130
|
-
|
|
131
|
-
fs.writeFileSync(tmpFilePath, content, "utf-8");
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
await runEditor(editor, tmpFilePath);
|
|
135
|
-
|
|
136
|
-
const edited = fs.readFileSync(tmpFilePath, "utf-8");
|
|
137
|
-
json.content = edited.split("\n");
|
|
138
|
-
|
|
139
|
-
const today = new Date();
|
|
140
|
-
const yyyy = today.getFullYear();
|
|
141
|
-
const mm = String(today.getMonth() + 1).padStart(2, "0");
|
|
142
|
-
const dd = String(today.getDate()).padStart(2, "0");
|
|
143
|
-
json.lastModified = `${yyyy}-${mm}-${dd}`;
|
|
144
|
-
|
|
145
|
-
fs.writeFileSync(absPath, `${JSON.stringify(json, null, 2)}\n`, "utf-8");
|
|
146
|
-
|
|
147
|
-
return { success: true, filePath };
|
|
148
|
-
} finally {
|
|
149
|
-
if (fs.existsSync(tmpFilePath)) {
|
|
150
|
-
fs.unlinkSync(tmpFilePath);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
141
|
+
return { success: true, filePath };
|
|
153
142
|
}
|
|
154
143
|
|
|
155
144
|
/** 에디터별 앱 번들 내 CLI 경로 + --wait 플래그 매핑 */
|
|
@@ -208,6 +197,157 @@ function runEditor(editor: { bin: string; args: string[] }, filePath: string): P
|
|
|
208
197
|
});
|
|
209
198
|
}
|
|
210
199
|
|
|
200
|
+
/* ========================================================================
|
|
201
|
+
* Schema 관리 API
|
|
202
|
+
* ======================================================================== */
|
|
203
|
+
|
|
204
|
+
/** contract/schemas/ 디렉터리 내 .schema.json 파일 경로 목록 반환 */
|
|
205
|
+
function scanSchemaFiles(): { absPath: string; relPath: string; fileName: string }[] {
|
|
206
|
+
const schemasDir = path.join(getContractDir(), "schemas");
|
|
207
|
+
if (!fs.existsSync(schemasDir)) return [];
|
|
208
|
+
|
|
209
|
+
return fs
|
|
210
|
+
.readdirSync(schemasDir, { withFileTypes: true })
|
|
211
|
+
.filter((e) => e.isFile() && e.name.endsWith(".schema.json"))
|
|
212
|
+
.map((e) => ({
|
|
213
|
+
absPath: path.join(schemasDir, e.name),
|
|
214
|
+
relPath: `schemas/${e.name}`,
|
|
215
|
+
fileName: e.name,
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 특정 schemaId를 참조하는 contract/spec 문서들을 재귀 수집 */
|
|
220
|
+
function collectSchemaReferences(
|
|
221
|
+
schemaId: string,
|
|
222
|
+
dirPath: string,
|
|
223
|
+
relativeTo: string,
|
|
224
|
+
): CddSchemaReference[] {
|
|
225
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
226
|
+
const refs: CddSchemaReference[] = [];
|
|
227
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
228
|
+
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
if (entry.name === "schemas") continue;
|
|
233
|
+
refs.push(...collectSchemaReferences(schemaId, fullPath, relativeTo));
|
|
234
|
+
} else if (entry.isFile()) {
|
|
235
|
+
const fileType = detectFileType(entry.name);
|
|
236
|
+
if (!fileType) continue;
|
|
237
|
+
try {
|
|
238
|
+
const raw = fs.readFileSync(fullPath, "utf-8");
|
|
239
|
+
const doc = JSON.parse(raw) as Record<string, unknown>;
|
|
240
|
+
if (doc.schema === schemaId) {
|
|
241
|
+
refs.push({
|
|
242
|
+
path: path.relative(relativeTo, fullPath),
|
|
243
|
+
fileType,
|
|
244
|
+
name: entry.name,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// JSON 파싱 실패 시 무시
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return refs;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** schema 파일명에서 기대되는 id 추출 */
|
|
256
|
+
function expectedSchemaId(fileName: string): string {
|
|
257
|
+
return fileName.replace(/\.schema\.json$/, "");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** schemas/ 하위 경로가 contract/schemas/ 내부인지 검증 */
|
|
261
|
+
function assertInsideSchemaDir(schemaKey: string): void {
|
|
262
|
+
const schemasDir = path.join(getContractDir(), "schemas");
|
|
263
|
+
const resolved = path.resolve(schemasDir, `${schemaKey}.schema.json`);
|
|
264
|
+
if (!resolved.startsWith(schemasDir + path.sep) && resolved !== schemasDir) {
|
|
265
|
+
throw new Error(`유효하지 않은 스키마 키입니다: ${schemaKey}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** schema 목록 반환 */
|
|
270
|
+
export function listSchemas(): { schemas: CddSchemaSummary[] } {
|
|
271
|
+
const contractDir = getContractDir();
|
|
272
|
+
const files = scanSchemaFiles();
|
|
273
|
+
const schemas: CddSchemaSummary[] = [];
|
|
274
|
+
|
|
275
|
+
for (const file of files) {
|
|
276
|
+
const key = expectedSchemaId(file.fileName);
|
|
277
|
+
try {
|
|
278
|
+
const raw = fs.readFileSync(file.absPath, "utf-8");
|
|
279
|
+
const schema = JSON.parse(raw) as CddSchema;
|
|
280
|
+
const refs = collectSchemaReferences(schema.id, contractDir, contractDir);
|
|
281
|
+
schemas.push({
|
|
282
|
+
key,
|
|
283
|
+
id: schema.id,
|
|
284
|
+
path: file.relPath,
|
|
285
|
+
type: schema.type,
|
|
286
|
+
fieldCount: schema.fields.length,
|
|
287
|
+
referenceCount: refs.length,
|
|
288
|
+
hasIdMismatch: schema.id !== key,
|
|
289
|
+
});
|
|
290
|
+
} catch (err) {
|
|
291
|
+
schemas.push({
|
|
292
|
+
key,
|
|
293
|
+
id: key,
|
|
294
|
+
path: file.relPath,
|
|
295
|
+
type: "contract",
|
|
296
|
+
fieldCount: 0,
|
|
297
|
+
referenceCount: 0,
|
|
298
|
+
hasIdMismatch: false,
|
|
299
|
+
parseError: err instanceof Error ? err.message : String(err),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { schemas };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** schema 상세 반환 (파일명 기반 key로 조회) */
|
|
308
|
+
export function readSchema(schemaKey: string): CddSchemaDetailEnvelope {
|
|
309
|
+
assertInsideSchemaDir(schemaKey);
|
|
310
|
+
|
|
311
|
+
const contractDir = getContractDir();
|
|
312
|
+
const absPath = path.join(contractDir, "schemas", `${schemaKey}.schema.json`);
|
|
313
|
+
|
|
314
|
+
if (!fs.existsSync(absPath)) {
|
|
315
|
+
throw new Error(`스키마를 찾을 수 없습니다: ${schemaKey}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const raw = fs.readFileSync(absPath, "utf-8");
|
|
319
|
+
const schema = JSON.parse(raw) as CddSchema;
|
|
320
|
+
const relPath = path.relative(contractDir, absPath);
|
|
321
|
+
const references = collectSchemaReferences(schema.id, contractDir, contractDir);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
key: schemaKey,
|
|
325
|
+
path: relPath,
|
|
326
|
+
schema,
|
|
327
|
+
references,
|
|
328
|
+
hasIdMismatch: schema.id !== schemaKey,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** schema 파일을 외부 에디터로 편집 (파일명 기반 key로 조회) */
|
|
333
|
+
export async function editSchema(
|
|
334
|
+
schemaKey: string,
|
|
335
|
+
): Promise<{ success: boolean; schemaKey: string }> {
|
|
336
|
+
assertInsideSchemaDir(schemaKey);
|
|
337
|
+
|
|
338
|
+
const contractDir = getContractDir();
|
|
339
|
+
const absPath = path.join(contractDir, "schemas", `${schemaKey}.schema.json`);
|
|
340
|
+
|
|
341
|
+
if (!fs.existsSync(absPath)) {
|
|
342
|
+
throw new Error(`스키마를 찾을 수 없습니다: ${schemaKey}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const editor = resolveEditorCli();
|
|
346
|
+
await runEditor(editor, absPath);
|
|
347
|
+
|
|
348
|
+
return { success: true, schemaKey };
|
|
349
|
+
}
|
|
350
|
+
|
|
211
351
|
/** 소스 파일을 외부 에디터로 열기 (대기하지 않음) */
|
|
212
352
|
export function openSourceFile(filePath: string): void {
|
|
213
353
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(Sonamu.apiRootPath, filePath);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type CddFileType = "contract" | "spec";
|
|
2
|
+
|
|
3
|
+
export type CddSchemaFieldType =
|
|
4
|
+
| "string"
|
|
5
|
+
| "string[]"
|
|
6
|
+
| "Record<string, string>"
|
|
7
|
+
| "Record<string, object>";
|
|
8
|
+
|
|
9
|
+
export type CddSchemaField = {
|
|
10
|
+
name: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
type: CddSchemaFieldType;
|
|
13
|
+
renderer?: string;
|
|
14
|
+
required: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type CddSchema = {
|
|
18
|
+
id: string;
|
|
19
|
+
type: "contract" | "spec";
|
|
20
|
+
fields: CddSchemaField[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type CddContentEnvelope = {
|
|
24
|
+
document: Record<string, unknown>;
|
|
25
|
+
schema: CddSchema | null;
|
|
26
|
+
fileType: CddFileType;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type CddTreeNode = {
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
type: "file" | "directory";
|
|
33
|
+
fileType?: CddFileType;
|
|
34
|
+
children?: CddTreeNode[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type CddSchemaSummary = {
|
|
38
|
+
key: string;
|
|
39
|
+
id: string;
|
|
40
|
+
path: string;
|
|
41
|
+
type: "contract" | "spec";
|
|
42
|
+
fieldCount: number;
|
|
43
|
+
referenceCount: number;
|
|
44
|
+
hasIdMismatch: boolean;
|
|
45
|
+
parseError?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type CddSchemaReference = {
|
|
49
|
+
path: string;
|
|
50
|
+
fileType: CddFileType;
|
|
51
|
+
name: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CddSchemaDetailEnvelope = {
|
|
55
|
+
key: string;
|
|
56
|
+
path: string;
|
|
57
|
+
schema: CddSchema;
|
|
58
|
+
references: CddSchemaReference[];
|
|
59
|
+
hasIdMismatch: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Acceptance Criterion 테스트 참조 */
|
|
63
|
+
export type AcceptanceCriterionTestRef = {
|
|
64
|
+
target: string;
|
|
65
|
+
pattern: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 구조화된 Acceptance Criterion */
|
|
69
|
+
export type AcceptanceCriterion = {
|
|
70
|
+
id: string;
|
|
71
|
+
condition: string;
|
|
72
|
+
testRef: AcceptanceCriterionTestRef;
|
|
73
|
+
};
|